Prediction
Optimistic client actions with server reconciliation: the effect applies at once behind a sequence number, and a rejection or a missing ack rolls it back.
- Realm
- Client
- Require
ReplicatedStorage.ChloeKernel .Net .Prediction - Since
- v0.1.0
- Source
- Net/Prediction.luau
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 and a rejection rolls it back. Silence counts as rejection: no ack inside the timeout (a lost packet, rate-limited spam, a server that never saw it) also rolls back, so a prediction never leaves the client holding something the server refused.
Prediction lives in ReplicatedStorage.ChloeKernel.Net.Prediction and pairs with Net:definePredictedIntent on
NetDriver. A Registry PredictedIntent channel wraps it as
.predict.
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. Sequences run 1 to 65,535 and wrap. BothPrediction.wrapanddefinePredictedIntentprepend it, so the wire schemas agree without you writing it. - An ack channel. A paired Packet channel,
CKACK_<name>, with schemaNumberU16, Boolean8, carries the verdict back. An ack frame is 5 bytes on the wire (id, length and 3 bytes of arguments), batched into the same heartbeat flush as everything else.
On the client, each fire records a pending entry keyed by sequence:
Predict(...)runs synchronously, before the intent is queued, and returns the rollback closure (or nothing when there is nothing to undo).- A timeout thread is scheduled with
task.delay. If it wins, the entry resolves as rejected. - The intent fires with the sequence prepended.
The timeout is TimeoutSeconds when set. Otherwise it is computed at each fire from the current round trip:
max(2, 3 × ping + 0.5) seconds, where ping comes from PingProvider (default
Players.LocalPlayer:GetNetworkPing(), in seconds). A player on a 600 ms link gets 2.3 seconds; one on a 1-second link
gets 3.5. A provider that errors or returns a non-finite or negative value falls back to 2 seconds. Before 0.9.0 the
default was a flat 2 seconds, which rolled back valid dashes on slow links.
Resolution is first-wins and exactly-once. An ack cancels the timeout thread. A timeout removes the entry, so a late ack
finds nothing and is ignored. The rollback runs only on rejection, and OnResolved fires once per sequence with
(seq, accepted, timedOut).
The server side, end to end
Section titled “The server side, end to end”Net:definePredictedIntent sends the intent through the same gate as any intent. Prediction changes when the client
acts, never what the server accepts:
- Session: no active session (not joined, already leaving). Dropped with no ack.
- Transaction lock: the profile is inside a durable trade or blocked after a failed one. Dropped with no ack.
- Token bucket: rate-limited. Dropped with no ack. A spamming client burns its own predictions through timeouts.
- Fail-closed absence check: no validator and not
Open. Ackfalse. - Hook chain:
Intent.<name>validators run. Afalseor a crash acksfalse. - Recheck: the session ended, changed or became transaction-locked while validators yielded. Ack
false. - Handler, under
pcall. A throw acksfalse; a clean return ackstrue.
The ack goes out after the handler on purpose: an accepted ack means the handler ran, so a kept prediction always
matches applied server state. The validator context carries the sequence as context.Seq; Args holds only the
channel’s own arguments.
A dash: the server checks a cooldown against server-side state, and the client moves at once.
Net:definePredictedIntent("Dash", {}, { RateLimit = 4, Handler = function(session) session.Data.LastDash = os.clock() end,})
-- The validator checks server state, never a client claimKernel.Hooks:on("Intent.Dash", function(context) if os.clock() - (context.Session.Data.LastDash or 0) < 2 then return false -- cooldown end return nilend)local Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)local Prediction = require(ReplicatedStorage.ChloeKernel.Net.Prediction)
local Net = NetClient.new()local Character = Players.LocalPlayer.Character or Players.LocalPlayer.CharacterAdded:Wait()local Root = Character:WaitForChild("HumanoidRootPart") :: BasePart
local Dash = Prediction.wrap(Net, "Dash", {}, { Predict = function() local Before = Root.CFrame Root.CFrame += Root.CFrame.LookVector * 12 -- at once, no round trip return function() Root.CFrame = Before -- rollback if the server says no end end,})
Dash.fire()React to the verdict with cooldown flashes, rejection feedback or retry UI:
Dash.OnResolved:connect(function(seq: number, accepted: boolean, timedOut: boolean) if not accepted then AbilityBar.flash("Dash", if timedOut then "NoResponse" else "Rejected") endend)Through the Registry
Section titled “Through the Registry”With channels in Game.Channels, declare Kind = "PredictedIntent" and the client handle exposes .predict, which
calls Prediction.wrap with the channel’s schema:
Dash = { Kind = "PredictedIntent", Schema = {}, RateLimit = 4, Open = true },local Dash = self.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 fires through its intent channel |
name |
Channel name; must match a Net:definePredictedIntent(name, ...) on the server |
schema |
The channel’s wire schema without the sequence. wrap prepends "NumberU16" itself |
options |
Options?, below |
Options field |
Description |
|---|---|
Predict: ((...any) -> (() -> ())?)? |
Runs at once with fire’s arguments and returns the rollback closure, or nil for nothing to undo |
TimeoutSeconds: number? |
Fixed ack deadline. Expiry resolves as rejected with timedOut = true. Default: max(2, 3 × ping + 0.5) at each fire |
PingProvider: (() -> number)? |
Round trip in seconds for the default timeout. Default Players.LocalPlayer:GetNetworkPing() |
Handle member |
Description |
|---|---|
fire(...) -> number |
Runs Predict, schedules the timeout and 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 options 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 and the handler ran | true |
none | (seq, true, false) |
A validator returned false or crashed |
false |
runs | (seq, false, false) |
| The session changed during validation | false |
runs | (seq, false, false) |
| The handler threw | false |
runs | (seq, false, false) |
| The channel is unguarded (fail-closed) | false |
runs | (seq, false, false) |
| Rate-limited, no session, or transaction-locked | none | at timeout | (seq, false, true) |
| Packet lost in 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, meaning things the client already owns or that nobody else observes authoritatively:
- The local character’s CFrame and velocity (dashes, blinks, knockback wind-up)
- Local VFX, sounds and animation starts
- UI state (pressed buttons, optimistic cooldown spinners)
Never predict server-owned state: currency, inventory, ammo, health. A rollback can restore a CFrame; it cannot un-spend coins the server never deducted, and the authoritative value arrives through a replica anyway and overwrites whatever the client invented.
Character prediction moves the real character
Section titled “Character prediction moves the real character”The client owns its character’s physics, so a predicted dash moves the real character. There is no server-simulated
body underneath for it to disagree with. The server’s movement monitor corrects with CFrame writes and
never calls SetNetworkOwner, so the client keeps simulating its character through a correction. See
Architecture and security.
Gotchas
Section titled “Gotchas”Predictruns synchronously insidefire, on the input path. Keep it cheap and yield-free.- A timed-out prediction was still sent, and the server may accept it after the window. The ping-scaled default keeps
honest players inside the window; if you set
TimeoutSeconds, keep it well above your worst honest round trip. Treat a timeout as “unknown, assume no”. - Sequences wrap at 65,535. Predicted intents are for ability-rate actions; high-frequency streams belong on unreliable state channels.
- Rate limiting shows up to a predicted client as no ack, a timeout and a rollback. Do not auto-retry on timeout: a retry loop against a rate limit is self-inflicted rubberbanding.