Speculative effects
A predicted game plays its juice before the server agrees. The client predicts an arrow hit, spawns a blood splatter, and then the server disagrees: the splatter stays. Or the server agrees, the state resyncs, and the confirmed hit splatters twice. @irtio/ecs ships both halves of the fix as one tested surface: the client claims a speculative effect, the server judges the action, and the manager guarantees each effect starts, commits, or retracts exactly once.
Add the package when you start a new game. It is a library over ordinary replicated state, with no runtime or protocol awareness, and it is designed for games built on it from day one.
npm install @irtio/ecs The round trip
Three pieces, one per file you already have:
// shared/schema.ts: the outcome transport is an ordinary collection
import { effectOutcomes } from '@irtio/ecs'
import { defineSchema } from '@irtio/schema'
export const schema = defineSchema(
{ ...game, outcomes: effectOutcomes() },
{ roles: ['player'] as const },
) // room.ts: the server judges actions inside tick()
import { effectJudge } from '@irtio/ecs/server'
export default defineRoom(schema, {
tick(state, dt, room) {
const fx = effectJudge(state, room.tick)
// ...simulation resolves the arrow...
fx.judge(`${actionId}:e42`, shooter, { status: 'accepted', payload: { x, y } })
fx.done(actionId, shooter) // the action is fully resolved
fx.sweep() // every tick, judged or not
},
}) // client: claim the effect the moment your local sim predicts it
import { effects } from '@irtio/ecs'
const fx = effects(room, { outcomes: 'outcomes' })
const actionId = fx.id()
send({ actionId, dir }) // your RPC or message carries the id
fx.claim(`${actionId}:e42`, {
timeoutMs: 5000, // abandonment deadline, never confirmation
start: () => spawnSplatter(predictedPoint),
commit: (h, outcome) => {
h.moveTo(outcome.payload) // the server's impact point wins
playHitSound()
},
retract: (h) => h.fadeOut(),
}) The client mints the action id and includes it in the action it sends. The server echoes it through judge(), which writes a row into the outcomes collection. The row is 'owner'-visible, so the verdict reaches the claiming client regardless of area-of-interest or role rules, and resolution is automatic when the row replicates. Validation stays entirely server-side; echoing an id only correlates the response.
One action can cause several effects. Append a discriminator to the key, as in `${actionId}:e42` for a hit on entity e42. When the action is fully resolved, fx.done(actionId, client) (or a rejected verdict on the bare action id) closes the set: every still-pending claim prefixed `${actionId}:` retracts, so a predicted hit the server never judged fades without waiting for its timeout. Custom keys must never make one action id a :-prefix of another; ids from fx.id() are safe by construction.
Lifecycle
Each claim runs start at most once and then exactly one terminal callback:
| Current state | Input | Result |
|---|---|---|
| Unseen | Claim | Run start once; become pending. |
| Unseen | Accepted or rejected outcome | Cache the outcome; no callbacks yet. |
| Pending | Accepted outcome | Run commit once; become committed. |
| Pending | Rejected outcome, fx.invalidate(key), or timeout | Run retract once; become retracted. |
| Pending | Terminal done or rejected for the claim’s action | Run retract once; covers `${a}:*` claims and a pending claim on the bare action key. |
| Unseen, conflicting cached outcomes | Claim | The newest cached outcome wins. |
| Cached accepted outcome | Claim | Run start, then commit, once each. |
| Cached rejected outcome | Claim | Suppress the claim; no visual, no callbacks. |
| Pending or terminal | Duplicate claim or outcome | No repeated callbacks. |
| Retracted | Late accepted outcome | Stay retracted; no restart, no commit. |
| Committed | Conflicting rejection | Stay committed; report a contract conflict on fx.conflicts. |
At-most-once callbacks are guaranteed per key while its record is retained. Terminal records and outcomes received before a claim are kept for outcomeRetentionMs (default 60 seconds); after that the manager cannot deduplicate an arbitrarily late duplicate.
Start now, commit on confirmation
start is for reversible or fadeable visuals: a splatter, a decal, a tracer. commit is for effects that must wait for acceptance, and it receives the authoritative payload, so a splatter placed at the predicted point gets nudged to the server’s impact point. A round trip can be noticeable, especially for audio: a hit sound in commit plays roughly one round trip after the splatter appeared. It sounds delayed, not wrongly played twice or wrongly kept. Choose per effect which side of that trade it sits on; the manager cannot give you both immediate feedback and an effect that only ever happens for accepted outcomes.
Timeouts are abandonment
timeoutMs is a monotonic elapsed-time deadline, never evidence of rejection. It is the backstop for a verdict that never arrives, for example one evicted from the outcome buffer before it replicated. An expiring timeout runs retract, and that includes claims that only ever ran start: an unjudged speculative visual does not persist forever. Short timeouts therefore belong only on claims where you explicitly want fast resolution. An accepted outcome that arrives after the timeout stays retracted; if your game needs a late authoritative presentation, handle that outcome separately under a distinct key.
With no clock option the manager owns a timer and processes deadlines itself, stopping it in dispose(). Pass clock to drive time yourself and call fx.checkTimeouts() from your frame loop.
The outcomes collection
effectOutcomes() returns an ordinary collection definition: status (accepted, rejected, done), an optional payload struct (default { x, y }, overridable), the judging tick, and ack. Rows are client-owned so 'owner' visibility can deliver them, and the verdict fields are protected with serverFields.
That protection has one visible seam. A client write to status or payload is not silently ignored: the row is owned by the client, so the local proxy applies the write optimistically and flushes it. The runtime then rejects the protected field with a warning in the room log and sends a correction that snaps the client back. The server value wins and nothing throws, but the client briefly sees its own doomed write, and the warning is server-side. Do not write those fields.
ack is the one field the owner writes. The manager sets it once a verdict is consumed (default ack: true), which lets the server sweep evict the row before its TTL and frees buffer headroom. The trade: an acked row is gone before a reconnect resync could re-deliver it, so deduplication after a reconnect rests on the manager’s in-memory record instead of the transport. Pass ack: false to keep rows on the server for their full TTL.
The server retains outcome rows in a per-owner ring of capacity rows (default 64) with a ttlTicks expiry (default 200), swept every tick by fx.sweep(). Eviction prefers acked rows, then expired TTL, then oldest rows on overflow, and overflow evictions are counted on fx.overflowCount and logged, because an evicted-before-delivery verdict downgrades that claim to its timeout path. The defaults are sized so ordinary games never hit overflow.
Who sees what
Speculation applies only to the client that predicted the cause. Everyone else renders from authoritative replicated state: the target’s health drop, or a short-lived public marker row the server adds for cosmetics. There is nothing to retract for spectators, so they need no claims and no manager.
For actions decidable at call time, such as using an item or casting off cooldown, resolving from the RPC reply is a supported fast path: call fx.resolve(key, outcome) yourself. Simulation-judged outcomes go through judge(). A physics correction does not automatically disprove an outcome, so corrections never auto-retract claims; call fx.invalidate(key) from your own predicate if your game can disprove one earlier.