Play it: island-doodle-adventure.netlify.app
— 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
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.