The networked store
For a page that has a world rather than a request: one node hosts an authoritative document, others join replicas of it, and the host decides what each caller may see and do.
import { defineStore, hostStore, joinStore } from '@net-mesh/browser';
const host = hostStore({
definition, store: 'world', transport: node,
initialState,
maxEventBytes: 8104,
// Only a `read` request carries `audience`; `action` / `input` carry `name` + `input`.
authorize: request => request.type !== 'read' || !request.audience.includes('command'),
project: (state, audience) =>
audience.includes('command') ? state : publicPart(state),
actions,
inputs,
});
const replica = joinStore({
definition, store: 'world', transport: node, host: hostNodeIdHex,
audience: ['crew'], key: 'player', maxEventBytes: 8104,
});
await replica.ready();Define once, host or join
defineStore({ id, version, state, empty, actions, inputs }) is a reusable typed
description and carries no state or connection. It holds validators, never
authority handlers, so a client bundle that joins a store does not carry the
owner's gameplay code.
Two properties are checked at definition time, because their violation is invisible until much later:
empty()must itself be a valid state. It is what a replica installs when it loses visibility, so anempty()the schema would reject would fail mid-transition, with the old projection already fenced.idandversionmust be usable as an incarnation identity. They travel in every envelope, and a blank id or a non-integer version turns a version-mismatch refusal into a confusing parse error at the far end.
Validators are the shape of schema.parse, so Zod, Valibot or a hand-written
check all work without the store depending on any of them.
The host is the authority
| Option | What it decides |
|---|---|
definition | The typed description |
store | Which store of that definition this is; defaults to the definition id |
transport | The node it serves over |
initialState | The document as it starts |
maxEventBytes | The largest frame your transport carries (the package's own tests and demo use 8104) |
authorize | Who may read, act or send an input — handed the authenticated peer |
project | What a given audience may see — or projectFor, what a given player may see (exactly one) |
actions | One handler per declared action |
inputs | One handler per declared input |
A node may host several stores of one definition — a lobby and a match, three
worlds — and each must name itself, because every host store on a node sees
every frame and a join names no handle. Two stores answering to one name on one
transport is refused at construction, where the mistake is legible, rather than
resolved by whichever listener registered first.
An action handler runs inside one synchronous transaction: it stages writes through its context and returns the output, or throws to reject. Returning, throwing or handing back a thenable invalidates the context, so a retained context cannot write from a later microtask.
fire: (input, context) => {
const target = context.getState().ships[input.at];
if (target === undefined) throw new Error('no such ship');
const hull = Math.max(0, target.hull - 25);
context.setState({
ships: { ...context.getState().ships, [input.at]: { ...target, hull } },
});
return { hull };
},context.peer is the caller's mesh node id in 16 lowercase hex, and it is the
identity the transport proved. No store frame has an originator field to
consult, so a page cannot claim to be someone else.
Actions and inputs are different instruments
- An action is acknowledged, validated and correlated:
replica.act(name, input)returns a promise resolving the handler's output, or rejecting with a typed code. - An input is coalesced latest-value intent with no per-call promise:
replica.input(name, value)returns a disposition (queued,replaced, ordroppedwith a reason) describing what happened locally. It is not remote acceptance, and a dropped input may be the last one — the handler is written to tolerate that.
Audiences and projection
A replica asks for an audience (audience: ['crew']) and sends a required, opaque
key join token; authorize does not see it — identify callers by
request.peer. The host's project(state, audience) returns a
validated S — never a partial — so visibility is expressed in the schema:
collections omit invisible entities, individually hidden fields are explicit
null or a tagged value, and empty() means absence.
A zero must never be readable as "you cannot see this", and a replica cannot read
what it was not given: the withheld part is absent from the frames, not hidden in
the renderer. replica.setAudience(names) asks for a different audience and
resolves when the new projection is installed.
Declared visibility
A definition can declare its secrets instead, and the host enforces them after any hand-written projection — so code can narrow a rule, never widen it:
defineStore({
/* … */
visibility: {
// `owner`: the key matched by the first `*` must be the viewer's peer id.
'players.*.hand': 'owner',
'deck': 'nobody', // the host only
'deck.length': 'everyone', // the count of a hidden array
'waypoint': ['command'], // viewers reading any of these audiences
},
});Hidden entries of a collection (a path ending in *) are removed; hidden fields
become the HIDDEN marker, so the state validator must accept it there —
hiddenOr(parse) wraps a field's parser, and a host whose validator refuses the
marker is refused at construction. Unlisted paths are visible. Presets: 'open'
(everything visible, stated rather than accidental) and 'card-game', which take
overrides ({ preset: 'card-game', 'players.*.score': 'nobody' }). Rules are
checked at defineStore: a malformed path, an 'owner' rule with no *, or an
unknown preset throws invalid-data. With declared visibility, project and
projectFor are optional.
assertHidden(definition, state, viewer, paths) throws if a viewer could read
any of the paths — for tests. hostStore({ dev: true }) (or a function) warns
when every player would receive the whole state with no visibility declared,
and when a projection fails the validator, which otherwise sends empty()
silently.
When the view depends on the player rather than the audience — each player's own
hand — give projectFor instead of project. It receives the authenticated
player with the audience they read:
projectFor: (state, { peer, audience }) => ({
...state,
hands: { [peer]: state.hands[peer] ?? [] },
}),project is computed once per distinct audience and shared; projectFor once
per distinct player and audience, so it costs one projection per player per
change. A player whose view did not change is sent nothing. A host given both,
or neither, is refused with invalid-data.
The transport, and one gate to know about
A store needs a StoreTransport — the structural subset of the node that
connect() and openSession() both satisfy. openStream is synchronous on the
direct node and a promise on a session, and the store awaits both.
connect() is the supported transport today. A joiner needs a session with its
host, which the store installs through connectPeer — and on a session that
call is a proxy round trip to the tab holding the lock. The store's own use of a
proxied stream on a follower (last-consumer cleanup, the peer/stream lifecycle
across a leader change) is not established: a host and a joiner in one tab are
exercised, two tabs sharing a leader are not. One tab per node is also what a
game usually wants.
A replica of the node it is running on is refused with invalid-data — a node
has no session with itself, and refusing at construction beats a no session with 0x… from inside the transport after the subscription was accepted. A host that
is also a player uses hostPlayer instead (below).
For development without an anchor, @net-mesh/browser/local provides a mesh
inside one page. Each node it creates is a StoreTransport, so the store on top
is the real one — frames are encoded, chunked and dispatched — while delivery is
a function call and the peer on each frame is assigned rather than proved:
import { createLocalMesh } from '@net-mesh/browser/local';
const mesh = createLocalMesh();
const hostNode = mesh.node();
const guestNode = mesh.node();
const host = hostStore({ definition, transport: hostNode, /* … */ });
const guest = joinStore({
definition,
transport: guestNode,
host: hostNode.nodeIdHex(),
/* … */
});Local nodes also announce and query like a real node — a node finds the
others' announcements, not its own, and an announcement expires unless it is
refreshed — so discovery code runs offline unchanged. It is evidence about game
logic, not about whether two browsers can reach each other.
An announcement is a lease, so both sides re-announce on a timer; a joiner that looks a few seconds late otherwise reports that the host never announced. A failed announcement is retried within a second or so (doubling to 8 s), not at the next 30 s refresh.
A dedicated host in Node
A world that outlives any one player runs its host in Node. @net-mesh/sdk
provides the transport, meshStoreTransport(mesh, { listen: ['store/<id>'] }),
and a snapshot of the document in RedEX, the mesh's durable log, restored on
start:
import { Redex, meshStoreTransport, persistStore, restoreStore } from '@net-mesh/sdk';
const redex = new Redex({ persistentDir: './state' });
const file = redex.openFile('game/world', { persistent: true, retentionMaxEvents: 4n });
const restored = restoreStore(file, world); // null on a first start
const host = hostStore({
definition: world,
transport: meshStoreTransport(mesh, { listen: ['store/my-game.world'] }),
initialState: restored?.state ?? freshWorld(),
/* … */
});
const saving = persistStore(host, { file, intervalMs: 5_000 });
// on shutdown:
saving.close(); // one last snapshotpersistStore writes only when the document changed since its last snapshot.
restoreStore returns the newest snapshot whose store id and version match the
definition and that passes its state validator. A snapshot from another version
is skipped, not migrated: migrations are the application's.
A native node on meshStoreTransport can use the discovery helpers too:
announce(tags) replaces the node's announced tags and query(tag) lists the
nodes that announced it, in the browser node's descriptor shape. That is what
lets a Node process host world regions or run a lobby.
A descriptor carries only the tag it was found by.
The replica handle
await replica.ready(); // a consistent view is installed
replica.getState(); // ReadonlyState<S>
replica.subscribe(listener); // state changes
replica.subscribeStatus(listener); // phase, stale, error
await replica.act('fire', { at });
replica.input('steer', { dx, dz, dt });
await replica.setAudience(['crew']);
await replica.reconnect(); // the session was replaced
await replica.close();joinStore returns as soon as the join is sent — ready() is what waits, so a
caller that wants a loading state does not have to await the world first.
getStatus() is readiness kept out of game state: a phase, a stale flag
meaning retained state is no longer known-current (not "empty world"), and the
last StoreError.
A replica has no setState. That is a type-level fact rather than a runtime
check: writes go through actions and inputs, and only the host's handle carries
the setter.
Interest management
A definition may name its top-level entity maps and how an entity is keyed —
interest: { ships: ship => cellKey(ship.x, ship.z, 32) }, any string, null
for "always delivered". A replica joined with interest: [...] (and the host's
own player) then receives only the entities whose key is in its set:
- Per entity on the wire. A change to an entity is sent as one op for that entity, and only to players whose interest covers its old or new key; an entity moving out of a player's set is removed from their view.
setInterest(keys)is additive. The host answers with one delta of the entities entering and leaving, then an acknowledgement; the view never blanks. (If that delta would not fit one message, the view is replaced whole, still without a blank frame.) A laterjoinorresumerestates the current set.- Bounds. At most
MAX_INTEREST_KEYS(256) keys of at most 64 bytes, and the set must fit one message. - A filter, not a permission. Interest never calls
authorize; what a player may see at all is the audience,project/projectForandvisibility, applied first. - Host cost. The view is still projected once per distinct audience (or
player, under
projectFor/ownerrules) per change; what interest removes is the per-player serialization and the bytes.
Writing entities without validating the world
setState runs the definition's state validator over the whole document on
every commit, which costs O(world). Declare a per-entity parser for a collection
and write that collection through setEntities instead:
const world = defineStore({
/* … */
entities: { ships: parseShip }, // one entity in, one validated entity out
});
// One commit, one delta: move two ships, remove a third.
host.setEntities('ships', { a: movedA, b: movedB, c: undefined });
host.setEntity('ships', 'd', shipD);Only the written entities are parsed, and untouched ones keep their identity.
With 8,000 ships and 5% moving per tick, a commit drops from about 16 ms to
about 2 ms. With interest and 16 players, it drops from 18 ms to 5 ms. Declaring
the collection is a contract: state must impose nothing on it beyond each
entity passing its parser. Keep rules that span entities in actions. Inside an
action handler the write joins the handler's transaction, and that commit
validates the document as usual.
Declaring the collection in both interest and entities also makes
per-player views cheap. When visibility comes only from declared rules (no
project / projectFor), each player's delta projects just the entities that
changed, not the whole world. With 8,000 entities under an owner rule and 16
players, a commit drops from 459 ms to 25 ms. host.counts().sparseViews counts
how often that path ran.
cellKey, cellsAround(x, z, { size, radius }), stickyCells(previous, x, z, { size, radius, margin }) (hysteresis at cell borders) and sameCells are the
grid helpers.
A related fix: a delta's base is now the revision each replica is actually at,
so a replica whose view did not change on a commit — sent nothing — applies the
next delta directly instead of detecting a gap and re-fetching its whole view.
One hook for players: onEvent
onEvent(event, context) is the host's single hook for what happens to players:
| Event | When |
|---|---|
{ type: 'join', peer, audience } | A player's first subscription is installed, or the host's own player starts |
{ type: 'leave', peer, reason } | Their last one ends: left, expired, refused (a kick or revoked read) or dropped (their view could not be delivered) |
{ type: 'area', peer, from, to } | areaOf(state, peer) answers differently than last time; from is null the first time |
The hook runs on the host as one transaction with context.peer set to the
player — the same contract as an action handler — so it can setState, and its
changes reach every replica. It runs after the frame that caused the event, never
during one. A throw discards its writes and is counted (event-rejected); hooks
whose writes keep causing new events are stopped after MAX_EVENT_ROUNDS runs
(event-rounds-bound). A closing store raises no events.
Inventories
Inventory is plain JSON (item id → whole count ≥ 1). addItems,
removeItems, countItems, hasItems, inventoryOf and parseInventory are
pure functions with optional rules (maxKinds, maxCount); a change that breaks
a rule throws InventoryError (full, insufficient, invalid), which inside
a handler refuses the action with nothing written. onlyOwn(byPeer, peer) is the
projectFor helper that leaves only the viewer's entry, so another player's
inventory is never sent. Trading is not built yet.
Lobbies
createLobby, listLobbies and joinLobby are a layer over the store and
discovery, with no protocol of their own:
const lobby = await createLobby({ node, game: 'arena', name: 'Friday arena', capacity: 8,
definition, initialState, project, actions, inputs });
const lobbies = await listLobbies({ node, game: 'arena' });
const world = await joinLobby({ node, definition, game: 'arena', code: lobby.code });A lobby is found through capability tags in the host's signed announcement: a
listing tag, a record (code, name, players, capacity, store version and up to 256
bytes of the game's own info; at most 512 bytes in all), and a hash of the
code. Unlisted lobbies publish only the hash. The record is the host's claim and
is validated before it is shown; the host id is taken from the signed
announcement. A code claimed by two nodes is refused (ambiguous) rather than
resolved.
One lobby per node, and it owns the node's announcement. announce replaces
a node's tags, so pass any other tags the node should keep announcing as tags,
to createLobby while it hosts and to joinLobby while it plays.
Capacity and kicks are enforced in front of the game's authorize, counted from
the store's live subscriptions; lobby.kick(peer) re-checks every installed
subscription at once. lobby.self is the host's own player (below).
What a real game runs into
- An empty list right after connecting is not real yet. Announcements take
a moment to reach a node that just connected. Poll
listLobbiesevery half second for a few seconds before showing "no games". - A closed tab's lobby stays listed for a moment. The host refreshes its
announcement every 30 seconds and a tab killed outright withdraws nothing, so its listing
lasts until the announcement lapses, and joining it answers
not-found. - Close a joined lobby before creating one on the same node. Leaving a
lobby announces the node's own
tagsagain, which replaces the whole tag set and wipes a listing created a moment before. When the host leaves and another player takes over,await joined.close()first, thencreateLobby.close()resolves only once the withdrawal (after any announcement still in flight) has settled, so awaiting it orders the two. A failed withdrawal is swallowed, not thrown. - Taking over. A player sees the host go through
subscribeStatus, as aphaseofclosedorfailed. When the host said goodbye the status carries no error:owner-lostsurfaces only from the nextready()oract()on the handle, so watch the phase. The player taking over callscreateLobbywith the sameinfo, so the others find the same game again under a new code. - Two different
gamenames.requestCredential({ game })names the anchor game, which is the isolation boundary.createLobby({ game })andlistLobbies({ game })scope listings within it. They may differ.
The host's own player
hostPlayer(host, { audience }) returns the same handle shape as joinStore,
for the node that hosts the store:
const me = hostPlayer(host, { audience: ['crew'] });
await me.ready();
await me.act('enlist', {});It is held to parity with a replica rather than trusted:
- The same policy. Reads, actions and inputs go to the host's
authorize, with the host's own node id aspeer. - The same transaction. Input validation, the handler, output validation and the result's message budget run in one transaction, and the change reaches every replica.
- The same values. A value the wire would refuse (
NaN, a cycle) is refused withinvalid-data. - The same view.
getState()is the projection for its audience, not the authoritative document, so the host's page renders what a player would.
Calls settle a turn later, as a replica's do, so a call started inside a handler
runs as its own transaction. When the host closes, the player's phase becomes
closed and its calls reject with owner-lost.
The projection keeps the host's own UI honest; it does not hide anything from the person running the host, whose page holds the whole document.
Codes to branch on
Every refusal is a StoreError with a code:
| Code | What it means |
|---|---|
invalid-data | A payload failed its validator |
version-mismatch | Definition id or version disagreement |
forbidden | The owner's authorize refused |
not-ready | The handle has no live, synchronized view |
capacity | A declared bound was reached |
timeout | A deadline fired before the answer arrived |
aborted | The handle closed before the answer, or a newer request (a later setAudience) superseded this one |
indeterminate | Submitted, and no response established the outcome |
owner-lost | The store incarnation ended — terminal |
closed | This handle is unusable: unknown, expired, fenced, or bound to another peer |
action-rejected | The owner refused this action, or a handler broke the transaction contract |
result-expired | This request cannot execute again, and its original result is unavailable |
Three of those carry a disposition worth stating plainly:
closedis deliberately one code for four causes, so a refusal cannot disclose whether a handle exists. It is terminal for the handle, while the subscription is recoverable by joining afresh — which yields a new handle, not a resumption of the old one.owner-lostis said, not inferred. A closing host sends a farewell to every handle it holds, because silence is not an answer and a replica whose host closed would otherwise learn nothing until its own deadline fired and reportedindeterminate.result-expiredis never a success receipt. It asserts nothing about whether the original attempt committed; a non-reexecution floor is not evidence of execution.