@siltrun/room
v0.2.0
Published
Silt contract authoring types: Room<State, Cmd>, Input, Ctx. Types only, zero runtime exports; import type is fully erased at build.
Maintainers
Readme
@siltrun/room
The TypeScript authoring types for a Silt room contract:
Room<State, Cmd>, Input<Cmd>, and Ctx.
This package is types only. It ships a single .d.ts and no runtime module at all,
so import type { Room } from "@siltrun/room" is fully erased at build and your
contract keeps its one-import, one-export shape.
Install
npm i -D @siltrun/roomA contract
import type { Room } from "@siltrun/room";
type Ship = { x: number; y: number };
type State = { ships: Record<string, Ship> };
type Cmd = { x: number; y: number };
export default {
tick(state = { ships: {} }, inputs, ctx) {
for (const ev of inputs) {
if (ev.kind === "join") state.ships[ev.id] = { x: 260, y: 140 };
else if (ev.kind === "leave") delete state.ships[ev.id];
else if (ev.kind === "input") {
const s = state.ships[ev.from];
if (s) { s.x = ev.data.x; s.y = ev.data.y; }
}
}
if (ctx.tick % 60 === 0) ctx.emit({ type: "heartbeat", t: ctx.time });
return state;
},
} satisfies Room<State, Cmd>;satisfies is the point of the pattern. It narrows inputs to Input<Cmd>[] and
checks the return against State, while the file still default-exports a plain
object.
The three types
Room<State, Cmd> requires one method, tick(state, inputs, ctx). It runs 60
times a second on the server while at least one peer is present. It receives a
fresh clone of canonical state, so mutate it freely and return it, and never stash
references across ticks. If the first tick has no state yet, state arrives as
undefined and a default parameter seeds the world. A throwing tick is skipped and
the last good state holds.
Input<Cmd> is the ordered per-tick batch, a union of four kinds: join,
leave (with reason "left" or "timeout"), input (the droppable latest-wins
command from one peer, typed as your Cmd), and event (the reliable lane, whose
payload stays unknown). Membership rides the same batch, so there are no
callbacks and no separate lifecycle hooks. Ordering within a tick is deterministic.
The wire is untyped JSON, so Cmd is an authoring-time assertion rather than a
runtime check. Validate anything you would not trust.
A room that opts into the lockstep lane (inputs, below) changes the input kind only:
entries become one-shot edges at their scheduled tick rather than sticky latest-wins, a
peer may contribute more than one in a tick, and intra-tick order is (from, seq). The
join/leave/event kinds are unchanged.
Ctx is the determinism surface: tick (the integer clock), dt (fixed
1/60), time (tick * dt in seconds), random() (seeded per room, so replaying a
tick replays the same draws), and emit(event) to broadcast on the reliable lane
after the tick. Wall-clock time and unseeded randomness are not available inside a
tick, which is what makes a room replayable.
Optional fields
Room also accepts fields that tune how state reaches clients, all with working
defaults:
init()runs once, server-side, when the room is created. The full runtime is available there, unlike insidetick.wirepicks the encoding: full JSON snapshots by default,"delta"for platform-diffed updates with no authoring, or a binary schema declared with@siltrun/wire.deliverypicks one droppable datagram per tick, or a reliable per-peer stream for rooms too large for a datagram.snapshotEverydecouples the broadcast cadence from the 60Hz tick under stream delivery.view(state, peerId, tick)projects per-peer state, so information a player should not have is never sent rather than merely hidden client-side.predictopts this room's clients into client-side prediction with@siltrun/client/predict.inputs: { schedule: "tick", delay: N }opts the room into the lockstep lane: the server assigns every input a tickdelayticks out and broadcasts the ordered per-tick log to every peer, so a deterministic simulation can run identically on every client from(seed, log).delayis a receive budget, not a feel knob — a peer whose one-way latency exceedsdelay x 16.67 msstalls its own engine. Not combinable withpredictorview, and the datagram input lane is disabled (inputs rideroom.inputs.send()on@siltrun/client). See theLockstepInputsdoc comment inindex.d.tsfor the full semantics.
Docs
Full guides and API reference: https://silt.run/docs/
