Structuring game logic
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”. The ECS half of @irtio/ecs 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.
Add the package when you start a new game. It holds no second state store, no runtime or protocol awareness, and nothing to migrate to later: all state stays in the room schema, and the library only adds handles and queries over it.
npm install @irtio/ecs 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 drawn e.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 speculative effects, 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.
For new games
The library is starting-a-new-game material, not a retrofit kit. Existing games lose nothing by ignoring it, and there is no schema or protocol difference between a game that uses it and one that does not. If you are also predicting hits and playing effects for them, the other half of the package is speculative effects, which works with or without the ECS.