Live rooms — a place people are in at the same time
prompts/pkg/llms/live-rooms.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
A room is a place several people are in at once: a game table, a shared build, a chat thread, a watch party, a standup. Every room has the same four parts, and each part has one home:
- The room is in the URL.
#room=<id>names it, so the link someone copies from the address bar is the invitation, and opening it is joining. - Presence is a draft that is never saved. Each person
merge()s a small presence document on a heartbeat; the platform drops it about 12 seconds after its last update and when the tab disconnects. Who is here is the set of fresh presence rows. - What lasts is saved, one document per thing, applied locally first and written after, so nobody waits on the network.
- A turn game passes one state document from seat to seat, and a shared clock is a time stamp every phone counts down to.
The who-is-here chips, the "N here" badge, cursors and the invite button are drawn by the presence surface below this doc; this doc hands it the people.
The room is in the URL
The app's hash is its own address: set it with history.replaceState, and the
platform mirrors it into the address bar the person sees and copies. A link
opened later reaches the app as a hashchange after the first render, so read
the hash on load and listen for it.
A new room is a short random id the creator's press saves as a room document
(_id: room:<id>). That saved document is what opens the room's channel:
a presence draft reaches only the peers already on its channel, and a channel's
grant comes from a saved document, so save the room on the creator's press,
never on load. An app with one shared space (one world, one board for the
office) uses a fixed id and saves its room record on the first person's first
action.
The invite is the room's public address, copied with
navigator.clipboard.writeText. The app's own host names its handle and slug
in its first label (<slug>--<handle>.…), so the address is built from that.
Presence: a heartbeat, an expiry, and who is here
- One presence draft per person per room:
_id: presence:<roomId>:<handle>, withtype,roomIdanduserHandleas its index fields.merge()it, neversave()it. A saved presence row would outlive its person forever. - Heartbeat. Send when the state changes, at most a few times a second, and every 4 seconds while nothing changes, so a still player stays inside the platform's 12-second expiry. Keep fast-changing state (a pose, a cursor) in a ref the heartbeat reads, not in React state.
- Expiry on the receiver. Stamp each row with the time it ARRIVED (a peer's
clock is not yours) and count a person as here while that arrival is under 15
seconds old. Rely on disconnect and expiry for leaving: a
merge()sent while a component unmounts may never reach anyone. - You are here too. Your own draft does not come back through your own live query, so who-is-here is you plus the fresh peers, one entry per handle.
- Same person, two tabs: one id per handle shows them once. If each tab must count (two cursors), suffix the id with a per-tab id and let the access rule accept that suffix.
- Signed-in people are present. A presence draft has to name its sender, so
an anonymous visitor watches the room (its channel is public) and is asked to
sign in to join, with
requestLogin()and the app's own words.
Saved state: optimistic, one document per thing
- Apply locally, then write. Update the screen from the person's own move
first and
putafter, so the room never waits on the network. - Know the substrate. A live query returns the latest version of every
matching document, deliveries can arrive out of order, and when two writes
target the same
_idthe server keeps whichever ARRIVED last, not the newer client time stamp. So give each move, message, mark or cell its own document with a deterministic_id, and never fold the whole room into one document everybody saves: one person's save would erase everyone else's. - Clear an optimistic overlay only when the synced value AGREES with it, never when the key merely exists: a re-edited key always has an older synced value, and clearing against it flashes the old state back.
- Gate the sync, never the local edit. Read
can.create(...)to decide whether to write; a slow or closed verdict must never freeze the room. - Many people editing the same keys many times a second (a shared block world) need an ordering fold on top of this; the engine skill that draws that world owns it.
Turns and the clock
- The game is one
statedocument per room (_id: state:<roomId>) withseats,phase("seating"then"playing"),turnand the board. A move writes the next state withturnpassed to the next seat; every phone renders from the live query, and a short highlight on the moved piece shows what changed. - Seats. While
phaseis"seating", a signed-in person takes the next seat by appending their own handle; a seated player starts the game, which fixes the seats and gives the first turn toseats[0]. A rejected seat means someone sat first: re-read and try again. - The turn clock is a time stamp, never a running timer: the mover writes
turnEndsAt: Date.now() + TURN_MS, and each phone showsMath.max(0, turnEndsAt - Date.now())on a short interval, so every screen ticks together. PastturnEndsAt, any seated player may pass the late turn. - Rounds a host runs (a quiz, a reveal) keep
{ round, phase, endsAt }on the room document; only the host writes it, and the whole room flips at once. - Tell the next player. A turn that waits on someone away from the app is a
ctx.notifyfrombackend.jswithpath: "#room=<id>", so the tap lands in the room.
A room people can join
lib/room.js
jsimport React from "react";
const HEARTBEAT_MS = 4000; // well inside the platform's 12 s draft expiry
const SEND_EVERY_MS = 250; // a moving cursor sends at most four times a second
const FRESH_MS = 15000; // counted as here while the last arrival is this recent
const ROOM_IN_HASH = /(?:^#|&)room=([\w-]+)/;
const readRoom = () => ROOM_IN_HASH.exec(window.location.hash)?.[1] ?? null;
export const newRoomId = () => crypto.randomUUID().slice(0, 8);
/** The room named in the URL, and a way to move to another one (null leaves). */
export function useRoomId() {
const [roomId, setRoomId] = React.useState(readRoom);
React.useEffect(() => {
const onHash = () => setRoomId(readRoom());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const go = React.useCallback((id) => {
history.replaceState(null, "", id ? `#room=${id}` : window.location.pathname + window.location.search);
setRoomId(id);
}, []);
return [roomId, go];
}
/** The room's public address, from the app's own host (`<slug>--<handle>.…`); a platform value replaces this in #8343. */
export function roomLink(roomId) {
const label = window.location.hostname.split(".")[0];
const cut = label.indexOf("--");
return `https://vibes.diy/vibe/${label.slice(cut + 2)}/${label.slice(0, cut)}#room=${roomId}`;
}
/**
* Sends this person's presence draft. Mount it with key={roomId + ":" + handle} so a new
* room starts a new draft. `stateRef.current` (a cursor, a pose, a selected cell) rides along.
*/
export function Presence({ roomId, handle, useDocument, stateRef }) {
const { merge } = useDocument({ _id: `presence:${roomId}:${handle}`, type: "presence", roomId, userHandle: handle });
React.useEffect(() => {
let sent = "";
let lastSent = 0;
const beat = () => {
const state = stateRef?.current ?? null;
const stamp = JSON.stringify(state);
const now = Date.now();
if (stamp === sent && now - lastSent < HEARTBEAT_MS) return;
merge({ state, at: now }); // broadcast only: presence is never saved
sent = stamp;
lastSent = now;
};
beat();
const timer = setInterval(beat, SEND_EVERY_MS);
return () => clearInterval(timer);
}, [merge, stateRef]);
return null;
}
/** Who is here: you first, then every peer whose draft arrived in the last FRESH_MS. */
export function useHere(roomId, useLiveQuery, me) {
const { docs } = useLiveQuery("roomId", { key: roomId });
const arrivals = React.useRef(new Map()); // _id -> { at, seenAt } stamped on OUR clock
const [now, setNow] = React.useState(() => Date.now());
React.useEffect(() => {
const t = Date.now();
for (const d of docs) {
if (d.type !== "presence") continue;
const prev = arrivals.current.get(d._id);
if (prev === undefined || prev.at !== d.at) arrivals.current.set(d._id, { at: d.at, seenAt: t });
}
setNow(t);
}, [docs]);
React.useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 3000);
return () => clearInterval(timer);
}, []);
const people = new Map();
if (me) people.set(me, { handle: me, state: null, isMe: true });
for (const d of docs) {
if (d.type !== "presence" || !d.userHandle || people.has(d.userHandle)) continue;
const seen = arrivals.current.get(d._id);
if (seen && now - seen.seenAt < FRESH_MS) people.set(d.userHandle, { handle: d.userHandle, state: d.state, isMe: false });
}
return [...people.values()];
}
App.jsx
jsx// The room's wiring; the presence bar (the surface doc's HereBar) draws its people.
import React from "react";
import { useFireproof } from "use-fireproof";
import { useViewer, useVibe } from "use-vibes";
import { Button, Skeleton, Stack } from "@vibes.diy/ui";
import { Presence, newRoomId, roomLink, useHere, useRoomId } from "./lib/room.js";
import { HereBar } from "./lib/here.jsx";
export default function App() {
const { database, useDocument, useLiveQuery } = useFireproof("room");
const { can, me } = useVibe("room");
const { requestLogin } = useViewer();
const [roomId, go] = useRoomId();
const handle = me?.userHandle;
const cursorRef = React.useRef(null);
const here = useHere(roomId, useLiveQuery, handle);
// The next room's id is drawn up front, so the verdict reads the very document the press writes.
const [nextId, setNextId] = React.useState(newRoomId);
const draft = { _id: `room:${nextId}`, type: "room", roomId: nextId, hostHandle: handle };
const canOpen = can.create(draft);
async function startRoom() {
go(nextId); // the screen moves at once; the saved room opens its channel for everyone after
setNextId(newRoomId());
await database.put({ ...draft, createdAt: Date.now() });
}
if (!roomId) {
if (!handle) return <Button onClick={requestLogin}>Sign in to start a room</Button>;
if (canOpen.pending) return <Skeleton />;
return (
<Button onClick={startRoom} disabled={canOpen.ok !== true}>
Start a room
</Button>
);
}
return (
<Stack>
{handle && <Presence key={`${roomId}:${handle}`} roomId={roomId} handle={handle} useDocument={useDocument} stateRef={cursorRef} />}
<HereBar people={here} onInvite={() => navigator.clipboard.writeText(roomLink(roomId))} />
{!handle && <Button variant="quiet" onClick={requestLogin}>Sign in to join the room</Button>}
<Button variant="quiet" onClick={() => go(null)}>Leave</Button>
</Stack>
);
}
access.js
jsexport default function (doc, oldDoc, user, ctx) {
const room = (id) => `room:${id}`;
if (doc.type === "room") {
if (!user) throw { forbidden: "sign in to start a room" };
if (typeof doc.roomId !== "string" || doc._id !== room(doc.roomId)) throw { forbidden: "rooms live at room:<id>" };
if (oldDoc && oldDoc.hostHandle !== user.userHandle) throw { forbidden: "only the host changes the room" };
if (!oldDoc && doc.hostHandle !== user.userHandle) throw { forbidden: "you host the room you start" };
return { channels: [room(doc.roomId)], grant: { public: [room(doc.roomId)] } };
}
if (doc.type === "presence") {
// Never saved, but the broadcast still runs this branch to find its channel.
if (!user || doc.userHandle !== user.userHandle) throw { forbidden: "not you" };
if (typeof doc.roomId !== "string" || doc._id !== `presence:${doc.roomId}:${user.userHandle}`) throw { forbidden: "presence ID" };
if (typeof doc.at !== "number") throw { forbidden: "presence needs its time" };
return { channels: [room(doc.roomId)] };
}
if (doc.type === "state") {
if (!user) throw { forbidden: "sign in to play" };
if (doc._id !== `state:${doc.roomId}` || !Array.isArray(doc.seats)) throw { forbidden: "one game per room" };
if (!oldDoc) {
if (doc.seats.join() !== user.userHandle || doc.phase !== "seating") throw { forbidden: "start seating with your own seat" };
} else if (oldDoc.phase === "seating") {
const kept = oldDoc.seats.every((s, i) => doc.seats[i] === s);
// Taking a seat changes the seats alone: the phase stays seating and the turn is untouched.
const sat = kept && doc.phase === "seating" && doc.turn === oldDoc.turn && doc.seats.length === oldDoc.seats.length + 1 && doc.seats.at(-1) === user.userHandle && !oldDoc.seats.includes(user.userHandle);
const started = doc.phase === "playing" && doc.seats.join() === oldDoc.seats.join() && oldDoc.seats.includes(user.userHandle) && doc.turn === doc.seats[0];
if (!sat && !started) throw { forbidden: "take the next seat, or start with these seats" };
} else {
if (doc.seats.join() !== oldDoc.seats.join()) throw { forbidden: "seats are fixed" };
const late = typeof oldDoc.turnEndsAt === "number" && Date.now() > oldDoc.turnEndsAt && oldDoc.seats.includes(user.userHandle);
if (oldDoc.turn !== user.userHandle && !late) throw { forbidden: "wait for your turn" };
const next = oldDoc.seats[(oldDoc.seats.indexOf(oldDoc.turn) + 1) % oldDoc.seats.length];
if (doc.turn !== next && !doc.winner) throw { forbidden: "the turn passes to the next seat" };
}
return { channels: [room(doc.roomId)] };
}
throw { forbidden: "unknown document type" };
}
Rename room to the app's own noun (table, world, party) in ids, types and copy, and
keep every document a room holds on the room's channel with its roomId field, so one live
query on roomId returns the room's people and its things. Each type the app adds (a move, a
message, a mark) gets its own branch before the final throw. A room only its members may enter
swaps grant.public for member grants and adds ctx.requireAccess(room(doc.roomId)) to the
presence branch.