← All posts

Building Island Doodle Adventure: a multiplayer drawing game in React Three Fiber

Gjorge Karakabakov

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

The Island Doodle Adventure landing page: the game's wordmark over a low-poly floating island with a stone tower, a waterfall and drifting clouds

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 six rules of the game laid out as cards: one player is summoned, they draw it, everyone else guesses, speed is everything, roam while you wait, claim the crown

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 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.