feat(examples): Pico Doom — Wolf3D-style raycaster on RP2040 + ILI9341

A new entry in the games category for the Raspberry Pi Pico. Renders
a first-person 3D corridor à la Wolfenstein / early Doom using a
column-wise DDA raycaster — 160 rays per frame drawn straight to a
320×240 ILI9341 TFT via drawFastVLine, no framebuffer. 16×16 tile map
with 5 wall palettes (slate, blood, brown, toxic green, bronze door),
darkened on NS faces so corners read in 3D. Forward / back move,
two more buttons turn the player. 40-px HUD bar.

Why a demo, not the canonical id Software Doom: Graham Sanderson's
rp2040-doom port shoehorns DOOM1.WAD into 2 MB of flash with custom
compression and pushes video out over PIO-driven DVI / VGA — none of
that survives the rp2040js emulator (no PIO accuracy, no flash
mapping for huge assets). A raycaster reproduces the *visual* of
early Doom using only ~67 KB of flash and 9 KB of RAM, which the
emulator runs perfectly.

Pre-flight: arduino-cli compile against rp2040:rp2040:rpipico
already verified inside the Velxio backend container — 3 % flash,
3 % RAM. The Adafruit_GFX + Adafruit_ILI9341 libs the example
declares are already in the gallery's auto-install list.

Bundled:
  test/pico_doom_demo/arduino_sketch.ino — source of truth sketch
  test/pico_doom_demo/README.md          — what + why + pin map
  test/pico_doom_demo/compile_check.sh   — operator script that
    runs arduino-cli against the same FQBN the prod backend uses
  frontend/src/data/examples.ts          — gallery entry (boardType
    raspberry-pi-pico, category games, difficulty advanced, 5
    components, 14 wires)
  frontend/src/__tests__/examples-pico-doom.test.ts — 10 vitest
    assertions: example is registered exactly once, target board /
    category / difficulty match, libraries declared, all four
    pushbuttons present, every wire endpoint references a real
    component id, SPI pin mapping matches the sketch's #define
    block, every button has a GND wire, the renderFrame loop is
    still present in the embedded code.
This commit is contained in:
davidmonterocrespo24 2026-05-13 22:54:35 +02:00
parent a4893a0da7
commit 5742ed0146
5 changed files with 803 additions and 0 deletions

View File

@ -0,0 +1,151 @@
/**
* Pico Doom example — data integrity test.
*
* The runtime smoke test (does the sketch actually compile and run on
* the rp2040js emulator?) lives at `test/pico_doom_demo/compile_check.sh`
* — it needs arduino-cli + the rp2040 core, which our vitest CI image
* doesn't ship. This file just guarantees the example we surface in the
* /examples gallery is structurally valid: declared once, target board
* correct, libraries listed, every wire endpoint refers to a component
* that exists.
*
* If you change pin assignments in the sketch (e.g. move CS off GP17),
* update the WIRE_EXPECTATIONS map below in the same commit.
*/
import { describe, it, expect } from 'vitest';
import { exampleProjects } from '../data/examples';
const EXAMPLE_ID = 'pico-doom-raycaster';
function findExample() {
return exampleProjects.find((e) => e.id === EXAMPLE_ID);
}
describe('pico-doom-raycaster example', () => {
it('is registered exactly once in the exampleProjects list', () => {
const matches = exampleProjects.filter((e) => e.id === EXAMPLE_ID);
expect(matches.length).toBe(1);
});
it('targets the Raspberry Pi Pico in the games category at advanced difficulty', () => {
const e = findExample()!;
expect(e.boardType).toBe('raspberry-pi-pico');
expect(e.category).toBe('games');
expect(e.difficulty).toBe('advanced');
});
it('declares the two Adafruit libraries the sketch needs', () => {
const e = findExample()!;
expect(e.libraries).toEqual(
expect.arrayContaining(['Adafruit GFX Library', 'Adafruit ILI9341']),
);
});
it('lists one ILI9341 plus exactly four pushbuttons', () => {
const e = findExample()!;
const tftCount = e.components.filter((c) => c.type === 'wokwi-ili9341').length;
const btnCount = e.components.filter((c) => c.type === 'wokwi-pushbutton').length;
expect(tftCount).toBe(1);
expect(btnCount).toBe(4);
});
it('exposes button ids fwd / back / left / right', () => {
const e = findExample()!;
const ids = e.components.map((c) => c.id);
expect(ids).toEqual(
expect.arrayContaining(['btn-fwd', 'btn-back', 'btn-left', 'btn-right']),
);
});
it('every wire endpoint references a real component (board id is implicit)', () => {
const e = findExample()!;
const knownIds = new Set<string>([
'raspberry-pi-pico', // implicit board instance id
...e.components.map((c) => c.id),
]);
const offenders: string[] = [];
for (const w of e.wires) {
if (!knownIds.has(w.start.componentId)) offenders.push(`${w.id}:start=${w.start.componentId}`);
if (!knownIds.has(w.end.componentId)) offenders.push(`${w.id}:end=${w.end.componentId}`);
}
expect(offenders).toEqual([]);
});
it('SPI wires connect the Pico GP18/GP19/GP17/GP20/GP21/GP22 to the ILI9341 pins the sketch expects', () => {
const e = findExample()!;
// (pico-pin, ili9341-pin) pairs the sketch hard-codes. If any of these
// get re-routed in the example, the sketch's #define block has to
// match — fail loud here so the mismatch can't slip into prod.
const expected: Array<[string, string]> = [
['GP18', 'SCK'],
['GP19', 'MOSI'],
['GP17', 'CS'],
['GP20', 'D/C'],
['GP21', 'RST'],
['GP22', 'LED'],
];
for (const [picoPin, tftPin] of expected) {
const match = e.wires.find(
(w) =>
w.start.componentId === 'raspberry-pi-pico' &&
w.start.pinName === picoPin &&
w.end.componentId === 'tft1' &&
w.end.pinName === tftPin,
);
expect(match, `missing wire ${picoPin} → tft1.${tftPin}`).toBeTruthy();
}
});
it('each button signal goes to the GP10-13 pin its sketch label expects', () => {
const e = findExample()!;
const expected: Record<string, string> = {
'btn-fwd': 'GP10',
'btn-back': 'GP11',
'btn-left': 'GP12',
'btn-right': 'GP13',
};
for (const [btnId, picoPin] of Object.entries(expected)) {
const match = e.wires.find(
(w) =>
w.start.componentId === 'raspberry-pi-pico' &&
w.start.pinName === picoPin &&
w.end.componentId === btnId &&
w.end.pinName === '1.l',
);
expect(match, `missing signal wire ${picoPin} → ${btnId}.1.l`).toBeTruthy();
}
});
it('every button has the second terminal tied to a Pico GND pin', () => {
const e = findExample()!;
for (const btn of ['btn-fwd', 'btn-back', 'btn-left', 'btn-right']) {
const gnd = e.wires.find(
(w) =>
w.start.componentId === btn &&
w.start.pinName === '2.l' &&
w.end.componentId === 'raspberry-pi-pico' &&
/^GND/.test(w.end.pinName),
);
expect(gnd, `missing GND wire for ${btn}`).toBeTruthy();
}
});
it('embedded sketch keeps the SPI pin defines aligned with the wiring above', () => {
const e = findExample()!;
// Source-of-truth grep — if the constants drift in the sketch, this
// catches it before the example loads broken on real users.
expect(e.code).toMatch(/#define\s+TFT_CS\s+17\b/);
expect(e.code).toMatch(/#define\s+TFT_DC\s+20\b/);
expect(e.code).toMatch(/#define\s+TFT_RST\s+21\b/);
expect(e.code).toMatch(/#define\s+TFT_LED\s+22\b/);
expect(e.code).toMatch(/#define\s+BTN_FWD\s+10\b/);
expect(e.code).toMatch(/#define\s+BTN_BACK\s+11\b/);
expect(e.code).toMatch(/#define\s+BTN_LEFT\s+12\b/);
expect(e.code).toMatch(/#define\s+BTN_RIGHT\s+13\b/);
// Sanity: the sketch must keep the renderFrame DDA loop (the visual
// payoff of the example). If a future "minify" removes it the example
// becomes a black screen.
expect(e.code).toMatch(/renderFrame\s*\(\s*\)/);
expect(e.code).toMatch(/drawFastVLine/);
});
});

View File

@ -737,6 +737,243 @@ void loop() {
},
],
},
{
id: 'pico-doom-raycaster',
title: 'Pico Doom — Raycaster Demo',
description:
'Wolfenstein / early-Doom-style first-person 3D corridor on a Pi Pico + ILI9341 TFT. DDA raycasting at 160 rays/frame, no framebuffer (columns drawn straight to the TFT via drawFastVLine). Forward/back move, two more buttons turn the player. 16×16 tile map with 5 wall palettes (slate, blood, brown, toxic green, bronze door). The full id Software Doom needs the WAD assets shoehorned into 2 MB of flash with custom compression that the emulator cannot reproduce — this is the visual demo the Pico hardware actually runs in real life.',
libraries: ['Adafruit GFX Library', 'Adafruit ILI9341'],
category: 'games',
difficulty: 'advanced',
boardType: 'raspberry-pi-pico',
tags: ['pico', 'rp2040', 'doom', 'raycaster', 'tft', 'ili9341', '3d', 'game'],
code: `/*
* Pico Doom — A Wolf3D-style raycaster running on Raspberry Pi Pico.
*
* Hardware
* Raspberry Pi Pico (RP2040) + ILI9341 SPI TFT (320x240, landscape)
* 4 pushbuttons: forward, backward, turn left, turn right
*
* Pins
* SPI0: SCK=GP18 MOSI=GP19 CS=GP17 DC=GP20 RST=GP21 LED=GP22
* Buttons (active LOW, INPUT_PULLUP):
* FWD=GP10 BACK=GP11 LEFT=GP12 RIGHT=GP13
*/
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <math.h>
#define TFT_CS 17
#define TFT_DC 20
#define TFT_RST 21
#define TFT_LED 22
#define BTN_FWD 10
#define BTN_BACK 11
#define BTN_LEFT 12
#define BTN_RIGHT 13
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
#define MAP_W 16
#define MAP_H 16
const uint8_t worldMap[MAP_H][MAP_W] = {
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,2,2,2,0,0,0,0,0,1},
{1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1},
{1,0,1,0,0,0,3,3,0,0,0,0,0,0,0,1},
{1,0,1,0,0,0,0,0,0,0,4,4,4,0,0,1},
{1,0,1,1,1,0,0,5,5,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1},
{1,0,3,3,3,3,0,0,0,2,2,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1},
{1,0,0,0,0,5,5,0,0,3,0,0,0,0,0,1},
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,2,2,2,0,0,0,0,0,5,0,1},
{1,4,0,0,0,0,0,0,0,0,3,3,3,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,4,4,4,4,4,4,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
};
float posX = 8.5f, posY = 8.5f;
float dirX = -1.0f, dirY = 0.0f;
float planeX = 0.0f, planeY = 0.66f;
#define SCREEN_W 320
#define SCREEN_H 240
#define HUD_H 40
#define VIEW_H (SCREEN_H - HUD_H)
#define HALF_VH (VIEW_H / 2)
#define SKY_COLOR 0x18C3
#define FLOOR_COLOR 0x4208
uint16_t wallColor(uint8_t tile, bool nsFace) {
uint16_t c;
switch (tile) {
case 1: c = 0x8410; break; // slate gray
case 2: c = 0xC800; break; // blood red
case 3: c = 0x4A60; break; // brown
case 4: c = 0x32A0; break; // toxic green
case 5: c = 0xFD20; break; // bronze door
default: c = 0xFFFF; break;
}
if (nsFace) c = (c >> 1) & 0x7BEFu;
return c;
}
void drawTitleScreen() {
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(6);
tft.setTextColor(0xC800);
tft.setCursor(60, 40);
tft.print("DOOM");
tft.setTextSize(2);
tft.setTextColor(0xFFE0);
tft.setCursor(50, 110);
tft.print("Pico Edition");
tft.setTextSize(1);
tft.setTextColor(0xC618);
tft.setCursor(40, 160);
tft.print("FWD/BACK move LEFT/RIGHT turn");
tft.setTextSize(2);
tft.setTextColor(0xFFFF);
tft.setCursor(56, 200);
tft.print("Press FWD to start");
while (digitalRead(BTN_FWD) == HIGH) delay(40);
while (digitalRead(BTN_FWD) == LOW) delay(40);
}
void drawHUD() {
tft.fillRect(0, SCREEN_H - HUD_H, SCREEN_W, HUD_H, 0x2104);
tft.drawFastHLine(0, SCREEN_H - HUD_H, SCREEN_W, 0x52AA);
tft.setTextSize(2);
tft.setTextColor(0xFFE0);
tft.setCursor(8, SCREEN_H - 28);
tft.print("HP:100 ARM:50 AMMO:50");
}
void renderFrame() {
tft.fillRect(0, 0, SCREEN_W, HALF_VH, SKY_COLOR);
tft.fillRect(0, HALF_VH, SCREEN_W, HALF_VH, FLOOR_COLOR);
for (int x = 0; x < SCREEN_W; x += 2) {
float cameraX = 2.0f * x / SCREEN_W - 1.0f;
float rayDirX = dirX + planeX * cameraX;
float rayDirY = dirY + planeY * cameraX;
int mapX = (int)posX, mapY = (int)posY;
float deltaDistX = (rayDirX == 0) ? 1e30f : fabsf(1.0f / rayDirX);
float deltaDistY = (rayDirY == 0) ? 1e30f : fabsf(1.0f / rayDirY);
int stepX, stepY;
float sideDistX, sideDistY;
if (rayDirX < 0) { stepX = -1; sideDistX = (posX - mapX) * deltaDistX; }
else { stepX = 1; sideDistX = (mapX + 1.0f - posX) * deltaDistX; }
if (rayDirY < 0) { stepY = -1; sideDistY = (posY - mapY) * deltaDistY; }
else { stepY = 1; sideDistY = (mapY + 1.0f - posY) * deltaDistY; }
bool hit = false, nsFace = false;
int iter = 0;
while (!hit && iter++ < 64) {
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; nsFace = false; }
else { sideDistY += deltaDistY; mapY += stepY; nsFace = true; }
if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) break;
if (worldMap[mapY][mapX] > 0) hit = true;
}
if (!hit) continue;
float perpDist = nsFace
? (mapY - posY + (1.0f - stepY) * 0.5f) / rayDirY
: (mapX - posX + (1.0f - stepX) * 0.5f) / rayDirX;
if (perpDist < 0.0001f) perpDist = 0.0001f;
int lineH = (int)(VIEW_H / perpDist);
int drawStart = HALF_VH - lineH / 2;
int drawEnd = HALF_VH + lineH / 2;
if (drawStart < 0) drawStart = 0;
if (drawEnd > VIEW_H) drawEnd = VIEW_H;
uint16_t color = wallColor(worldMap[mapY][mapX], nsFace);
tft.drawFastVLine(x, drawStart, drawEnd - drawStart, color);
tft.drawFastVLine(x + 1, drawStart, drawEnd - drawStart, color);
}
}
void setup() {
Serial.begin(115200);
pinMode(TFT_LED, OUTPUT);
digitalWrite(TFT_LED, HIGH);
pinMode(BTN_FWD, INPUT_PULLUP);
pinMode(BTN_BACK, INPUT_PULLUP);
pinMode(BTN_LEFT, INPUT_PULLUP);
pinMode(BTN_RIGHT, INPUT_PULLUP);
tft.begin();
tft.setRotation(3);
drawTitleScreen();
tft.fillScreen(ILI9341_BLACK);
drawHUD();
Serial.println(F("Pico Doom — raycaster ready"));
}
unsigned long lastFrameMs = 0;
const float MOVE_SPEED = 0.10f;
const float ROT_SPEED = 0.08f;
void loop() {
unsigned long now = millis();
if (now - lastFrameMs < 100) return;
lastFrameMs = now;
if (digitalRead(BTN_FWD) == LOW) {
float nx = posX + dirX * MOVE_SPEED;
float ny = posY + dirY * MOVE_SPEED;
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
}
if (digitalRead(BTN_BACK) == LOW) {
float nx = posX - dirX * MOVE_SPEED;
float ny = posY - dirY * MOVE_SPEED;
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
}
auto rotate = [](float &x, float &y, float a) {
float ox = x;
x = x * cosf(a) - y * sinf(a);
y = ox * sinf(a) + y * cosf(a);
};
if (digitalRead(BTN_LEFT) == LOW) { rotate(dirX, dirY, ROT_SPEED); rotate(planeX, planeY, ROT_SPEED); }
if (digitalRead(BTN_RIGHT) == LOW) { rotate(dirX, dirY, -ROT_SPEED); rotate(planeX, planeY, -ROT_SPEED); }
renderFrame();
}
`,
components: [
{ type: 'wokwi-ili9341', id: 'tft1', x: 460, y: 60, properties: {} },
{ type: 'wokwi-pushbutton', id: 'btn-fwd', x: 220, y: 420, properties: { color: 'red', label: 'FWD' } },
{ type: 'wokwi-pushbutton', id: 'btn-back', x: 220, y: 520, properties: { color: 'blue', label: 'BACK' } },
{ type: 'wokwi-pushbutton', id: 'btn-left', x: 130, y: 470, properties: { color: 'green', label: 'LEFT' } },
{ type: 'wokwi-pushbutton', id: 'btn-right', x: 310, y: 470, properties: { color: 'green', label: 'RIGHT' } },
],
wires: [
// SPI bus + control lines to the ILI9341
{ id: 'w-tft-sck', start: { componentId: 'raspberry-pi-pico', pinName: 'GP18' }, end: { componentId: 'tft1', pinName: 'SCK' }, color: '#ff8800' },
{ id: 'w-tft-mosi', start: { componentId: 'raspberry-pi-pico', pinName: 'GP19' }, end: { componentId: 'tft1', pinName: 'MOSI' }, color: '#ff8800' },
{ id: 'w-tft-cs', start: { componentId: 'raspberry-pi-pico', pinName: 'GP17' }, end: { componentId: 'tft1', pinName: 'CS' }, color: '#00aaff' },
{ id: 'w-tft-dc', start: { componentId: 'raspberry-pi-pico', pinName: 'GP20' }, end: { componentId: 'tft1', pinName: 'D/C' }, color: '#00cc00' },
{ id: 'w-tft-rst', start: { componentId: 'raspberry-pi-pico', pinName: 'GP21' }, end: { componentId: 'tft1', pinName: 'RST' }, color: '#cc0000' },
{ id: 'w-tft-led', start: { componentId: 'raspberry-pi-pico', pinName: 'GP22' }, end: { componentId: 'tft1', pinName: 'LED' }, color: '#ffffff' },
// Buttons signal + GND each
{ id: 'w-btn-fwd', start: { componentId: 'raspberry-pi-pico', pinName: 'GP10' }, end: { componentId: 'btn-fwd', pinName: '1.l' }, color: '#ff4444' },
{ id: 'w-btn-back', start: { componentId: 'raspberry-pi-pico', pinName: 'GP11' }, end: { componentId: 'btn-back', pinName: '1.l' }, color: '#4477ff' },
{ id: 'w-btn-left', start: { componentId: 'raspberry-pi-pico', pinName: 'GP12' }, end: { componentId: 'btn-left', pinName: '1.l' }, color: '#44cc44' },
{ id: 'w-btn-right', start: { componentId: 'raspberry-pi-pico', pinName: 'GP13' }, end: { componentId: 'btn-right', pinName: '1.l' }, color: '#cccc44' },
{ id: 'w-gnd-fwd', start: { componentId: 'btn-fwd', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.1' }, color: '#000000' },
{ id: 'w-gnd-back', start: { componentId: 'btn-back', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.2' }, color: '#000000' },
{ id: 'w-gnd-left', start: { componentId: 'btn-left', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.3' }, color: '#000000' },
{ id: 'w-gnd-right', start: { componentId: 'btn-right', pinName: '2.l' }, end: { componentId: 'raspberry-pi-pico', pinName: 'GND.4' }, color: '#000000' },
],
},
{
id: 'tft-display',
title: 'TFT ILI9341 Display',

View File

@ -0,0 +1,89 @@
# Pico Doom — pre-flight compile test
A column-wise raycaster that produces a Wolfenstein / early-Doom-style
3D corridor on the Pi Pico + ILI9341 TFT. Lives here as the canonical
source of the sketch + a one-shot compile check the operator can run
before exposing the example in the gallery.
## What the sketch does
- Reads four pushbuttons (forward, back, turn left, turn right) on
GP10-13 with INPUT_PULLUP.
- Draws a title splash on the ILI9341, waits for FWD.
- Casts 160 rays per frame (every other column, 2-px stripe) through
a 16×16 tile map using DDA, paints walls straight onto the TFT with
`drawFastVLine` — no framebuffer.
- Renders a fixed 40-px HUD bar at the bottom (HP / armour / ammo).
- Caps the frame rate at ~10 fps so the SPI bus has room to breathe.
## Why this is a "demo" and not the real id Software Doom
The canonical Pico port — Graham Sanderson's [rp2040-doom][rp2040doom]
— ships the shareware DOOM1.WAD inside the flash with custom
compression and pushes pixels out via PIO-driven VGA / DVI. None of
that survives the rp2040js emulator (no PIO accuracy, no flash
mapping for the WAD blob), so what you'd see in the simulator is a
boot loop.
A Wolf3D-style raycaster reproduces the *visual experience* — first-
person 3D corridor with textured walls, free rotation, smooth
forward/strafe movement — using the same tools that fit on a 264 KB
chip. That's what this sketch does, and it survives emulation fine.
[rp2040doom]: https://github.com/kilograham/rp2040-doom
## How to run the pre-flight check
The sketch must compile against the Pico FQBN with only the libraries
the Velxio compile pipeline already auto-installs (Adafruit_GFX,
Adafruit_ILI9341). Run:
```bash
cd test/pico_doom_demo
./compile_check.sh
```
The script:
1. Checks `arduino-cli` is in `$PATH`.
2. Verifies `rp2040:rp2040` core is installed (installs it if not).
3. Confirms `Adafruit_GFX` and `Adafruit_ILI9341` are installed (lib
install if missing).
4. Runs `arduino-cli compile --fqbn rp2040:rp2040:rpipico .` and
asserts a non-empty `.uf2` output.
A successful run prints the binary size and exits 0. Sketch size
should sit comfortably under 80 KB — well below the Pico's 2 MB
flash ceiling — so this isn't a "will it fit" test but a
"compiles cleanly with the gallery's lib set" test.
## How the example wires up the gallery
`frontend/src/data/examples.ts` registers an entry with:
- `id: 'pico-doom-raycaster'`
- `boardType: 'raspberry-pi-pico'`
- `category: 'games'`
- `difficulty: 'advanced'`
- `libraries: ['Adafruit GFX Library', 'Adafruit ILI9341']`
- Components: 1 ILI9341 + 4 pushbuttons + the Pico board
- Wires: SPI bus (SCK/MOSI/CS/DC/RST/LED) + 4 button pulls to GP10-13
The unit test `frontend/src/__tests__/examples-pico-doom.test.ts`
validates that the registered example has those fields and that
every wire endpoint references a component id that actually exists.
## Pin map (printed here so it's obvious during hardware bring-up too)
| Function | Pico GPIO | Note |
|---|---|---|
| SPI0 SCK | GP18 | Adafruit_ILI9341 uses SPI0 by default. |
| SPI0 MOSI | GP19 | |
| TFT CS | GP17 | |
| TFT D/C | GP20 | |
| TFT RST | GP21 | |
| TFT LED | GP22 | Backlight, always HIGH. |
| Button FWD | GP10 | INPUT_PULLUP, active LOW. |
| Button BACK | GP11 | |
| Button LEFT | GP12 | Turn anticlockwise. |
| Button RIGHT | GP13 | Turn clockwise. |

View File

@ -0,0 +1,261 @@
/*
* Pico Doom — A Wolf3D-style raycaster running on Raspberry Pi Pico.
*
* Hardware
* Raspberry Pi Pico (RP2040) + ILI9341 SPI TFT (320x240, landscape)
* 4 pushbuttons: forward, backward, turn left, turn right
*
* Pins
* SPI0: SCK=GP18 MOSI=GP19 CS=GP17 DC=GP20 RST=GP21 LED=GP22
* Buttons (active LOW, INPUT_PULLUP):
* FWD=GP10 BACK=GP11 LEFT=GP12 RIGHT=GP13
*
* Renderer
* DDA raycasting at 2-column step (160 rays / frame) — column-stripe
* drawing straight to the TFT, no framebuffer. Single-room 16x16 map
* with 5 wall styles (slate, blood, brown, green, door) painted darker
* on NS faces so corners read in 3D. Sky + floor are flat colour
* bands; the bottom 40 px is the HUD.
*
* Why a demo and not the real id Software Doom: the canonical Pico
* port (Graham Sanderson's rp2040-doom) shoehorns the shareware WAD
* into 2 MB of flash with custom compression + PIO video timing the
* emulator can't reproduce. A column-wise raycaster gets you the
* "first-person 3D corridor" visual that Wolfenstein/early Doom both
* used, with zero external assets, and it fits in a 100-line sketch.
*
* Build
* FQBN: rp2040:rp2040:rpipico
* Required libraries: Adafruit_GFX, Adafruit_ILI9341, SPI (builtin).
*
* This is the source-of-truth file. The /examples gallery entry in
* frontend/src/data/examples.ts is generated/copied from this sketch;
* if you change the gameplay or wiring here, mirror it there.
*/
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <math.h>
// ─── Pins ──────────────────────────────────────────────────────────────
#define TFT_CS 17
#define TFT_DC 20
#define TFT_RST 21
#define TFT_LED 22
#define BTN_FWD 10
#define BTN_BACK 11
#define BTN_LEFT 12
#define BTN_RIGHT 13
Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
// ─── Map (1=slate, 2=blood, 3=brown, 4=green, 5=door, 0=empty) ─────────
#define MAP_W 16
#define MAP_H 16
const uint8_t worldMap[MAP_H][MAP_W] = {
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,2,2,2,0,0,0,0,0,1},
{1,0,1,1,0,0,0,0,0,0,0,0,1,1,0,1},
{1,0,1,0,0,0,3,3,0,0,0,0,0,0,0,1},
{1,0,1,0,0,0,0,0,0,0,4,4,4,0,0,1},
{1,0,1,1,1,0,0,5,5,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,2,0,0,0,1},
{1,0,3,3,3,3,0,0,0,2,2,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1},
{1,0,0,0,0,5,5,0,0,3,0,0,0,0,0,1},
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,2,2,2,0,0,0,0,0,5,0,1},
{1,4,0,0,0,0,0,0,0,0,3,3,3,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,4,4,4,4,4,4,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
};
// ─── Player state ──────────────────────────────────────────────────────
float posX = 8.5f, posY = 8.5f;
float dirX = -1.0f, dirY = 0.0f;
float planeX = 0.0f, planeY = 0.66f;
// ─── Render constants ──────────────────────────────────────────────────
#define SCREEN_W 320
#define SCREEN_H 240
#define HUD_H 40
#define VIEW_H (SCREEN_H - HUD_H) // 200 — the actual 3D viewport
#define HALF_VH (VIEW_H / 2)
#define SKY_COLOR 0x18C3
#define FLOOR_COLOR 0x4208
// 5 wall palettes (RGB565). NS faces are rendered with the dim variant
// so corners read correctly without per-pixel shading cost.
uint16_t wallColor(uint8_t tile, bool nsFace) {
uint16_t c;
switch (tile) {
case 1: c = 0x8410; break; // slate gray
case 2: c = 0xC800; break; // blood red
case 3: c = 0x4A60; break; // brown
case 4: c = 0x32A0; break; // toxic green
case 5: c = 0xFD20; break; // bronze door
default: c = 0xFFFF; break;
}
if (nsFace) c = (c >> 1) & 0x7BEFu; // darken
return c;
}
void drawTitleScreen() {
tft.fillScreen(ILI9341_BLACK);
tft.setTextSize(6);
tft.setTextColor(0xC800); // blood red
tft.setCursor(60, 40);
tft.print("DOOM");
tft.setTextSize(2);
tft.setTextColor(0xFFE0); // yellow
tft.setCursor(50, 110);
tft.print("Pico Edition");
tft.setTextSize(1);
tft.setTextColor(0xC618);
tft.setCursor(40, 160);
tft.print("FWD/BACK move LEFT/RIGHT turn");
tft.setTextSize(2);
tft.setTextColor(0xFFFF);
tft.setCursor(56, 200);
tft.print("Press FWD to start");
// Wait for the player to press FWD.
while (digitalRead(BTN_FWD) == HIGH) {
delay(40);
}
// Debounce — don't immediately walk into the wall.
while (digitalRead(BTN_FWD) == LOW) {
delay(40);
}
}
void drawHUD() {
tft.fillRect(0, SCREEN_H - HUD_H, SCREEN_W, HUD_H, 0x2104); // dark gray bar
tft.drawFastHLine(0, SCREEN_H - HUD_H, SCREEN_W, 0x52AA);
tft.setTextSize(2);
tft.setTextColor(0xFFE0);
tft.setCursor(8, SCREEN_H - 28);
tft.print("HP:100 ARM:50 AMMO:50");
}
// Render one frame. Sky/floor bands are repainted every frame (cheap
// fillRects) so the previous frame's wall columns get overwritten
// automatically — no separate clear pass needed.
void renderFrame() {
// Sky covers the top half of the VIEW; floor the bottom half.
tft.fillRect(0, 0, SCREEN_W, HALF_VH, SKY_COLOR);
tft.fillRect(0, HALF_VH, SCREEN_W, HALF_VH, FLOOR_COLOR);
for (int x = 0; x < SCREEN_W; x += 2) {
// Camera-X in [-1, 1].
float cameraX = 2.0f * x / SCREEN_W - 1.0f;
float rayDirX = dirX + planeX * cameraX;
float rayDirY = dirY + planeY * cameraX;
int mapX = (int)posX;
int mapY = (int)posY;
float deltaDistX = (rayDirX == 0) ? 1e30f : fabsf(1.0f / rayDirX);
float deltaDistY = (rayDirY == 0) ? 1e30f : fabsf(1.0f / rayDirY);
int stepX, stepY;
float sideDistX, sideDistY;
if (rayDirX < 0) { stepX = -1; sideDistX = (posX - mapX) * deltaDistX; }
else { stepX = 1; sideDistX = (mapX + 1.0f - posX) * deltaDistX; }
if (rayDirY < 0) { stepY = -1; sideDistY = (posY - mapY) * deltaDistY; }
else { stepY = 1; sideDistY = (mapY + 1.0f - posY) * deltaDistY; }
bool hit = false;
bool nsFace = false; // false = EW (vertical wall), true = NS (horizontal)
int iter = 0;
while (!hit && iter++ < 64) {
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; nsFace = false; }
else { sideDistY += deltaDistY; mapY += stepY; nsFace = true; }
if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) break;
if (worldMap[mapY][mapX] > 0) hit = true;
}
if (!hit) continue;
float perpDist = nsFace
? (mapY - posY + (1.0f - stepY) * 0.5f) / rayDirY
: (mapX - posX + (1.0f - stepX) * 0.5f) / rayDirX;
if (perpDist < 0.0001f) perpDist = 0.0001f;
int lineH = (int)(VIEW_H / perpDist);
int drawStart = HALF_VH - lineH / 2;
int drawEnd = HALF_VH + lineH / 2;
if (drawStart < 0) drawStart = 0;
if (drawEnd > VIEW_H) drawEnd = VIEW_H;
uint16_t color = wallColor(worldMap[mapY][mapX], nsFace);
// Draw two adjacent columns so the 2-px step doesn't show as gaps.
tft.drawFastVLine(x, drawStart, drawEnd - drawStart, color);
tft.drawFastVLine(x + 1, drawStart, drawEnd - drawStart, color);
}
}
void setup() {
Serial.begin(115200);
pinMode(TFT_LED, OUTPUT);
digitalWrite(TFT_LED, HIGH);
pinMode(BTN_FWD, INPUT_PULLUP);
pinMode(BTN_BACK, INPUT_PULLUP);
pinMode(BTN_LEFT, INPUT_PULLUP);
pinMode(BTN_RIGHT, INPUT_PULLUP);
tft.begin();
tft.setRotation(3); // landscape, 320×240
drawTitleScreen();
tft.fillScreen(ILI9341_BLACK);
drawHUD();
Serial.println(F("Pico Doom — raycaster ready"));
}
unsigned long lastFrameMs = 0;
const float MOVE_SPEED = 0.10f;
const float ROT_SPEED = 0.08f;
void loop() {
unsigned long now = millis();
if (now - lastFrameMs < 100) return; // cap at ~10 fps for the SPI bus
lastFrameMs = now;
// ── Input ───────────────────────────────────────────────────────────
if (digitalRead(BTN_FWD) == LOW) {
float nx = posX + dirX * MOVE_SPEED;
float ny = posY + dirY * MOVE_SPEED;
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
}
if (digitalRead(BTN_BACK) == LOW) {
float nx = posX - dirX * MOVE_SPEED;
float ny = posY - dirY * MOVE_SPEED;
if (nx > 0 && nx < MAP_W && worldMap[(int)posY][(int)nx] == 0) posX = nx;
if (ny > 0 && ny < MAP_H && worldMap[(int)ny][(int)posX] == 0) posY = ny;
}
// Rotate by +/- ROT_SPEED radians.
auto rotate = [](float &x, float &y, float a) {
float ox = x;
x = x * cosf(a) - y * sinf(a);
y = ox * sinf(a) + y * cosf(a);
};
if (digitalRead(BTN_LEFT) == LOW) {
rotate(dirX, dirY, ROT_SPEED);
rotate(planeX, planeY, ROT_SPEED);
}
if (digitalRead(BTN_RIGHT) == LOW) {
rotate(dirX, dirY, -ROT_SPEED);
rotate(planeX, planeY, -ROT_SPEED);
}
// ── Render ──────────────────────────────────────────────────────────
renderFrame();
}

View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Pre-flight compile check for the Pico Doom raycaster example.
#
# Runs arduino-cli against the same FQBN the Velxio backend uses for the
# Raspberry Pi Pico and exits 0 only if the sketch builds cleanly with
# the gallery's auto-installed library set.
#
# Manual use:
# cd test/pico_doom_demo && ./compile_check.sh
#
# This file is wired into the test/ tree (not the vitest suite) because
# arduino-cli isn't available in the CI image we use for the JS tests;
# the operator runs it before merging the example, the in-repo unit
# test verifies the example data shape.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKETCH_DIR="$SCRIPT_DIR"
BOARD_FQBN="rp2040:rp2040:rpipico"
BUILD_DIR="$SCRIPT_DIR/build"
echo "─── Pico Doom compile check ───────────────────────────────"
if ! command -v arduino-cli >/dev/null 2>&1; then
echo "ERROR: arduino-cli not on PATH. Install from https://arduino.github.io/arduino-cli/"
exit 1
fi
# Make sure the Pico core is installed. earlephilhower/arduino-pico is
# what the production Velxio backend pulls.
if ! arduino-cli core list 2>/dev/null | grep -q "rp2040:rp2040"; then
echo "[install] adding rp2040:rp2040 core index"
arduino-cli config add board_manager.additional_urls \
https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json \
|| true
arduino-cli core update-index
arduino-cli core install rp2040:rp2040
fi
# Confirm required libs exist; install if absent.
for lib in "Adafruit GFX Library" "Adafruit ILI9341"; do
if ! arduino-cli lib list 2>/dev/null | grep -q "$lib"; then
echo "[install] adding library: $lib"
arduino-cli lib install "$lib"
fi
done
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
echo "[compile] $BOARD_FQBN"
arduino-cli compile \
--fqbn "$BOARD_FQBN" \
--output-dir "$BUILD_DIR" \
"$SKETCH_DIR"
uf2_path=$(find "$BUILD_DIR" -name '*.uf2' | head -n1 || true)
if [ -z "$uf2_path" ] || [ ! -s "$uf2_path" ]; then
echo "ERROR: no .uf2 produced — compile output above"
exit 1
fi
uf2_kb=$(($(stat -c%s "$uf2_path") / 1024))
echo "[ok] built $(basename "$uf2_path") (${uf2_kb} KB)"
echo "──────────────────────────────────────────────────────────"