Close
0%
0%

Claude Sonnet plays DOOM on SCINTIX P4

An LLM plays real DOOM on a custom ESP32-P4 module — seeing only what a player sees on screen, through an MCP interface.

Similar projects worth following
145 views
0 followers
We ported DOOM to SCINTIX P4 (an ESP32-P4 + ESP32-C6 module in the Raspberry Pi CM4/CM5 form factor) and then let Claude Sonnet play it. The game runs on a 1024×600 MIPI-DSI panel, hardware-scaled to 30 FPS by the P4's PPA; a small WebSocket "lockstep" API + an MCP server expose the running game to an LLM. The agent is deliberately limited to what a human sees on screen — a raycast depth fan across the field of view, on-screen enemies in line of sight, an ASCII automap of discovered walls, plus "door ahead" and "you're blocked" hints — and issues moves, turns, fire and use. Fully open source.

The setup. SCINTIX P4 is a system-on-module with an ESP32-P4 (dual-core RISC-V @ 400 MHz, MIPI-DSI, a 2D pixel-processing accelerator, USB) plus an ESP32-C6 for Wi-Fi/BLE, in the Raspberry Pi CM4/CM5 form factor. DOOM renders to a 1024x600 DSI panel; the 320x200 frame is scaled to full screen by the P4's PPA with a zero-copy page-flip, holding a steady ~30 FPS. Input is USB keyboard / gamepad; audio is an ES8311 codec.

Letting an AI play. The interesting part isn't DOOM — it's how the model drives it. The firmware runs the game in lockstep: the agent sends an action, the game advances a few tics, and it gets back a structured observation, then the game pauses until the next move. A tiny MCP server wraps this so any MCP-capable client (Claude Code, etc.) can register the game as a set of tools — observe, move_forward, turn_left/right, fire, use, get_map — and just play.

Teaching it to see, honestly. The whole point is that the model gets only what a player sees on the screen — no cheating:
- a 51-ray depth fan across the field of view (the "depth" of the rendered image), so it can judge distance and spot openings;
- visible things — only enemies/items on-screen and in line of sight, left-to-right, never through walls;
- an ASCII automap showing just the walls it has already discovered (like the in-game map);
- door_ahead — a closed door in view (so a re-shut door reads as "press use again," not a solid wall) — and blocked — your last move made no progress.

The bug that mattered. Early on the model kept swapping left and right. The reported bearings were sign-inverted relative to the docs, and the facing angle didn't match the map. We made positive = right everywhere and turned the heading into a compass aligned with the north-up automap — after that it navigates instead of headbutting walls. We also learned the movement looks "jerky" on purpose: lockstep pauses the game between the model's moves, so the marine moves in little bursts.

Open source: https://github.com/relocsrl/scintix-p4-playing-doom
The board: https://hackaday.io/project/206074-scintix-p4-esp32-p4-board-cm45-form-factor

  • 1 × SCINTIX P4 ESP32-P4 + ESP32-C6 system-on-module in the Raspberry Pi CM4/CM5 form factor; runs the game
  • 1 × Raspberry Pi CM5 (CM4) I/O Board Carrier board; breaks out the module's MIPI-DSI display, USB, Ethernet and GPIO
  • 1 × 7" 1024x600 MIPI-DSI panel Display taken from the Espressif ESP32-P4-Function-EV-Board kit
  • 1 × MIPI-DSI adapter FFC cable Adapts the carrier's Raspberry-Pi-style DSI connector to the Espressif kit panel
  • 1 × M5 Atomic EchoBase (ES8311) I2S audio codec module (ES8311 + NS4150B amp) for DOOM sound effects

View all 9 components

  • Teaching the AI to see — honestly

    Andrea Ricci08/25/2026 at 21:21 0 comments

    The hard part wasn't the plumbing, it was the perception. The model gets only what a human sees on the screen — no reading enemy positions out of the map, no seeing through walls. Concretely, each observation carries: a fan of raycast wall distances across the field of view (the "depth" of the rendered image); the things actually on-screen and in line of sight, ordered left to right; an ASCII automap showing only the walls it has already discovered; and two hints — door_ahead (a closed door in view) and blocked (your last move made no progress).

    We started with a coarse 7-ray depth fan, and the agent kept walking into things — doorways and wall edges fell between the rays and were simply invisible. Bumping it to 51 rays across the field of view made the world legible: a ray suddenly much longer than its neighbours is an opening.

    The bug that taught us the most: the agent kept swapping left and right. It turned out our reported bearings were sign-inverted relative to the docs, and the facing angle used a different convention than the map. Once we made positive = right everywhere (things, walls, doors) and turned the heading into a compass aligned with the north-up automap, it stopped headbutting walls and started navigating. door_ahead got the same care — it points at the centre of a door, and reappears as "press use again" when a door has re-shut, instead of being mistaken for a solid wall. blocked was reworked too, projecting movement onto the facing axis so that sliding sideways along a wall still counts as "not getting where I'm pointing."

  • Letting an LLM play: a lockstep WebSocket and an MCP server

    Andrea Ricci08/25/2026 at 21:18 0 comments

    Here's where it gets weird. We wanted a large language model to actually play the game running on the board, so we gave the firmware a small control channel: a WebSocket /agent endpoint that runs the game in lockstep. You send one action, the game advances a few tics, and you get back a structured JSON observation — then the game pauses until the next action. That determinism is the whole point: the model always reasons about a frozen, coherent frame instead of a scene that keeps moving while it thinks. It's also why, in the video, the marine moves in little bursts — the game is literally paused between the model's moves.

    On top of that we wrote a tiny MCP server. MCP (Model Context Protocol) lets any MCP-capable client — Claude Code, for instance — register external tools; ours exposes the game as observe, move_forward, move_back, turn_left, turn_right, strafe_left, strafe_right, fire, use, select_weapon, and get_map. Point the client at the board's IP, and the model can just play — no glue code, no screen scraping.

  • Going wireless: Wi-Fi through the C6 (and a BLE gamepad experiment)

    Andrea Ricci08/25/2026 at 20:58 0 comments

    The ESP32-P4 has no radio of its own — the wireless lives on a companion ESP32-C6, reached over ESP-Hosted on a 4-bit SDIO link. For DOOM that means Wi-Fi comes up on the P4 as if it were local: on first boot it runs a captive portal (SSID SCINTIX-P4-xxxx) so you can hand it your network credentials, then it joins as a station. That same Wi-Fi link is what the AI uses to reach the game (see the next logs).

    We're also experimenting with a native-BLE controller. Because the P4 has no radio, we bring up the NimBLE host on the P4 and let the C6 act as the BLE controller, with the HCI carried over the hosted VHCI transport — the goal being to pair an 8BitDo Micro without a USB dongle. That branch is a work in progress: the BLE scan already comes up through the C6; the HID-host side is next.

  • Real controls and sound: USB HID and an ES8311 codec

    Andrea Ricci08/25/2026 at 20:58 0 comments

    A DOOM port isn't much fun without a keyboard and some noise. The ESP32-P4 has a USB host, so we drive input straight off it: USB keyboards (boot protocol) mapped to the usual DOOM keys and generic HID gamepads. Keyboard and gamepad coexist — whichever enumerates just works.

    One gotcha worth flagging for anyone doing USB host on the P4: a full-speed device behind a high-speed hub needs a Transaction Translator, and that isn't implemented in the IDF USB host stack we used — so a FS controller plugged into a HS hub simply won't enumerate. Connected directly, it's fine.

    For audio we hung an external ES8311 codec (an M5 Atomic EchoBase) off I2S. The catch: the EchoBase has no external MCLK, so the codec derives its clock from the bit clock (use_mclk = false) — at 22050 Hz / 16-bit the internal multiplier lands on the right ratio. The onboard amplifier is un-muted through a little PI4IOE I/O-expander rather than a plain GPIO pin. SFX only (music off), and DOOM has its sound back.

  • DOOM at 30 FPS — letting the hardware scaler do the work

    Andrea Ricci08/25/2026 at 20:56 0 comments

    DOOM renders at 320x200, but our panel is 1024x600, so every frame has to be scaled up. Doing that on the CPU was the whole bottleneck: about 24 ms per frame just for the scale-and-blit, which capped us around 20 FPS.

    The ESP32-P4 has a 2D Pixel-Processing Accelerator (PPA), so we handed the job to it. The PPA scales the 320x200 frame straight into the back framebuffer of the MIPI-DSI panel, and because that buffer is one the display driver already owns, presenting it is a zero-copy page-flip at the next frame boundary — no extra copy. Scale + flip dropped from ~24 ms to about 9 ms, and the frame rate settled at a steady 30 FPS. Above that, DOOM's own renderer and its 35 Hz game tick are the ceiling, which is fine.

    One fun bug: the PPA quantizes its scale factor to 1/16 steps, so 3.2x becomes 3.1875x = 1020 px wide — it never writes the last 4 columns. With double buffering those stale columns held different leftover boot-pattern data in each buffer, so the right edge flickered every time we page-flipped. Fix: clear both framebuffers to black once at startup; the PPA never touches that strip again, leaving an invisible 4 px black edge.

View all 5 project logs

Enjoy this project?

Share

Discussions

Tim wrote 5 days ago point

>The interesting part isn't DOOM — it's how the model drives it

To many em-dashes and too much claudish

  Are you sure? yes | no

Similar Projects

Does this project spark your interest?

Become a member to follow this project and never miss any updates