Skip to content

NetClient

NetClient is the client half of the NetDriver, and it is thin on purpose. All validation lives on the server, where it cannot be read or bypassed — the client’s job is to pack arguments into typed buffers and unpack what the server sends back. Nothing in this module decides anything; a modified copy in an exploiter’s hands changes nothing about what the server accepts.

The one obligation that comes with that split: schemas must match the server’s exactly. The wire is positional bytes with no field names and no type tags, so both halves must agree on every argument’s type and order. Feed both sides from one shared module — that is what the Registry exists for.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)
local Net = NetClient.new()

NetClient builds Packet channels with the same prefixed wire names the server uses (CKI_ intents, CKS_ states, CKR_ requests). Channel ids are assigned by the server and replicate as attributes on Packet’s shared RemoteEvent, which has two consequences:

  • The server defines, the client binds. A client channel is a name waiting for its id. If the client gets ahead of replication, the first fire / invoke waits for the id to resolve — firing with a nil id would corrupt the send buffer.
  • A channel the server never defined is a hard error. After 10 seconds without an id, the send errors with channel "<name>" was never defined on the server. A typo’d channel name fails loudly instead of whispering into the void.

Sends are batched: Packet coalesces everything fired in a frame into one buffer and flushes once per heartbeat (~60Hz), so ten fire calls in one frame cost one RemoteEvent invocation. Handles are cached per channel name — calling Net:intent("UseItem", …) twice returns the same handle, and the underlying Packet channel is a per-name singleton across the whole client.

There is no client-side rate limiting. The token buckets live in server memory, where they count what actually arrived rather than what a well-behaved client chose to send.

local UseItem = Net:intent("UseItem", { "NumberU16" })
UseItem.fire(3)

fire is fire-and-forget: it returns nothing and the client is never told whether the intent passed the server’s gates. That silence is deliberate — rejection feedback is a probing tool. If the player needs feedback, push it as a state or design the interaction as a request.

For intents whose effect the client should show immediately (dashes, ability wind-ups), don’t fire raw intents and guess — use a predicted intent through Prediction or a Registry .predict handle, which wraps this same module with sequence numbers, acks, and rollback.

local Connection = Net:onState("KillFeed", { "String", "String" }, function(killer, victim)
addFeedLine(`{killer} eliminated {victim}`)
end)
-- later, if the screen goes away:
Connection:Disconnect()

onState returns a connection; disconnect it when the consumer dies. Repeated onState calls on the same channel each add a handler to the same underlying Packet channel.

local GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0 })
local Count = GetStock.invoke(3) -- yields the calling thread
if Count == 0 then
showSoldOut()
end

invoke semantics, exactly:

  • It yields. The calling coroutine suspends until the response arrives or the wire’s response timeout (10 seconds) expires. Never call it from a render-critical loop; wrap it in task.spawn if the caller cannot afford to wait.
  • Rejection and timeout share one failure encoding. The server answers RejectValue for every rejection; passing the same RejectValue client-side makes a timeout resume with it too. The caller writes one failure branch, not two.
  • In-flight requests are bounded. Packet supports at most 128 concurrently yielded threads per direction; exceeding that errors. If you are anywhere near it, the design wants a state push, not 128 open questions.

Pick a RejectValue the server can never legitimately return. If you skip it, a timed-out invoke resumes with nil — make sure the response schema can’t produce nil on success, or you cannot tell them apart.

Server → client high-frequency cosmetic data over an UnreliableRemoteEvent, decoded from Serde struct buffers. The schema is string-keyed (field → wire type), not positional:

local Connection = Net:onUnreliableState(
"BossAim",
{ Pitch = "NumberF16", Yaw = "NumberF16" },
function(data)
aimBossHead(data.Pitch, data.Yaw)
end
)

onUnreliableState yields until ReplicatedStorage.ChloeKernelUnreliable.<name> exists — call it after the server has defined the channel (a booted server defines before any client finishes loading in practice). Packets on this channel can be lost or reordered by design; write the handler so every message stands alone.

What actually happens when the two sides disagree — the failure mode the Registry exists to delete:

The wire carries no type information. A NumberU16 is two raw bytes; the receiver knows to read two bytes only because its schema says so. If the client declares { "NumberU8" } where the server wrote { "NumberU16" }, the receiver reads one byte of a two-byte value and then keeps reading — now misaligned — into whatever follows. Three outcomes, none of them a type error:

  1. Framing desync. The misaligned reader eventually lands on a byte that isn’t a valid channel id and the decode loop errors with unknown packet id <n>. The receive loop is pcall-isolated, so the rest of that flush buffer is discarded — in Studio you get a warning; in production, silence.
  2. Garbage that parses. If the widths happen to realign, the reader produces plausible-looking wrong values. On the server these then walk the validation chain, which is why validators check game legality, never “is this well-formed” — a misdecode that survives framing looks exactly like a hostile payload and gets rejected like one.
  3. Nothing at all. Because one flush buffer carries many messages, a single misframed message can take unrelated channels’ messages down with it for that frame.

None of this is detectable at define time across the network — which is why the fix is structural, not defensive: define every channel once, in one shared module, and let both halves read the same table.

Member Description
NetClient.new() → NetClient Per-context handle cache. One per client is typical; multiple instances still share the underlying per-name Packet channels
Client:intent(name, schema) → { fire(...) } Typed intent handle, cached per name. fire returns nothing and waits (≤10s) for the server’s channel id on first use
Client:onState(name, schema, handler) → connection Subscribes to a server state channel; returns a connection with Disconnect
Client:request(name, schema, responseSchema, { RejectValue? }?) → { invoke(...) → ...any } Typed request handle, cached per name. invoke yields; timeout resumes with RejectValue
Client:onUnreliableState(name, structSchema, handler) → RBXScriptConnection Subscribes to an unreliable stream; yields until the channel’s remote exists

NetClient fires no hooks and publishes no bus topics — it is transport, not policy. The debug panel’s NET window mirrors both wire sides (with byte counts and request round-trip times) when the tap is active.