Prediction
Prediction makes abilities feel instant without trusting the client. The client applies an effect the moment the button is pressed — behind a sequence number and a rollback closure — while the server validates the intent through its normal fail-closed chain. An accepted ack confirms the effect; a rejection rolls it back. The defining decision: silence is rejection. No ack within the timeout window — dropped packet, rate-limited spam, server never saw it — also rolls back. The failure mode of prediction is never “the client kept something the server refused”.
Prediction lives in ReplicatedStorage.ChloeKernel.Net.Prediction and pairs with Net:definePredictedIntent on the NetDriver.
Mental model
Section titled “Mental model”A predicted channel is an ordinary intent channel with two additions:
- A sequence number. The client prepends a
NumberU16to the channel’s schema (1 … 65535, wrapping). BothPrediction.wrapanddefinePredictedIntentprepend it, so the wire schemas agree without you writing it. - An ack channel. A paired Packet channel named
CKACK_<name>with schema("NumberU16", "Boolean8")carries the verdict back — 4 bytes plus the channel id, batched into the same heartbeat flush as everything else on the wire.
Client-side, each fire records a pending entry keyed by sequence number:
Predict(...)runs synchronously, immediately — before the intent is even queued — and returns the rollback closure (or nothing, if there is nothing to undo).- A timeout thread (
TimeoutSeconds, default 2) is scheduled; if it wins, the entry resolves as rejected. - The intent fires with the sequence number prepended.
Resolution is first-wins and exactly-once: an ack cancels the timeout thread; a timeout removes the entry so a late ack finds nothing to resolve and is ignored. The rollback runs only on rejection, and OnResolved fires exactly once per sequence with (seq, accepted, timedOut).
The server side, end to end
Section titled “The server side, end to end”Net:definePredictedIntent wires the intent through the exact same gate chain as any intent — prediction changes when the client acts, never what the server accepts:
- Session check — no session (not joined, already leaving): the message is dropped. No ack.
- Token bucket — rate-limited: dropped. No ack — deliberate silence; a spamming client burns its own predictions via timeout rollbacks.
- Unguarded check — a channel with a handler, no validator, and no
Open = trueis fail-closed: ackfalse. - Hook chain —
Intent.<name>validators run; anyfalse(or a crash — fail-closed): ackfalse. - Handler — runs under
pcall. A throw: ackfalse. Success: acktrue.
The ack is sent after the handler on purpose: an accepted ack means the handler actually ran, so the client’s kept prediction always corresponds to applied server state. A throwing handler acks a rollback to keep the optimistic client in sync.
The validator context for a predicted intent carries the sequence number: { Name, Player, Session, Args, Seq } — Args excludes the sequence, which is stripped before validation.
The README’s dash, in full — server validates a cooldown against server-side state, client moves instantly:
-- Server (Bootstrap or a service)Net:definePredictedIntent("Dash", {}, { RateLimit = 4, Handler = function(session) session.Data.LastDash = os.clock() end,})
-- The validator gates on SERVER state, never client claimsKernel.Hooks:on("Intent.Dash", function(context) return os.clock() - (context.Session.Data.LastDash or 0) >= 2 -- cooldownend)-- Clientlocal ReplicatedStorage = game:GetService("ReplicatedStorage")local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)local Prediction = require(ReplicatedStorage.ChloeKernel.Net.Prediction)
local Net = NetClient.new()local Root = character:WaitForChild("HumanoidRootPart")
local Dash = Prediction.wrap(Net, "Dash", {}, { Predict = function() local Before = Root.CFrame Root.CFrame += Root.CFrame.LookVector * 12 -- instantly, zero round-trip return function() Root.CFrame = Before -- rollback if the server says no end end,})
Dash.fire()React to the verdict — cooldown flashes, “rejected” feedback, retry UX:
Dash.OnResolved:connect(function(seq, accepted, timedOut) if not accepted then AbilityBar.flash("Dash", if timedOut then "NoResponse" else "Rejected") endend)Through the Registry
Section titled “Through the Registry”If your channels live in a shared Registry module, declare Kind = "PredictedIntent" and the client handle exposes .predict, which is Prediction.wrap underneath:
-- Shared channel definitionsDash = { Kind = "PredictedIntent", Schema = {}, Open = true },-- Clientlocal Dash = Net.Dash.predict({ Predict = dashLocally })Dash.fire()API reference
Section titled “API reference”Prediction.wrap
Section titled “Prediction.wrap”Prediction.wrap(netClient, name, schema, options?) → Handle| Parameter | Description |
|---|---|
netClient |
A NetClient — the handle rides its intent channel and is cached on it |
name |
Channel name; must match a Net:definePredictedIntent(name, ...) on the server |
schema |
The channel’s wire schema without the sequence number — wrap prepends "NumberU16" itself |
options |
Options? — see below |
Options field |
Description |
|---|---|
Predict: ((...any) -> (() -> ())?)? |
Runs immediately with fire’s arguments; returns the rollback closure (or nil for nothing to undo) |
TimeoutSeconds: number? |
Ack deadline; default 2. Expiry resolves as rejected with timedOut = true |
Handle member |
Description |
|---|---|
fire(...) → number |
Runs Predict, schedules the timeout, fires the intent. Returns the sequence number |
OnResolved |
Signal firing (seq: number, accepted: boolean, timedOut: boolean) — exactly once per sequence |
Server: Net:definePredictedIntent
Section titled “Server: Net:definePredictedIntent”Net:definePredictedIntent(name, schema, options?)Same signature and ChannelOptions as Net:defineIntent (RateLimit, Burst, Handler, Open) — see NetDriver. It prepends the NumberU16 sequence to schema, marks the channel predicted, and creates the CKACK_<name> ack channel.
Reconciliation semantics
Section titled “Reconciliation semantics”| What happened | Ack | Rollback | OnResolved |
|---|---|---|---|
| Validators passed, handler ran | true |
— | (seq, true, false) |
A validator returned false (or crashed) |
false |
runs | (seq, false, false) |
| Handler threw | false |
runs | (seq, false, false) |
| Channel unguarded (fail-closed) | false |
runs | (seq, false, false) |
| Rate-limited or no session | none | at timeout | (seq, false, true) |
| Packet lost either direction | none | at timeout | (seq, false, true) |
| Ack arrives after the timeout | ignored | already ran once | nothing — resolved entries are gone |
What is safe to predict
Section titled “What is safe to predict”Predict local and cosmetic state only — things the client already owns or that nobody else can observe authoritatively:
- Your own character’s CFrame and velocity (dashes, blinks, knockback wind-up)
- Local VFX, sounds, animation starts
- UI state (button pressed-states, optimistic cooldown spinners)
Never predict server-owned state — currency, inventory, ammo, health. A rollback closure can restore a CFrame; it cannot un-spend coins the server never deducted, and the authoritative value arrives through a replica anyway, stomping whatever the client invented.
Why character prediction is real, not visual
Section titled “Why character prediction is real, not visual”The kernel guarantees your character stays yours: it never reassigns a character’s network ownership and never anchors character parts, so the client keeps simulating its own character at all times. A predicted dash is not a visual trick layered over a server-simulated body — it is the movement, because the client is the simulation owner. Anti-exploit corrections are plain CFrame writes with ownership intact. See Architecture & Security.
Gotchas
Section titled “Gotchas”Predictruns synchronously insidefire— keep it cheap and yield-free; it sits on the input path.- A timed-out prediction was still sent — the server may have accepted it after the window. Keep
TimeoutSecondscomfortably above your worst honest round trip before shortening it, and treat timeouts as “unknown, assume no”, not proof of rejection. - Sequence numbers wrap at 65,535. Irrelevant at ability rates; do not use predicted intents as a high-frequency stream (that is what unreliable state channels are for).
- Rejections are also how rate limiting manifests to a predicted client: no ack, timeout, rollback. Do not auto-retry on timeout — a retry loop against a rate limit is self-inflicted rubberbanding.