1. Home
  2. Guides
  3. How it works

How it works

Updated · Markdown

For whoever extends it next: you, a developer, or your AI.

One Worker, four kinds of storage

            phones                       presenter
   /K7M4P   /@handle   /             /app/   /stage/   /console/
      |         |      |                |        |          |
      +---------+------+-------+--------+--------+----------+
                               |
                     Cloudflare Worker (src/index.ts)
        static files straight from the edge, no Worker involved
                               |
     +-----------+-------------+---------------+---------------+
     |           |                             |               |
     D1          R2                    Durable Object: Room    MCP + API
  users, decks,  slides, pictures,     one per live session:   for agents
  versions,      coded decks,          every connection,
  sessions       photos, voice notes   every answer (SQLite)
  • D1 (SQLite) holds presenters, decks (one JSON document each), every saved version, and the list of sessions. remotes holds phones paired as remotes: a pairing code works once, for three minutes, and each phone that claims one gets a key of its own the presenter can remove (only hashes are kept).
  • R2 holds files: a/ pictures and downloads, u/ coded decks, m/ the room's photos and voice notes. Everything is served with a sandboxing Content-Security-Policy.
  • A Durable Object per session is the live room. Every phone, the stage, the console and any remote hold a WebSocket to it. It decides what phones show on each beat, checks and stores answers, and rolls them up. It uses the hibernation API, so a quiet room costs almost nothing.
  • Static files (the app, the stage, the phone app, stickers, fonts) come from Cloudflare's edge without waking the Worker at all.

Brand and sites

  • brand.json holds the name, tagline, domain and pictures. npm run rebrand writes it, then public/lib/brand.js (which the app and the Worker import) and every place a page needs it before any script runs, marked with data-brand attributes. Only marked places are touched.
  • wordmark says whether the logo spells the name. Pages write the name beside a symbol only (data-brand-if="symbol" in pages, public/lib/ui/logo.js in the app), so a wordmark is never doubled. logo_width and logo_height keep its proportions while it loads.
  • scripts/brand-pictures.mjs (sharp) makes the web copies: logo.webp, the icon set in public/brand/ (512, 192, 180 for iPhones, maskable for Android), favicon.ico, share.jpg for link previews, and site.webmanifest. Every page carries the same icon links. Join links get the preview picture at their own address (an HTMLRewriter in src/index.ts), so previews work on any domain.
  • sites.json lists the sites this code runs on: account, worker, database, bucket, address. wrangler.toml is the plan for all of them and runs the laptop copy; scripts/sites.mjs writes each site's copy (.site-<id>.toml) before running wrangler for it.

The public landing page

public/index.html and public/css/landing.css form the public home page. public/lib/landing/landing.js coordinates it, lazily loading the local demos in demos.js; sound.js supplies synthesized interaction sounds. These previews use sample data and pause their ongoing work when inactive. The code box and account/early-access flows still connect to the app.

The landing-page guide records the hero tour, AI preview, video behavior, volume and timing controls, responsive rules and validation limits.

One source of truth for the deck format

public/lib/core/ is plain JavaScript that runs in the browser and in the Worker:

FileHolds
spec.jsThe deck format: themes, settings, validation, repair, beats.
layouts.jsEvery stage layout and phone view, as field lists. The parts of a video slide.
interactions.jsEvery question type: fields, answer checks, roll-ups (and split roll-ups), presets.
personal.jsQuestions that depend on who answers: who sees a screen, {name} and {question} tokens, splits.
fields.jsThe field mini-language: validation, defaults, descriptions.
patch.jsJSON Patch with id paths, merge, and exact inverses for undo.
sources.jsFolding re-imported files into a deck without losing work.
css.jsA deck's own CSS, scoped so it can never touch the app around it. An @import stays only for a font service's sheet, its address read the way a browser reads it.
sandbox.jsThe two tiers a presenter's own HTML runs in: the open stage tier and the sealed audience tier (phones). Builds each one's Content-Security-Policy.
guard.jsWhat a room is never asked for: a card number (Luhn), a password or one-time-code field.
words.jsWords that wait for the presenter: a name, a word or a question from the room with a swear word or a slur in it.
kit.jsThe library kit's import map and catalog (three.js, GSAP, and more), written by scripts/kit.mjs; merging a page's own map into it.
motion.jsTransitions as data: the built-ins, checking a design, resolving what plays.
ids.js, templates.jsIds and join codes; the tour deck.
starters.jsStarter templates for custom slides and phone screens.
catalog.jsThe catalog agents read, generated from the above.
brief.jsThe note a presenter pastes into their own AI with a request, and the link that opens a deck.

The editor draws its panels from the same field lists the server validates with, and the agent catalog is generated from them too. They cannot disagree.

Drawing lives in public/lib/render/, shared by the stage, the console, the editor and phones:

FileDraws
slide.jsOne slide at one beat, for any screen and any size.
views.jsLive results: bars, pillars, the ball pit, clouds, walls, splits.
pit.jsThe ball pit's physics: a small solver that sleeps once settled.
inputs.jsWhat phones answer with.
coded.jsCoded decks and Keynote exports, in sandboxed frames (with /kit/bridge.js).
frames.jsThe documents a custom slide and a phone screen are written into: the bridge, the kit's import map and the deck's theme, and the relay that hands the frame the kit files it imports.
motion.jsThe motion engine: plays a transition with the browser's own animations (layers, pieces one by one, magic move, backwards in time for going back). Built for frames as /kit/motion.js by scripts/motion.mjs.
videos.jsOne video player per file under the slides, so the parts of a video deck play on without reloading.

public/lib/live/client.js is the one connection every screen and phone holds to the room: it falls back to following the room's feed over HTTP, and backs off when the room does not answer.

Adding a question type

Say, ranking: drag options into order.

  1. public/lib/core/interactions.js: add TYPES.ranking with fields, check(ix, value) (is this a valid order?), aggregate(ix, rows) (average position per option), views (['bars'] to start) and sample(ix). Add a preset to PRESETS if it deserves a tile.
  2. public/lib/render/inputs.js: add INPUTS.ranking(host, ctx), the phone screen. Call ctx.send(value) when they are done.
  3. public/lib/render/views.js: a new view only if none fits.

That is all. The Worker, the live room, the editor, validation, the agent catalog and the MCP tools pick it up from those files.

Adding a layout

  1. public/lib/core/layouts.js: add it to LAYOUTS with fields, steps(stage) and make().
  2. public/lib/render/slide.js: add its HTML to HTML, sized in cqw and cqh, with data-s="n" on whatever lights up on beat n.
  3. public/css/slide.css: its styles, using only theme variables.

The editor's insert sheet and Screen tab show it automatically.

The live protocol

Messages are small JSON objects on one WebSocket.

FromMessage
phoneanswer {iid, value, rid}, vote {item, on}, react {k, n}, name {name}, unsend {item}, signal {k, d}, sync
presentergoto {slide, step, nonce}, nav {dir}, control {action, ...}, broadcast {k, d}, names {q}, sync
room to phoneswelcome, view {seq, view, spread}, ack {rid, ...}, agg, clear, epoch, deck, crowd, signal {k, d}
room to screenshello, state, agg, people, cheer, deck, play, signal {k, d, from}, names, remotes

Live signals are cues kept nowhere. A phone screen's Room.emit is a signal to the presenter's screens, with from, a short stable tag for that phone; the big screen's Stage.broadcast (or an agent's broadcast command) is a signal to every phone. Names are letters first, up to 32, and the data is JSON up to 512 bytes. A phone sends at most 15 a second and the big screen 20; a phone's signal is dropped while the room is paused, or if a string in it is a word the room would filter.

A view meant for only some people carries when and otherwise; every phone gets the same view and picks its own branch, and the room picks the same way before it keeps an answer. Results split by an earlier answer travel as agg under question@by.

src/room.ts handles everything in handle(). Phones that cannot hold a WebSocket (some venue networks break them) send with /live/CODE/act, through the same handle(), and follow /live/CODE/events: the room's numbered feed of every message to phones, read with long polls. A read answers at once with what came after the last one, or waits for the next message, or 25 seconds; a phone that is new, behind, or from before the room restarted (another era) gets what phones see now. They hear what phones with a socket hear, as fast, and a quiet room costs one request a phone every 25 seconds. (/live/CODE/view stays for pages from before.)

From 250 people (CROWD), the room tells phones to show still stickers: 15 KB a phone instead of 228 KB, on a Wi-Fi a whole room shares.

The stage moves the moment a key is pressed and tells the room after, with a nonce so its own echoes are ignored. If the network drops, the talk carries on and the room catches up on reconnect.

Security, briefly

  • Passwords: PBKDF2-SHA256. Sessions and agent keys are random tokens, stored only as hashes.
  • Browser requests that change anything must carry the app's own header, so other sites cannot forge them.
  • Every user file and every coded deck is served sandboxed, with no origin, so even hostile HTML or SVG cannot reach anybody's session.
  • Custom HTML runs in sandboxed frames and talks only through postMessage bridges (/kit/bridge.js, /kit/phone.js). There are two tiers (core/sandbox.js). The stage tier, on the presenter's own machine (the big screen, the console, the editor), is open inside the frame: any library from any CDN, but no popups and no downloads, in coded decks and custom slides alike. The audience tier, on phones, is sealed: served at /p/ with a strict per-request Content-Security-Policy that lets a page reach only the self-hosted library kit (/vendor/kit/, built by scripts/kit.mjs), the deck's own files and a font service. No fetch, picture, socket, form or navigation reaches another site, so whatever a presenter's screen asks for, anything typed into it can go only to the room. The phone page may frame only /p/, and nothing under /p/ redirects, so a screen cannot slip its frame to a looser policy.
  • The room is the last gate on what the audience sends (core/guard.js): a payment-card number, or a field named like a password or a one-time code, is refused, and a phone screen asking for one is never served. Every custom phone screen also carries a line, which it cannot hide, saying never to type a secret into it.
  • The big screen sees public roll-ups only: a filtered word and a hidden name stay on the moderation console and never reach the projector, or a coded or custom deck drawing the answers there. The console is sent the public roll-ups too, worked out by the room beside its own, and a custom slide it shows gets only those.
  • A presenter's phone screen is told the person's own name (Room.me.name) only while the room shows it. A filtered, hidden or removed name reaches no screen, and the room never sends a phone anybody else's.
  • A deck's CSS is scoped to the deck (core/css.js). The one way out of the scope, @import, is kept only for a font service's sheet over https, and only when its address is written plainly: no escapes, so what is checked is what the browser fetches.
  • What a phone says in its own voice (the line under a presenter's screen, where a link really goes, asking to use the tilt, a refused card number) lives in ui/trusted.js: a closed shadow root in the browser's top layer, its host's styles pinned with !important from inside, outside the part of the page a deck's CSS can reach. A deck can restyle every question, never those words.
  • Transitions a deck designs are data (core/motion.js): checked on save against a list of visual properties, with url(), braces and semicolons refused and every time clamped, and played with Element.animate, which takes values, never code. Code that moves things runs only inside a sandboxed frame: a coded deck, a custom slide, a phone screen.
  • Phones never see speaker notes or a quiz's answer before it is revealed.
  • Photos are shrunk on the phone before upload, which also strips location data. The room's photos and voice notes are served private: never kept by a shared cache, so deleting a session deletes them everywhere.
  • MCP takes a key, never a cookie. A cookie opens the presenter's socket only from this site's own pages.
  • Sign-in limits count an IPv6 address by its /64. A browser that has signed in to an account before is counted on its own, so strangers' guesses never lock the presenter's own laptop out (known_browsers). The audience path has no per-address limits (a venue is hundreds of phones behind one address); only different wrong codes count, 300 a minute per place, so a script cannot hunt for live rooms, while a whole room typing the same stale code counts once.
  • Room safety. public/lib/core/words.js filters words, questions and names with a swear word or slur (English, Hinglish, some Devanagari, seen through l33t, spacing and stretching): each waits for the presenter's OK; the room stores why in the item's meta. A custom screen's answer is read the same way, every string and key in it, and a filtered one is kept for the console and left out of every public roll-up until the presenter shows it. The deck's switch and each question's own ("Filter bad words") must both be on for a question's answers to be filtered; the deck's own words wait regardless. A filtered or hidden name shows as "Guest 12" wherever names are public (people.hidden: 1 filtered, 2 hidden by the presenter). The presenter can remove a person (people.removed: their answers, items, votes and files go, their sockets close with 4403 and the reason), pause answers (state.paused, also on phones' view) and close the room to new people (state.locked).

Offline mode

The same Worker, run on the presenter's computer for a venue with bad internet or none: built into one minified file (the bundle wrangler deploy uploads) and run by Miniflare on workerd, Cloudflare's own runtime. It starts in a fraction of a second, and the desktop app ships the same file, never the source. The full design is design/OFFLINE-MODE.md; the presenter's guide is Offline mode.

 phones ──venue Wi-Fi──> audience door ──> the copy (the same Worker)
                         offline/door.mjs   listens on localhost only,
                         an allow list:     its own D1, R2 and rooms
                         join, phone page,  in .offline/
                         the room's socket        ^
                                                  | localhost
                                       the presenter's browser
  • Two doors. The copy listens on this computer only: the presenter's door. Phones come in through the audience door (offline/door.mjs), which passes on only what a phone needs and answers 404 to everything else: no app, no API, nothing a cookie opens. It sends each request on with the phone's own address, and without any cookie, Authorization or header a phone could pass itself off with. The presenter's phone as the clicker and a second screen come in the same way as online, with their keys.
  • Two addresses. The copy calls itself by the venue address (Miniflare's upstream), so join links and the QR name what phones reach, while the presenter's own screens stay on localhost (originsOf in src/live.ts, meta.origin in the room).
  • Linking a computer. It shows a code; the presenter allows it in the app; it collects a key of its own with scope offline, which reads that presenter's decks and files and brings rooms back, and is no key at all anywhere else (SCOPES in src/auth.ts).
  • Copying. offline/prepare.mjs copies the account (the same ids), the decks and every file they use into the copy, with its own secret (OFFLINE_SECRET, src/offline.ts). Again, only what changed.
  • Bringing rooms back. The room packs every row it keeps (src/room-dump.js); the cloud checks every field before it keeps any, unpacks it into the session's own room, ended, and asks for the photos and voice notes it does not have. An ended room then leaves the computer.
  • Off on a copy: sign ups, keys, emails, the AI tools and fetching from the web (CLOUD_ONLY in src/index.ts).
  • The desktop app (desktop/, Electron, one code base for Mac and Windows). desktop/src/engine.mjs does everything (link, decks, copy, start, stop, sync) on top of offline/, and runs in plain Node for tests; main.mjs is the glue: the windows, the keychain (the key is kept encrypted by the system, never in a file as text), the stage on the second display, sleep kept off while the room runs. Every window is sandboxed with context isolation; only our own pages load in them, and the control window reaches the app through one channel and a short list of calls. desktop/build.mjs stages only what the app runs: the minified Worker (no source map), public/, the migrations, Miniflare and workerd. The runtime and what it reads sit outside the app's archive, where a program can run; MINIFLARE_WORKERD_PATH points Miniflare there.

The library kit, and why the page hands it over

scripts/kit.mjs builds the kit in one pass, with shared chunks, so every library that needs React (or three's core) gets the same copy; CommonJS packages are given their names one by one; three's whole examples/jsm tree is there as three/addons/.... Inside the kit, files name each other @kit/<path>, never by a relative path, and graph.json says which file needs which.

A frame with no origin of its own never reuses the browser's cache (measured: every new frame downloads a module again). So for a custom slide and a phone screen, the page around the frame, whose cache works, fetches the kit files the author's code imports (render/frames.js) and sends their text with the document; kit/frame.js makes each a blob: module and points the import map at it. A phone downloads three.js once a talk, not once a slide. Coded decks load once and stay loaded, so they fetch by address.

Moving between slides

render/motion.js plays a transition (core/motion.js) on layers: the leaving and arriving slide, and the coded deck's frame and the video under them when those change too. The layers over a coded deck's frame catch the pointer, so a click on the edge moves the show. Only while the deck or the slide allows the mouse (settings.mouse, stage.mouse, off by default, core/spec.js codedMouse) and its bridge is listening do they let it through (css/stage.css), so the deck takes the mouse, trackpad and pen; the app's own controls stay above. A frame is listening once its bridge says ready, until it loads again (render/coded.js). A page the deck linked to is not: it hears no answers, takes no pointer, and the keys come back to the stage. A move going back plays backwards in time unless it has its own back. A press while a move plays ends that move at once and starts the next, so slides never stack. A coded deck's own pages play the same definitions through /kit/motion.js in its frame, with magic move between data-morph pieces, and Stage.transition(fn) for code of its own.

Limits worth knowing

  • A deck: 500 slides, 300 questions, about 1.5 MB of JSON.
  • A session: one Durable Object. Comfortable into the thousands of phones; results reach phones at most every 1.5 seconds (a little slower above a thousand phones, 3 seconds at 2000) so a big room stays light. Phones are sent at most 40 items of a list. After a drop, phones come back spread over 2.5ms per phone in the room (spread, at most 8 seconds), about 400 a second, under what one Durable Object takes. A session makes room for 10,000 people and keeps 4 GB of photos and voice notes.
  • A phone keeps anything the room has not acknowledged and sends it again on the next line, for five minutes; the room takes a repeat as the same answer.
  • Uploads: pictures 25 MB, videos 95 MB, PDFs and zips 60 MB, coded deck files 25 MB each, phone photos 12 MB, voice notes 6 MB. Each presenter has 2 GB unless the owner says otherwise.

Tests

npm test          # the deck format, patches, question types, transitions, the sandbox tiers, the live room, room safety, the brand
npm run test:setup  # setup and deploy, against a pretend Cloudflare
npm run dev       # in one terminal
npm run test:live # in another: accounts, decks, a live room with six phones, MCP
npm run test:offline  # also with npm run dev: offline mode end to end, PHONES=1500 for a crowd
npm run test:desktop  # also with npm run dev: the desktop app's engine, as its buttons drive it
node test/desktop-smoke.mjs  # the app itself, windows hidden, a picture of every screen
PHONES=300 node test/load.mjs   # a crowd: 300 phones join and answer at once
npx playwright-core install chromium webkit firefox   # once, for the browser tests
npm run test:browser  # also with npm run dev: the sealed phone tier, the library kit, the Stage bridge, phone screens, the stage, in a real browser
BROWSER=webkit npm run test:browser   # the same in Safari's engine; BROWSER=firefox for Firefox
npm run check     # writing style and file weights

All guidesJoocy home