quickable
Guides

A couch multiplayer game

Server-authoritative realtime with optimistic CAS — a shared tally game two people play from their phones.

Multiplayer on quickable is the realtime primitive used symmetrically: every player's browser sends intents, the server commits them one CAS at a time against a single KV key, and every committed move fans out to all connected sandboxes. No game server to write, no websocket plumbing — the platform is the game server.

What the platform guarantees

  • No lost acknowledged move. An intent is acknowledged only after its KV commit; a move computed from stale state fails CAS server-side and is retried from a re-read.
  • Idempotent retries. Each intent carries a client cmdId; a retry within the dedup window (60 s) returns the recorded result without re-applying.
  • Durable resume. State lives in KV. A player who disconnects re-syncs with one rt.resume {fromRev} and receives the state plus every missed move.
  • Cheap ephemera. Cursor-class traffic (rt.cursor) is fire-and-forget fanout with no persistence — right for pointers and presence, wrong for score.

The game

A shared tally: anyone can add points to either team; everyone sees the same committed truth. Create it like any app (see the companion guide for the bootstrap and MCP/REST calls):

api_app_create   { "name": "tally" }
api_material_put { "app": "tally", "path": "app.tsx", "src": "<below>" }
import { rt, state } from "@quickable/bridge";
import { useEffect, useState } from "react";

const TEAMS = ["red", "blue"] as const;

export function App() {
  const [snap, setSnap] = useState(state.get());
  useEffect(() => state.subscribe(setSnap), []);
  const doc = (snap.state ?? {}) as Record<string, number>;

  return (
    <main style={{ fontFamily: "sans-serif", padding: 16, display: "flex", gap: 24 }}>
      {TEAMS.map((team) => (
        <section key={team} style={{ textAlign: "center" }}>
          <h1 style={{ color: team }}>{doc[team] ?? 0}</h1>
          <button type="button" onClick={() => rt.intent("inc", { key: team })}>
            +1 {team}
          </button>
        </section>
      ))}
      <small>rev {snap.rev}</small>
    </main>
  );
}

Both players open $BASE/a/<ns>/tally on their phones. Each tap is api.rt.tally.intent {type:"inc", payload:{key:"red"}} under the hood; the committed result — delta plus full post-commit state — arrives at every open sandbox and both screens tick in lockstep. Mash both buttons at once from both phones: CAS serializes every move, and revisions stay gapless per client (a gap triggers an automatic resume).

The sandbox API in one look

Inside the iframe, @quickable/bridge (injected, no install) is the whole surface:

CallMeaning
rt.intent(type, payload)Apply one server-authoritative move; resolves {rev, result, dedup} after commit.
rt.resume(fromRev)Authoritative state + missed deltas — reconnect/first paint.
rt.cursor(payload)Fire-and-forget ephemeral fanout (presence, pointers).
rt.onEvt(fn)Raw app-scoped events (evt.<app>.…), unsubscribe returned.
state.get() / state.subscribe(fn)The live {rev, state} snapshot, kept fresh from the event stream.
call(subject, payload)Generic typed call — the parent forwards only bridge-grant subjects.

The deployed reducer is the shared JSON document (set / merge / inc / del). Game rules beyond that (turn order, legality checks) belong in a custom server-side reducer — the CAS/dedup/replay machinery is shared and reducer injection is a core-service extension point (RtDeps.reduce), not something untrusted clients can override.

Who may join

Rendering and playing requires a bridge session for the app (api.session.<app>.mint, owner-only, bearer TTL ≤ 1h) — the shell connects with it and it can reach exactly api.rt.<app>.> plus that app's events, nothing else (broker-denied from material and app management). Sharing a game means the owner mints sessions for its viewers; an expired session re-resumes with a fresh mint. Both players share one app's state by that app's design — that is the multiplayer feature, scoped to exactly that app (D4).

On this page