← All posts

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

Gjorge Karakabakov

Play it: island-doodle-adventure.netlify.app — no download, no account, just send someone the link.

The Island Doodle Adventure landing page: the game logo over a low-poly floating island, with panels to host or join a game

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

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

The fix is small and worth stealing — sample the offset once, then derive every subsequent time from it:

// useServerTime: one round trip, then arithmetic forever.
const offset = serverNow - Date.now();
const trustedNow = () => Date.now() + offset;

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 on an interval rather than per pointer event. Each flush is one write containing the points since the last one. 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

Six 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, rocky, volcanic — and each one has a guardian. 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.

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.

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.

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.

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

island-doodle-adventure.netlify.app

No download, no account, no install — it runs in the browser and you join by sending someone a link. Two players or more. Arrow keys to walk, space to jump, scroll to zoom.