@irtio/ecs
v4.8.0
Published
Optional irtio library: an ECS-style query and system layer over the room schema, plus a correction-aware speculative-effect manager (claim/judge round trip, outcome transport, retention). For games built on it from the start; see the package README.
Readme
@irtio/ecs
An optional library for irt.io games: an ECS-style query and system layer over the room schema, and a speculative-effect manager that keeps predicted client juice honest under server corrections. The two halves are independent — use either, or both.
This package is not part of the core platform. Nothing in the runtime, protocol, CLI or schema hash knows it exists, and a game that ignores it loses nothing. It is written for games that adopt it from the start, not as a retrofit kit or a migration target. This README is its documentation; the platform docs at https://irt.io/docs do not cover it.
npm install @irtio/ecsBoth halves are exercised by examples/burn-arena (the ECS half, shared systems on both
sides) and games/arrow-fx (the effect round trip) in this repository. Implementation
notes and residual risks are in PHASE2-NOTES.md; the design is docs/ecs-proposal.md.
The ECS half
As a game grows, its logic tends to tangle through tick() and event handlers with no
structure for "run this rule over every entity that has X and Y". This half gives that rule
a shape: queries over your existing schema collections, systems that run them in order, and
one shared module of rules that the server and the client each attach where the rule's side
permits. It holds no second state store — all state stays in the room schema, and the
library only adds handles and queries over it.
Predicted poses come from e.render
Read this first, because it is the mistake every predicted game makes once. On the client,
room.state is confirmed server state plus your own unflushed owned writes. Predicted
physics poses live in the engine and are only readable through room.render. A client
system that needs the predicted pose must read it through the handle's render accessor:
const pose = e.render('position') // the predicted or interpolated pose
const confirmed = e.position // confirmed state, NOT where the body is drawne.render(collection) is the only way a system should read a predicted pose. It works for
every collection (non-owned entities come back interpolated, exactly as room.render draws
them), and it throws on the server, where state is the authority and there is nothing
predicted to read. Gameplay-consequential decisions about predicted poses belong on the
server; client systems are for presentation and for writing your own owned intent fields.
Collections are component tables
The schema you already have is an entity component model:
- An entity is a shared string id used across collections.
- A component is a row in one collection under that id. Adding and removing a component
is
addandremoveon that collection, and "has Burning" is presence of a row, not a boolean field. - Per-row ownership and per-collection visibility (
all,role,spatial-grid,server,owner) apply to components individually, because they already apply to collections.
// shared/schema.ts: a plain schema, nothing new
const position = entity({ x: f32, y: f32 })
const health = entity({ hp: u16, max: u16 }, { serverOwned: true })
const burning = entity({ until: u32 }, { serverOwned: true }) // presence = "has Burning"
export const schema = defineSchema({ collections: { position, health, burning } }, ...)@irtio/ecs adds three things over this: queries, entity handles, and system scheduling.
Queries and systems
A query is a static shape: which collections an entity must have a row in, and which it must not.
// shared/systems.ts: importable from room.ts AND your client entry
import { query, system, type Entity } from '@irtio/ecs'
import type { Schema } from './schema'
export const burnSystem = system(
query('health', 'burning'),
(e: Entity<Schema, 'health' | 'burning'>, { tick, dt, ctx }) => {
if (tick >= e.burning.until) return e.detach('burning')
e.health.hp = Math.max(0, e.health.hp - 1)
},
{ side: 'server' },
)The callback's entity handle has one accessor per collection. Accessors for the queried
collections are non-optional; the rest resolve to the row or undefined. Annotating the
entity as Entity<Schema, ...> is what types the rows; without the annotation the accessors
are untyped and the code still runs.
system(query, fn, opts) takes side: 'server' | 'client' | 'both' (default 'both').
burnSystem mutates health and detaches components, so it is 'server'; attaching it on
the client is a runtime error at attach time, not a silent no-op. pipeline(...systems)
composes systems in order.
Queries are evaluated by scanning the smallest queried collection and membership-checking the rest. At room sizes (hundreds of entities) this is fast, and it means a component added by one system is immediately visible to the next; there are no archetype buckets to go stale.
Attaching on the server
// room.ts
import { pipeline, world } from '@irtio/ecs'
import { attachServer } from '@irtio/ecs/server'
import { burnSystem, regenSystem } from './systems'
const w = world(schema)
export default defineRoom(schema, {
tick: attachServer(w, pipeline(burnSystem, regenSystem)),
})attachServer returns an ordinary tick(state, dt, room) function. It composes: call it
from an existing tick body if your room does more than run systems. In a system, ctx is
the server Room. If the game also uses the effect half, pass
attachServer(w, pipe, { effects: { def } }) and the retention sweep runs after the
pipeline every tick, so you do not call fx.sweep() yourself.
Attaching on the client
// client entry
import { attachClient, pipeline, world } from '@irtio/ecs'
import { makeSpriteSystem } from './irtio/systems'
const w = world(schema)
const runner = attachClient(w, room, pipeline(makeSpriteSystem(sprites)))
function frame(dt) {
runner.run(dt) // you own the loop; the library has none
draw()
}attachClient returns { run, stop }. Call run from your own frame loop; stop()
releases the row-event subscriptions. In a system, ctx is the client room handle.
Read access and write authority are separate. Client queries iterate all visible rows,
so presentation systems over remote entities are a core use case, and writes keep the
existing ownership rules: writing a field of a row you do not own is ignored at runtime with
a one-time warning, exactly as a direct room.state write is. Writing your own owned rows
through a handle replicates like any owned write. e.detach is server-side; the client has
no remove API for replicated rows.
.without() sees the local view
Visibility scopes query membership. On the client, .without('dead') means "no locally
visible dead row", which can differ from the server's answer: a visibility: 'server'
collection is always empty in the client's view, and a spatial-grid or owner collection
holds only the rows this client is entitled to. Write shared systems knowing each side
queries its own view, and keep authoritative exclusions (.without('dead') deciding damage,
say) in 'server' systems.
Entity handles and their lifetime
A handle is keyed by id, never by row object reference, because row identity breaks on
resync, on re-add of an existing id, and on ownership transfers. Every accessor re-resolves
through the live store, so a handle held across ticks always reads current rows and holds no
copies.
- A handle stays valid while its id exists in any collection of its side's view. Removing one component does not kill it while another remains, and a reconnect resync preserves it for ids still present afterwards.
- Once the id is gone from every collection the handle is invalidated:
e.aliveturnsfalseand any other access throws. Checke.aliveif you hold handles across frames. - A re-added id gets a fresh handle, so a stale reference can never silently target a new
entity. Re-acquire through a query or
world.entity(id).
On the server this is settled by a sweep after each tick. On the client it is settled after each replication batch, so a remove and re-add arriving in one delta never causes a temporary absence.
Create one world(schema) per side. A server world attached with attachServer follows
whatever room state it is ticked with, which matters because one worker can host several
rooms of the same module; when it switches rooms, the previous room's handles are
invalidated. A room module that holds live handles across ticks while serving several rooms
should create its world per room instead of at module level.
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. This half ships both sides 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. It works with or without the ECS half.
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.
Action ids are client-minted, so the room must not trust them across clients: another player
can read or guess an id. judge() refuses a key whose live row belongs to a different client
(logged and counted in refusedCount), so a reused id cannot take over someone else's verdict.
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.
