Skip to content

Packet and wire types

The buffer transport under every channel: heartbeat-batched frames over one RemoteEvent, exact byte costs for every wire type, and the inbound limits that run before decode.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Net.Packet
Since
v0.1.0
Source
Packet/init.luau

Packet is the wire under every typed channel in ChloeKernel: intents, states, requests, replicas and the bus bridge all serialize into one shared buffer per direction and ride a single RemoteEvent, flushed once per heartbeat. The module is ReplicatedStorage.ChloeKernel.Net.Packet, a kernel-maintained port of Packet v1.7 by Suphi Kaner (5uphi). Both sides declare the same ordered list of wire types for each channel, so no type tags or field names cross the wire, only the bytes the values need.

Game code rarely touches Packet directly. NetDriver, NetClient and the Registry add the session gate, fail-closed validation and per-channel rate limiting on top. This page is the layer underneath: what the bytes are, how they batch, how the client recovers frames it cannot decode yet, and the limits the server applies before any decode work.

This module is a derived work. The original design and implementation remain credited to Suphi Kaner (5uphi).

  • Original source: official Roblox asset 104116977416770, extracted verbatim from Studio on 2026-06-09 (DevForum thread).
  • Ported into the kernel on 2026-06-10. ChloeKernel no longer depends on an external package. Both sides of the wire run the same copy, which is maintained and fixed as part of the kernel.
  • Attribution and the full modification list live in Net/Packet/CREDITS.md. Every local change carries a ChloeKernel comment in source.
Change Why
Inbound byte budget reads the RateLimitBytes attribute (default 8,000). A value that is not a finite number of at least 1 warns and falls back to 8,000 Upstream hardcoded the limit. A bad attribute value used to drop all client traffic
RateLimitExceeded BindableEvent fires (player, windowBytes, droppedBytes, budgetBytes, reason) for every drop Feeds the anti-exploit layer and the NET debug panel. Upstream dropped silently
Per-player message budget and pending-request admission (TransportGuard) The byte budget alone does not bound thousands of tiny frames in one buffer
Server-to-client frames carry a varint body length, and the client parks frames it cannot decode yet One frame for a channel the client had not built used to take down every later frame in the batch
Decode rejects non-Instance values in Instance slots from clients A client controls the remote’s instances argument and could hand a forged table or string to a handler
Replies that do not encode under the Response schema drop with one warning per channel The failed write used to error the server’s respond thread
Any strings and buffers over 255 bytes use type ids 29 and 30 with a U32 length The U8 length prefix wrapped and corrupted the stream
F16 and F24 mantissa carry bumps the exponent Values whose mantissa rounded up to a power of two (63.99, 127.99) decoded at half their value. Sub-normal magnitudes now flush to zero
Integer writers round and saturate. NumberVlq clamps to 0 to 2^53 Out-of-range values wrapped silently. A negative or NaN NumberVlq desynced the rest of the frame and an infinity never finished writing
Typed arrays cap at 65,535 entries on write, must be dense, and share an 8,192-element decode budget per message A sparse or oversized array wrote a misleading length prefix. Zero-byte element schemas could loop without consuming bytes
257th channel definition errors Ids ride a single U8; upstream silently collided with channel 1
Name-to-id mappings are paged across Configuration children Roblox caps an instance’s total attribute payload at 1,024 bytes
Length prefixes are checked against the remaining payload before allocation A crafted U32 prefix must not force a multi-gigabyte buffer.create
String and Buffer length caps are enforced at write A 300-byte value wrapped its U8 prefix to 44 and desynced the stream
Unknown enum ids and values, and missing Static entries, error on both sides Upstream wrote index 0 or decoded to a silent nil
Any table decode is capped at 48 levels of nesting Hostile bytes cannot overflow the receive thread’s stack
Receive loops and the server flush thread are pcall-isolated One malformed payload or a FireClient to a departing player cannot kill the connection handler or stop all outgoing traffic
Idle heartbeat pumps disconnect after 1 second and wake on traffic An idle transport costs nothing per frame
NumberVlq added Variable-length unsigned integer: 1 byte under 128, 2 under 16,384, up to 2^53

One buffer per direction, flushed per heartbeat

Section titled “One buffer per direction, flushed per heartbeat”

Every Fire appends to a cursor: a growable buffer plus a parallel Instances array. Buffers start at 128 bytes and double on demand. At flush the buffer is copied to its exact length, so the wire carries no slack.

  • Server: one broadcast cursor (every server Fire goes to all clients through FireAllClients) plus one cursor per player, created on first use (FireClient). One flush thread sends whatever accumulated, once per Heartbeat.
  • Client: one cursor, flushed through FireServer at most once per 1/60 s of accumulated heartbeat time.

The heartbeat pump connects on the first write or incoming server payload and disconnects after 1 second with nothing to do. Batching is why a message is cheap: a heartbeat’s worth of traffic on every channel shares one remote invocation.

Frames are laid end to end in the buffer. The two directions differ:

server to client: [id U8] [body length, varint] [thread index U8, RPC only] [arguments]
client to server: [id U8] [thread index U8, RPC only] [arguments]

The body length is an unsigned LEB128 varint that counts everything after the id byte, including the thread index:

Body bytes Length prefix
0 to 127 1 byte
128 to 16,383 2 bytes
16,384 to 2,097,151 3 bytes

The writer reserves one byte and shifts the body only when the varint needs more. A small state frame therefore costs one byte more server to client than client to server. Client-to-server framing has no length prefix.

Channel ids are assigned by the server when a channel is defined. The name-to-id mapping rides paged Configuration children of the shared RemoteEvent (Ids1, Ids2, …), each filled to an 800-byte budget (name length plus 16 bytes per entry) before the next page starts. Clients watch every page and pages added later. At most 256 channels exist; the 257th definition errors.

Request and response channels (:Response()) add the thread-index byte. Indexes 0 to 127 mark a request and the same index plus 128 marks its reply, so at most 128 requests are in flight per direction (per player on the server). The 129th Fire errors with Cannot have more than 128 yielded threads.

All encoding and decoding drives one module-global cursor between Import and Export or Truncate. Nothing on those paths may yield: an interleaved encode would corrupt the in-flight buffer. Every write function is synchronous. Keep it that way if you ever edit Types/init.luau.

Instance values are never serialized. They go into the cursor’s Instances array, which travels as the second remote argument, and are matched back by position on decode. They cost zero buffer bytes. An instance the receiver does not have (not replicated yet, streamed out, destroyed) arrives as nil, as with any remote.

Server-to-client payloads that carry Instance refs add a third remote argument: a flat list of (frame offset, slot count) pairs naming the frames that consumed Instance slots. That list is what lets the client park one frame with its own refs without misaligning the refs of the frames after it.

The server treats the instances argument from a client as hostile. A non-table instances argument drops the payload as Malformed. A non-Instance value in an Instance slot (plain Instance or Any type id 13) is a decode error and drops the rest of the payload.

A client can receive a frame for a channel it cannot decode yet: the channel’s id page has not replicated, or the client has not constructed that channel (a ReplicaClient.new, a SettingsClient.new or a late BusBridgeClient.attach still to run). The length prefix lets the client skip that one frame and keep decoding the rest of the payload.

  • The skipped frame parks under its channel id with its body and its own Instance refs.
  • Each channel keeps at most 64 parked frames. The 65th drops the oldest.
  • Each frame expires 10 seconds after it arrived. Replays never reset that clock. Expired frames drop at replay, and Studio warns dropped N frame(s) for <channel> parked over 10s.
  • Parked frames replay in arrival order when the channel’s id arrives, or on the next deferred resume after the client constructs a channel whose id is already known. The deferral lets the caller connect its listeners first.
  • A frame that arrives while its channel still has parked frames queues behind them, so order holds.

Channels built eagerly never park: Registry.client constructs State channels at once through NetClient:declareState, so their frames decode immediately and drop if nothing listens.

Every client decode must stop exactly at its frame’s byte and Instance boundaries. A decode that stops short or runs long means the two sides disagree on that channel’s schema. The client raises frame desync on <channel> and drops the rest of that payload; frames before it were already dispatched. A length prefix that runs past the payload is treated the same way.

The server has no length prefix to check against. An unknown channel id, a truncated argument or any other decode error drops the rest of the client’s payload.

The server applies these per player to everything a client sends, before any channel’s own gate:

Limit Default Attribute on the Packet ModuleScript On exceed Reason reported
Byte budget per heartbeat window. Each call counts at least 800 bytes 8,000 bytes RateLimitBytes The whole payload drops before decode ByteLimit
Call shape: a buffer plus a table or nil The payload drops after paying its 800 bytes Malformed
Message budget, one token per frame, refilled by elapsed time 240 per second, burst 128 RateLimitMessages, RateLimitMessageBurst The rest of the payload drops before its next frame decodes MessageLimit
Typed-array elements per decoded message, across every nested array 8,192 MaxDecodedArrayElements Decode error; the rest of the payload drops none
Request slots: an index stays reserved until its handler finishes 128 A valid duplicate frame is consumed and skipped DuplicateRequest

Because every call costs at least 800 bytes, the default budget admits at most 10 remote invocations per heartbeat whatever their size. An honest client batches into one per heartbeat. Attributes are live: changing one takes effect on the next payload, and the message budget updates existing players’ buckets. A value that is not a finite number of at least 1 (a positive integer for MaxDecodedArrayElements) warns and falls back to the default.

The element budget also applies on the client, to server frames. Raising it for a legitimate large array means setting MaxDecodedArrayElements where both ends read it, such as in the place file. The U16 length prefix alone does not authorize the extra decode work.

RateLimitExceeded is a BindableEvent created under the Packet module at runtime. It fires (player, windowBytes, droppedBytes, budgetBytes, reason) for every drop. droppedBytes counts only what that report discarded: the whole payload for ByteLimit and Malformed (0 for a non-buffer payload), the unparsed tail for MessageLimit, and the duplicate frame for DuplicateRequest. NetDriver turns these into Net.FloodDetected, one report per player and reason; see Rate limiting.

Net/Packet/TransportGuard.luau (added in 0.8.0) holds the per-player work limits the server’s receive loop checks. Each player gets one guard on their first payload:

  • A TokenBucket for the message budget. The receive loop takes one token before decoding each frame.
  • A pending-request table. A request index is reserved before its handler is scheduled and released only when the handler returns, so a timeout on the client never frees a slot while the handler can still run. A duplicate of a pending index is decoded under the normal limits (the channel’s schema still defines the frame boundary), skipped and reported, and the frames behind it proceed.
  • Reply decoding before the waiter is touched. readResponse decodes a reply, then checks that a waiter exists at that index for that same channel. A truncated reply errors before the waiter or its timeout is removed. A reply naming the wrong channel is consumed and ignored.

Direct Packet use: a shared ModuleScript defines the channels once for both sides.

ReplicatedStorage.Game.Packets
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packet = require(ReplicatedStorage.ChloeKernel.Net.Packet)
return {
-- Event channel. Client to server: id + U8 + Vector3F24 = 11 bytes. Server to client adds the length byte: 12
Position = Packet("Position", Packet.NumberU8, Packet.Vector3F24),
-- Request channel with a 10 s default response timeout
GetHealth = Packet("GetHealth", Packet.NumberU8):Response(Packet.NumberU16),
}
Server
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packets = require(ReplicatedStorage.Game.Packets)
Packets.Position.OnServerEvent:Connect(function(player: Player, id: number, position: Vector3)
updateTracker(player, id, position)
end)
Packets.GetHealth.OnServerInvoke = function(_player: Player, id: number)
return getHealth(id)
end
Client
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packets = require(ReplicatedStorage.Game.Packets)
Packets.Position:Fire(1, Vector3.new(12.5, 3, -40)) -- batched; flushes this heartbeat
local Health = Packets.GetHealth:Fire(1) -- yields for the reply

Type names are also plain strings. Packet.NumberU8 and "NumberU8" are the same key, and every kernel schema spells them as strings.

Serialize and Deserialize use the same type engine without touching the wire. SerializeResponse encodes values under the Response schema, which is how NetDriver proves a request’s RejectValue fits before any client asks. For storage schemas, prefer Serde.

local Buffer, Instances = Packets.Position:Serialize(1, Vector3.new(12.5, 3, -40))
local Id, Position = Packets.Position:Deserialize(Buffer, Instances)
local Reply = Packets.GetHealth:SerializeResponse(100) -- errors if 100 cannot encode as NumberU16

Costs are exact and include each type’s own prefixes. Add the frame overhead from Framing: 1 id byte, plus 1 thread-index byte on request channels, plus the varint length server to client.

Try a value. The encoders below are ported byte for byte from Types/init.luau, including the rounding, saturation and the F16 and F24 carry and flush rules. Under the bytes is what the receiver decodes. Add values as arguments to see what one message costs each way.

Integer types round to the nearest whole number and saturate at their range. NaN writes the type’s minimum.

Type Bytes Range
NumberU8 1 0 to 255
NumberU16 2 0 to 65,535
NumberU24 3 0 to 16,777,215
NumberU32 4 0 to 4,294,967,295
NumberS8 1 -128 to 127
NumberS16 2 -32,768 to 32,767
NumberS24 3 -8,388,608 to 8,388,607
NumberS32 4 -2,147,483,648 to 2,147,483,647
NumberVlq 1 to 8 Unsigned, 0 to 2^53. Seven payload bits per byte: 1 byte below 128, 2 below 16,384. Writes clamp (negative and NaN write 0, infinity writes 2^53). Decode refuses to run past 8 bytes
Type Bytes Precision
NumberF16 2 Integer-exact to ±2,048. Magnitudes of 65,520 and above encode as ±inf; below 2^-14 flush to ±0
NumberF24 3 Integer-exact to ±262,144. Magnitudes of 4,294,959,104 and above encode as ±inf; below 2^-30 flush to ±0
NumberF32 4 Single precision. Integer-exact to ±16,777,216
NumberF64 8 Full double. Integer-exact to ±2^53
Type Bytes Limit
String 1 + n 255 bytes (U8 length prefix). Longer values error at write
StringLong 2 + n 65,535 bytes (U16 prefix)
Buffer 1 + n 255 bytes
BufferLong 2 + n 65,535 bytes
Characters 1 + ceil(n × 6 / 8) Restricted-alphabet string. Each character is an index into Types/Characters.luau. The default 64-symbol table (space, period, 0-9, A-Z, a-z) costs 6 bits per character, so a 20-character name is 16 bytes instead of 21. The table is zero-based and bits per character are ceil(log2(symbol count)), so keep it at a power-of-two size (2, 4, 8 up to 256). A character outside the table fails the encode
Type Bytes Carries
Boolean8 1 One boolean
Boolean1 1 An array of 8 booleans, one bit each. Missing entries encode as false; extras are ignored
BooleanNumber 1 { Boolean = boolean, Number = 0 to 127 }
NumberU4 1 An array of two integers, 0 to 15 each
Type Bytes Notes
Vector2S16 4 Integer components
Vector2F24 6
Vector2F32 8
Vector3S16 6 Integer studs: grid positions, cell coordinates
Vector3F24 9 The usual choice for world positions
Vector3F32 12
CFrameF24U8 12 F24 position and XYZ Euler angles at U8 resolution (about 1.4° steps)
CFrameF32U8 15 F32 position and U8 rotation
CFrameF32U16 18 F32 position and U16 rotation (about 0.006° steps)
Type Bytes Notes
Color3 3 8 bits per channel (quantized to 1/255)
BrickColor 2 Palette number
UDim 4 Scale rides as S16 × 1000, clamped to ±32.767. Offset as S16
UDim2 8 Two UDims
Rect 16 Four F32
Region3 24 Min and max corners, six F32
NumberRange 8 Two F32
NumberSequence 1 + 3k k keypoints; time, value and envelope each quantized to U8
ColorSequence 1 + 4k Time U8 and RGB
EnumItem 3 U8 index into Types/Enums.luau and a U16 value. Registered by default: AccessoryType, Axis, BodyPart, BodyPartR15, EasingDirection, EasingStyle, KeyCode, Material, NormalId. Add your own, up to 255. An unregistered enum errors at write; an unknown id or value errors at decode
Instance 0 Rides the instances sideband. Arrives nil if the receiver does not have it
Type Bytes Notes
Nil 0 Placeholder slot
Static1, Static2, Static3 1 U8 index into a project-owned table of up to 255 values of any wire type (Types/Static1.luau and so on). One byte sends a whole error message, a constant vector or a config value. A value missing from the table errors on write; a missing index errors on decode
Any 1 + payload Adaptive: one type byte, then the smallest encoding that round-trips the value

Any inspects each value at write time and picks the narrowest representation that survives the round trip:

Value Cost
nil 1
Integer 2 to 5 (type byte and a U8, U16, U24 or U32 magnitude; the sign lives in the type id). Beyond ±2^32 it falls back to F64 (9)
Fraction 5 as F32 only when single precision round-trips it exactly (0.1 does not); otherwise 9 as F64
String or buffer 2 + n up to 255 bytes; 5 + n above (type ids 29 and 30)
Boolean 2
Vector3 13. CFrame 19, Color3 4, EnumItem 4, other datatypes at their fixed cost + 1
Instance 1, plus a sideband slot
Table 2 + the encoded key and value pairs, each Any-encoded recursively. Decode depth is capped at 48 levels

A value type Any does not know, such as a function, errors at write. Any is the escape hatch. A typed field costs no type byte because the schema is the type information.

A table in a schema compiles to read and write functions at definition time, so there is no reflection per message:

  • { Type } (one element) is a list: a U16 count and then the elements. { "NumberU16" } carrying 10 ids costs 2 + 20 bytes. Writes error past 65,535 entries or on a sparse table (typed arrays must be dense sequences). Decode does not preallocate from the count; the table grows only as elements decode.
  • { Key = Type, ... } (several keys) is a struct. Fields serialize in sorted key order with zero bytes for the keys. Both sides sort the same keys, so the order always agrees. Nested tables follow the same rules.
local Loadout = Packet("Loadout", {
Slots = { "NumberU16" }, -- list: 2 + 2n bytes
Flags = "Boolean1", -- 8 flags in 1 byte
Position = "Vector3F24", -- 9 bytes
})

The schema decides how a value is encoded and says nothing about whether the value is legal. Values outside a type’s range degrade in type-specific ways:

  • Integer types round and saturate. 300 through NumberU8 arrives as 255, -1 as 0, 2.6 as 3, NaN as the minimum. Nothing errors. Saturation is not authorization: validate game ranges in the hook chain or with Registry Validate rules.
  • F16 and F24 quantize to the nearest representable value. Magnitudes past the format maximum encode as ±inf and sub-normals flush to ±0. NaN round-trips through every float type, so check for it server-side on anything used in math.
  • Quantized datatypes round: Color3 channels and sequence keypoints to 1/255, CFrame rotations to their U8 or U16 angle step. UDim scale clamps at ±32.767.
  • Length-prefixed types error at write when over their cap (String over 255, StringLong over 65,535, the same for buffers). A write error leaves no partial frame in the buffer.
  • Registered-value types error on both sides: unregistered enums and statics fail the write; unknown indexes fail the decode.

Packet is callable. Packet(name, ...types) returns the channel with that name and creates it on the first call.

Member Description
Packet(name, ...types) -> packet Defines or fetches a channel. The server assigns the id; clients resolve it from the id pages. At most 256 channels. A client constructing a channel with parked frames schedules their replay
packet:Response(...types) -> packet Makes the channel a request channel. Sets ResponseTimeout from the ResponseTimeoutSeconds attribute on the Packet module (a positive finite number), or 10 seconds, unless already set
packet:Fire(...) -> ... Client: sends to the server, and yields for the reply on request channels. Server: broadcasts to every client; errors on request channels with You must use FireClient(player)
packet:FireClient(player, ...) -> ... Server: sends to one player, yielding for the reply on request channels. Does nothing if the player already left
packet:WaitForId(timeout) -> boolean Client: yields until the channel’s id replicates or timeout seconds pass. Wakes from the id page’s attribute event, with one timer per call. timeout must be finite and positive
packet:Serialize(...) -> (buffer, { Instance }?) Encodes without sending
packet:SerializeResponse(...) -> (buffer, { Instance }?) Encodes values under the Response schema without sending. Errors if they do not fit
packet:Deserialize(buffer, instances?) -> ... Decodes a serialized buffer
packet.OnServerEvent Signal (player, ...): server receive
packet.OnClientEvent Signal (...): client receive
packet.OnServerInvoke, packet.OnClientInvoke Reply handler for request channels. With no handler set, incoming requests are discarded (warned in Studio). A server reply that does not encode is dropped with one warning per channel
packet.ResponseTimeout Seconds a request Fire waits before resuming with ResponseTimeoutValue
packet.ResponseTimeoutValue Returned on timeout. NetClient sets it to the request’s RejectValue, so timeout and rejection share one encoding
Module Role
Packet/Signal.luau Linked-list signal behind OnServerEvent and OnClientEvent: Connect, Once, Wait, Disconnect. Handlers run on recycled threads
Packet/Task.luau Thread pool (Spawn, Defer, Delay) that reuses coroutines instead of allocating one per dispatch
Packet/TransportGuard.luau Per-player message budget, request-slot reservation and reply decoding. See TransportGuard
Packet/Types/init.luau The type engine: every read and write above, the cursor, the frame length writer and the Any machinery
Member Description
TransportGuard.new(rate, burst, now?) -> guard One message bucket and an empty pending-request table
guard:take(now) -> boolean Takes one message token
guard:beginRequest(index) -> boolean Reserves a request index. False for an index outside 0 to 127, a fraction, or one already pending
guard:finishRequest(index) Releases the index after the handler returns
TransportGuard.readResponse(waiting, packet, index, decode, reads) -> (pending?, values) Decodes a reply, then removes and returns the waiter only if it belongs to the same channel
  • There is no Actor or Parallel Luau support. The module-global cursor is single-VM by design.
  • Parked frames expire 10 seconds after arrival. A client that constructs a channel later than that, or receives more than 64 frames for it first, loses the oldest ones. Construct channels early in the client bootstrap.
  • Keep wire schemas in one shared module so both sides always agree. The Registry is that pattern with validation attached.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.