@siltrun/wire
v0.1.2
Published
Silt binary wire codec: a declarative field-type combinator that encodes and decodes from one schema on both sides, for the wire:"binary" tier. Zero-dep, realm-safe.
Downloads
51
Maintainers
Readme
@siltrun/wire
Binary encoding for Silt room state. Declare the field types of
your state once and the same declaration gives you both
encode(state) -> Uint8Array and decode(bytes) -> state. The server encodes with
it and the browser decodes with it, from one shared module, so the two sides cannot
drift apart.
Zero dependencies. It uses only DataView, TextEncoder, and Uint8Array, and it
runs outside tick(), so it never touches room determinism.
This package also holds the delta codec behind a room's wire: "delta" tier.
Install
npm i @siltrun/wireDeclare a schema
// schema.ts, imported by the room and by the client
import { schema, struct, array, str, u8, varint } from "@siltrun/wire";
export type State = { tick: number; ships: { id: string; x: number; y: number }[] };
export const gameWire = schema(
struct({
tick: varint,
ships: array(struct({ id: str, x: u8, y: u8 })),
}),
);const bytes = gameWire.encode({ tick: 42, ships: [{ id: "a1", x: 10, y: 200 }] });
gameWire.decode(bytes); // back to the same objectThat state is 48 bytes as JSON and 7 bytes here.
Wire it into a room
The room declares the schema, and the client passes the same object to joinRoom:
// room.ts
import type { Room } from "@siltrun/room";
import { gameWire, type State } from "./schema.ts";
export default {
wire: { encoding: "binary", schema: gameWire },
tick(state = { tick: 0, ships: [] }, inputs) { /* ... */ return state; },
} satisfies Room<State>;// the client
import { joinRoom } from "@siltrun/client";
import { gameWire } from "./schema.ts";
const room = await joinRoom(url, { id, wire: gameWire });Only decode is used browser-side. If a room negotiates binary and the client
supplies no codec, per-tick state datagrams throw rather than silently arriving as
garbage.
Field types
| Combinator | Encodes |
|---|---|
| u8, u16, i16 | fixed-width integers |
| varint | unsigned LEB128, so small numbers cost one byte |
| zigzag | signed integers, compact near zero |
| bool | one byte |
| str | length-prefixed UTF-8 |
| array(el) | length-prefixed homogeneous list |
| nullable(el) | a value or null, one tag byte |
| struct(fields) | a fixed set of named fields |
| enumOf(values) | a string enum as a single tag byte |
| rle(el) | run-length-encoded flat number array, for grids and tile maps |
Declaration order inside a struct is the wire order. Both sides read the same
declaration, so that order stays in sync by construction.
Writer, Reader, and the Codec<T> type are exported too, for the cases where a
field wants packing the combinators do not cover. A hand-written codec is an object
with w(writer, value) and r(reader), and it composes with the rest.
A schema does not have to be lossless. Anything you leave out of the codec simply does not ride the per-tick snapshot, which is a normal way to keep server-only state server-side and send flavor over the reliable lane instead.
Delta
diffState, applyDelta, encodeDelta, and decodeDelta implement the
platform-generic delta tier. A room opts in with wire: "delta" and returns its
natural state; the platform diffs consecutive states and sends the difference. That
path needs no authoring, so most rooms never call these directly.
Docs
Full guides and API reference: https://silt.run/docs/
