Pico Doom — Raycaster Demo is a runnable microcontroller simulation example on SensBench — open the wired circuit, inspect the code, and compile real firmware in your browser, no install needed. Last updated Aug 14, 2026.

FREE SIMULATOR EXAMPLE · ADVANCED

Pico Doom — Raycaster Demo

This example runs a Wolfenstein-style raycaster on the Raspberry Pi Pico: a first-person 3D corridor rendered column-by-column onto an ILI9341 TFT over SPI, navigable with four pushbuttons. It is the classic early-Doom rendering technique, hand-written to fit on a real Pico — the full WAD-based game would not fit in 2 MB of flash, so this is the demo the hardware actually runs.

Board Raspberry Pi Pico (RP2040 dual-core Cortex-M0+, 133 MHz)
Difficulty advanced
Category games
Libraries Adafruit GFX Library, Adafruit ILI9341
Runs in Sensbench browser simulator — free, no account
Pico Doom raycaster demo running a first-person 3D corridor on the Raspberry Pi Pico with an ILI9341 TFT in the Sensbench simulator

The exact circuit you get when you open this example — already wired, ready to run.

The sketch

/*
 * 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();
}

How it works

The render loop is the classic DDA (Digital Differential Analyzer) raycast: for every screen column, a ray is cast from the player position through the camera plane, and the algorithm steps through the 16×16 tile map accumulating side distances until it hits a non-zero wall tile. The perpendicular distance — not the euclidean distance — drives the wall slice height, which is what makes the perspective look correct.

The world map encodes five wall types (slate, blood, brown, toxic green, bronze door) as tile values 1-5, and wallColor() maps each to a 16-bit RGB565 color. The North/South face flag from the DDA halves the color brightness on one axis, giving the walls visible shading — the same trick the original Wolfenstein used to fake lighting. Movement and rotation use trig on the direction and camera-plane vectors, with simple wall collision tests before each move.

The HUD is drawn once per frame below the view: HP, armor, and ammo in RPG styling. Four buttons control forward, backward, and turning, each with INPUT_PULLUP — active LOW. The frame loop is throttled to 10 FPS with millis(), which keeps the raycast and TFT writes from spinning the CPU flat out.

Before you build it for real

A real Pico pushing an ILI9341 at 10 FPS is hungry: the backlight LED pin plus the SPI clock draw noticeable current, and the sketch’s continuous renderFrame() loop leaves the CPU mostly pegged — on battery power this will drain in hours, not days. The buttons are read without debounce, so direction changes can feel slightly sloppy at speed. And the 16×16 tile map is hard-coded: it is the classic room layout, not a level loader — extending it means editing the array, not adding a level file. The simulator also models the TFT’s exact RGB565 color order, which differs subtly between ILI9341 clones on real panels.

Get the parts

Related examples

FAQ

Why is this called Doom if it is not the actual Doom?

The real Doom needs the game’s WAD asset files, which are bigger than the Pico’s entire 2 MB flash and rely on compression tricks the emulator cannot reproduce. This is the visual heart of Doom — the raycaster that renders the 3D view — reimplemented in bare C++ that a real Pico actually runs: DDA raycasting, textured-style walls, a HUD, and button controls.

How does it render 3D without a framebuffer?

The ILI9341 TFT has its own internal GRAM, so the sketch skips a RAM framebuffer entirely: for each screen column it raycasts to find the wall distance, computes the vertical span of that wall slice, and draws it straight to the panel with drawFastVLine(). Only 320 columns are drawn per frame, so the Pico’s 264 KB RAM never holds a full frame.