npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/ecs

Both 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 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 add and remove on 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.alive turns false and any other access throws. Check e.alive if 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.