← All Projects
In Progress

Doodle Room AI Player

An AI player for my own Doodle Room Pictionary game. I wanted to see if I could get an AI to actually play the game I built with my friends, and not just watch it, so it joins as a real player and plays both sides. It guesses sketches from a live canvas snapshot, and it takes its own turn by planning and drawing a sketch stroke by stroke.

Python Claude API WebSockets AI
View on GitHub →
Doodle Room AI Player

After I built Doodle Room with friends, I kept thinking about what it would take to have an AI actually sit in one of the seats. Not a script that draws canned shapes, but something that looks at the same messy, half-finished sketch a human guesser sees and has to make the same call, what is that supposed to be. So this bot connects to a running PictionaryServer as a completely ordinary WebSocket player, with zero changes to Doodle Room itself, and plays the full game on both sides of the board.

As a guesser, it replays the drawer's stroke events onto a virtual canvas, periodically hands a snapshot to Claude's vision, and sends its best guess as a chat message. It paces itself with cooldowns, backs off once it guesses correctly, and avoids guesses other players already tried. As the drawer, it asks Claude to design a simple line-art sketch of its word as a JSON stroke plan, then executes that plan as real timed DRAW events, budgeted to fit inside the turn's clock.

Both roles boil down to the same idea in opposite directions. One call turns pixels into a word, and the other turns a word into pixels, and both are wrapped in a small state machine that tracks turns, scoring, and timing entirely from the server's own messages.

Before writing a line of the bot, I read straight through GameRoom.java and pictionary.html to get the message shapes exactly right, the phase machine (LOBBY → PICKING_WORD → DRAWING → TURN_END → GAME_OVER), what a masked word looks like, and how a close guess is scored. I wanted the bot to be indistinguishable from another browser tab at the protocol level, not a special-cased client the server has to know about. From there, the guesser and drawer roles are each a small background loop hanging off the same state machine that tracks turns, scoring, and timing from the server's messages.

Diagram: the guesser loop and drawer loop against the shared protocol state machine

My first instinct was to trigger a guess attempt on every incoming DRAW event, but that's both wasteful and a bad way to play, since a human doesn't re-guess after every pen movement either. Instead there's a background loop that waits a couple seconds, snapshots whatever's on the canvas so far, and asks Claude, then backs off on a cooldown. That cooldown resets early if a LETTER_REVEAL comes in, since that's genuinely new information worth acting on sooner. It also tracks every other player's wrong guesses from the chat feed so it doesn't repeat them.

Asking Claude for a stroke plan as JSON works well most of the time, but "most of the time" isn't good enough when a stuck turn stalls the whole game for every human player waiting on it. So there's a strict parser that only accepts well-formed strokes, clamps any coordinate outside the 0 to 1 range, and drops anything malformed. If the whole response fails to parse, or the API call itself fails, a fallback artist draws a simple generic shape instead, so the turn always produces something, even in the worst case.

The most confusing bug came from constructing things too early. I originally created the bot's asyncio.Event in __init__, which works fine until you remember Python 3.9 binds that object to whatever event loop is running the moment it's built. A bot object gets constructed before asyncio.run() ever starts one, so it surfaced as a confusing crash the first time I tried to unit test the bot directly instead of running it live. The fix was to make the event lazy, created on first real use inside an already-running loop.

I didn't want "run two browser tabs and watch" to be the only way to know if this worked. So the test suite spins up an actual WebSocket server in process with a scripted sequence of real server messages and drives the bot through a full turn. It connects, watches strokes come in, guesses, gets told it was correct, and sees the game end. That test caught wiring bugs a unit test on the bot's internal logic alone never would have.

Python (asyncio) websockets Anthropic API (vision + generation) Pillow pytest
Doodle Room AI Player placeholder illustration
Illustration only for now. A real gameplay recording is coming once it's run against a live game