Skip to content

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.

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 a ChloeKernel comment in source.
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⁵³

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 via FireAllClients) plus one lazily-created cursor per player (FireClient). A single flush thread resumes on every Heartbeat and sends whatever accumulated.
  • Client: one cursor, flushed via FireServer at 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.

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 129th Fire errors.
  • 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.

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.

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 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 RemoteEvent payload 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 RateLimitExceeded BindableEvent (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 RateLimitBytes attribute 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),
}
-- Server
local 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
-- Client
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 response

Type 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)

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).

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
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⁵³
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
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
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)
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
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 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.

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
})

The schema is a contract, not a validator. Out-of-contract values degrade in type-specific ways:

  • Integer types wrap modulo their width — 300 through NumberU8 arrives as 44, -1 as 255. Nothing errors. Validate game ranges in the hook chain or with Registry Validate rules; 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, UDim scale 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.

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
Module Role
Packet/Signal.luau Minimal intrusive doubly-linked-list signal behind OnServerEvent/OnClientEventConnect, 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
  • No Actor/Parallel Luau support — the module-global cursor is single-VM by design.
  • Boolean1 reads and writes exactly eight slots: missing entries encode as false, 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.