Inside DrawLa: real-time multiplayer drawing in the browser

DrawLa looks simple from the outside: eight people draw the same word at the same time, an AI referee guesses everyone’s sketch live, fastest recognizable drawing wins. This post walks through the architecture that makes those thirty seconds work – and the decisions we’d make again.

One server, one truth

The single most important decision came first: everything that matters is decided on the server. Round timing, word selection, scores, which chaos effect hits which round, even what our bot players draw – the client renders state, it never owns it. For a competitive game this is non-negotiable (you can’t trust eight browsers to agree on who won), but it also turned out to be the best debugging decision we ever made: every game is fully described by the message stream the server emitted, so we can replay any round from logs.

The server is Python: FastAPI with native WebSocket support behind Uvicorn. No message broker, no microservices – a single process owns all rooms, each room is a plain Python object, and an asyncio task per room drives the round state machine. At our scale (a room is at most 8 players, a round lasts under a minute) this is not a compromise; it is the architecture. The WebSocket handler alone is about 1,200 lines, the round engine another 1,000 – small enough that one person can hold the whole game in their head.

Rooms, codes, and the lobby

Players meet in rooms identified by a six-digit code. The host creates a room, shares the code or a join link, and configures the round count, the “chaos” level (how often sabotage effects appear – more on those in the chaos post) and a precision threshold the AI must reach before a drawing counts as recognized.

DrawLa lobby with room code, invite link, chaos slider and player list.
The lobby: room code, invite link, chaos slider – all host-controlled, all server-validated.

Join flows are where multiplayer games quietly die, so we kept ours strict: the server is the only authority on who is in a room, nicknames are per-room (no accounts needed), and a reconnecting player is matched back to their seat by a session token, not by name. Browsers on phones drop WebSocket connections constantly – tab switches, screen locks, elevator rides – and the difference between “laggy but fine” and “unplayable” is entirely in how boring your reconnect path is.

The drawing pipeline

While you draw, the client batches pen points and streams them over the WebSocket. Every ~120 ms the server runs the current canvas through a 345-class sketch classifier (exported to ONNX, served in-process) and broadcasts the top guesses back to everyone. That cadence – roughly eight predictions per second – is the heartbeat of the whole game; the full latency story has its own post.

Strokes themselves live in a normalized 1000×1000 model space, not in screen pixels. Every device – a 27-inch monitor, a Pixel in portrait mode – maps its canvas into that space before sending points. This one early decision quietly pays for itself everywhere: spectator views, round-review thumbnails, replays and the chaos effects all operate on the same coordinates regardless of who drew on what screen.

DrawLa gameplay: canvas with live prediction bars while drawing.
Live prediction bars update ~8×/second while you draw – every player sees their own AI verdict.

Bots that draw like people

Empty lobbies kill multiplayer games faster than any bug, so DrawLa ships with bot players. Our bots don’t generate strokes from thin air: they replay real human sketch traces for the round’s word, re-timed and jittered so two bots never draw identically. A bot “draws” through exactly the same server API as a human client – same message types, same rate limits – which means every bot round doubles as an integration test of the real pipeline. When we break something, the bots usually find it before the players do.

Deployment: one container, deliberately

The whole server – game loop, WebSockets and the ONNX model – ships as one container to Google Cloud Run, pinned to a single warm instance (rooms are in-process state, so horizontal scaling needs sticky rooms; an optional Redis-backed room registry exists for the day we need it, and stays switched off until then). We deploy with a shell script that builds, uploads and then refuses to finish until the health endpoint confirms the model actually loaded:

URL="$(gcloud run services describe "$SERVICE" --format='value(status.url)')"
HEALTH_JSON="$(curl -fsS "$URL/health")"
python3 - <<'PY'
import json, sys
health = json.loads(sys.stdin.read())
if health.get("status") != "ok" or health.get("model_loaded") is not True:
    raise SystemExit(f"Unhealthy deployment: {health}")
PY

It’s not Kubernetes. That’s the point. A game this size earns complexity only when a limit actually hurts – and “one warm instance” has yet to hurt.

What we’d do again (and what not)

Next in the series: where the 150 milliseconds go – measuring and shaving the pipeline between your pen and the AI’s verdict.

Kick the tires

Open a room, add a bot, watch the prediction bars – everything in this post is visible in one free round.

Play DrawLa