Mirage
Client-side prediction and reconciliation for streamed Roblox state.
Mirage lets a server stream specific, opt-in pieces of state to clients at a configurable rate, while each client maintains its own read/write Predicted copy that renders ahead of confirmation and reconciles against the server's Confirmed truth as it arrives. The server remains fully authoritative — prediction only changes when a player sees the result of their own action, never who decides that result.
Why Mirage
At 200 ms ping a coin pickup feels sluggish: the client waits a full round trip before it can show the coin collected. Mirage removes that wait. The coin disappears the instant it's touched, the server validates in the background, and if the action is rejected you get a hook to roll the visual back — no silent snapping.
Because keys are opt-in, Mirage is not all-or-nothing. Register only the state that benefits from prediction (UI, pickups, meters) and leave everything else on ordinary Roblox replication. It coexists with server-authority setups instead of replacing them.
Install
Add Mirage to your wally.toml and run wally install:
[dependencies]
Mirage = "aslyumm/mirage@0.1.0"
Mirage has zero runtime dependencies.
Quick example
-- Server
local Coins = Mirage.Server.RegisterKey({
name = "Coins",
rate = 20,
initialState = coinGrid,
blendMode = "Discrete",
applyAction = CoinActions.apply,
validate = function(player, action, serverState)
return { accepted = true }
end,
})
-- Client
local Coins = Mirage.Client.RegisterKey({
name = "Coins",
blendMode = "Discrete",
applyAction = CoinActions.apply, -- must match the server's
})
Coins.Predicted:Do({ kind = "Collect", payload = { coinId = 5 } })
Coins.Render:OnBlend(function(coinId, blendedState)
-- spawn VFX/SFX, update UI, etc.
end)
The four layers
| Layer | Written by | Role |
|---|---|---|
| Server Truth | the server | the canonical value for a key |
| Confirmed | incoming broadcasts only | the client's read-only mirror of Server Truth |
| Predicted | the player's own actions | the client's read/write copy, rendered ahead of confirmation |
| Render | Mirage (Render:OnBlend) | the blended value your VFX/SFX/UI actually draws |
Every optimistic action gets a per-key sequence id, and Predicted is always
re-derived as Confirmed + pending actions. When the server acks up to an id,
accepted actions drop out silently; rejected ones fire OnMispredict so you can
animate the correction. Mirage owns no renderer — Render:OnBlend is the only
bridge between predicted state and what the player sees.
Blend modes
Set blendMode per key at registration:
"Discrete"— trust Predicted immediately, snap only on rejection. Ideal for pickups and other discrete events."Continuous"— always ease Render toward Confirmed over a few frames via exponential smoothing, so small corrections don't visibly pop. Ideal for meters, progress bars, and positions.
Key scopes
Every key has a scope, set at RegisterKey time on the server:
"Global"(the default) — one shared state table, streamed to every client. A coin grid, a world boss's health, a shared door."PerPlayer"— an independent state table per player, streamed only to its owner. Inventories, quest progress, personal currency. Other clients never receive another player's copy.
For PerPlayer keys, initialState is either a template table (shallow-cloned
for each player) or a (player) -> state factory, and the handle's mutators
take the owning player first:
-- Server
local Quests = Mirage.Server.RegisterKey({
name = "Quests",
scope = "PerPlayer",
rate = 10,
initialState = function(player)
return loadQuestProgress(player)
end,
blendMode = "Discrete",
applyAction = QuestActions.apply,
validate = function(player, action, playerState)
-- playerState is THIS player's copy; other players are unreachable here
return { accepted = true }
end,
})
Quests:SetFor(player, "quest_7", { stage = 2 })
print(Quests:GetFor(player, "quest_7"))
A player's state is created lazily on first touch (or on their join handshake) and cleaned up automatically when they leave. The client API is identical for both scopes — a client only ever sees its own stream.
Anti-cheat hooks
Mirage stays opinion-free about consequences. It emits structured signals —
ValidationFailed, RateExceeded, and ActionTimedOut — and never kicks on
its own. You connect to those signals and decide your own thresholds and
responses.
See the Server, Client, PredictedStore, ConfirmedStore, and Blend pages in the sidebar for the full API.