Skip to main content

Mirage

Client-side prediction & reconciliation for streamed Roblox state, packaged for Wally.

Mirage lets a server stream opt-in pieces of state to clients at a configurable rate. Each client keeps its own read/write Predicted copy that renders a player's actions immediately, then reconciles against the server's Confirmed truth as acknowledgements arrive. The server stays fully authoritative — prediction only changes when a player sees the result of their own action, never who decides it.

The classic case: at 200 ms ping a coin pickup feels sluggish, because the client waits a full round trip before showing the coin collected. With Mirage the coin disappears the instant it's touched; if the server later rejects the action, Mirage hands you a rollback hook so you can re-show it. You choose which state opts in, so you can mix Mirage with ordinary Roblox replication however you like.

Installation​

Add Mirage to your wally.toml:

[dependencies]
Mirage = "aslyumm/mirage@0.1.0"

Then run:

wally install

Mirage has zero runtime dependencies — installing it pulls in nothing else.

Quick start​

Register the same key on both sides, mutate it optimistically on the client, and let the server validate.

-- Server
local Mirage = require(ReplicatedStorage.Packages.Mirage)

local Coins = Mirage.Server.RegisterKey({
name = "Coins",
rate = 20, -- broadcast Hz
initialState = coinGrid, -- your own data, owned by the server
blendMode = "Discrete",
applyAction = CoinActions.apply,
validate = function(player, action, serverState)
-- return { accepted = true } or { accepted = false, reason = "..." }
return { accepted = true }
end,
})

-- Mirage emits signals and never kicks; the anti-cheat policy is yours.
Coins.ValidationFailed:Connect(function(player, action, reason)
-- e.g. strike the player, and kick past a threshold
end)
-- Client
local Mirage = require(ReplicatedStorage.Packages.Mirage)

local Coins = Mirage.Client.RegisterKey({
name = "Coins",
blendMode = "Discrete",
applyAction = CoinActions.apply, -- MUST be the same function as the server's
})

-- Touched a coin? Show it collected right now, before the server answers:
Coins.Predicted:Do({ kind = "Collect", payload = { coinId = 5 } })

-- Draw whatever the player should see (Mirage owns no renderer):
Coins.Render:OnBlend(function(coinId, blendedState)
-- spawn VFX/SFX, update UI counters, tween the coin out, ...
end)

-- Server rejected an action? Roll that specific visual back:
Coins.Predicted:OnMispredict(function(actionId, reason, payload)
-- re-show the coin, play an "undo" cue
end)

How it works​

LayerWritten byRole
Server Truththe serverthe canonical value for a key
Confirmedincoming broadcasts onlythe client's read-only mirror of Server Truth (always ~1 RTT stale)
Predictedthe player's own actionsthe client's read/write copy, rendered ahead of confirmation
RenderMirage, via Render:OnBlendthe blended value your VFX/SFX/UI actually draws

Every optimistic action gets a per-key sequence id. The client keeps a small pending queue and re-derives Predicted as Confirmed + pending actions. When a broadcast acks up to an id, accepted actions drop out of the queue silently and rejected ones fire OnMispredict. If an action goes unanswered past a ping-sized grace window, Mirage resyncs that entry from Confirmed rather than trusting a stale guess.

Blend modes​

  • "Discrete" — trust Predicted immediately, correct only on rejection. For pickups and other discrete events.
  • "Continuous" — always ease Render toward Confirmed over a few frames. For meters, progress bars, and positions, so small corrections don't visibly pop.

Key scopes​

  • "Global" (default) — one shared state table streamed to every client (a coin grid, a world boss's health).
  • "PerPlayer" — an independent state table per player, streamed only to its owner (inventory, quest progress, personal currency). Other clients never receive another player's copy.

Documentation​

Full API reference and guides live on the Moonwave docs site. Start with the intro, then the Server, Client, PredictedStore, ConfirmedStore, and Blend pages.

Contributing​

Mirage is built with Rojo and its tests run against a real Roblox engine. The local development toolchain (Rojo, Wally, Selene, StyLua, run-in-roblox) is pinned in aftman.toml and installed with aftman install — these are tools for working on Mirage, not dependencies of the package. Source lives under src/ (Server/, Client/, Shared/), with specs in tests/ — run them with ./scripts/test.ps1 (requires Roblox Studio). Lint and formatting (selene, stylua --check) run in CI on every push.

License​

MIT © aslyumm. See LICENSE.