Skip to content

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.

A predicted channel is an ordinary intent channel with two additions:

  1. A sequence number. The client prepends a NumberU16 to the channel’s schema (1 … 65535, wrapping). Both Prediction.wrap and definePredictedIntent prepend it, so the wire schemas agree without you writing it.
  2. 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).

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:

  1. Session check — no session (not joined, already leaving): the message is dropped. No ack.
  2. Token bucketrate-limited: dropped. No ack — deliberate silence; a spamming client burns its own predictions via timeout rollbacks.
  3. Unguarded check — a channel with a handler, no validator, and no Open = true is fail-closed: ack false.
  4. Hook chainIntent.<name> validators run; any false (or a crash — fail-closed): ack false.
  5. Handler — runs under pcall. A throw: ack false. Success: ack true.

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 claims
Kernel.Hooks:on("Intent.Dash", function(context)
return os.clock() - (context.Session.Data.LastDash or 0) >= 2 -- cooldown
end)
-- Client
local 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")
end
end)

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 definitions
Dash = { Kind = "PredictedIntent", Schema = {}, Open = true },
-- Client
local Dash = Net.Dash.predict({ Predict = dashLocally })
Dash.fire()
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
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.

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

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.

  • Predict runs synchronously inside fire — 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 TimeoutSeconds comfortably 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.