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