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.