Play it: idad.fyi/games/doodle — no download, no account, just send someone the link.

Everyone has played a draw-and-guess game. You get a word, you draw it badly, your friends type
guesses into a chat box. It works, but it all happens in a list of messages — the game is a
form with a canvas stapled to it.
I wanted to know what it felt like if the players were actually somewhere. So I built
Island Doodle Adventure: the same familiar game, except everyone is a small character
walking around a floating island. One player at a time gets teleported to an ancient tower to
draw. Everyone else roams, swims the river, startles the wildlife — and guesses. Wrong guesses
float above your head for the whole island to see. The correct one stays your secret.

The shape of it
The stack is deliberately small:
- React Three Fiber and drei for the 3D
- Firebase Realtime Database for state that changes many times a second
- Cloud Functions for anything a player must not be able to lie about
- Zustand for local UI state
- a shared TypeScript workspace imported by both the client and the functions
- Netlify for the static bundle, because the client is the only part that is a website
That last-but-one turned out to matter more than anything else.
Put the rules where the players aren’t
The first real decision was where scoring lives.
It is very tempting to score on the client. The client already knows the word, already knows
when the guess arrived, already knows how many people have solved it. Writing
if (guess === word) addPoints(...) takes a minute.
It also means the score is a number the browser asks the database to believe. In a game you
send to friends over a link, somebody will open the console. Not maliciously — just because
it is there.
So the word, the timer, and the points are decided in a Cloud Function. The client sends a
guess. The server decides whether it was right, how fast it was, and what that is worth. The
client is told the outcome.
The interesting part is that this immediately creates a second problem: the client still needs
to show a countdown, and its clock is not the server’s clock. A laptop that is thirty seconds
fast would show a round ending before it does — and would then try to advance the phase early,
only to be refused.
The fix is small and worth stealing, and on Realtime Database it is nearly free: the socket
already publishes how far off your clock is.
// useServerTime: subscribe once, then it is arithmetic forever.
onValue(ref(db, '.info/serverTimeOffset'), (snap) => {
offset.current = snap.val();
});
const trustedNow = () => Date.now() + offset.current;
Every timer in the UI runs off trustedNow(). The countdown is smooth because it is local; it
is correct because it is anchored.
Strokes are not state
The drawing is where a naive Realtime Database schema falls apart.
A stroke is not one event. It is a point every few milliseconds for as long as someone is
dragging. Write each point as its own key under a room and you get hundreds of writes per
drawing, hundreds of listener fires per guesser, and a payload that keeps growing for the whole
round.
What worked instead was treating strokes as an append-only log of batched segments, flushed
every 60ms rather than per pointer event, with an early flush once a batch passes forty points
so a long sweep does not lag behind the hand drawing it. Each batch repeats the previous point,
so consecutive batches join seamlessly instead of leaving a gap at every flush boundary.
Guessers replay the log onto a canvas.
Two things fall out of this for free. Somebody who joins mid-round replays the log and catches
up instantly — no special “send me the current canvas” path. And at the end of the game you can
replay every drawing of the match, because you never threw the log away.
The canvas itself is then used as a texture on the tower in the 3D scene. It is an ordinary
2D canvas that happens to be uploaded to the GPU each time it changes, which is far cheaper
than trying to draw in 3D.
Movement: the lie that makes it feel good
Eight players walking around, each broadcasting a position, is the classic multiplayer bandwidth
problem in miniature.
The compromise is the standard one and it works: your own character moves locally and
immediately on key press, and your position is broadcast on a throttle. Everyone else’s
character is interpolated toward the last position you heard about.
The result is a small lie — remote players are always slightly in the past — but the lie you
notice is input lag on your character, and that one never happens. A buffer between the
network layer and the render loop keeps the two decoupled, so a slow update never stutters
the scene.
Adventure mode, or: giving points a job
Classic mode ends with a scoreboard. That is fine, but the points do not do anything.
So there is a second mode where the party sails three islands — Meadow Isle, Stonereach, and a
volcanic last stop — and each one has a guardian: a minotaur, then Medusa, then a dragon. Every
point you score is damage. Fail to fell a guardian in three rounds and the run ends there.
The change is tiny in code and large in feel. Nothing about drawing or guessing changed. What
changed is that a fast guess is now help, and the whole table is on the same side.
The guardians did not stay decorative for long. They roam the island and shove you off your
feet; Medusa’s look turns you to stone for a few seconds, which is a genuinely annoying thing
to have happen mid-sprint and exactly the right amount of annoying. The tower fires on the
guardian when a round scores, so the damage number has something to look at. Each one gets its
own written ending, because “The Minotaur proved too strong” three times over reads like a
validation error rather than the end of an adventure.
Each island is a distinct biome with its own palette, which is stated once in shared data
rather than re-derived by everything that draws ground, water or sky:
export interface Stage {
monster: MonsterId;
island: string;
biome: Biome;
difficulty: Difficulty;
roundsToKill: number;
palette: { sky: string; grass: string; water: string; /* … */ };
}
Because the palette is data, “the second island is rocky” is a fact the terrain, the river and
the scenery all read from the same place. Adding a fourth island is adding an object to an
array.
The difficulty on each stage picks from a word list that has since grown to about 2,300 words
in three bands — short ones for the minotaur, nine-letters-and-up by the time you reach the
dragon.
Team mode: one word, two towers
The third mode is the one I expected to be a variant and turned out to be a different game.
Two banners, Crimson and Azure, each with a tower on its own flank of the meadow. Both sides
draw at the same time, on canvases only their own team can see, and at the end of the round
the side that did better fires on the other’s tower. Last tower standing wins.
Three decisions did all the work.
Both teams draw the same word. Giving each side its own was the obvious first try and it is
simply unfair: two words are never equally hard, so half the rounds are decided by the draw
rather than the drawing. One banner picks, both draw it, and the banners alternate who chooses.
If the picking clock runs out, the server chooses for them exactly as the drawer would have.
Damage is the gap, not the score. The attacker hits for the difference between the two
sides, so a round both teams played well is close to a wash and a rout takes a chunk out of the
wall. And the scores are normalised per guesser before they are compared, because raw totals
would hand the game to whoever has the extra player:
export function normalisedScore(points: number, guesserCount: number): number {
return points / Math.max(1, guesserCount);
}
That is what makes a 3-v-2 a contest rather than an arithmetic certainty.
Isolation is the database’s job, not the client’s. Each banner draws to
teamStrokes/{team}, and the security rule is the entire feature:
"teamStrokes": {
"$team": {
// Only a member of this banner may read its canvas.
".read":
"auth != null && root.child('rooms/' + $code + '/players/'
+ auth.uid + '/team').val() === $team"
}
}
Writing is narrower still — only the uid the server recorded as that side’s drawer for the
round. “Your team only sees your team’s drawing” is then true rather than merely
conventional. Chat
had to be split the same way once both sides raced on the same word — a wrong guess said out
loud is a hypothesis, and overhearing the other team’s is worth nearly as much as seeing their
canvas.
Two bugs in that mode were found by playing it rather than by reading it, which is the honest
summary of testing a party game. Both auto-picks shared a single advance-phase lock, so when
both clocks expired together the second banner sat on a dead timer forever. And the first tower
placement put one tower in a pond, inside a ruin.
The cold start that cost more than the rendering
Here is the thing I got most wrong, and it had nothing to do with 3D.
Under the Cloud Functions v2 runtime, every callable is its own Cloud Run service. I had
fourteen of them — createRoom, submitGuess, chooseWord, and so on — which meant fourteen
separate containers, each with its own cold start, each paid at whatever moment a room first
happened to reach that endpoint. Measured against the deployed game, a warm call answers in
about 90ms and a cold one took 2.3 seconds for claimHost and 4.4 seconds for
returnToLobby. Those seconds land in the middle of somebody’s turn.
minInstances is the fix for a cold start, and it is billed per idle instance, per
function. Fourteen idle containers is not a hobby project’s bill.
So there is one callable now, named api, that dispatches on an action name, and it is
deployed with minInstances: 1. One warm container serves everything. The handlers themselves
did not change at all — what was fourteen onCall wrappers is now one dispatch table, keyed by
a union that lives in shared:
export const API_ACTIONS = [
'createRoom', 'joinRoom', 'updateSettings', 'startGame', 'chooseTeam',
'getWordChoices', 'getMyWord', 'chooseWord', 'submitGuess',
'revealHint', 'touchMonster', 'advancePhase', 'returnToLobby',
] as const;
The client’s calls and the server’s table cannot drift apart without failing the build.
The realtime path, incidentally, was never the problem. Strokes, presence and room state go
straight down the RTDB websocket and never touch a function at all — which is why the game felt
fine to play and only starting things felt broken.
There was a second, dumber version of the same mistake underneath it. The callables were
deployed to the default us-central1 while the database lives in europe-west1, so every call
crossed the Atlantic to reach the function and then crossed back for each database round trip
inside it — and submitGuess makes several in sequence. Co-locating them took createRoom
from 650ms warm to about 250ms.
Setting the region was itself a trap worth naming: setGlobalOptions only affects functions
defined after it runs, and ESM evaluates a module’s dependencies before any of its own
statements. My setGlobalOptions call sat in index.ts, which re-exports the callables from
game.ts — so nine functions were already defined by the time the call executed. Four moved
region and nine did not. The call now lives in its own module, imported first, where dependency
order guarantees it wins.
Letting a room die
A room used to outlive everyone in it. Nothing under rooms/ is client-writable, so there was
nobody to clean up.
The presence flag was already there, doing the hard part: the client arms
onDisconnect(playerRef).update({ connected: false }) every time its socket comes up, so the
database itself flips that flag on a closed tab, a slept laptop or a tunnel. A database trigger
watches it, and if the player who dropped was the host, the room and its secrets are deleted.
It has to be a trigger rather than an action, because the host is very often the only person in
the room and a departing client cannot ask for the cleanup it is the subject of.
The cost is written at the top of the file, because it is real: a socket drop is not the same
as leaving. A host who merely refreshes ends the game for everyone still in it. The
alternative was promoting the longest-present player, and the two cannot coexist — if the room
dies with its host there is never anyone left to promote. A grace period before the delete is
the obvious middle ground if it ever bites hard enough.
Two details in there are the kind that only show up when you run it. Deleting the room deletes
a connected node per player, and each of those re-enters the same trigger, so the guard on a
vanished node is what stops it recursing. And minInstances is pinned back to 0 for this one
function against the global 1 — nobody is waiting on a janitor that runs after they have
already gone.
The link is the product
The game has no install, no account and no store page. The entire distribution mechanism is
somebody pasting a URL into a group chat, so the URL itself deserved work.
It lives at idad.fyi now, which is short enough to say out loud.
Rooms are the links people actually send, and they were the ones that unfurled worst. Every
room is /r/CODE, which is the same index.html as everything else — that is what an SPA
fallback does — so an invitation previewed in Slack as a link to the home page. Crawlers do not
run scripts, so the React app rewriting document.title is far too late to help.
A Netlify edge function now rewrites four things in the HTML before a crawler sees it: the
<title>, the og:title (“Join room ABCD”, the line a person actually reads), the og:url,
and a noindex — because a room code sitting in a search index is a room strangers can walk
into.
The image is deliberately left alone. Rasterizing a per-room card inside an edge function, on a
crawler’s very short fuse, is a bad trade for information that reads better in the title
anyway. The card it falls back to is a real 1200×630 PNG, generated by a script from the same
isometric projection the hero scene renders with — so the island in the Slack unfurl is the
island that loads when somebody clicks it.
What I would tell myself at the start
Share the types, not just the intent. A shared/ workspace that both the client and the
Cloud Functions import is the single highest-leverage thing in the repo. Scoring is written
once and tested once. Client and server cannot drift, because there is nothing to drift from.
Team mode’s rules went in the same place for the same reason.
Decide what is authoritative before you write the schema. Retrofitting authority onto a
database whose shape assumed a trusted client is a rewrite.
Batch anything that fires on pointer move. Per-event writes look fine with two players on
localhost and fall over with six on hotel wifi.
Count your cold starts before you optimise your frame time. The slowest thing in the game
was never the rendering. It was a container booting in another continent.
Let the database enforce the secret. A rule that says “only this team can read this path”
survives a curious player with a console; a client that politely does not subscribe does not.
Give the 3D a job. The island is not decoration — it is where you wait, and waiting is most
of a drawing game. Making the waiting fun is the entire reason the game exists.
Play it
idad.fyi/games/doodle
No download, no account, no install — it runs in the browser and you join by sending someone a
link. Two to eight players; four or five is the sweet spot, and team mode needs at least four
so each banner has someone to draw and someone to guess. Arrow keys to walk, space to jump,
scroll to zoom.