Skip to content

NetClient

The client half of the channel layer: fires intents, listens to state, invokes requests and filters unreliable streams, with no validation of its own.

Realm
Client
Require
ReplicatedStorage.ChloeKernel.Net.Client
Since
v0.1.0
Source
Net/Client.luau

NetClient is the client half of NetDriver, and it is thin on purpose. All validation lives on the server, where it cannot be read or bypassed. The client packs arguments into typed buffers and unpacks what the server sends back. Nothing in this module decides anything, so an exploiter’s modified copy changes nothing about what the server accepts.

That split carries one obligation: schemas must match the server’s exactly. The wire is positional bytes with no field names and no type tags. Feed both halves from one shared module, which is what the Registry is for. Most game code never calls NetClient directly; Registry.client builds its handles from Game.Channels.

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

NetClient builds Packet channels under the same prefixed wire names the server uses (CKI_ intents, CKS_ states, CKR_ requests). The server assigns channel ids and they replicate on Packet’s paged id instances. Two consequences:

  • The server defines and the client binds. A client channel is a name waiting for its id. If the client is ahead of replication, the first fire or invoke yields on Packet:WaitForId(10) until the id arrives. The wait wakes from the id page’s attribute event, with one timer per waiting call and no per-frame polling. Firing with a nil id would corrupt the send buffer.
  • A channel the server never defined is an error. After 10 seconds without an id, the call errors with channel "<name>" was never defined on the server. A misspelled channel name fails loudly.

Sends are batched. Packet coalesces everything fired in a frame into one buffer and flushes at most once per 1/60 s, so ten fire calls in one frame cost one remote invocation. intent and request handles are cached per name on each NetClient, and the underlying Packet channel is one per name for the whole client.

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

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

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

For an effect the client should show at once (a dash, an ability wind-up), use a predicted intent through Prediction or a Registry .predict handle. Both wrap this module with sequence numbers, acks and rollback.

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

onState returns a connection; disconnect it when the consumer goes away. Each call adds another handler to the same Packet channel.

declareState(name, schema) constructs a state channel without a listener. Frames that arrive before onState then decode and drop, like any signal nobody hears. Without it, a frame for a channel the client has not built parks for up to 10 seconds and replays once the channel is constructed (see frame parking). Registry.client declares every State channel up front for that reason: a state is an event, and replaying a stale one seconds later is worse than missing it.

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 rules:

  • It yields until the reply arrives or the response timeout expires. The timeout is 10 seconds, or the ResponseTimeoutSeconds attribute on the Packet ModuleScript when the channel is first given its response schema. Wrap it in task.spawn if the caller cannot wait.
  • Rejection and timeout share one failure encoding. The server answers RejectValue on every rejection. Passing the same RejectValue here sets the channel’s timeout value, so a timeout resumes with it too. The caller writes one failure branch.
  • At most 128 requests are in flight per client across all request channels. The 129th errors with Cannot have more than 128 yielded threads. Anywhere near that, the design wants a state push.

Pick a RejectValue the server can never return on success. Without one, a timed-out invoke resumes with nil. The server refuses at definition a RejectValue its Response schema cannot encode, so a typed response always has a typed sentinel.

High-frequency cosmetic data over an UnreliableRemoteEvent, decoded from Serde struct buffers. The schema is string-keyed (field to 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. One 10-second deadline covers both the folder and the remote, and it then errors with unreliable channel "<name>" was never defined on the server. Before 0.9.0 the two waits could stack to 20 seconds.

Every datagram ends in the server’s 2-byte sequence footer. The client keeps the last sequence it applied per stream (one stream for broadcasts and one for sends to this player) and drops a datagram that duplicates it or arrives behind it. A stream quiet for 1 second accepts its next datagram whatever the sequence. A channel the server defined with Ordered = false skips the filter, and every datagram reaches the handler. Datagrams can still be lost, so write the handler so each message stands alone.

When the two sides disagree on a schema, nothing raises a type error. A NumberU16 is two raw bytes and the receiver reads two bytes only because its own schema says so. What happens next depends on the direction.

Server to client, every frame carries its body length, so the damage stays inside one payload:

  1. The client decodes the frame by its own schema. If the decode does not end exactly at the frame’s length (or its Instance slot count), it raises frame desync on <channel> and drops the rest of that payload. Frames earlier in the payload were already delivered. Studio warns; production is silent.
  2. If the wrong widths happen to add up to the right length (one NumberU16 read as two NumberU8), plausible wrong values reach the handler with no error at all.

Client to server, frames carry no length, so the server has nothing to check against:

  1. The misaligned reader keeps going into the next frame’s bytes. It either lands on an unknown channel id or runs out of data, errors, and the server drops the rest of that payload.
  2. Or the widths line up and garbage decodes. On the server that garbage walks the validation chain, which is why validators check game legality and not “is this well formed”: a misdecode looks exactly like a hostile payload and is rejected like one.

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

Member Description
NetClient.new({ PacketFactory? }?) -> NetClient A handle cache. One per client is typical. Several instances share the per-name Packet channels. PacketFactory is a test seam
Client:intent(name, schema) -> { fire(...) } Typed intent handle, cached per name. fire returns nothing and waits up to 10 s for the channel id on first use
Client:declareState(name, schema) Constructs a state channel with no listener, so its frames decode (and drop) instead of parking
Client:onState(name, schema, handler) -> connection Subscribes to a state channel. Returns a connection with Disconnect
Client:request(name, schema, responseSchema, { RejectValue? }?) -> { invoke(...) -> ...any } Typed request handle, cached per name. invoke yields; a timeout resumes with RejectValue
Client:onUnreliableState(name, structSchema, handler) -> RBXScriptConnection Subscribes to an unreliable stream with the latest-wins filter. Yields up to 10 s for the remote, then errors

NetClient fires no hooks and publishes no Bus topics. The debug panel’s NET window records both wire sides, with byte counts and request round-trip times, while capture is active.

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