← the tour · concept
concept ii
Why multiplayer is free
Shared state always flows back through live — so single-player and multiplayer are the same code. Nothing in the feature changes.
Most frameworks treat real-time collaboration as a feature you add. In orion it falls out of the loop by construction. A command writes to the store and publishes a topic. Every live stream subscribed to that topic re-reads the store and re-renders its region. With one viewer open that's a normal app. With fifty open it's multiplayer. The feature code that publishes and subscribes is identical either way — it has no idea how many streams are listening.
live(store → Html)
One SSE stream per page region. A pure function of the store, re-run on every relevant event and morphed into the viewer's DOM. No client state to drift, no diff bookkeeping to get wrong.
command(ctx)
Validate signals, mutate the store, publish. Answers the requester only with
requester-scoped feedback — a form reset, a validation error. Shared truth goes out
through live, to every viewer including the one who acted.
The discipline that keeps it honest
The rule is simple and load-bearing: a command never renders shared state back in its
response. The respond object exists only for the person who clicked — clear
their draft, show them a toast, patch a validation error. Anything shared flows through the
bus and lands in every open stream via their live views. Because the requester also holds a
live stream, they see their own change too — they just see it the same way every other
viewer does.
// features/todos/todos.commands.ts — the write side
import { command } from "../../belt/http.ts"
export const create = command(async (ctx, respond) => {
const title = String(ctx.signals.title ?? "").trim()
if (!title) return
sql.insert(ctx.store, title)
ctx.bus.publish("todos", { type: "todos.created" }) // → every watcher re-renders
respond.signals({ title: "" }) // requester-only: reset the draft field
})
The event carries no payload — just a type string. Subscribers re-read the store, so a dropped or coalesced event can never leave anyone on stale data. The next render is always the full current truth.
// features/todos/todos.routes.ts — the read side
live: live({ topics: ["todos"] }, (ctx) => TodoList(sql.all(ctx.store)))
That one line is the whole multiplayer story. Every connected client holds
one of these streams, and they all re-render from the same store whenever anything publishes
to "todos". Open it in two tabs and add a todo in one; it appears in the other
with no extra wiring.
The Bus: two methods, one seam
The piece that makes scale-out possible without touching feature code is the Bus — an interface of exactly two methods.
// belt/bus.ts — the multiplayer seam
export type Bus = {
publish(topic: string, event: BusEvent): void
subscribe(topic: string, fn: (event: BusEvent) => void): () => void
}
The default implementation is an in-process Map — a
Set of subscriber functions per topic, iterated on every publish. No broker,
no network hop, no configuration. It is fast enough to run a real product on a single node
without ever touching it.
When you outgrow one machine, you swap the Bus for a Redis, NATS, or Durable
Object adapter that speaks the same two methods. The feature code that calls
ctx.bus.publish("todos", …) and that wires live({ topics: ["todos"] })
never changes a line — it has no idea which implementation is underneath. That is the seam:
a stable interface whose cost of replacement is limited to exactly one constructor call in
main.ts.
| Scale | Bus implementation | Feature code changes |
|---|---|---|
| Single node | in-process Map (the default) | none |
| Multi-node | Redis / NATS adapter | none |
| Cloudflare | in-process inside a Durable Object (the DO is the node) | none |
region(): the optimized live view
live() is the primitive — re-run a function on every matching event, stream
the result. region() is the ergonomic layer on top: declare a model
(the data a view needs) separately from the view function, and the optimizations fall out
of the wiring.
import { region } from "../../belt/region.ts"
// the model-level dirty check: if the serialized model hasn't changed
// for this viewer, the view never runs and no SSE frame is emitted
export const list = region({
topics: ["todos"],
model: (ctx) => ({ todos: sql.all(ctx.store), me: session.who(ctx) }),
}, ({ todos, me }) => TodoList(todos, me))
Three things happen automatically:
- Model-level dirty check. The model is serialized per stream on every event. If the result is identical to the last render for this viewer, the view function never runs and no SSE frame is emitted — the stream stays open but silent.
- Shared rendering. With
shared: true, the model value is the cache key. If fifty viewers share the same model (same data, same permissions), the view runs once and every stream gets the same rendered fragment — content-addressed, no invalidation to manage. - Dev contract enforcement. In dev mode, the gameloop runs the view even on a "skip" and compares the output. If a view reads data the model doesn't capture, it would go silently stale in production — so dev throws a contract violation with an exact diagnosis instead.
The load-bearing insight: putting viewer-specific data in the model is what makes dirty-checking correct. A permission check, a draft, a selected-row id — if the view depends on it and the model doesn't include it, the check can't see it change. The model is where that decision is made visible, per region, not buried in framework internals.
Presence and per-viewer affordances — same loop
Richer collaboration follows the same shape. Presence is an ephemeral store the live view watches; a permission check inside the model decides what each viewer's HTML even contains. Because the model re-runs on every relevant event — including a viewer joining or a permission changing — the HTML for each person updates automatically.
import { region } from "../../belt/region.ts"
import { html } from "../../belt/html.ts"
export const board = region({
topics: ["todos"],
watch: [presence], // repaint on join/leave too
model: (ctx) => ({
todos: sql.all(ctx.store),
viewers: presence.here(ctx), // who's looking right now
canEdit: authz.can(ctx, "edit", "todos"),
}),
}, ({ todos, viewers, canEdit }) => html`
<header>${avatars(viewers)}</header>
<ul id="todos">${todos.map(t => row(t, canEdit))}</ul>`)
Grant a viewer the edit role mid-session and their edit
controls appear on the next event — no refresh, no separate WebSocket, no client
flag to flip. The permission change repaints every affected region because
authz is watchable: it participates in the same loop everything else does.
Ephemeral state — where presence actually lives
Presence isn't in the database, and it shouldn't be. "Who's looking right now," cursors,
"X is typing," a draft someone hasn't saved, a soft lock — this is connection-scoped
state, and orion gives it a home that feels like client-side state management
(zustand / XState Store) but lives on the server: ephemeral(). It's a keyed store
with get / set / update / delete — and the
part that makes it fit the loop, subscribe(). A live region watches
an ephemeral store directly, exactly like it subscribes to a bus topic: every mutation
re-renders the watchers. No bus wiring, and because the store is declared at module scope, no
dependency injection either.
import { ephemeral } from "../../belt/ephemeral.ts"
// Connection state, NOT the domain store. Keyed by IDENTITY, so the same
// user in three tabs is one viewer with tabs=3 — guests stay per-connection.
const viewers = ephemeral<{ name: string; tabs: number }>()
export const board = region(
{
topics: ["todos"],
watch: [viewers], // repaint on every join/leave
enter: (ctx) => { // runs when a stream CONNECTS…
const me = currentUser(ctx)?.username ?? "guest"
viewers.update(me, (v) => ({ name: me, tabs: (v?.tabs ?? 0) + 1 }))
return () => { // …and the returned fn runs on DISCONNECT
const v = viewers.get(me)
if (!v || v.tabs <= 1) viewers.delete(me)
else viewers.set(me, { ...v, tabs: v.tabs - 1 })
}
},
model: (ctx) => ({ viewers: viewers.values() }),
},
({ viewers }) => html`<header>${avatars(viewers)}</header>`,
)
The cleanup is the whole trick. A live stream's enter hook returns a function
that fires when the connection drops — close the tab, lose the network, and the viewer count
corrects itself with no heartbeat protocol to design. Because live streams are stateless and
every render is the full current truth, a reconnecting client rebuilds its presence from
scratch; nothing to reconcile.
Persistence is a provider seam
Where the state physically lives is a swap, not a rewrite — same get/set/watch
surface, different backing:
| Provider | Backing | For |
|---|---|---|
memory() | process-local Map; dies with the process | The default. Presence, cursors, typing — state that should die with the process, since stateless streams rebuild it on reconnect anyway. |
table(store, "name") | a key/value table in your app db | State that must survive a restart: drafts, soft locks, dismissed-banner flags. |
database("/path.db") | a separate SQLite file (a /dev/shm tmpfs path = fast shared scratch) | Heavier ephemeral state you want isolated from the domain db; pair with a notify mechanism for cross-process later. |
Be honest about the singleton: a module-level ephemeral store is process state,
shared by everything in the process — which is exactly how two viewers see each other. In
production that's the point; in tests it would leak between cases, so each store self-registers
and testApp() resets them on construction. The ergonomic stays; "isolated by
construction" stays honest.
Why this is cheaper than it sounds
Re-rendering a whole region on every change looks expensive. Three things make it not so:
- Morph, not replace. Datastar applies only what changed to the DOM, preserving focus and input state. The server sends a full region; the browser applies a surgical patch.
- Brotli's streaming window. The compressor sees the previous, nearly-identical fragment in its history — repeated HTML costs almost nothing on the wire.
- Send-suppression. If a repaint produces byte-identical output for a given stream, the belt doesn't send it at all. Model-level dirty checks cut further upstream, before the view even runs.
The deeper trade is about reasoning: there is no diff bookkeeping to get wrong. There is nothing to debug but your render. A dropped SSE frame, a reconnect, an out-of-order delivery — all heal on the next push, because the next push is always the full current truth.
The lineage: this shape — server-rendered HTML, morphed over SSE, one SQLite node as the source of truth — is the one Anders Murphy's Hyperlith (github) proves out in Clojure. orion carries it into a Node toolbelt — in the spirit of, not a port.