Skip to content

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.

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. Sequences run 1 to 65,535 and wrap. Both Prediction.wrap and definePredictedIntent prepend it, so the wire schemas agree without you writing it.
  2. An ack channel. A paired Packet channel, CKACK_<name>, with schema NumberU16, 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).

Net:definePredictedIntent sends the intent through the same gate as any intent. Prediction changes when the client acts, never what the server accepts:

  1. Session: no active session (not joined, already leaving). Dropped with no ack.
  2. Transaction lock: the profile is inside a durable trade or blocked after a failed one. Dropped with no ack.
  3. Token bucket: rate-limited. Dropped with no ack. A spamming client burns its own predictions through timeouts.
  4. Fail-closed absence check: no validator and not Open. Ack false.
  5. Hook chain: Intent.<name> validators run. A false or a crash acks false.
  6. Recheck: the session ended, changed or became transaction-locked while validators yielded. Ack false.
  7. Handler, under pcall. A throw acks false; a clean return acks true.

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.

Server
Net:definePredictedIntent("Dash", {}, {
RateLimit = 4,
Handler = function(session)
session.Data.LastDash = os.clock()
end,
})
-- The validator checks server state, never a client claim
Kernel.Hooks:on("Intent.Dash", function(context)
if os.clock() - (context.Session.Data.LastDash or 0) < 2 then
return false -- cooldown
end
return nil
end)
Client
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")
end
end)

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()
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
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.

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

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.

  • Predict runs synchronously inside fire, 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.

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