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()Mental model
Section titled “Mental model”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
fireorinvokeyields onPacket: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.
Firing intents
Section titled “Firing intents”local UseItem = Net:intent("UseItem", { "NumberU16" })UseItem.fire(3)local Net = Kernel:net()Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5 })
Kernel.Hooks:on("Intent.UseItem", function(context) if ItemCatalog[context.Args[1]] == nil then return false end return nilend)
Net:onIntent("UseItem", function(session, itemId: number) applyItem(session, itemId)end)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.
Listening to state
Section titled “Listening to state”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.
Invoking requests
Section titled “Invoking requests”local GetStock = Net:request("GetShopStock", { "NumberU16" }, { "NumberU8" }, { RejectValue = 0 })
local Count = GetStock.invoke(3) -- yields the calling threadif Count == 0 then showSoldOut()endinvoke rules:
- It yields until the reply arrives or the response timeout expires. The timeout is 10 seconds, or the
ResponseTimeoutSecondsattribute on the Packet ModuleScript when the channel is first given its response schema. Wrap it intask.spawnif the caller cannot wait. - Rejection and timeout share one failure encoding. The server answers
RejectValueon every rejection. Passing the sameRejectValuehere 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.
Unreliable state streams
Section titled “Unreliable state streams”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.
Schema mismatch
Section titled “Schema mismatch”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:
- 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. - If the wrong widths happen to add up to the right length (one
NumberU16read as twoNumberU8), 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:
- 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.
- 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.
API reference
Section titled “API reference”| 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.