Packet & Wire Types
Every typed channel in ChloeKernel — intents, states, requests, replicas, the bus bridge — serializes into one shared buffer and rides a single RemoteEvent, flushed once per heartbeat. The transport is ReplicatedStorage.ChloeKernel.Net.Packet, a kernel-maintained port of Packet v1.7 by Suphi Kaner (5uphi). The defining decision is the schema-as-contract: both sides declare the same ordered list of wire types, so no type information, field names, or lengths ride the wire — only the bytes the values need.
Game code should almost never touch Packet directly — NetDriver, NetClient, and the Registry add the session gate, fail-closed validation, and rate limiting on top. This page is the layer underneath: what the bytes are, how they batch, and where the transport itself draws its one security line.
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: 2026-06-10. ChloeKernel no longer depends on an external package; this copy is maintained, formatted, and bug-fixed as part of the kernel, and both sides of the wire always run the same copy.
- Attribution and the full modification list live in
Net/Packet/CREDITS.md; every local change is marked with aChloeKernelcomment in source.
Local fixes in the port
Section titled “Local fixes in the port”| Change | Why |
|---|---|
Inbound byte budget reads the RateLimitBytes attribute (live-updatable, default 8,000) |
The upstream limit was hardcoded |
RateLimitExceeded BindableEvent fires (player, bytes, payloadBytes, budgetBytes) on every dropped payload |
Feeds the anti-exploit layer and the NET debug panel; upstream dropped silently |
Any-engine strings/buffers over 255 bytes use new type ids 29/30 with a U32 length |
The U8 length prefix wrapped and silently corrupted the stream |
| F16/F24 mantissa-carry overflow fix | Values whose mantissa rounded up to a power of two (63.99, 127.99, …) wrapped the bit field and decoded at half their value; the carry now bumps the exponent, clamping at the format max. Sub-normal magnitudes (below 2⁻¹⁴ for F16, 2⁻³⁰ for F24) flush to ±0 instead of writing a negative exponent field |
| 257th channel definition errors | Ids ride a single U8; upstream silently collided with channel 1 |
| Unknown packet ids error during decode | Without the packet’s reads the rest of the buffer cannot be framed; upstream nil-indexed |
| Receive loops pcall-isolated on both sides | One malformed payload cannot kill the connection’s handler |
Server flush thread pcall-isolates FireClient |
A player disconnecting inside the PlayerRemoving cleanup window would otherwise kill the flush thread and silently stop every outgoing packet for the server’s life |
| Decode length prefixes checked against remaining payload before allocation | A crafted U32 prefix must not force a multi-GB buffer.create |
String/Buffer length caps enforced at write |
A 300-byte value would wrap its U8 prefix to 44 and desync the whole stream |
Unknown enum ids/values and missing Static entries error on both sides |
Upstream wrote index 0 or nil-indexed, decoding to a silent nil |
Adaptive (Any) table decode depth-capped at 48 levels |
Hostile bytes cannot stack-overflow the receive thread |
NumberVlq added (variable-length unsigned integer, idea from light/holy) |
1 byte under 128, 2 under 16,384, up to 2⁵³ |
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 time they are truncated to the exact byte offset, so the wire never carries slack.
- Server: one broadcast cursor (everything
Fired server-side goes to all clients viaFireAllClients) plus one lazily-created cursor per player (FireClient). A single flush thread resumes on everyHeartbeatand sends whatever accumulated. - Client: one cursor, flushed via
FireServerat most once per 1/60s of accumulated heartbeat time.
Batching is why one message costs so little: a heartbeat’s worth of channel traffic shares one RemoteEvent invocation and its overhead.
Framing
Section titled “Framing”Each message in the buffer is:
[channel id: U8] [thread index: U8 — request/response channels only] [arguments per schema]- Channel ids are assigned by the server at definition time and published as attributes on the shared
RemoteEvent; clients resolve names to ids from those attributes. At most 256 channels — the 257th definition errors. - Request/response channels (
:Response()) add one byte: thread indexes 0–127 mark a request, the same index +128 marks its response. At most 128 in-flight yielded requests per direction (per player on the server); the 129thFireerrors. - The decode loop reads messages until the buffer ends. An unknown channel id errors the whole payload (the stream cannot be re-framed without the schema), caught by the pcall isolation.
The no-yield invariant
Section titled “The no-yield invariant”All encoding and decoding drives one module-global cursor between Import and Export/Truncate. Nothing on those paths may yield — an interleaved encode would silently corrupt the in-flight buffer. Every write function is synchronous by construction; keep it that way if you ever touch Types/init.luau.
Instances ride a sideband
Section titled “Instances ride a sideband”Instance values are never serialized. They are appended to the cursor’s Instances array, which is passed as the RemoteEvent’s second argument, and re-associated by position on decode. Costs zero buffer bytes — but an instance the receiver does not have (not yet replicated, streamed out, destroyed) arrives as nil, per standard remote semantics. Validate on the consumer side.
The 8KB/heartbeat flood cap
Section titled “The 8KB/heartbeat flood cap”The transport’s single built-in defense, enforced server-side on inbound traffic:
- Each player’s received bytes accumulate per heartbeat window; the counter clears at every flush.
- Every inbound
RemoteEventpayload counts as at least 800 bytes (math.max(payloadBytes, 800)), so a client cannot dodge the cap with many tiny invocations — at the default budget that is at most 10 inbound invocations per heartbeat regardless of size. Batched traffic from an honest client fits easily; a spam loop firing raw remotes does not. - A payload that would exceed the budget is dropped whole (every message batched inside it), and the
RateLimitExceededBindableEvent (created under the Packet module at runtime) fires(player, bytes, payloadBytes, budgetBytes). In Studio a warning names the player. - The budget defaults to 8,000 bytes and reads the
RateLimitBytesattribute on the Packet ModuleScript — live-updatable, no restart.
NetDriver forwards these drops to the Net.FloodDetected hook and bus topic so your anti-exploit policy can observe flooding clients — see Rate Limiting for the full two-layer picture and payloads.
Direct Packet usage looks like this — a shared ModuleScript defines channels once for both sides:
-- ReplicatedStorage.Game.Packets (required by both sides; server must run it first)local ReplicatedStorage = game:GetService("ReplicatedStorage")local Packet = require(ReplicatedStorage.ChloeKernel.Net.Packet)
return { -- event channel: id byte + U8 + Vector3F24 = 11 bytes per message Position = Packet("Position", Packet.NumberU8, Packet.Vector3F24), -- request/response channel: 10s default response timeout GetHealth = Packet("GetHealth", Packet.NumberU8):Response(Packet.NumberU16),}-- Serverlocal Packets = require(ReplicatedStorage.Game.Packets)
Packets.Position.OnServerEvent:Connect(function(player, id, position) updateTracker(player, id, position)end)
Packets.GetHealth.OnServerInvoke = function(player, id) return getHealth(id)end-- Clientlocal 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 responseType names are also plain strings ("NumberU8"), which is how every kernel schema spells them — Packet.NumberU8 and "NumberU8" are the same key.
Serialize/Deserialize reuse the same type engine without touching the wire — useful for storage or custom transports (for storage schemas proper, prefer Serde):
local Buffer, Instances = Packets.Position:Serialize(1, Vector3.new(12.5, 3, -40))local Id, Position = Packets.Position:Deserialize(Buffer, Instances)Wire type reference
Section titled “Wire type reference”Costs are exact and include each type’s own prefixes. Add 1 byte of channel id per message (plus 1 thread-index byte on request/response channels).
Integers
Section titled “Integers”| Type | Bytes | Range |
|---|---|---|
NumberU8 |
1 | 0 … 255 |
NumberU16 |
2 | 0 … 65,535 |
NumberU24 |
3 | 0 … 16,777,215 |
NumberU32 |
4 | 0 … 4,294,967,295 |
NumberS8 |
1 | -128 … 127 |
NumberS16 |
2 | -32,768 … 32,767 |
NumberS24 |
3 | -8,388,608 … 8,388,607 |
NumberS32 |
4 | -2,147,483,648 … 2,147,483,647 |
NumberVlq |
1–8 | Unsigned, up to 2⁵³. 7 payload bits per byte: 1 byte below 128, 2 below 16,384. Decode refuses to run past 8 continuation bytes |
Floats
Section titled “Floats”| Type | Bytes | Precision |
|---|---|---|
NumberF16 |
2 | Integer-exact to ±2,048; magnitudes ≥ 65,520 encode as ±inf; below 2⁻¹⁴ flush to ±0 |
NumberF24 |
3 | Integer-exact to ±262,144; ≥ 4,294,959,104 encode as ±inf; below 2⁻³⁰ flush to ±0 |
NumberF32 |
4 | Single precision; integer-exact to ±16,777,216 |
NumberF64 |
8 | Full double; integer-exact to ±2⁵³ |
Strings & buffers
Section titled “Strings & buffers”| Type | Bytes | Limit |
|---|---|---|
String |
1 + n | ≤ 255 bytes (U8 length prefix; longer errors at write) |
StringLong |
2 + n | ≤ 65,535 bytes (U16 prefix) |
Buffer |
1 + n | ≤ 255 bytes |
BufferLong |
2 + n | ≤ 65,535 bytes |
Characters |
1 + ⌈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 — a 20-char name is 16 bytes instead of 21. Bits per character is ⌈log₂(symbol count)⌉, so keep the table at a power-of-two size (2, 4, 8, … 256). Characters outside the table fail the encode |
Booleans & bit packs
Section titled “Booleans & bit packs”| Type | Bytes | Carries |
|---|---|---|
Boolean8 |
1 | One boolean |
Boolean1 |
1 | An array of 8 booleans, one bit each — the cheapest flag set on the wire |
BooleanNumber |
1 | { Boolean = boolean, Number = 0…127 } |
NumberU4 |
1 | An array of two integers 0…15 |
Vectors & CFrames
Section titled “Vectors & CFrames”| Type | Bytes | Notes |
|---|---|---|
Vector2S16 |
4 | Integer components |
Vector2F24 |
6 | |
Vector2F32 |
8 | |
Vector3S16 |
6 | Integer studs — grid positions, cell coordinates |
Vector3F24 |
9 | The workhorse for world positions |
Vector3F32 |
12 | |
CFrameF24U8 |
12 | F24 position + Euler angles at U8 resolution (~1.4° steps) |
CFrameF32U8 |
15 | F32 position + U8 rotation |
CFrameF32U16 |
18 | F32 position + U16 rotation (~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 + max corners, six F32 |
NumberRange |
8 | Two F32 |
NumberSequence |
1 + 3k | k keypoints; Time/Value/Envelope each quantized to U8 |
ColorSequence |
1 + 4k | Time U8 + RGB |
EnumItem |
3 | U8 index into Types/Enums.luau + U16 value. Registered by default: AccessoryType, Axis, BodyPart, BodyPartR15, EasingDirection, EasingStyle, KeyCode, Material, NormalId — add your own (max 255). Unregistered enums error at write; unknown ids/values error at decode |
Instance |
0 | Rides the RemoteEvent’s instance array, not the buffer. Arrives nil if the receiver doesn’t 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 etc.) — send a whole error message, constant vector, or config value for one byte. Both sides ship the same table by construction. 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 exactly (see below) |
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–5 (type byte + U8/U16/U24/U32 magnitude; sign lives in the type id); beyond ±2³² falls to F64 (9) |
| Fraction | 5 as F32 only when single precision round-trips it exactly (0.1 does not); otherwise 9 as F64 |
| String / buffer | 2 + n up to 255 bytes; 5 + n above (U32-length ids 29/30) |
| Boolean | 2 |
| Vector3 | 13 · CFrame 19 · Color3 4 · EnumItem 4 · other datatypes at their fixed cost + 1 |
| Table | 2 + encoded key/value pairs, each value Any-encoded recursively; nil-terminated. Decode depth is capped at 48 levels |
Any is the escape hatch, not the default — a typed field costs zero type bytes 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 reads/writes at definition time — no reflection per message:
{ Type }(single element) — a list: U16 count prefix + elements.{ "NumberU16" }carrying 10 ids costs 2 + 20 bytes.{ Key = Type, ... }(multiple keys) — a struct: fields serialize in sorted key order, zero bytes for the keys themselves. Both sides sort the same keys, so the order always agrees. Nest freely; nested tables follow the same rule.
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 is a contract, not a validator. Out-of-contract values degrade in type-specific ways:
- Integer types wrap modulo their width —
300throughNumberU8arrives as44,-1as255. Nothing errors. Validate game ranges in the hook chain or with RegistryValidaterules; the wire will not do it for you. - F16/F24 quantize to the nearest representable value; magnitudes past the format max encode as ±inf; sub-normals flush to ±0. NaN round-trips intact through every float type — 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/U16 angle step,
UDimscale clamps at ±32.767. - Length-prefixed types error at write when over cap (
String> 255,StringLong> 65,535, same for buffers) — a loud error beats a silently desynced stream. - Registered-value types error on both sides: unregistered enums/statics fail the write; unknown indices fail the decode.
API reference
Section titled “API reference”Packet is callable: Packet(name, ...types) returns the channel with that name, creating it on first call (subsequent calls with the same name return the cached channel).
| Member | Description |
|---|---|
Packet(name, ...types) → packet |
Define/fetch a channel. Server assigns the wire id; clients resolve it from RemoteEvent attributes. Max 256 channels |
packet:Response(...types) → packet |
Makes the channel request/response. Sets ResponseTimeout = 10 (seconds) unless already set |
packet:Fire(...) → ... |
Client: send to server (yields for the response on response channels). Server: broadcast to all clients — errors on response channels ("You must use FireClient(player)") |
packet:FireClient(player, ...) → ... |
Server: send to one player (yields for the response on response channels). No-ops if the player already left |
packet:Serialize(...) → (buffer, { Instance }?) |
Encode without sending |
packet:Deserialize(buffer, instances?) → ... |
Decode a serialized buffer |
packet.OnServerEvent |
Signal (player, ...) — server receive |
packet.OnClientEvent |
Signal (...) — client receive |
packet.OnServerInvoke / packet.OnClientInvoke |
Response handler for request/response channels. Unset handler = incoming requests discarded (warned in Studio) |
packet.ResponseTimeout |
Seconds a Fire waits before resuming with ResponseTimeoutValue (default 10) |
packet.ResponseTimeoutValue |
Returned on timeout — NetClient sets this to the request’s RejectValue so timeout and rejection share one encoding |
Support modules
Section titled “Support modules”| Module | Role |
|---|---|
Packet/Signal.luau |
Minimal intrusive doubly-linked-list signal behind OnServerEvent/OnClientEvent — Connect, Once, Wait, Disconnect; handlers run on recycled threads |
Packet/Task.luau |
Thread pool (Spawn/Defer/Delay) that parks and reuses coroutines instead of allocating one per dispatch |
Packet/Types/init.luau |
The type engine — every read/write above, the cursor, and the Any machinery |
Gotchas
Section titled “Gotchas”- No Actor/Parallel Luau support — the module-global cursor is single-VM by design.
Boolean1reads and writes exactly eight slots: missing entries encode asfalse, extras are ignored.- The response-thread pool holds 128 slots per direction — deep fan-out of concurrent yielding requests on one channel will hit “Cannot have more than 128 yielded threads”. Batch instead.
- Wire schemas live in one shared module per game so both sides always agree — see Registry for the pattern with validation attached.