Technical documentation · single-file web app

French Toast

A real-time party game for a group of friends, together or apart, each on their own browser — built as one HTML file with no build step, no framework, and one live Firebase listener per player.

Source
1 file
Lines
1,653
Build step
none
Backend code
0 lines
Live listeners
1 / player

Play the game →

01 · Overview

What it is

It started as a game we played out loud on holiday. The app came later, so we could keep playing once we'd gone home — without one person running the whole round by hand in a group chat.

There was a second reason to build it. I wanted to learn how a real-time multiplayer app fits together, and to get better at working with an AI assistant on something that had to hold up in use rather than a tutorial exercise, so I built this with AI throughout. A game your friends actually play turns out to be a good test of both — they tell you when it breaks, and the sections that follow are largely a record of the things I only understood after it did.

One player is the host and picks a secret noun. Everyone else is a guesser, and nobody may ask a direct question about it. The only legal move is a comparison:

“Is it more like French Toast or a pancake?”

Every round opens from the same place: the first comparison is always against French Toast, which is where the game gets its name.

The host answers by tapping one of the two options. That answer becomes the left-hand side of the next comparison, so the question drifts steadily toward the secret as the group narrows it down. Guessers can also request a free-text clue, guess outright, or collectively concede. Whoever guesses correctly hosts the next round.

Everything runs in a mobile browser with no install. One player opens the file, taps Create a room, and taps the six-character code to copy it; everyone else pastes it into their own browser to join. Two is enough for a game — a host and one guesser — and it stays comfortable up to about eight, past which the host spends the whole round answering. Nothing in the app enforces either end: a room holds as many people as join it. The screens below show a three-player round.

The opening screen: a name field, a Create a room button, and a room-code field for joining.
Create or join
The lobby showing the room code above a Tap the code to copy hint, the player roster, and the host's secret-noun field.
Lobby · tap to copy
The whole setup is two screens. The code is the only thing that has to travel between players; tapping it copies it, which is what makes pasting it into a group chat the natural way to invite everyone.
02 · Foundations

Stack and self-imposed constraints

The whole app is french-toast.html — roughly 440 lines of CSS, 150 of markup, and 1,050 of JavaScript in a single <script type="module">. There is no package.json, no bundler, and no server code. It is also the file the game link serves, unminified and commented — open the game and View Source (Ctrl/⌘ + U) to read along with the extracts below. Two things load from a CDN at runtime: the Firebase modular SDK (v10.12.2, app + database only) and three Google Fonts.

The single-file shape was a deliberate choice. A party game gets opened cold, by people who just want to start playing — so the deploy story had to be “host this file anywhere,” and the cold-start cost had to be one HTML request plus one socket.

The shape suited how it was built, too. One file with no build step means the whole app fits in an AI assistant's view at once, so a question about the presence code could be answered with the rendering code still in front of it, rather than reasoned about across a tree of modules. It also means there is nothing between what you read and what runs. The cost of that is in the table below: no type checking and no test runner, so anything neither of us spotted stayed unspotted until a real round turned it up.

ConstraintWhat it boughtWhat it cost
Single file, no build Deployable to any static host by copy; editable on a phone; nothing to break between source and output No modules, no type checking, no test runner
No framework Zero framework payload; render logic is ~200 lines of explicit DOM building Every guess, clue or other change rebuilds the whole screen, instead of updating just the part that changed — everywhere except the feed, which reconciles instead (see §04)
Firebase Realtime Database Multiplayer sync, presence, and server timestamps with no backend to write or host Game rules live on the client; security rests entirely on database rules
No accounts Join in two taps — a name and a code Identity is a random per-tab id, kept in sessionStorage so a reload keeps your seat; close the tab and you are a new player
03 · Core mechanic

The comparison chain

The one piece of genuinely stateful game logic is the subject — stored as inPlay. It seeds as the literal string "French Toast", and every answer the host gives overwrites it. This is what makes the game converge instead of producing a flat list of unrelated comparisons.

SUBJECT · inPlay French Toast is X GUESSER ASKS more like French Toast or a pancake? host picks X subject unchanged "French Toast" picks Y subject advances "a pancake" becomes X for the next comparison
Only the right-hand answer moves the game. answerComparison() writes inPlay: answer unconditionally — picking X is a no-op that rewrites the same value, while picking Y moves the subject one step closer to the secret.
// Record the answer AND promote the chosen option to be the new subject
async function answerComparison(cid, answer) {
  await update(roomRef(state.roomCode), {
    [`comparisons/${cid}/answer`]: answer,
    inPlay: answer
  });
}

Two ways to win

A round can be won in two ways:

  1. by typing the secret into the Guess box, or
  2. by naming the secret as the other half of a comparison in the Ask box.

The second is possible because a comparison is free text, so a guesser can land on the secret as their option Y — sometimes by accident, sometimes on purpose. To catch wins entered that way, askComparison() checks the option against the secret before writing the comparison, and routes a match into the same win path a direct guess uses:

// If the asked option is actually the secret word, that's a win.
if (norm(y) === norm(secret)) {
  showToast("You named it! 🎉");
  await declareWin(y, secret);
  return;
}
A guesser's view: the feed of answered comparisons and a clue, an Ask field prefixed with the current subject, and a separate Guess field.
A guesser's screen
The two routes are two separate boxes. Ask carries a comparison against the current subject — here it has already advanced from French Toast to a harbour wall — and Guess names the secret outright. Typing the secret into either one wins the round.

Both entry points funnel into one declareWin(), which appends the winning guess to the shared feed and ends the round in a single update() — so there is exactly one place where “round over, and who hosts next” is decided.

04 · Architecture

One listener, whole-room render

There is no client-side state machine mirroring the server. Each player holds a single onValue subscription on rooms/{CODE}, and every change to any part of the room delivers the entire room object to render(data), which rebuilds the screen from scratch.

Guesser taps “Ask” push() — one node comparisons/-NqA1x Realtime Database rooms/K4M2PQ single source of truth onValue → full room Host sees a pending card + buttons Other guessers see “waiting for the host…” The asker, too no local echo — same round trip
The writer learns about its own write the same way everyone else does. Nothing is applied optimistically, so every screen in the room is rendered from one identical snapshot and they cannot disagree about whose turn it is.
The host's screen showing the secret word at the top and an unanswered comparison with two tappable options.
The host, mid-round
The same snapshot, rendered for the other role. The host gets the secret word pinned at the top and two tappable options on the unanswered card; every guesser renders that same comparison as a line in the feed with no buttons on it.

The cost is that every keystroke-sized change rebuilds the feed, but for a room of a handful of players with a few dozen entries that is free.

When Camille taps Ask, nothing appears on Camille's own screen yet. The comparison goes to Firebase, and Firebase sends the updated room back to everyone — Camille included — so the new card lands on every screen at the same moment. That costs the asker one round trip of waiting, and in return whole classes of bug never come up: nothing is ever drawn and then taken back, no screen keeps its own copy of the room that could drift out of step, and no “my screen says pending but yours says answered.”

Rendering the feed

Comparisons, guesses, and clues live in three separate collections but read as one chronological conversation. renderLog() flattens all three into a single array, sorts by server timestamp with the push-id as a stable tiebreaker, and dispatches each item to its own card renderer:

items.sort((a, b) => (a.ts === b.ts ? (a.id < b.id ? -1 : 1) : a.ts - b.ts));

serverTimestamp() resolves at coarse millisecond granularity, so two players tapping at once will collide. When that happens the lexicographic push-id breaks the tie, and since it reads the same everywhere, the ordering comes out identical on every device.

Handling the clue input

Rebuilding the feed is free while every card is read-only text. The clue card is not: on the host's screen a pending request renders a text field and a Send button, so the DOM is holding state the room document has no copy of — the partly typed clue, the caret position, and an open keyboard.

Snapshots keep arriving regardless. Heartbeats (§07) deliver one every few seconds, and any other player asking, guessing or conceding delivers more. Each one wiped the log and built a fresh <input>. On iOS, losing the focused element dismisses the keyboard and the replacement re-presents it, so the keystrokes in between go nowhere — a keyboard that flickers and a field that intermittently ignores you.

So the feed reconciles, and nothing else does. Each item is keyed by its push id and cached against a signature: every field the card renderer reads, including which role is looking, flattened into one string.

function itemSig(item) {
  const d = item.data;
  const role = state.isHost ? "h" : "g";
  // …comparison, guess and clue each flatten their own fields
  return ["l", role, d.name, d.clue == null ? "" : d.clue].join(S);
}

reconcileLog() rebuilds a card only when its signature changes, then walks the desired order calling insertBefore only where a node is not already in the right slot. A card that has neither changed nor moved is never detached from the document, so focus, the caret and the half-typed clue survive any snapshot that doesn't concern them.

Design note

Everything above the feed still rebuilds wholesale. Reconciliation is only worth the complexity where the DOM owns state the snapshot cannot restore, and nowhere else does — the roster, the chips and the buttons are all pure functions of the room.

The field also carries autocomplete="off", autocorrect="off" and spellcheck="false", which suppress Safari's AutoFill and QuickType bars — the pill with a tick above the iOS keyboard, which players read as a third Send button alongside the real one and the return key. enterkeyhint="send" labels the return key to match. All three routes call one handler, behind a sending flag so a tap and a return in quick succession write the clue once.

05 · Data

Data model

One room is one subtree, keyed by its share code. Everything about a round lives under it, so resetting a round means clearing the four collections that fill up as it is played — comparisons, guesses, clues, and surrenders — while the room code, the players, and the host stay put. Writing null to a path is how Firebase deletes it, and all four go in a single update(), so no client can catch the room half-cleared.

rooms/
└── K4M2PQ
    ├── host          "p_8fk2ma91x7q"   // each winner — or an heir, if the host leaves
    ├── status        "lobby" | "active" | "ended"
    ├── word          "bGlnaHRob3VzZQ=="   // base64(secret noun)
    ├── inPlay        "a pancake"          // the current subject
    ├── winner        "Camille"
    ├── winnerId      "p_8fk2ma91x7q"
    ├── winnerWord    "lighthouse"         // plaintext, only once ended
    ├── endReason     "guessed" | "revealed" | "surrender" | "hostleft"
    ├── players/
    │   └── p_8fk2ma91x7q   { name, id, lastSeen }
    ├── comparisons/
    │   └── -NqA1x…         { x, y, askerName, askerId, answer, ts }
    ├── guesses/
    │   └── -NqA2y…         { name, id, guess, correct, ts }
    ├── clues/
    │   └── -NqA3z…         { name, id, clue, ts }
    └── surrenders/
        └── p_8fk2ma91x7q   "Camille"
PathWritten byNotes
hostCreator, then each winner — or the elected heir when a host leaves (§07)Host identity is derived, never stored per-client: state.isHost = (data.host === state.playerId) is recomputed on every render, so the role transfers with no reconnection
wordHost on startBase64 so the secret is not legible at a glance in the console. Obfuscation, not encryption — see §10
inPlayHost on each answerSeeds as "French Toast"; drives the guesser's prompt label
comparisons/*/answerHostnull until answered — the single flag that decides whether a card renders as pending with buttons or resolved with a pill
players/*/lastSeenEvery client, on a timerserverTimestamp(), so presence never depends on a device clock
surrenders/*GuessersKeyed by player id, so the same player conceding twice is idempotent
endReasonWhoever ends the roundFour ways out — a correct guess, a host reveal, unanimous concession, and the host walking out. The reveal screen reads it to decide what to say and whether there is a winner to name

Room codes

Six characters from a 31-symbol alphabet with the confusable glyphs removed — no I, L, O, 0, or 1. The code gets copied into a group chat and pasted back in to join, so it stays short, and dropping the look-alikes means it still reads cleanly on the rare occasion someone types it by hand. That is ~887 million combinations; createRoom() still probes for an unused code, retrying up to eight times.

const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no easily-confused chars
06 · State

Round lifecycle

status has three values and render() routes on it directly — the screen you are looking at is a pure function of the room document. Three different things can end a round, and only one of them moves the crown.

lobby roster + code active the feed ended reveal screen host submits a word startGame() correct guess host := winner host reveals every guesser cédes host unchanged the host leaves host := heir host taps “Play again” — clears comparisons, guesses, clues, surrenders
The crown moves on two edges, and both of them end the round. declareWin() sets host: state.playerId in the same write that ends the round, so the winner is already the host by the time the reveal screen paints — which is also why they are the one holding the “Play again” button. The second edge is the host walking out mid-round, covered in §07: the crown goes to an elected heir, and the round ends rather than travelling with it.
Design note

“Play again” is deliberately host-only. An earlier version let anyone restart, and the reveal screen would vanish from the other phones before their owners had finished reading it. Keeping the reset with the one player who already knows the answer means the reveal stays up until everyone has read it.

The end-of-round screen naming the winner, revealing the secret word, and telling them they host the next round.
The reveal
The reveal screen is also the handover. The winner is told they are hosting next in the same screen that shows the word, and the Play again button is only rendered for them.

The four collections from §05 are actually cleared twice, by two different functions writing the same fields. playAgain() clears them on the way back to the lobby, and startGame() clears them again as the next round begins. The second pass is the one that matters: it means a room arriving at active starts with an empty feed no matter how it got to the lobby, so nothing from a previous round can survive into a new one.

Joining mid-round needs no special handling: a player who enters an active room is routed straight to the game screen as a guesser by the same status switch. That is why the room code stays visible as a tappable chip during play.

Leaving mid-round mostly needs none either — a guesser who quits is just a name that stops heartbeating. The host is the exception, because the round cannot continue without the one person who can answer it, and it cannot simply be handed on either: the secret word travels with the crown, and every candidate heir is someone still trying to guess it. So the fourth exit edge takes the round to ended with endReason: "hostleft", reveals the word to everyone at once, and leaves the new host holding “Play again” for a fresh round with a word of their own. Who that new host is, and how the room agrees on them without a server to decide, is §07.

07 · Presence

Presence is a heartbeat, not a lifecycle event

This took more iterations than the rest of the app combined. The naïve approach — Firebase's onDisconnect() to remove your player record — is correct on desktop and wrong on a phone, which is where the game is actually played.

The problem: iOS Safari fires pagehide and visibilitychange when you merely switch apps or lock the screen. Glance at a text message mid-round and you would disappear from everyone's roster. Meanwhile the opposite failure also existed — genuinely quitting the app doesn't reliably close the socket promptly on mobile, so players who had left lingered in the list.

Both are solved by not trusting lifecycle events at all. Presence became a heartbeat: a player is “here” while their lastSeen timestamp stays fresh, with three thresholds doing three different jobs.

listed and playing a glance at another app costs you nothing hidden from the roster record still present deleted by the host 0s 10s 20s 30s 40s 45s STALE_MS PRUNE_MS last heartbeat HEARTBEAT_MS = 10s · writePresence() on a timer, on reconnect, and on every focus / visibility regain fast path: a real socket close fires onDisconnect and removes the record at once
The gap between 30s and 45s is deliberate. Hiding is a local display decision every client makes for itself from presentPlayers(); deletion is a destructive write only the host performs. Separating them means a client with a lagging snapshot can't delete a player who is actually still there.

Clock skew

Freshness checks compare your clock against their server-stamped lastSeen, which breaks on any device whose clock is off. The app subscribes to Firebase's measured offset and corrects for it everywhere:

onValue(ref(db, ".info/serverTimeOffset"), (snap) => { state.serverOffset = snap.val() || 0; });

function serverNow() { return Date.now() + (state.serverOffset || 0); }

Records with no lastSeen at all are always treated as present, so player rows written by an older version of the app never vanish mid-game.

Handling a page reload

A reloading player is disconnected for a moment, and the app used to treat that as leaving: the player id was made up fresh on every load, so you came back a stranger with the same name while the seat you had just vacated sat in the room going stale.

On a phone that happens by accident. The lobby is short enough to sit at scroll-top, where a stray downward flick becomes iOS Safari's pull-to-refresh — so players were being dropped out of rooms by a gesture they had not meant to make, and a host making one left everyone else stranded.

Two independent fixes. overscroll-behavior-y: contain on the root stops pull-to-refresh firing where it is supported; the rest covers the browsers that ignore it, and deliberate reloads.

Identity moved into sessionStorage, which is scoped to the tab and survives a reload but not a close. Refresh and you return to your seat; close the tab and you are gone. A second tab is still a second player, which is what lets one browser play both roles while the game is being worked on — localStorage would have persisted identity just as well and broken that.

const state = {
  // Same tab, same player — a reload keeps the id, a new tab mints one.
  playerId: (restored && restored.playerId) || randomId(),
  playerName: (restored && restored.name) || storageGet(localStorage, NAME_KEY) || "",
  // …
};

The room code is stored in the same record, so resumeSession() re-stakes the seat and reattaches the room listener before anything else reads the roster. The status switch of §06 does the rest: a reload lands you back in the lobby, the game or the reveal screen according to the room, with no separate resume path per screen. The crown needs no special handling — data.host === state.playerId is the same comparison as always, and the id came back with the tab.

Design note

Two knock-ons. The host grace window below had to grow, since a refreshing host looks the same as a departing one for as long as the page takes to load. And a resumed session skips the Create/Join tap that unlocks WebAudio (§08), so the unlock is armed on the player's first touch instead — otherwise the cues stay silent for the rest of the round.

Every storage call is wrapped in a try: Safari throws on sessionStorage access in Private Browsing rather than returning null, and a game that cannot remember you is better than one that will not start.

When the player who goes is the host

Presence answers “who is here”. It took a real round to notice that nothing was reading the answer back against host. That field is only a player id, so a host who closed their tab left it pointing at somebody who was no longer in the room — and nothing in the app ever looked. The lobby stayed up with the roster ticking along and not one phone showing a Start button; a round in progress kept accepting questions that would never be answered. There was no way back short of everyone leaving and starting again, because becoming the host was not something a player could do.

The fix has to work without a server to arbitrate, and without every remaining phone reaching a different conclusion at the same moment. Two paths cover the two ways a host can go.

A clean leave hands the crown over on the way out. The leaver still has the room in front of them, so handOverBeforeLeaving() picks the successor and writes it in the same trip that removes them. If there is nobody to hand it to, they delete the room instead — the last player out closes the door, which is also the one case of an abandoned room this app now cleans up after itself.

A closed tab cannot hand anything over, so the room elects. Every remaining client already receives the same snapshot and already computes presentPlayers() from it, so all of them can see the host is gone at roughly the same time. What stops that becoming a scramble is that the election rule is arithmetic rather than a race: the heir is the lowest-sorting present player id, which reads identically on every device. Everyone works out the same answer, and only the one client that recognises itself in it writes anything.

// Deterministic successor: lowest-sorting present id that isn't the outgoing
// host. Every client computes the same answer from the same snapshot.
function successorHost(ids, outgoingId) {
  return ids.filter(id => id !== outgoingId).sort()[0] || null;
}

Two guards sit in front of that write. A grace window of HOST_GRACE_MS (10s) has to elapse with the host still missing before the heir acts, and the heir re-reads the room immediately before writing, in case the host reappeared inside the window or another client got there first. Ten seconds is deliberately generous, because the two things the window has to tell apart look identical from outside: a host who has quit, and a host whose page is reloading. Both drop the socket, and onDisconnect clears the seat for both. Promoting into that gap is the destructive mistake — it ends the round and reveals the word to a room that was still playing — so the window is sized to sit out a page load rather than to be quick. Both are cheap, and both fail in the harmless direction — the worst case is that nothing happens on this snapshot and the next one tries again.

Design note

Auto-promotion rather than a “become host” button. A button would have been fewer lines, but it puts a decision in front of a group of people who are mid-round and have just watched the game stop, and it invites two of them to press it at once. Election needs no one to decide anything: the room simply has a host again a few seconds later, and a toast on every phone says who it is.

What the new host does not inherit is the round. The secret word is in the room document, so promoting a guesser mid-round would hand them the answer to the question they were still working on. hostHandoverFields() therefore ends the round in the same write as the handover — the word is revealed to everybody at once, which is the same outcome as the host pressing Reveal, and the new host starts a fresh round with a word of their own. In the lobby and on the reveal screen there is no round to protect, so the crown moves on its own.

function hostHandoverFields(data, newHostId) {
  const fields = { host: newHostId };
  if (data.status === "active") {
    fields.status = "ended";
    fields.endReason = "hostleft";
    fields.winner = null;
    fields.winnerId = null;
    fields.winnerWord = decodeWord(data.word || "");
  }
  return fields;
}
08 · Feedback

Notification layering

A player looking at their phone needs to know the host answered. Every obvious mechanism for that is unavailable on at least one target platform, so notify() fires several in parallel and relies on whichever ones the device supports.

Two of those channels are easy to mix up. A Web Notification is handed to the operating system, so it can surface on a lock screen or in a notification centre while the tab is hidden. An in-app toast is drawn by the page itself — the dark pill that slides up from the bottom of the screen, sits for a couple of seconds and fades on its own — so it only lands if the player is already looking. That is why the table below has them firing in opposite situations.

ChanneliOS SafariAndroid ChromeDesktopFires when
WebAudio beep yes once unlocked yes yes Always
Vibration API never yes n/a Always
Web Notification home-screen only yes yes Tab hidden + permission granted
In-app toast yes yes yes Tab visible, and the event isn't routine

Sound is the channel that has to work on iOS — the platform this game is mostly played on — but WebAudio requires a user gesture to unlock. That gesture is harvested from the only two taps guaranteed to happen before any game activity:

$("create-btn").addEventListener("click", () => { unlockAudio(); requestNotifyPermission(); createRoom()… });
$("join-btn").addEventListener("click",   () => { unlockAudio(); requestNotifyPermission(); joinRoom()…   });

The beep is a two- or three-note motif rather than a single tone, pitched by event class: rising thirds for a win, a bright pair for an answer, a plain fifth for routine pings — so you can tell them apart without looking.

Not spamming the room

Two mechanisms keep cues from becoming noise. Routine feed events pass quiet: true, which suppresses the visual toast while keeping the sound — the feed itself is already the visual signal. And every counter is resynchronised on the first render after joining or reconnecting:

// On first render of a game (join / (re)connect / start) just sync the
// counters so we never replay history as fresh cues.
if (!state.synced) {
  state.synced = true;
} else {
  /* … diff counts against last render, fire cues … */
}

Without that flag, a player reconnecting to a busy room would get a burst of beeps for twenty things that happened while they were away. Cues are also role-aware: the host is told “new question to answer,” guessers are told “the host answered a comparison,” and neither is notified about their own guess.

09 · Correctness

Correctness and safety details

Anything a player types stays text

Names, comparison options, clues and guesses are all typed by one player and then drawn on everyone else's phone. So suppose Margot joins the room under the name <b>Margot</b>. On every other screen that has to appear exactly as typed, angle brackets and all — it must not turn the roster bold, and if someone typed a <script> tag instead, it must not run.

The app never builds a page by gluing strings of HTML together. Every piece of player text goes through one small helper, el(), which makes the element and then puts the text in with textContent:

function el(tag, opts = {}, children = []) {
  const node = document.createElement(tag);
  if (opts.class) node.className = opts.class;
  if (opts.text != null) node.textContent = opts.text;
  …
}

textContent hands the browser characters rather than markup: a < stays a < on screen instead of starting a tag. That is the whole defence, and it means there is nothing to escape and no list of banned characters to keep up to date. The one place the app does write raw HTML is innerHTML, which appears three times — all three inserting the same fixed mascot drawing, with no player text anywhere near it. There is also an escapeText() helper, but despite the name it escapes nothing: it only turns a missing value into an empty string.

Judging a guess the way a person would

In the round in the screenshots, the secret was lighthouse and Camille typed a lighthouse. That has to count — nobody refereeing in person would rule it out on the strength of one article. norm() does that judging before the two strings are compared: it trims the ends, lowercases, squashes runs of spaces down to one, and drops a leading the, a or an. The article is only removed when something is left over afterwards, so a secret that is the word “the” still works:

v = v.replace(/^(the|a|an)\s+(?=\S)/, "");

When the last two players give up at once

A round can also end by everyone conceding. Say Camille and Margot are the last two guessers left and both tap Céder in the same second. Each vote reaches Firebase before the other one has come back, so neither player is ever looking at a room where everybody has given up — and without a fix the round would sit there, ended by nobody.

Two things prevent that. First, whoever concedes adds their own vote to the copy of the room they are already holding before checking whether the vote is unanimous, rather than waiting for it to come back from Firebase. Second, the host checks the same thing on every render, as a backstop:

// Host safety net: if everyone has given up, reveal (covers a simultaneous
// last-two-votes race where neither voter saw the other's write).
if (state.isHost && guesserIds.length > 0 && gaveUp === guesserIds.length && data.status === "active") {
  revealFromSurrender(data).catch(() => {});
}

Both routes call the same function, which checks the round is still active and then writes a fixed set of fields. If both fire, the second one writes exactly the same values as the first, so the room ends up in the same state either way — which is why nothing here needs a lock or a queue.

Errors that say something

Firebase's own errors are unhelpful the first time you set the game up — you get a raw code where you wanted to know what to do about it. errMsg() looks for the two failures that actually happen at that point and swaps in a sentence naming the fix:

Matched onShown to the player
/permission/i“Database permission denied — check your Firebase rules (test mode).”
/invalid-api-key/i“Firebase not configured — edit firebaseConfig in the file.”
anything else“Something went wrong. Check the console.” — with the real error logged

Three things cannot be taken back once done — leaving a room, revealing the word, and conceding — so each one puts up the browser's own confirm() box first. A mis-tap on Reveal the word would end the round for everybody. The plain browser dialog is not pretty, but it is the one thing that comes out correct on every device without any styling of its own.

10 · Trade-offs

Known trade-offs

Every one of these was a conscious call for a game played by a group of friends who know and trust each other — not strangers on the internet. The app uses Firebase's free tier and its test-mode database rules on purpose: it costs nothing to run, rather than coming with a backend to maintain, a bill, and an account system nobody wants to sign up for.

So read the fixes below as what this would need if it were ever opened up to strangers, not as a list of outstanding work. None of them are worth doing for the group it was built for, and several would make it worse — a login screen between a friend and a party game is a cost, not a feature.

The big one

The browser is trusted completely. Whether a guess is right, who won, and who hosts next are all decided on the player's own phone and written straight to the database. Nothing on the server ever checks any of it. Anyone who opens their browser's developer console can read the room, turn word back into the secret, and declare themselves the winner. With the database left in test mode, they can do it in a room they were never invited to.

Tightening the app's own code cannot fix this, because the person cheating is running that code and can change it. The only real fix is to have something the player cannot edit decide who won — a Cloud Function or database rules that compare the submitted guess against a word the browser is never allowed to read.

That fix is for a version of this game with strangers in it. Among friends the exploit is that someone can open the console and spoil the answer for themselves, which they could equally do by asking. It stays unfixed because fixing it means a paid Firebase plan for Cloud Functions, an auth system, and a much longer afternoon — all to stop a cheat whose only victim is the cheat.

Trade-offConsequenceWhat it would take to fix
The secret noun is stored base64-encoded The host's secret word — lighthouse in the screenshots — is stored as bGlnaHRob3VzZQ== rather than in plain sight. Stops it being read over a shoulder or spotted at a glance in the browser's network tab. Anyone who looks up how to reverse it can, deliberately so Nothing, for a game among friends — but it should never be called encryption
Test-mode database rules Anyone at all can read or write any room, including deleting the lot Turn on anonymous Firebase sign-in, then add database rules so a player can only write to the room they are in and to their own player record
You are only a player for as long as the tab is open Reloading is fine — the tab remembers who it was and puts you back. Closing it is final, and reopening makes you a new player whose old name sits in the roster until it goes stale Move the id from sessionStorage to localStorage, which would survive a close too — at the price of two tabs in one browser no longer being two players
Rooms outlive a crash The last player to leave a room deliberately deletes it, so a game that ends properly cleans up after itself. A room everyone abandons by closing the tab has nobody left to do that, and stays in the database forever A scheduled job that deletes old rooms, or an expiry date written when the room is made and swept up later
No score persistence Winning grants the crown but no running tally across rounds A counter per player, stored alongside the room and bumped in declareWin()
norm() only understands English It copes with “the” and stray spaces, but not accents or plurals — so “café” and “cafe” count as different words, in a game with a French theme Strip the accents off both words before comparing them; plurals are a judgement call, better left to the host's clue
Two things load from the internet With no connection the app will not start at all, and the fonts fall back to whatever the device already has Copy the Firebase code and the fonts into the file itself — at the cost of it staying one readable file you can edit on a phone

Where I'd start on a version for strangers

  1. Turn on anonymous sign-in and write proper database rules. Firebase can sign players in behind the scenes, without anyone creating an account, and the rules can then stop a player writing anywhere except their own room. Nobody playing would notice, and the database would no longer be open to anyone who finds the address.
  2. Move the winner check off the player's phone, so a Cloud Function compares the guess against a word the browser is never sent. This is the only one that costs money — Cloud Functions need a paid Firebase plan — which is why it has stayed on the list rather than getting done.
  3. Keep score between rounds. The crown moves to whoever wins, but nothing is written down, so a counter per player would be enough to see who is ahead over an evening.
11 · Setup

Setting up your own database and hosting

You need a Firebase project with Realtime Database enabled, and any way to serve one file over HTTPS. HTTPS is important: copy-to-clipboard and the sound only work on a page served over HTTPS, or over localhost, which browsers treat as safe. Opening the file directly with file:// is fine for a look around but not for a real game.

1 · Create the database

In the Firebase console, create a project, then Realtime Database → Create Database. Start in test mode to get playing; see §10 for why you should not leave it there if you intend to play more than a few rounds, or open up to a wider audience.

2 · Paste your config

Register a web app under Project settings → Your apps and replace the firebaseConfig block near the top of the module. databaseURL is required and Firebase sometimes omits it from the snippet:

const firebaseConfig = {
  apiKey:            "…",
  authDomain:        "<project-id>.firebaseapp.com",
  databaseURL:       "https://<project-id>-default-rtdb.firebaseio.com",
  projectId:         "<project-id>",
  storageBucket:     "<project-id>.firebasestorage.app",
  messagingSenderId: "…",
  appId:             "…"
};

3 · Serve it

python3 -m http.server 8000
# then open http://localhost:8000/french-toast.html

For a real game, any host that can serve a plain file over HTTPS works — there is nothing to compile first. Netlify is the easy one: drag the folder onto the dashboard, or point it at a repository, and it serves the file over HTTPS on a free plan. This game runs there, at frenchtoastgame.netlify.app. Open it on a phone, create a room, and join from a second device to test both roles.

Testing multiplayer alone

Two ordinary tabs are enough: each tab makes up a new player id when it loads, so one browser can host and guess at the same time. Some behaviour only appears on real hardware, though — the iOS backgrounding that shaped §07 cannot be reproduced on a desktop, and neither can the spread of notification behaviour in §08.

Timing knobs

Presence thresholds are three constants near attachPresence(): HEARTBEAT_MS (10s), STALE_MS (30s), and PRUNE_MS (45s). Turn them down to watch a player drop out of the roster without waiting; keep PRUNE_MS comfortably above STALE_MS or the host will delete players who are still showing up on everyone else's phones. HOST_GRACE_MS (10s) sits with the migration code and is the extra pause before an heir takes a vacant crown — closing the host's tab and watching a second browser promote itself is the quickest way to exercise it.