ESP32 Doom — Raycaster (ILI9341) 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

ESP32 Doom — Raycaster (ILI9341)

This example runs a Wolfenstein-style raycaster — early Doom, minus the textures — on an ESP32 driving an ILI9341 320×240 TFT, redrawing the entire screen every frame over 40 MHz hardware SPI. Four buttons walk and turn you through the 16×16 map; touch nothing and an auto-demo tours it instead. The HUD and the Serial Monitor both report FPS, because this sketch doubles as an emulation-speed benchmark.

Board ESP32 DevKit (Xtensa LX6, 240 MHz)
Difficulty advanced
Category games
Libraries Adafruit GFX Library, Adafruit ILI9341, Adafruit BusIO
Runs in Sensbench browser simulator — free, no account
ESP32 DevKit wired to an ILI9341 320x240 TFT on VSPI pins 18, 23, 5, 2 and 4 running a raycaster in the Sensbench simulator

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

The sketch

/*
 * SENSBENCH DOOM - ESP32 raycaster on an ILI9341 320x240 color TFT.
 *
 * Purpose: stress-test sensbench's QEMU ESP32 emulation by redrawing the whole
 * 320x240 screen every frame with a Wolfenstein/early-Doom style raycaster.
 * Rendering uses Adafruit_ILI9341 over HARDWARE SPI (VSPI) with the fast
 * block-transfer transaction API: a single startWrite()/endWrite() per frame
 * and writeFastVLine() column fills, so each frame is one big SPI burst
 * (the path QEMU forwards to the display sim as batched bytes).
 *
 * Every 10 frames it prints "STAT frame=.. t_ms=.. devfps=.." over Serial so
 * the host can compute BOTH on-device FPS (frames per emulated second) and
 * emulation speed (frames per real wall-clock second).
 *
 * Wiring (VSPI):  SCK=18  MOSI=23  MISO=19  CS=5  DC=2  RST=4  LED/VCC=3V3
 * Buttons (active-LOW, INPUT_PULLUP, other leg to GND):
 *   FWD=32  BACK=33  LEFT=25  RIGHT=26   (auto-demo runs with no input)
 */
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <math.h>

#define TFT_SCK  18
#define TFT_MOSI 23
#define TFT_MISO 19
#define TFT_DC    2
#define TFT_CS    5
#define TFT_RST   4

#define BTN_FWD   32
#define BTN_BACK  33
#define BTN_LEFT  25
#define BTN_RIGHT 26

#define RGB565(r, g, b) ((uint16_t)((((r) & 0xF8) << 8) | (((g) & 0xFC) << 3) | ((b) >> 3)))

SPIClass tftSPI(VSPI);
Adafruit_ILI9341 tft = Adafruit_ILI9341(&tftSPI, TFT_DC, TFT_CS, TFT_RST);

static const int SCREEN_W = 320;
static const int SCREEN_H = 240;

#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,3,3,0,1},
  {1,0,1,0,0,0,4,4,0,0,0,0,0,0,0,1},
  {1,0,1,0,0,0,0,0,0,0,5,5,5,0,0,1},
  {1,0,1,1,1,0,0,2,2,0,0,0,0,0,0,1},
  {1,0,0,0,0,0,0,0,0,0,0,3,0,0,0,1},
  {1,0,4,4,4,4,0,0,0,2,2,0,0,0,0,1},
  {1,0,0,0,0,0,0,0,0,0,0,0,0,5,5,1},
  {1,0,0,0,0,3,3,0,0,4,0,0,0,0,0,1},
  {1,0,2,2,0,0,0,0,0,0,0,1,1,0,0,1},
  {1,0,0,0,0,0,5,5,0,0,0,0,0,0,0,1},
  {1,0,3,0,0,0,0,0,0,2,2,2,0,0,0,1},
  {1,0,0,0,4,4,0,0,0,0,0,0,0,3,0,1},
  {1,0,0,0,0,0,0,0,5,0,0,0,0,0,0,1},
  {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
};

static uint16_t wallColor(uint8_t t) {
  switch (t) {
    case 1: return RGB565(150, 150, 165); // slate
    case 2: return RGB565(190,  35,  35); // blood
    case 3: return RGB565(165, 100,  45); // brown
    case 4: return RGB565( 45, 180,  70); // toxic green
    case 5: return RGB565(180, 140,  50); // bronze door
  }
  return RGB565(100, 100, 100);
}

// Darken an RGB565 colour by a 0..256 factor (256 = unchanged).
static uint16_t shade(uint16_t c, uint16_t f) {
  uint16_t r = (c >> 11) & 0x1F, g = (c >> 5) & 0x3F, b = c & 0x1F;
  r = (r * f) >> 8; g = (g * f) >> 8; b = (b * f) >> 8;
  return (uint16_t)((r << 11) | (g << 5) | b);
}

const uint16_t CEIL_COLOR  = RGB565(28, 28, 44);
const uint16_t FLOOR_COLOR = RGB565(48, 42, 36);

// Player camera (DDA raycaster state).
float posX = 8.5f, posY = 8.5f;
float dirX = -1.0f, dirY = 0.0f;
float planeX = 0.0f, planeY = 0.66f;

uint32_t frames = 0;
uint32_t fpsT0 = 0;
int fpsWhole = 0, fpsFrac = 0;

static void rotate(float a) {
  float od = dirX;
  dirX = dirX * cosf(a) - dirY * sinf(a);
  dirY = od   * sinf(a) + dirY * cosf(a);
  float op = planeX;
  planeX = planeX * cosf(a) - planeY * sinf(a);
  planeY = op     * sinf(a) + planeY * cosf(a);
}

static bool isWall(float x, float y) {
  int mx = (int)x, my = (int)y;
  if (mx < 0 || mx >= MAP_W || my < 0 || my >= MAP_H) return true;
  return worldMap[my][mx] != 0;
}

static void moveStep(float dist) {
  float nx = posX + dirX * dist;
  float ny = posY + dirY * dist;
  if (!isWall(nx, posY)) posX = nx;
  if (!isWall(posX, ny)) posY = ny;
}

static void handleInput() {
  bool any = false;
  if (digitalRead(BTN_FWD)   == LOW) { moveStep( 0.10f); any = true; }
  if (digitalRead(BTN_BACK)  == LOW) { moveStep(-0.10f); any = true; }
  if (digitalRead(BTN_LEFT)  == LOW) { rotate( 0.06f);   any = true; }
  if (digitalRead(BTN_RIGHT) == LOW) { rotate(-0.06f);   any = true; }
  if (!any) {
    // Auto-demo: walk forward, slowly pan, and turn hard if about to hit a wall.
    rotate(0.013f);
    moveStep(0.05f);
    if (isWall(posX + dirX * 0.25f, posY + dirY * 0.25f)) rotate(0.25f);
  }
}

static void renderFrame() {
  tft.startWrite();
  for (int x = 0; x < SCREEN_W; x++) {
    float cameraX = 2.0f * x / (float)SCREEN_W - 1.0f;
    float rayDirX = dirX + planeX * cameraX;
    float rayDirY = dirY + planeY * cameraX;

    int mapX = (int)posX, mapY = (int)posY;
    float deltaX = (rayDirX == 0.0f) ? 1e30f : fabsf(1.0f / rayDirX);
    float deltaY = (rayDirY == 0.0f) ? 1e30f : fabsf(1.0f / rayDirY);

    int stepX, stepY;
    float sideX, sideY;
    if (rayDirX < 0) { stepX = -1; sideX = (posX - mapX) * deltaX; }
    else             { stepX =  1; sideX = (mapX + 1.0f - posX) * deltaX; }
    if (rayDirY < 0) { stepY = -1; sideY = (posY - mapY) * deltaY; }
    else             { stepY =  1; sideY = (mapY + 1.0f - posY) * deltaY; }

    int side = 0;
    uint8_t tile = 1;
    for (int guard = 0; guard < 64; guard++) {
      if (sideX < sideY) { sideX += deltaX; mapX += stepX; side = 0; }
      else               { sideY += deltaY; mapY += stepY; side = 1; }
      if (mapX < 0 || mapX >= MAP_W || mapY < 0 || mapY >= MAP_H) { tile = 1; break; }
      tile = worldMap[mapY][mapX];
      if (tile != 0) break;
    }

    float perp = (side == 0) ? (sideX - deltaX) : (sideY - deltaY);
    if (perp < 0.05f) perp = 0.05f;

    int lineH = (int)(SCREEN_H / perp);
    int drawStart = SCREEN_H / 2 - lineH / 2;
    int drawEnd   = SCREEN_H / 2 + lineH / 2;
    if (drawStart < 0) drawStart = 0;
    if (drawEnd > SCREEN_H - 1) drawEnd = SCREEN_H - 1;

    // Distance fog + darker E/W faces for a sense of depth.
    float fog = 1.0f - perp / 16.0f;
    if (fog < 0.18f) fog = 0.18f;
    uint16_t f = (uint16_t)(fog * 256.0f);
    if (side == 1) f = (f * 180) >> 8;
    uint16_t col = shade(wallColor(tile), f);

    if (drawStart > 0)
      tft.writeFastVLine(x, 0, drawStart, CEIL_COLOR);
    tft.writeFastVLine(x, drawStart, drawEnd - drawStart + 1, col);
    if (drawEnd < SCREEN_H - 1)
      tft.writeFastVLine(x, drawEnd + 1, SCREEN_H - 1 - drawEnd, FLOOR_COLOR);
  }
  tft.endWrite();
}

static void drawHud() {
  tft.fillRect(0, 0, 168, 16, RGB565(0, 0, 0));
  tft.setTextSize(1);
  tft.setTextColor(RGB565(0, 255, 80));
  tft.setCursor(4, 4);
  tft.print("SENSBENCH DOOM  FPS ");
  tft.print(fpsWhole);
  tft.print('.');
  tft.print(fpsFrac);
}

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println("SENSBENCH DOOM raycaster -- ESP32 + ILI9341 (Adafruit, HW SPI)");
  pinMode(BTN_FWD,   INPUT_PULLUP);
  pinMode(BTN_BACK,  INPUT_PULLUP);
  pinMode(BTN_LEFT,  INPUT_PULLUP);
  pinMode(BTN_RIGHT, INPUT_PULLUP);

  tftSPI.begin(TFT_SCK, TFT_MISO, TFT_MOSI, TFT_CS);
  tft.begin(40000000); // 40 MHz nominal SPI
  tft.setRotation(1);  // landscape 320x240
  tft.fillScreen(RGB565(0, 0, 0));
  tft.setTextSize(3);
  tft.setTextColor(RGB565(0, 255, 80));
  tft.setCursor(36, 96);
  tft.print("SENSBENCH DOOM");
  tft.setTextSize(1);
  tft.setTextColor(RGB565(180, 180, 180));
  tft.setCursor(60, 140);
  tft.print("raycaster emulation benchmark");
  delay(600);
  fpsT0 = millis();
}

void loop() {
  handleInput();
  renderFrame();
  drawHud();
  frames++;

  if (frames % 10 == 0) {
    uint32_t now = millis();
    uint32_t dms = now - fpsT0;
    if (dms == 0) dms = 1;
    // FPS x10 (tenths), integer math to avoid %f (nano newlib has no float printf).
    uint32_t fps_x10 = (10u * 1000u * 10u) / dms;
    fpsWhole = fps_x10 / 10;
    fpsFrac  = fps_x10 % 10;
    fpsT0 = now;
    Serial.print("STAT frame=");
    Serial.print(frames);
    Serial.print(" t_ms=");
    Serial.print(now);
    Serial.print(" devfps=");
    Serial.print(fpsWhole);
    Serial.print('.');
    Serial.println(fpsFrac);
  }
}

How it works

The renderer is classic DDA raycasting. For each of the 320 screen columns the sketch fires a ray into the worldMap grid and steps cell by cell — using the precomputed deltaX/deltaY distances, never trig per step — until it hits a wall tile, then converts perpendicular distance into wall height with SCREEN_H / perp. A 64-iteration guard caps rays that would otherwise wander out of bounds. It is all single-precision float math, which the ESP32’s FPU handles comfortably at 240 MHz.

The display strategy is why a full-screen redraw is even possible: the whole frame happens inside one tft.startWrite()/endWrite() transaction, and every column is three writeFastVLine() calls — ceiling, wall slice, floor. That turns 76,800 pixels into a single 153 KB SPI burst at the 40 MHz clock set in tft.begin(40000000), instead of per-pixel transactions that would drown the bus. This block-write pattern is the same trick you want on real hardware.

Depth is faked with two cheap tricks: shade() scales each RGB565 wall colour by a 0–256 factor for distance fog, and east/west-facing walls get an extra x180/256 so corners read as geometry. Watch the FPS math too — it is computed in tenths with integers (fps_x10) because the nano newlib printf the Arduino core links against has no %f. Every ten frames a STAT frame=.. t_ms=.. devfps=.. line goes to serial, so you can compare emulated time against wall-clock time.

Before you build it for real

The simulator’s SPI link is perfect; your jumper wires are not. 40 MHz over breadboard leads typically means sparkle pixels, shifted columns or a white screen — drop to tft.begin(20000000) or keep wires under about 10 cm before blaming the code. The four buttons on GPIO 32, 33, 25 and 26 use INPUT_PULLUP with the other leg to ground: fine on a tidy bench, but long leads on internal ~45 kΩ pull-ups pick up noise, and there is no debounce — phantom turns that look like the auto-demo waking up are the first symptom. Finally, MISO on GPIO19 is wired but never used; the ILI9341 is write-only here, so on a real build that pin is free.

Get the parts

Related examples

FAQ

Why does it redraw the entire screen every frame?

Because a raycaster changes every column every frame — the whole screen is always dirty, so partial-redraw bookkeeping buys nothing. The performance comes from the transport instead: one startWrite()/endWrite() burst per frame over 40 MHz SPI. If you are benchmarking drivers or emulators, a guaranteed full-screen load is exactly what you want.

What do the STAT lines on the Serial Monitor mean?

Every ten frames the sketch prints the frame counter, the current millis() value and the on-device FPS. Since the simulator runs on emulated time, comparing t_ms against your wall clock tells you how fast the emulation itself is running — that is why this sketch exists as a benchmark, not just a demo.