← 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.

read · long-lived

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.

write · short-lived

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.

ScaleBus implementationFeature code changes
Single nodein-process Map (the default)none
Multi-nodeRedis / NATS adapternone
Cloudflarein-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:

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:

ProviderBackingFor
memory()process-local Map; dies with the processThe 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 dbState 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:

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.

orion ✦ a belt of stars · built on datastar