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.
Provenance & attribution
Section titled “Provenance & attribution”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 aChloeKernelcomment in source.
Local fixes in the port
Section titled “Local fixes in the port”| 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 |
Mental model
Section titled “Mental model”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
Firegoes to all clients throughFireAllClients) plus one cursor per player, created on first use (FireClient). One flush thread sends whatever accumulated, once perHeartbeat. - Client: one cursor, flushed through
FireServerat 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.
Framing
Section titled “Framing”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.
The no-yield invariant
Section titled “The no-yield invariant”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.
Instances ride a sideband
Section titled “Instances ride a sideband”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.
Frame parking and replay
Section titled “Frame parking and replay”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.
Decode boundaries
Section titled “Decode boundaries”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.
Inbound limits
Section titled “Inbound limits”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.
TransportGuard
Section titled “TransportGuard”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.
readResponsedecodes 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.
--!strictlocal 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),}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)endlocal ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packets = require(ReplicatedStorage.Game.Packets)
Packets.Position:Fire(1, Vector3.new(12.5, 3, -40)) -- batched; flushes this heartbeatlocal Health = Packets.GetHealth:Fire(1) -- yields for the replyType 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 NumberU16Wire type reference
Section titled “Wire type reference”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.
Integers
Section titled “Integers”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 |
Floats
Section titled “Floats”| 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 |
Strings and buffers
Section titled “Strings and buffers”| 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 |
Booleans and bit packs
Section titled “Booleans and bit packs”| 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 |
Vectors and CFrames
Section titled “Vectors and CFrames”| 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) |
Roblox datatypes
Section titled “Roblox datatypes”| 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 |
Special
Section titled “Special”| 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, the adaptive engine
Section titled “Any, the adaptive engine”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.
Table schemas: lists and structs
Section titled “Table schemas: lists and structs”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})Coercion & clamping
Section titled “Coercion & clamping”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.
300throughNumberU8arrives as255,-1as0,2.6as3, NaN as the minimum. Nothing errors. Saturation is not authorization: validate game ranges in the hook chain or with RegistryValidaterules. - 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.
UDimscale clamps at ±32.767. - Length-prefixed types error at write when over their cap (
Stringover 255,StringLongover 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.
API reference
Section titled “API reference”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 |
Support modules
Section titled “Support modules”| 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 |
Gotchas
Section titled “Gotchas”- 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.