Skip to content

Doom on Raspberry Pi

Time estimate: ~90 minutes Prerequisites: Kiosk Service, DSI Display (for touch), IMU Controller (for IMU mode)

Learning Objectives

By the end of this tutorial you will be able to:

  • Build an SDL2 application (Chocolate Doom) from source on the Raspberry Pi
  • Create a systemd kiosk service for a third-party application
  • Map touch regions to keyboard events using evdev and uinput
  • Inject synthetic keyboard input from IMU tilt data
  • Explain the complete input-to-display pipeline for each control method
Why Doom?

Chocolate Doom is a faithful open-source port of the original 1993 Doom engine. It uses SDL2 for video, audio, and input — the same library stack used in previous tutorials. Building and running it exercises every layer of the embedded Linux graphics pipeline: package management, source compilation, DRM/KMS display, touch input, and systemd services. The 4:3 aspect ratio also creates an interesting problem on 16:9 displays — letterbox margins that we can repurpose as touch control panels.

Chocolate Doom is not a toy project — it is a well-maintained, portable C codebase (~50,000 lines) with autotools build system, cross-platform input handling, and network multiplayer. Building it from source is a realistic exercise in compiling third-party software for an embedded target.

Course Source Repository

This tutorial references source files from the course repository. If you haven't cloned it yet on your Pi:

cd ~
git clone https://github.com/OE-KVK-H2IoT/embedded-linux.git

Source files for this tutorial are in ~/embedded-linux/solutions/doom-on-pi/.


1. Install Build Dependencies

Chocolate Doom depends on SDL2, SDL2_mixer (audio), SDL2_net (networking), and standard build tools.

sudo apt update
sudo apt install -y build-essential automake autoconf \
    libsdl2-dev libsdl2-mixer-dev libsdl2-net-dev \
    pkg-config python3
Checkpoint

Verify SDL2 is available:

sdl2-config --version
Should print 2.x.x.


2. Build Chocolate Doom

Clone the repository and build:

cd ~
git clone https://github.com/chocolate-doom/chocolate-doom.git
cd chocolate-doom

Generate the configure script, configure, and build:

autoreconf -fi
./configure
make -j$(nproc)
What autoreconf Does

The Chocolate Doom source uses GNU Autotools — a build system common in C projects. autoreconf -fi regenerates the configure script from configure.ac and Makefile.am templates. The configure script then detects your system's libraries, compilers, and paths, producing a Makefile tailored to your Pi. This is the same pattern used by thousands of open-source C projects — understanding it is essential when building third-party software from source.

The build produces several binaries:

ls -la src/chocolate-doom src/chocolate-setup
Build errors?
  • SDL2_mixer not found — install libsdl2-mixer-dev
  • SDL2_net not found — install libsdl2-net-dev
  • autoconf: command not found — install automake autoconf
Checkpoint

src/chocolate-doom binary exists and runs (it will exit with an error about missing WAD — that is expected).


3. Download the Shareware WAD

Doom requires a WAD file containing game data (levels, textures, sounds). The shareware version (doom1.wad) is freely redistributable:

mkdir -p ~/.local/share/chocolate-doom
cd ~/.local/share/chocolate-doom
wget https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad

Verify the file:

ls -lh doom1.wad
# Should be ~4.2 MB
Warning

The full registered WAD (doom.wad) is commercial software and must not be redistributed. The shareware WAD contains Episode 1 (9 levels) — more than enough for this tutorial.

Checkpoint

~/.local/share/chocolate-doom/doom1.wad exists and is approximately 4.2 MB.


4. Run on HDMI + Keyboard

Test basic operation on the HDMI display:

cd ~/chocolate-doom
sudo SDL_VIDEODRIVER=kmsdrm src/chocolate-doom -iwad ~/.local/share/chocolate-doom/doom1.wad
Why sudo and SDL_VIDEODRIVER=kmsdrm?

Without a desktop environment, SDL2 needs direct access to /dev/dri/card0 (the GPU). The kmsdrm video driver opens DRM directly, bypassing X11 or Wayland. Root access is required for direct DRM on most configurations. This is the same pattern used in Level Display and Touch Paint.

Controls (Keyboard)

Key Action
Arrow keys Move / turn
Ctrl Fire
Space Use / open doors
Alt + arrow Strafe
17 Select weapon
Tab Automap
Escape Menu

Resolution and Aspect Ratio

Doom renders internally at 320×200 (a 16:10 ratio that was displayed as 4:3 on CRT monitors with non-square pixels). Chocolate Doom scales this to the display resolution. On a 1920×1080 HDMI display, the 4:3 image is centered with black letterbox bars on the left and right sides:

┌────┬──────────────────┬────┐
│    │                  │    │
│    │   Doom (4:3)     │    │
│ L  │   1440 × 1080    │ R  │
│    │                  │    │
│    │                  │    │
│    │                  │    │
└────┴──────────────────┴────┘
  240       1440          240

These 240-pixel margins become useful in Section 6 (Touch Overlay).

Checkpoint

Doom runs fullscreen on HDMI. You can navigate the menu and start a game with the keyboard.


5. Create a Systemd Kiosk Service

Turn Doom into a kiosk app that starts automatically at boot. This follows the same pattern from the Kiosk Service tutorial.

Systemd service anatomy for embedded kiosk apps

A systemd service unit controls the complete lifecycle of a process — start, stop, restart, and resource cleanup. For embedded kiosk applications, several directives are critical:

  • Type=simple — systemd considers the service "started" as soon as ExecStart runs. This fits Doom: it does not fork or daemonize, it just runs until killed.
  • Conflicts= — acts as a mutex between services. When Doom starts, systemd stops any listed conflicting service (and vice versa). This ensures only one kiosk application owns the display at a time — without this, two SDL2 apps fighting for the DRM master would crash.
  • ExecStartPre= — runs a command before the main process. Here it unbinds the Linux virtual terminal (vtcon1) from the framebuffer, so the kernel's text console doesn't paint over Doom's output. This is a hardware resource handoff.
  • Environment=SDL_VIDEODRIVER=kmsdrm — environment variables set here propagate to the main process and all its children. This is how the wrapper script (added later) passes the DRM backend selection to Doom without modifying Doom's code.
  • Restart=on-failure — if Doom crashes, systemd restarts it after RestartSec=3. This is the process supervisor pattern: the init system ensures the kiosk application is always running, similar to how a watchdog timer resets hardware on hang.

Create /etc/systemd/system/doom.service:

sudo tee /etc/systemd/system/doom.service << 'EOF'
[Unit]
Description=Chocolate Doom (Kiosk)
After=multi-user.target
Conflicts=level-sdl2.service sdl2-dashboard.service

[Service]
Type=simple
ExecStartPre=/bin/sh -c 'echo 0 > /sys/class/vtconsole/vtcon1/bind'
ExecStart=/home/linux/chocolate-doom/src/chocolate-doom -iwad /home/linux/.local/share/chocolate-doom/doom1.wad
Environment=SDL_VIDEODRIVER=kmsdrm
Restart=on-failure
RestartSec=3
User=root
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF
Warning

Adjust the paths in ExecStart= and -iwad to match your Pi's username and build location. After editing, always reload:

sudo systemctl daemon-reload

Enable and start

sudo systemctl daemon-reload
sudo systemctl enable doom.service
sudo systemctl start doom.service

Verify

systemctl status doom.service

You should see Active: active (running) and Doom displayed fullscreen.

Test the Conflicts directive

If you have other kiosk services from the Kiosk Service tutorial:

sudo systemctl start level-sdl2.service
systemctl is-active doom.service
# Should be: inactive (killed by Conflicts=)

Inspect the running process

While Doom is running, examine what systemd manages:

# Find Doom's PID
systemctl show doom.service --property=MainPID

# See what files Doom has open (DRM device, WAD file, audio)
ls -l /proc/$(systemctl show doom.service -p MainPID --value)/fd

# See the environment systemd passed to Doom
cat /proc/$(systemctl show doom.service -p MainPID --value)/environ | tr '\0' '\n' | grep SDL

This demonstrates that systemd is a process supervisor — it knows the PID, manages its environment, and can inspect its state. The open file descriptors show the kernel resources Doom holds: the DRM device (/dev/dri/card*), the WAD file, and the audio device.

Reboot test

sudo reboot

After reboot, Doom should start automatically.

Checkpoint
  • Doom starts automatically after reboot
  • Starting another kiosk service (e.g., level-sdl2.service) stops Doom via Conflicts=
  • journalctl -u doom.service shows service logs

6. Run on DSI Touch Display

If you have a DSI display connected (see DSI Display), Doom works on it without code changes — DSI shares the DRM/KMS pipeline with HDMI.

Switch display output

If both HDMI and DSI are connected, configure which display DRM uses. Check available connectors:

ls /sys/class/drm/card0-*/status
cat /sys/class/drm/card0-DSI-1/status

To force output to DSI only, add to /boot/firmware/config.txt:

# Disable HDMI, use DSI only
dtoverlay=vc4-kms-v3d,nohdmi

Reboot after changing display configuration.

Touch as mouse input

SDL2 automatically maps touchscreen events to mouse events (SDL_HINT_TOUCH_MOUSE_EVENTS is enabled by default). This means Doom's mouse controls work with touch out of the box — no code changes needed.

In menus:

  • Tap a menu item → mouse click → selects the item
  • Tap "New Game" → starts the game directly

This is the key advantage over keyboard-only control — you can navigate menus by tapping directly on items instead of using arrow keys + Enter.

In-game:

  • Drag left/right → mouse X movement → turns the player
  • Tap → mouse button 1 → fires current weapon
  • Drag up/down → mouse Y movement → (ignored by default in Doom, unless mouselook is enabled)
How SDL2 Maps Touch to Mouse

When a finger touches the screen, SDL2 generates both SDL_FINGERDOWN and SDL_MOUSEBUTTONDOWN events. When the finger moves, it generates both SDL_FINGERMOTION and SDL_MOUSEMOTION. Doom only handles mouse events, so the finger events are ignored — but the synthetic mouse events drive the game. This dual-event approach means any SDL2 application that handles mouse input gets basic touch support for free.

See also: Input Subsystem reference § SDL2's Input Handling

What touch alone can and cannot do

Action Touch equivalent Works?
Select menu items Tap the item Yes
Fire weapon Tap (mouse click) Yes
Turn left/right Drag horizontally Yes
Walk forward/backward No (requires arrow keys)
Open doors / use No (requires Space key)
Strafe No (requires Alt + arrow)
Switch weapons No (requires number keys)

Touch gives you menu navigation, aiming, and firing. For full gameplay, you need the touch overlay from Section 7, which adds the missing keyboard controls via the letterbox margins.

Resolution considerations

The DSI display is 800×480. Doom's 320×200 internal resolution scales cleanly to this size. The 4:3 aspect ratio produces smaller letterbox margins than on 1080p:

┌──┬──────────────┬──┐
│  │              │  │
│  │  Doom (4:3)  │  │
│  │  640 × 480   │  │
│  │              │  │
└──┴──────────────┴──┘
 80      640       80
Checkpoint

Doom runs on the DSI display. You can tap menu items to navigate and drag to aim in-game.


7. Touch Overlay: Side Panel Buttons

The Linux Input Subsystem

Every input device in Linux — keyboard, mouse, touchscreen, IMU — is exposed as a character device at /dev/input/eventN. All devices speak the same protocol: a stream of struct input_event packets, each containing a type (e.g., EV_KEY for buttons, EV_ABS for absolute axes like touch coordinates), a code (which key or which axis), a value (pressed/released, or the coordinate), and a timestamp (when the kernel received the event).

You can observe this directly:

sudo evtest /dev/input/event0   # pick your touch device
# Touch the screen → events appear:
# Event: time 1234.567, type 3 (EV_ABS), code 53 (ABS_MT_POSITION_X), value 412
# Event: time 1234.567, type 3 (EV_ABS), code 54 (ABS_MT_POSITION_Y), value 238
# Event: time 1234.567, type 0 (EV_SYN), code 0 (SYN_REPORT), value 0

The SYN_REPORT marks the end of one event "frame" — all events between two SYN_REPORTs belong to the same physical moment. This is how multi-touch works: the kernel sends position updates for multiple fingers within a single frame, then SYN_REPORT to say "this set is complete, process it now."

uinput is the reverse path: a userspace process creates a virtual input device by writing to /dev/uinput. The kernel creates a new /dev/input/eventN for it, indistinguishable from a hardware device. Any application reading evdev (including SDL2) sees the virtual device as a real keyboard or mouse. This is the foundation of the touch overlay: we read real touch events, decide what they mean, and inject synthetic keyboard/mouse events.

For the complete reference — event types, the input_event struct, multi-touch Protocol A vs B, and the kernel-side API — see the Input Subsystem reference.

The 4:3 letterbox margins on a 16:9 (or wider) display are wasted space. We can turn them into virtual buttons by intercepting touch events in the margin regions and injecting synthetic keyboard events.

Architecture

┌──────────────────────────────────────────────────┐
│                                                  │
│  Touch panel (/dev/input/eventN)                 │
│       │                                          │
│       │ (grabbed — SDL2 cannot see it directly)  │
│       ▼                                          │
│  doom_touch_overlay.py                           │
│       │                                          │
│       ├── Touch in margin? ──► virtual keyboard  │
│       │   (inject KEY_UP,        /dev/uinput     │
│       │    KEY_LEFTCTRL, etc.)       │           │
│       │                              │           │
│       └── Touch in center? ──► virtual mouse     │
│           (inject ABS_X/Y +      /dev/uinput     │
│            BTN_LEFT)                 │           │
│                                      ▼           │
│                               Chocolate Doom     │
│                               (reads virtual     │
│                                kbd + mouse)      │
└──────────────────────────────────────────────────┘

The overlay process grabs the real touch device for exclusive access, reads raw touch events from evdev, and decides what to do with each touch:

  • Margin touch → inject a keyboard key via a virtual keyboard (uinput)
  • Center touch → re-inject as a mouse event via a virtual mouse (uinput)

The grab is essential: without it, both the overlay and SDL2 read the same touches, causing double input (e.g., a margin press sends both a KEY_UP and a mouse click, making menus skip two items).

Install Python dependencies

sudo apt install -y python3-evdev

The evdev package provides access to Linux input devices, and uinput allows creating virtual keyboard devices.

Identify the touch device and display resolution

Find your touchscreen device:

sudo python3 -c "
import evdev
for d in [evdev.InputDevice(p) for p in evdev.list_devices()]:
    caps = d.capabilities(verbose=False)
    abs_codes = [code for code, _ in caps.get(3, [])]  # EV_ABS = 3
    if 0x35 in abs_codes or 0x00 in abs_codes:         # ABS_MT_POSITION_X or ABS_X
        print(f'{d.path}: {d.name}  <-- touchscreen')
    else:
        print(f'{d.path}: {d.name}')
"

Note the touchscreen path (e.g., /dev/input/event0). Common controllers:

Panel Controller Name in evdev
Official 7" DSI FT5x06 FT5406 memory based driver
Third-party DSI Goodix GT911 Goodix Capacitive TouchScreen
SPI resistive XPT2046 ADS7846 Touchscreen

Also check your display resolution — the overlay needs to know the pixel dimensions:

# Check connected displays and their resolution
cat /sys/class/drm/card0-*/modes | head -1
# e.g., 1920x1080, 800x480

How the overlay works

The overlay script follows a pattern common to embedded input handling. Here are the key building blocks:

1. Margin geometry — calculate where buttons are based on the 4:3 letterbox:

# 4:3 game area centered in the display
GAME_W = int(DISPLAY_H * 4 / 3)       # e.g., 640 for 800x480
MARGIN_LEFT = (DISPLAY_W - GAME_W) // 2  # e.g., 80px
MARGIN_RIGHT = DISPLAY_W - MARGIN_LEFT
# Each margin is split into 4 rows (buttons)

2. Zone detection — check if a touch coordinate falls in a margin button:

def find_button(x, y):
    """Return the key code if (x, y) is in a margin button, else None."""
    for pos, (x0, x1, y0, y1) in LEFT_BUTTONS.items():
        if x0 <= x < x1 and y0 <= y < y1:
            return BUTTON_KEYS.get(("left", pos))
    for pos, (x0, x1, y0, y1) in RIGHT_BUTTONS.items():
        if x0 <= x < x1 and y0 <= y < y1:
            return BUTTON_KEYS.get(("right", pos))
    return None

3. Virtual device creation — create a fake keyboard and mouse via uinput:

# Virtual keyboard for margin buttons
ui_kbd = UInput({ecodes.EV_KEY: [ecodes.KEY_UP, ecodes.KEY_DOWN, ...]},
                name="doom-touch-overlay-kbd")

# Virtual mouse for center-area touches (absolute positioning)
ui_mouse = UInput({
    ecodes.EV_KEY: [ecodes.BTN_LEFT],
    ecodes.EV_ABS: [(ecodes.ABS_X, AbsInfo(0, 0, DISPLAY_W, 0, 0, 0)),
                     (ecodes.ABS_Y, AbsInfo(0, 0, DISPLAY_H, 0, 0, 0))],
}, name="doom-touch-overlay-mouse")

4. Device grab — exclusive access prevents SDL2 from double-reading touches:

touch.grab()  # EVIOCGRAB ioctl — only this process can read events

Without the grab, both the overlay and SDL2 read the same touches, causing double input (e.g., a margin press sends both a KEY_UP and a mouse click, making menus skip two items).

5. Event loop — read events, decide what to do, inject:

for event in touch.read_loop():
    if event.type == ecodes.EV_SYN and event.code == ecodes.SYN_REPORT:
        key = find_button(x, y)
        if key:
            ui_kbd.write(ecodes.EV_KEY, key, 1)  # press
            ui_kbd.syn()
        else:
            ui_mouse.write(ecodes.EV_ABS, ecodes.ABS_X, x)
            ui_mouse.write(ecodes.EV_ABS, ecodes.ABS_Y, y)
            ui_mouse.syn()

Deploy the overlay script

The complete script (~250 lines) adds multi-touch Protocol B support, single-touch fallback, coordinate normalization, auto-detection, debug mode, and proper cleanup. Copy it from the solutions:

cp src/embedded-linux/solutions/doom-on-pi/doom_touch_overlay.py ~/
chmod +x ~/doom_touch_overlay.py
Reading the full source

Study the complete script to understand: - Multi-touch Protocol BABS_MT_SLOT selects which finger, ABS_MT_TRACKING_ID = -1 means finger lifted - Coordinate normalization — touch coordinates (0..4096) differ from display pixels (0..800) - Cleanup — releasing all held keys and ungrabbing the device in a finally block prevents stuck keys if the script crashes

The source is at src/embedded-linux/solutions/doom-on-pi/doom_touch_overlay.py.

# Verify it works (replace event path and resolution for your setup):
sudo python3 ~/doom_touch_overlay.py --width 800 --height 480 --debug
# Touch the screen — you should see zone detection output
Checkpoint

The --debug flag prints each touch with its zone assignment. Verify: - Touches in the left margin print FWD, BACK, STRAFE, or ESC - Touches in the right margin print FIRE, USE, ENTER, or MAP - Touches in the center print center

Button layout diagram

┌──────────┬───────────────────────┬──────────┐
│  FWD     │                       │  FIRE    │
│  (↑)     │                       │  (Ctrl)  │
├──────────┤                       ├──────────┤
│  BACK    │                       │  USE     │
│  (↓)     │      Doom (4:3)       │  (Space) │
├──────────┤                       ├──────────┤
│  STRAFE  │                       │  ENTER   │
│  (Alt)   │                       │  (⏎)     │
├──────────┤                       ├──────────┤
│  ESC     │                       │  MAP     │
│  (menu)  │                       │  (Tab)   │
└──────────┴───────────────────────┴──────────┘
  Left margin     Game area         Right margin

Use Left ↑/↓ to navigate menus, Right ENTER to select, and Left ESC to go back — full menu navigation without a keyboard.

Run the overlay

Start order matters

SDL2 scans for input devices during SDL_Init(). The virtual keyboard created by the overlay must exist before Doom starts, otherwise SDL2 will never read from it. Always start the overlay first, then Doom.

Step 1: Start the overlay in one terminal. Pass your display resolution and touch device:

# For 800×480 DSI display:
sudo python3 ~/doom_touch_overlay.py --width 800 --height 480 --device /dev/input/event0

# For 1920×1080 HDMI + external touch panel:
sudo python3 ~/doom_touch_overlay.py --width 1920 --height 1080 --device /dev/input/event0

# Add --debug to see every touch event (useful for troubleshooting):
sudo python3 ~/doom_touch_overlay.py --width 800 --height 480 --device /dev/input/event0 --debug

Wait until you see the "Overlay ready" message:

Touch device: Goodix Capacitive TouchScreen (/dev/input/event0)
Protocol: multi-touch (MT)
Touch range: 0..800 x 0..480
Display: 800x480, margins: 80px each side
Virtual keyboard: /dev/input/event5

Overlay ready. Start Doom NOW (after this script) so SDL2
discovers the virtual keyboard during initialization.

Step 2: In a second terminal, start Doom:

sudo SDL_VIDEODRIVER=kmsdrm ~/chocolate-doom/src/chocolate-doom \
    -iwad ~/.local/share/chocolate-doom/doom1.wad

Touch the margin regions — the overlay injects keyboard events that Doom responds to. Touch the center area to aim/fire with the normal mouse mapping.

Troubleshooting
  • ERROR: No touchscreen found — run the device discovery command from above and pass the path with --device.
  • Doom does not respond to margin touches — most likely SDL2 started before the overlay. Kill Doom, verify the overlay is running, then start Doom again. Use --debug to confirm the overlay sees your touches and injects keys.
  • --debug shows coordinates but no key presses — the --width/--height do not match your display, so margin boundaries are wrong. Check with cat /sys/class/drm/card0-*/modes.
  • All touches trigger buttons — margins are too wide. Your display resolution is smaller than the default 1920×1080; pass the correct --width and --height.

Visible button labels in the margins

The black letterbox bars work as touch targets but give no visual feedback. Two approaches can render labels during gameplay — each teaches a different layer of the graphics stack.

Option A: Patch Doom's renderer

Chocolate Doom renders each frame in I_FinishUpdate() in src/i_video.c. After the game frame is blitted to the SDL texture but before SDL_RenderPresent(), we can draw button labels in the margin areas using SDL2's renderer. This modifies the application — quick and reliable.

Create a patch file:

cat > ~/chocolate-doom/touch-buttons.patch << 'PATCH_EOF'
--- a/src/i_video.c
+++ b/src/i_video.c
@@ -1,5 +1,6 @@
 #include "i_video.h"

+#include <string.h>
 #include "config.h"
 #include "d_loop.h"
 #include "deh_str.h"
@@ -113,6 +114,71 @@ static boolean initialized = false;

 static boolean need_resize = false;

+// ── Touch button overlay ───────────────────────────────────
+
+typedef struct {
+    const char *label;
+    SDL_Color   color;
+} touch_button_t;
+
+static const touch_button_t left_buttons[4] = {
+    {"FWD",    {60, 140, 60, 255}},
+    {"BACK",   {60, 100, 140, 255}},
+    {"STRAFE", {140, 100, 60, 255}},
+    {"ESC",    {140, 60, 60, 255}},
+};
+
+static const touch_button_t right_buttons[4] = {
+    {"FIRE",   {180, 50, 50, 255}},
+    {"USE",    {50, 140, 180, 255}},
+    {"ENTER",  {50, 180, 80, 255}},
+    {"MAP",    {140, 140, 50, 255}},
+};
+
+static void DrawButtonLabel(SDL_Renderer *r, int x, int y, int w, int h,
+                            const touch_button_t *btn)
+{
+    // Button background
+    SDL_SetRenderDrawColor(r, btn->color.r, btn->color.g,
+                              btn->color.b, 180);
+    SDL_Rect bg = {x + 2, y + 2, w - 4, h - 4};
+    SDL_RenderFillRect(r, &bg);
+
+    // Border
+    SDL_SetRenderDrawColor(r, btn->color.r + 40, btn->color.g + 40,
+                              btn->color.b + 40, 255);
+    SDL_Rect border = {x + 1, y + 1, w - 2, h - 2};
+    SDL_RenderDrawRect(r, &border);
+}
+
+static void DrawTouchOverlay(SDL_Renderer *renderer)
+{
+    int rw, rh;
+    SDL_GetRendererOutputSize(renderer, &rw, &rh);
+
+    // 4:3 game area centered in the display
+    int game_w = rh * 4 / 3;
+    int margin = (rw - game_w) / 2;
+
+    // Need at least 20px margins to be usable
+    if (margin < 20)
+        return;
+
+    int row_h = rh / 4;
+
+    for (int i = 0; i < 4; i++)
+    {
+        // Left margin
+        DrawButtonLabel(renderer, 0, i * row_h, margin, row_h,
+                        &left_buttons[i]);
+        // Right margin
+        DrawButtonLabel(renderer, rw - margin, i * row_h, margin, row_h,
+                        &right_buttons[i]);
+    }
+}
+
 static int LimitTextureSize(int val)
 {
     if (val > MAXWIDTH)
@@ -491,6 +557,11 @@ void I_FinishUpdate (void)
     SDL_RenderClear(renderer);
     SDL_RenderCopy(renderer, texture, NULL, NULL);

+    // Draw touch button labels in letterbox margins.
+    // This runs every frame after the game is rendered,
+    // so labels are always visible on top of the black bars.
+    DrawTouchOverlay(renderer);
+
     SDL_RenderPresent(renderer);
 }

PATCH_EOF

Apply the patch and rebuild:

cd ~/chocolate-doom
patch -p1 < touch-buttons.patch
make -j$(nproc)
If the patch doesn't apply cleanly

The exact line numbers depend on your Chocolate Doom version. If patch reports offsets or failures, apply the changes manually:

  1. Open src/i_video.c
  2. Add #include <string.h> near the top includes
  3. Paste the touch_button_t struct, button arrays, DrawButtonLabel, and DrawTouchOverlay functions before LimitTextureSize
  4. In I_FinishUpdate, find SDL_RenderCopy(renderer, texture, NULL, NULL); and add DrawTouchOverlay(renderer); right after it, before SDL_RenderPresent

After rebuilding, Doom will show colored button labels in the margins:

┌──────────┬───────────────────────┬──────────┐
│ ██FWD██  │                       │ █FIRE██  │
│ (green)  │                       │ (red)    │
├──────────┤                       ├──────────┤
│ █BACK██  │                       │ ██USE██  │
│ (blue)   │      Doom (4:3)       │ (cyan)   │
├──────────┤                       ├──────────┤
│ STRAFE█  │                       │ ENTER██  │
│ (orange) │                       │ (green)  │
├──────────┤                       ├──────────┤
│ ██ESC██  │                       │ ██MAP██  │
│ (red)    │                       │ (yellow) │
└──────────┴───────────────────────┴──────────┘
Why patch the source?

Doom owns the DRM primary plane via SDL2's kmsdrm backend. While the display controller supports additional planes (see Option B), patching the renderer is the simplest way to add UI elements — inject rendering code between SDL_RenderCopy (which blits the game frame) and SDL_RenderPresent (which flips the buffer). This is a common pattern in game modding.

Checkpoint

After rebuilding with the patch, colored button labels are visible in the margins during gameplay.

Modern GPUs support multiple hardware planes per display output. Doom renders to the primary plane — we can draw button labels on a separate overlay plane. The display controller composites both planes in hardware every refresh cycle, with zero CPU overhead and no modification to Doom.

DRM plane architecture — hardware composition
Display Controller (CRTC)
 ├─ Primary plane  →  Doom framebuffer (RGB888)
 ├─ Overlay plane  →  Button labels (ARGB8888, alpha = transparent center)
 └─ Cursor plane   →  Mouse pointer (64×64)
         Hardware blender (scanout)
         Display panel (one pixel stream)

A CRTC (CRT Controller — the name is historical) is a display output pipeline. It reads pixel data from one or more planes (memory buffers), blends them in hardware, and sends the result to the display panel. This blending happens during scanout — the moment the display controller reads each pixel row to refresh the screen.

The three plane types serve different purposes: - Primary: the main application framebuffer (mandatory, every CRTC has one) - Overlay: additional layers composited on top — used for subtitles, OSD, HUD - Cursor: small (typically 64×64) hardware sprite for the mouse pointer

Why hardware composition matters for embedded systems: Software composition (X11/Wayland compositor) requires the CPU or GPU to read all layers, blend pixels, and write the result to a single buffer every frame. Hardware composition lets each process render to its own buffer — the display controller reads them all and blends during scanout. This is why drm_overlay uses 0% CPU after setup: the kernel maps the buffer once, the display controller reads it every refresh automatically.

The overlay plane uses ARGB8888 format: 8 bits each for alpha, red, green, blue. Alpha = 0x00 means fully transparent (the primary plane shows through), alpha = 0xFF means fully opaque. This is how button labels appear only in the margins while the game area is untouched.

DRM master: only one process can configure planes and set display modes at a time. The overlay program sets up its plane, then calls drmDropMaster() so Doom can claim master status for its own rendering. This is a cooperative protocol — both processes must agree to share the display.

First, verify your Pi has overlay planes available:

sudo apt install -y libdrm-tests
sudo modetest -p | grep -E "Overlay" -b3

Look for a plane with type: Overlay in the output. The Pi 4's vc4 driver typically provides overlay planes for each CRTC (display output).

Install libdrm-dev and create the overlay program:

sudo apt install -y libdrm-dev

The complete DRM overlay program (~250 lines of C) handles plane discovery, buffer allocation, font rendering, and cleanup. The core DRM API sequence is:

/* Key DRM API calls — the complete file is in solutions/ */

/* 1. Enable overlay plane visibility */
drmSetClientCap(fd, DRM_CLIENT_CAP_UNIVERSAL_PLANES, 1);

/* 2. Find the active CRTC (display output) */
drmModeRes *res = drmModeGetResources(fd);
drmModeCrtc *crtc = drmModeGetCrtc(fd, res->crtcs[i]);

/* 3. Find an overlay plane for this CRTC */
drmModePlaneRes *planes = drmModeGetPlaneResources(fd);
// Filter by type == DRM_PLANE_TYPE_OVERLAY and possible_crtcs mask

/* 4. Create a dumb buffer (GPU-mapped memory, ARGB8888) */
struct drm_mode_create_dumb cr = { .width=W, .height=H, .bpp=32 };
ioctl(fd, DRM_IOCTL_MODE_CREATE_DUMB, &cr);
drmModeAddFB2(fd, W, H, DRM_FORMAT_ARGB8888, ...);  // register as framebuffer

/* 5. Draw button rectangles (alpha=0 for transparent center) */
uint32_t *px = mmap(NULL, cr.size, PROT_WRITE, MAP_SHARED, fd, ...);
memset(px, 0, cr.size);  // fully transparent
fill_rect(px, x, y, w, h, 0xB0B43232);  // semi-transparent red button

/* 6. Activate overlay plane */
drmModeSetPlane(fd, plane_id, crtc_id, fb_id, 0, ...);

/* 7. Release DRM master so Doom can claim it */
drmDropMaster(fd);

/* 8. Sleep until killed — display controller reads buffer automatically */
while (running) sleep(1);

Copy the full source and build it:

cp src/embedded-linux/solutions/doom-on-pi/drm_overlay.c ~/

Build:

gcc -o ~/drm_overlay ~/drm_overlay.c $(pkg-config --cflags --libs libdrm)

Run the overlay first, then start Doom:

# Terminal 1: DRM overlay (sets up hardware plane, drops master)
sudo ~/drm_overlay

# Terminal 2: Doom (claims DRM master for primary plane)
sudo SDL_VIDEODRIVER=kmsdrm ~/chocolate-doom/src/chocolate-doom \
    -iwad ~/.local/share/chocolate-doom/doom1.wad
How it works
  1. Opens the DRM device and enables DRM_CLIENT_CAP_UNIVERSAL_PLANES to see all plane types
  2. Finds the active CRTC (display output currently driving a monitor)
  3. Locates an overlay plane compatible with that CRTC (filtered by possible_crtcs bitmask)
  4. Creates a dumb buffer — a GPU-mapped memory region in ARGB8888 format (alpha = 0 means transparent)
  5. Draws colored rectangles in the letterbox margins; the center stays transparent
  6. drmModeSetPlane() assigns the buffer to the overlay plane — hardware composition starts immediately
  7. drmDropMaster() releases DRM control so Doom can claim master when it starts
  8. The process stays alive to keep the buffer valid — the kernel cleans up everything when the fd closes
DRM master limitation

Only one process can be DRM master at a time. drmModeSetPlane() requires master privileges. The overlay program must set up the plane and call drmDropMaster() before Doom starts. Once Doom claims master, it configures the primary plane — the overlay plane persists because SDL2's kmsdrm backend only touches the primary plane.

To include the DRM overlay in the kiosk script, add it before Doom starts:

# In doom_kiosk.sh, add before the Doom launch:
/home/linux/drm_overlay &
DRM_PID=$!
sleep 0.5

# ... (Doom starts here) ...

# In cleanup, add:
kill $DRM_PID 2>/dev/null
Checkpoint

Colored button rectangles appear in the margins. Starting Doom fills the center with the game — the buttons remain visible because they are on a separate hardware plane, composited by the display controller.

Exercise: Verify zero-CPU composition

While both Doom and the DRM overlay are running, check CPU usage:

top -p $(pgrep -f drm_overlay)
The drm_overlay process should show 0.0% CPU — it sleeps in while(running) sleep(1) after initial setup. The display controller reads the overlay buffer from memory on every refresh without any process involvement. Compare this to Option A (patching Doom's renderer), where the button-drawing code executes on every single frame inside the game loop.

This is the key advantage of hardware composition in embedded systems: once configured, the display pipeline operates independently of the CPU.

Other overlay approaches

Two other techniques exist for drawing on top of a DRM application:

  • Wayland compositor (e.g., Weston, gamescope) — runs Doom as a client window and layers a separate UI window on top. Very flexible, but adds compositor latency.
  • LD_PRELOAD interception — intercept drmModePageFlip() or eglSwapBuffers() and inject drawing before the flip. This is how MangoHud and the Steam overlay work. Powerful but fragile across library versions.

DRM overlay planes are the cleanest approach for an embedded system: no compositor overhead, no source patching, pure hardware composition.

Create a combined service

To run both Doom and the overlay together as a kiosk, use the wrapper script. The key design pattern is process orchestration: start helper processes first (DRM overlay → touch overlay → IMU controller), wait for virtual input devices to appear, then start Doom so SDL2 discovers them during initialization.

cp src/embedded-linux/solutions/doom-on-pi/doom_kiosk.sh ~/
chmod +x ~/doom_kiosk.sh

The critical timing in the kiosk script:

# Start input processors FIRST — they create /dev/uinput devices
python3 /home/linux/doom_touch_overlay.py ... &
python3 /home/linux/imu_doom.py ... &

# Wait for virtual input devices to appear
sleep 2   # SDL2 enumerates /dev/input/event* during init

# THEN start Doom — now it sees the virtual keyboards
/home/linux/chocolate-doom/src/chocolate-doom -iwad ...

Edit ~/doom_kiosk.sh to match your display resolution (DISP_W, DISP_H) and paths.

Update the service file to use the wrapper:

sudo sed -i 's|ExecStart=.*|ExecStart=/home/linux/doom_kiosk.sh|' \
    /etc/systemd/system/doom.service
sudo systemctl daemon-reload
sudo systemctl restart doom.service
Checkpoint
  • Touch in the left margin moves the player (FWD/BACK) and navigates menus (↑/↓)
  • Touch in the right margin fires, uses doors, and selects menu items (ENTER)
  • Touch in the center area aims/turns (mouse movement) and fires (tap)
  • Left ESC opens the menu, Right ENTER selects — full touch-only gameplay

8. IMU Control (Optional)

If you have a BMI160 IMU connected, you can map physical motion to Doom controls. The default mode uses yaw (rotating around the vertical axis) for turning left/right — you physically turn your body to turn in-game — and pitch (tilting forward/backward) for walking.

Prerequisites: IIO driver must be set up first

This section reads sensor data from /sys/bus/iio/devices/iio:device0/. You need the mainline BMI160 IIO driver, not the custom chardev driver from the BMI160 SPI Driver tutorial.

Approach Tutorial What you get
Custom chardev driver BMI160 SPI Driver /sys/class/bmi160/ — custom sysfs, educational
Mainline IIO driver IIO Buffered Capture §1 /sys/bus/iio/devices/ — standard IIO interface (needed here)

Follow IIO Buffered Capture Section 1 ("Switch to the Mainline IIO Driver") to build the modules, compile the bmi160-iio device tree overlay, and load everything. The DTS source is in src/embedded-linux/overlays/bmi160-iio.dts.

Yaw from Gyroscope Only

The BMI160 has no magnetometer, so absolute yaw (compass heading) is not available from the accelerometer. Instead, we integrate the Z-axis gyroscope to track relative yaw — the same approach used for the compass heading in the Level Display. This works well for short play sessions but drifts over time. The accelerometer still provides absolute pitch (from gravity), so pitch uses the complementary filter while yaw uses pure gyro integration.

Architecture

BMI160 (SPI)
    → imu_doom.py (gyro yaw + complementary pitch + uinput)
    → synthetic arrow keys → Chocolate Doom

Touch panel
    → doom_touch_overlay.py (margin buttons)
    → synthetic Ctrl/Space/Alt keys → Chocolate Doom

Both input sources inject keyboard events via uinput. Doom sees all keys as coming from keyboards — it does not know or care that they originate from an IMU and a touch overlay.

Check the IMU hardware

Before integrating with Doom, verify the BMI160 is connected and the driver is loaded:

# Load the driver
sudo modprobe bmi160_spi

# Check that the IIO device exists (device number may vary)
cat /sys/bus/iio/devices/iio:device*/name
# Should print "bmi160"
Stuck?

The device number (iio:device0, iio:device1, ...) can change between reboots depending on driver load order. All scripts in this tutorial auto-detect the BMI160 by scanning device names, so the number doesn't matter.

If no device appears at all, check the SPI wiring and device tree overlay. See BMI160 SPI Driver for setup details.

Determine your IMU axis orientation

The BMI160 measures two things:

  • Accelerometer — measures gravitational force (which way is down) and linear acceleration, in m/s². When the sensor is flat on a table, one axis reads ~9.8 (1g) — that axis points up.
  • Gyroscope — measures rotation speed around each axis, in rad/s. When you rotate the sensor, the gyro axis aligned with the rotation axis spikes.

For Doom, we need to map these to two game motions:

Game motion Physical motion What we measure
Walk forward/backward Tilt the display forward/backward (pitch) Accelerometer: gravity vector shifts between axes as you tilt. We compute the tilt angle with atan2. Gyroscope: the axis perpendicular to the tilt gives the rotation rate.
Turn left/right Rotate the display left/right (yaw) Gyroscope: the vertical axis gives the rotation rate (°/s). We use the rate directly — rotate faster = turn faster.
Pitch vs yaw — the two rotations
                 YAW (rotate on table)
                 ↻ ↺ around vertical axis
                   ↑ Z
             Y ←───┼───→

         PITCH (tilt forward/backward)
         ↻ ↺ around the left-right axis
                   ↑ Z
             ──────┼───→ X
                 ╱ │
               ╱   ↓    ← display tilts this way

Pitch = how much the display tilts (absolute angle from accelerometer + gyro rate). Used for walk forward/backward. Yaw rate = how fast you're rotating (gyro only, no accelerometer reference). Used for turn left/right.

Why axes change with mounting

The BMI160 datasheet defines axes relative to the chip package markings. Your sensor's physical orientation determines which chip axis maps to which real-world direction.

1. Chip flat on table           2. On back of display,          3. On back of display,
   (datasheet default):            lying flat:                     held upright (handheld):

     Z ↑  (up)                      Z ↓  (inverted)                    X ↑  (up!)
     │                              │                                   │
Y ←──┼──→ X  (forward)        ←────┼──→ X  (inverted)            Y ←──┼──→ Z  (forward!)
     │                              │                                   │
                                    Y    (same)
     gravity → accel Z              gravity → accel Z               gravity → accel X

Case 3 is the typical handheld setup: the DSI display is held vertically with the BMI160 on the back. The chip's coordinate system rotates with the board — which axis maps to which real-world direction depends on how the chip is oriented on the PCB. The axis finder script (below) detects this automatically using gravity.

Real-world direction Chip flat (case 1) Held upright (varies!)
Up (gravity) Z run axis finder
Forward (tilt pitch) X run axis finder
Left-right Y run axis finder
Yaw rotation axis Z run axis finder

Find your axis mapping

Since the IMU is physically attached to the display, we can use gravity to auto-detect the axis mapping. Place the display in two known orientations — gravity tells us exactly which chip axis points in which real-world direction.

The axis finder uses two key ideas:

1. Gravity identifies axes — when flat, gravity points through the screen (the forward/backward tilt axis). When upright, gravity points down (the up axis):

# UP axis: strongest gravity reading when held upright
up_axis = max(AXES, key=lambda a: abs(upright[a]))

# FWD axis: strongest gravity reading when flat, excluding UP axis
remaining = [a for a in AXES if a != up_axis]
fwd_axis = max(remaining, key=lambda a: abs(flat[a]))

2. Auto-detect IIO device — the device number (iio:device0, iio:device1, ...) changes between reboots depending on driver load order. Scan by name instead of hardcoding:

for dev in sorted(glob.glob("/sys/bus/iio/devices/iio:device*")):
    name = open(f"{dev}/name").read().strip()
    if "bmi160" in name:
        IIO = dev
        break

Copy and run the axis finder:

cp src/embedded-linux/solutions/doom-on-pi/imu_axis_finder.py ~/
chmod +x ~/imu_axis_finder.py
sudo python3 ~/imu_axis_finder.py

The script uses gravity to auto-detect axes in 3 steps — no guessing.

Checkpoint

The script prints a configuration block like:

ACCEL_FWD_AXIS   = "z"
ACCEL_FWD_SIGN   = -1
ACCEL_UP_AXIS    = "y"
GYRO_YAW_AXIS    = "y"
GYRO_YAW_SIGN    = -1
Copy these values into imu_doom.py. The sanity checks should show ✓ marks — if you see warnings, repeat with the display in the correct positions.

Apply the mapping

Copy the output from imu_axis_finder.py into the axis configuration section of imu_doom.py:

# ── Axis remapping — paste output from imu_axis_finder.py ──
ACCEL_FWD_AXIS   = "z"       # gravity axis when display is FLAT
ACCEL_FWD_SIGN   = -1        # tilt forward should give positive angle
ACCEL_UP_AXIS    = "y"       # gravity axis when display is UPRIGHT
GYRO_YAW_AXIS    = "y"       # gyro axis for yaw rotation
GYRO_YAW_SIGN    = -1        # rotate left → positive yaw
Why does flat = forward?

When the display is flat on a table, gravity points through the screen — that's the same direction the display "faces" when tilted forward from upright. So the axis that has gravity when flat is the axis that measures forward/backward tilt during play.

After setting the axes: tilt forward → should walk forward. If backwards, press f in the SSH terminal to flip. Rotate left → should turn left. If reversed, flip GYRO_YAW_SIGN in the script.

Test the IMU

With the correct axis mapping, run a visual test to verify everything works before integrating with Doom. The test script shows a live gauge with interactive controls for tuning the deadzone.

The core pitch calculation uses the gravity vector — atan2 with exactly two accelerometer axes so side tilt (roll) doesn't affect pitch:

# Pitch angle from gravity, relative to calibrated neutral
a_fwd = read_iio("accel", ACCEL_FWD_AXIS) * accel_scale * ACCEL_FWD_SIGN
a_up  = read_iio("accel", ACCEL_UP_AXIS)  * accel_scale
pitch = math.atan2(a_fwd, a_up) * 180 / math.pi - pitch_offset

Copy and run:

cp src/embedded-linux/solutions/doom-on-pi/imu_test.py ~/
chmod +x ~/imu_test.py
sudo python3 ~/imu_test.py

The script calibrates at startup — hold the display in your comfortable playing position (tilted forward ~20-30° is fine). That becomes the neutral zero point. The cursor shows how far you've tilted from there:

  Pitch: -12.3°  BACK ·····────────│·····█··········· FWD  |  Yaw: +45°/s L ·····················█··· R  [FWD +RIGHT]
  • = deadzone (no movement)
  • = calibrated neutral center
  • = your current tilt position

If pitch direction is inverted, press f to flip it and recalibrate.

Checkpoint
  • Hold in your comfortable position → cursor sits on the center , action shows ---
  • Tilt forward (top away from you) → cursor moves right, action shows FWD
  • Tilt backward → cursor moves left, action shows BACK
  • Rotate left/right → yaw gauge moves, action shows +LEFT or +RIGHT
  • If pitch is inverted, press f to flip

IMU-to-keyboard script

This reuses the imu_reader.py pattern from the IMU Controller tutorial but outputs keyboard events instead of angles:

  • Pitch (walk) uses the gravity vector (accelerometer) to measure absolute tilt angle. The key is held while tilted past the deadzone — return to neutral and the key releases. Using atan2(a_forward, a_up) with only two accel axes means side tilt doesn't affect the pitch reading.
  • Yaw (turn) uses the gyro angular velocity directly (rate-based, no drift).

The key design decisions in imu_doom.py:

Fixed neutral angle — instead of interactive calibration (which requires a terminal and fails in kiosk mode), the neutral lean angle is a command-line argument:

parser.add_argument("--neutral", type=float, default=25.0,
                    help="Neutral tilt angle from vertical (default: 25)")
pitch_offset = args.neutral  # No calibration loop — works headless

Key hold/release — only emit key events on state changes, not every poll cycle:

def set_key(key, pressed):
    if held[key] != pressed:  # Only on transitions
        ui.write(ecodes.EV_KEY, key, 1 if pressed else 0)
        held[key] = pressed

Auto-modprobe — if the BMI160 driver isn't loaded, try loading it automatically before failing:

IIO = find_bmi160()
if not IIO:
    os.system("modprobe bmi160_spi 2>/dev/null")
    time.sleep(0.5)
    IIO = find_bmi160()

Copy and run:

cp src/embedded-linux/solutions/doom-on-pi/imu_doom.py ~/
chmod +x ~/imu_doom.py
Why gravity for pitch, gyro for yaw?

Pitch uses the accelerometer (gravity vector) because it gives a reliable absolute tilt angle regardless of IMU mounting. The formula atan2(a_forward, a_up) uses exactly two accel axes, so side tilt (roll) does not affect the pitch reading — roll changes a third axis that isn't part of the calculation. The script prints all three accel axes at startup — the UP axis must be the one with the strongest reading (~9.8 m/s²). If it's wrong, the angles will be unstable.

Yaw uses the gyro rate because there is no accelerometer reference for rotation around the vertical axis (gravity doesn't help with heading). The gyro rate naturally gives zero when you stop rotating, so yaw control is instant and drift-free.

Tuning via command-line arguments

Use imu_test.py (which has interactive controls) to find comfortable settings, then pass them to imu_doom.py:

sudo python3 ~/imu_doom.py --neutral 25 --pitch-dz 15 --yaw-dz 15
- --neutral 25 — the comfortable resting angle from vertical (degrees). 0 = fully upright, 25 = slightly leaned forward. - --pitch-dz 15 — how far past neutral you must tilt before movement starts - --yaw-dz 15 — rotation speed threshold before turning starts - --flip — swap forward/backward if pitch direction is wrong

When the script exits, it prints its current settings as a command line you can copy.

Python vs C for real-time input injection

The IMU controller polls at 100 Hz (10 ms period). Each iteration reads 5 sysfs files, computes atan2, and writes to uinput. In Python, this takes ~2-3 ms per iteration (dominated by sysfs file I/O, not computation). In C, the same reads take ~0.2 ms.

For this application, Python is adequate: the 10 ms poll interval is much larger than the processing time, and human reaction time (~150 ms) dwarfs the Python overhead.

When would C be necessary? - Poll rates above 500 Hz (e.g., gaming mice at 1000 Hz) - Processing time approaching the poll interval (no headroom for garbage collector pauses) - Hard real-time guarantees (Python's GC introduces unpredictable 1-10 ms pauses)

The DRM overlay (drm_overlay.c) must be in C because the DRM/KMS API is a C-only kernel interface (libdrm). There are Python bindings (pydrm), but they are incomplete and not packaged for Raspberry Pi OS. This is a common pattern in embedded Linux: kernel subsystem interfaces are C, application logic can be Python.

Measure it yourself:

# Time 1000 sysfs reads in Python vs the poll loop overhead:
time sudo python3 -c "
import glob
IIO = glob.glob('/sys/bus/iio/devices/iio:device*/name')[0].rsplit('/',1)[0]
for _ in range(1000):
    open(f'{IIO}/in_accel_x_raw').read()
"

Run all three together

# Terminal 1: touch overlay (for fire/use/strafe)
sudo python3 ~/doom_touch_overlay.py &

# Terminal 2: IMU controller (for movement)
sudo python3 ~/imu_doom.py --neutral 25 &

# Wait for virtual input devices to appear
sleep 2

# Terminal 3: Doom
sudo SDL_VIDEODRIVER=kmsdrm ~/chocolate-doom/src/chocolate-doom \
    -iwad ~/.local/share/chocolate-doom/doom1.wad

Rotate the Pi to turn, tilt forward to walk, touch the right margin to fire.

Warning

Start the IMU and touch overlay scripts before Doom. SDL2 enumerates input devices during initialization — virtual keyboards created after Doom starts won't be seen.

Add IMU to the kiosk service

The combined doom_kiosk.sh script from Section 7 already includes the IMU controller. To adjust the tuning, edit the arguments:

# In doom_kiosk.sh:
python3 /home/linux/imu_doom.py --neutral 25 --pitch-dz 15 --yaw-dz 15 &
IMU_PID=$!

The cleanup line at the end:

kill $OVERLAY_PID $IMU_PID $DRM_PID 2>/dev/null

If you don't have a BMI160 connected, comment out the imu_doom.py line — the kiosk still works with touch only.

Checkpoint

Rotating the Pi left/right turns the player. Tilting forward walks. Touch margin buttons fire and open doors.


What Just Happened?

You built three different input pipelines feeding into the same application:

graph LR
    subgraph "Input Sources"
        KB[USB Keyboard]
        TS[Touch Panel]
        IMU[BMI160 IMU]
    end

    subgraph "Processing"
        TO[Touch Overlay<br>doom_touch_overlay.py]
        IC[IMU Controller<br>imu_doom.py]
    end

    subgraph "Linux Input Layer"
        KD[/dev/input/event: keyboard/]
        UI1[/dev/uinput: doom-touch/]
        UI2[/dev/uinput: doom-imu/]
    end

    subgraph "Application"
        DOOM[Chocolate Doom<br>SDL2]
    end

    KB --> KD --> DOOM
    TS --> TO --> UI1 --> DOOM
    IMU --> IC --> UI2 --> DOOM

The input-to-display pipeline

Every input event travels through multiple layers before it affects pixels on screen:

Touch/IMU → kernel driver → /dev/input/eventN → overlay process →
/dev/uinput → /dev/input/eventM → SDL2 event poll → game logic →
render frame → DRM page flip → display scanout

Each arrow is a measurable delay. The total input-to-photon latency is the sum:

Stage Typical latency How to measure
Touch controller → kernel < 1 ms evtest timestamps between events
Overlay processing 1-3 ms --debug timestamps in overlay
uinput → SDL2 poll < 1 ms evdev timestamps on virtual device
Game logic + render 16-33 ms (30-60 FPS) SDL2 frame time, or manual time.time()
DRM page flip → scanout 0-16 ms (vsync) drmWaitVBlank() in C
Total ~20-50 ms High-speed camera (ground truth)
Exercise: Measure input latency

Run evtest on the virtual keyboard created by the touch overlay (the one named doom-touch-keyboard). In another terminal, run evtest on the real touchscreen. Tap a margin button and compare the timestamps:

# Terminal 1: real touch events
sudo evtest /dev/input/event0
# Terminal 2: synthetic key events from overlay
sudo evtest /dev/input/event4   # (find the right eventN)
The difference between the touch SYN_REPORT timestamp and the synthetic EV_KEY timestamp is the overlay's processing latency.

For the IMU, the status readout in imu_doom.py prints every ~0.5s. Tilt and count how many status lines pass before Doom responds — this gives a rough estimate of the IMU pipeline latency.

Input method comparison

Method Est. latency Language Bottleneck
HDMI + keyboard ~5 ms - USB poll interval
DSI + touch (SDL2 auto-map) ~15 ms - Touch controller scan rate
Touch overlay (Python) ~20 ms Python evdev read + uinput write
IMU controller (Python, 100 Hz) ~25 ms Python sysfs I/O + poll interval
Touch overlay (C, hypothetical) ~17 ms C Touch controller scan rate

The Python overhead is ~3-5 ms per event — acceptable for gaming (human reaction time ~150 ms) but measurable. For industrial applications with tighter latency budgets (< 10 ms), the same architecture works in C with direct /dev/input/ reads.

Key concepts exercised

Concept Where
Build third-party C from source Section 2 (autotools: autoreconf, configure, make)
systemd kiosk service Section 5 (Conflicts=, ExecStartPre=, Environment=, process supervisor)
Process inspection Section 5 (/proc/<PID>/fd, systemctl show)
DRM/KMS display pipeline Sections 4, 6 (SDL_VIDEODRIVER=kmsdrm, display modes)
Hardware composition (DRM planes) Section 7 (overlay plane, zero-CPU composition, ARGB8888)
Linux input subsystem (evdev) Section 7 (struct input_event, multi-touch protocol, evtest)
Virtual input injection (uinput) Sections 7, 8 (synthetic keyboard/mouse creation)
IIO subsystem + sensor fusion Section 8 (sysfs reads, atan2 gravity decomposition)
Python vs C tradeoffs Section 8 (when Python is adequate, when C is necessary)
Latency analysis Section 9 (measuring pipeline latency, identifying bottlenecks)

Challenges

Challenge 1: Improve the Button Overlay

The default overlay uses flat colored rectangles with different colors per side. Improve it:

Consistent color scheme — use the same style on both sides. A dark semi-transparent background with a brighter border gives a uniform "button" look:

/* Replace per-button colors with a uniform style */
#define BTN_BG    0x80202020   /* dark semi-transparent fill */
#define BTN_BORDER 0xC0808080  /* lighter border */

/* Draw border (2px) then fill interior */
fill_rect(px, pitch4, bx, by, bw, bh, BTN_BORDER);
fill_rect(px, pitch4, bx+2, by+2, bw-4, bh-4, BTN_BG);

Add weapon select — Chocolate Doom uses 17 for weapons. Replace STRAFE (left row2) with a weapon-cycle button. In the touch overlay, cycle through keys KEY_1 to KEY_7 on each tap:

weapon_slot = 0
WEAPON_KEYS = [ecodes.KEY_1, ecodes.KEY_2, ecodes.KEY_3,
               ecodes.KEY_4, ecodes.KEY_5, ecodes.KEY_6, ecodes.KEY_7]

# On tap (in the event handler):
weapon_slot = (weapon_slot + 1) % len(WEAPON_KEYS)
ui.write(ecodes.EV_KEY, WEAPON_KEYS[weapon_slot], 1)
ui.syn()
ui.write(ecodes.EV_KEY, WEAPON_KEYS[weapon_slot], 0)
ui.syn()

Note: Vanilla Doom (Chocolate Doom) has no jump. Jump is available in source ports like Crispy Doom (crispy-doom package) — mapped to KEY_SLASH by default.

Challenge 2: Native Touch Input (No External Overlay)

Replace the external Python overlay process entirely. In src/i_video.c, intercept SDL_FINGERDOWN/SDL_FINGERMOTION events in the event loop, check if the normalized coordinates fall in the margin regions, and generate internal Doom key events directly (call D_PostEvent() with ev_keydown/ev_keyup). This eliminates the Python process, the uinput round-trip, and the device grab — measure the latency improvement. Add touch feedback: highlight the pressed button rectangle with a brighter color.

Challenge 3: Custom WAD

Use a Doom level editor (e.g., SLADE) to create a custom level. Export as a PWAD and load it alongside the shareware WAD:

chocolate-doom -iwad doom1.wad -file custom.wad

Challenge 4: Network Multiplayer

Chocolate Doom supports IPX-over-UDP multiplayer. Start a two-player game between two Pis on the same network:

# Pi 1 (server):
chocolate-doom -server -iwad doom1.wad
# Pi 2 (client):
chocolate-doom -autojoin -iwad doom1.wad
Investigate: what happens to input latency when network round-trip is added?

Challenge 5: Buildroot Doom Image

Add Chocolate Doom to your custom Buildroot image from Buildroot: SDL2 + SPI Image. Create a Buildroot package (package/chocolate-doom/) with a .mk file that downloads, configures, and builds Chocolate Doom. Include the shareware WAD in the rootfs overlay. Target: a self-contained image that boots directly into Doom.


Deliverable

  • [ ] Chocolate Doom built from source and running on HDMI
  • [ ] systemd kiosk service auto-starts Doom at boot
  • [ ] Touch overlay maps margin regions to game controls (if DSI display available)
  • [ ] Brief note: draw the input pipeline for your configuration (which processes, which /dev/input devices, which direction does data flow)

Course Overview | Previous: ← Kiosk Service