Project 16: ARM-Based Retro Game Console (Capstone)
Build a complete handheld game console with color LCD display, audio output, button input, game ROM loading from SD card, and a playable game—the ultimate ARM mastery project that integrates everything you’ve learned.
Quick Reference
| Attribute | Value |
|---|---|
| Language | C + ARM Assembly |
| Difficulty | Master |
| Time | 2-3 months |
| Prerequisites | Most previous projects (3-13) |
| Key Topics | Graphics, Audio, Input, Integration |
| Coolness | Level 5: Pure Magic (Super Cool) |
| Portfolio Value | Micro-SaaS / Pro Tool |
| Hardware | STM32F4 + LCD + Buttons + Speaker |
Learning Objectives
By completing this project, you will:
- Integrate all ARM skills: Combine bare-metal, peripherals, DMA, timers, interrupts
- Build a graphics system: Double-buffered framebuffer, sprite rendering, tile maps
- Implement real-time audio: Sound effects and music mixed during gameplay
- Handle real-time input: Button debouncing and responsive game controls
- Manage limited resources: Fit game in constrained Flash/RAM
- Design a complete system: Hardware and software working together seamlessly
- Create something tangible: A physical device you built from scratch
The Core Question You’re Answering
“How do you build a complete embedded system that handles graphics, audio, and input simultaneously in real-time while maintaining consistent frame rates—and how does this integrate everything learned about ARM architecture?”
This capstone project forces you to bring together every skill: bare-metal initialization, peripheral configuration, DMA for performance, interrupt handling for responsiveness, timing for smooth animation, and memory management for limited resources. The result is a working game console—tangible proof of ARM mastery.
Why This Is the Capstone
This project integrates concepts from nearly every previous project:
| Previous Project | How It’s Used Here |
|---|---|
| P3: Bare-Metal LED | System initialization, GPIO for buttons/LCD |
| P4: UART Driver | Debug console during development |
| P5: Memory Allocator | Game asset memory management |
| P7: Context Switcher | Game loop timing (optional RTOS) |
| P10: I2C OLED | Display communication concepts |
| P11: SPI SD Card | Loading game ROMs and assets |
| P12: PWM Motor | Audio output via PWM (alternative to DAC) |
| P13: DMA Audio | Sound effects and music playback |
Integration Map:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Game Console │
│ │
│ ┌─────────────┐ │
│ │ Project 3 │──▶ GPIO initialization, bare-metal boot │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Project 11 │──▶ SD card reading for game assets │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Project 13 │──▶ DMA audio for sound effects │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Project 5 │──▶ Memory management for sprites │
│ └─────────────┘ │
│ ┌─────────────┐ │
│ │ Project 7 │──▶ Timing for consistent frame rate │
│ └─────────────┘ │
│ │
│ Combined into a single, cohesive system! │
│ │
└─────────────────────────────────────────────────────────────────┘
Concepts You Must Understand First
Before starting this project, ensure you understand these concepts:
| Concept | Why It Matters | Where to Learn |
|---|---|---|
| SPI communication | LCD and SD card both use SPI | Project 11 |
| DMA transfers | Fast framebuffer-to-LCD without CPU | Project 13 |
| Timer interrupts | Consistent game loop timing | Project 7, 12 |
| GPIO input | Button reading with debouncing | Project 3 |
| Audio output | Sound effects during gameplay | Project 13 |
| Memory management | Loading/unloading game assets | Project 5 |
Key Concepts Deep Dive
- Game Loop Architecture
- What are the three phases of a game loop (input, update, render)?
- How do you maintain consistent frame rate?
- What is the difference between fixed and variable timestep?
- How do you handle slow frames?
- “Game Programming Patterns” Chapter 1
- Double Buffering for Graphics
- Why is double buffering necessary?
- How do you avoid screen tearing?
- How do you synchronize buffer swapping with LCD refresh?
- What is the memory cost of double buffering?
- “Game Programming Patterns” Chapter 8
- Sprite Rendering
- How do you represent sprites in memory?
- What is a sprite sheet and why use one?
- How do you handle transparency?
- How do you optimize sprite blitting?
- “Computer Graphics from Scratch”
- Real-Time Audio Mixing
- How do you play multiple sounds simultaneously?
- What is audio mixing and how does it work?
- How do you prevent clipping when mixing?
- How do you synchronize audio with gameplay?
- “The Audio Programming Book”
- Resource Management
- How do you fit a game in limited Flash/RAM?
- What is asset streaming and when is it needed?
- How do you organize game data efficiently?
- How do you handle loading screens?
- “Making Embedded Systems” Chapter 8
Theoretical Foundation
Game Console Architecture
A complete game console needs these components:
Game Console Block Diagram:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ STM32F4 MCU │ │
│ │ ┌───────────────────────────────────────────────────┐ │ │
│ │ │ Game Engine │ │ │
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ │ │ Input │──│ Update │──│ Render │ │ │ │
│ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │
│ │ └───────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────┬───────────┼───────────┬──────────┐ │ │
│ │ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ ▼ │ │
│ │ GPIO Timer DMA SPI DAC │ │
│ │ (buttons) (60Hz) (transfer) (LCD/SD) (audio) │ │
│ └──────┬────────┬─────────┬──────────┬────────────┬──────┘ │
│ │ │ │ │ │ │
│ ┌──────┴──────┐ │ │ ┌──────┴───────┐ │ │
│ │ Buttons │ │ │ │ SD Card │ │ │
│ │ ←↑↓→ A B │ │ │ │ (game data) │ │ │
│ └─────────────┘ │ │ └──────────────┘ │ │
│ │ │ │ │
│ ┌──────┴─────────┴──────┐ ┌─────┴─────┐ │
│ │ LCD Display │ │ Speaker │ │
│ │ 320x240 RGB │ │ (audio) │ │
│ └───────────────────────┘ └───────────┘ │
│ │
└───────────────────────────────────────────────────────────────┘
The Game Loop
The fundamental structure of any real-time game:
Game Loop Structure:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ void game_loop(void) { │
│ uint32_t last_time = get_ms(); │
│ │
│ while (1) { │
│ // 1. INPUT: Read button state │
│ uint8_t buttons = read_buttons(); │
│ │
│ // 2. UPDATE: Game logic │
│ game_update(buttons); │
│ │
│ // 3. RENDER: Draw to back buffer │
│ render_game(); │
│ │
│ // 4. SWAP: Send back buffer to LCD │
│ swap_buffers(); │
│ │
│ // 5. TIMING: Maintain 60 FPS │
│ uint32_t elapsed = get_ms() - last_time; │
│ if (elapsed < 16) { │
│ delay_ms(16 - elapsed); │
│ } │
│ last_time = get_ms(); │
│ } │
│ } │
│ │
│ Target: 60 FPS = 16.67ms per frame │
│ │
└─────────────────────────────────────────────────────────────────┘
Framebuffer and Double Buffering
Prevent visual artifacts with double buffering:
Double Buffering:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Memory: │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Buffer A │ │ Buffer B │ │
│ │ (being displayed) │ │ (being drawn) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │ ▲ │
│ │ │ │
│ ▼ CPU draws │
│ ┌─────────────────────┐ sprites │
│ │ LCD │ and tiles │
│ │ (shows Buffer A) │ │
│ └─────────────────────┘ │
│ │
│ After frame complete: │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ Buffer A │ │ Buffer B │ │
│ │ (being drawn) │ │ (being displayed) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ ▲ │ │
│ CPU draws │ │
│ sprites ▼ │
│ and tiles ┌─────────────────────┐ │
│ │ LCD │ │
│ │ (shows Buffer B) │ │
│ └─────────────────────┘ │
│ │
│ Swap pointers, not data (fast!) │
│ │
└─────────────────────────────────────────────────────────────────┘
LCD Communication (ILI9341 Example)
The LCD uses SPI with some additional control signals:
LCD Interface:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ STM32 ILI9341 LCD │
│ ───── ────────── │
│ SPI_SCK ────────────────────▶ CLK │
│ SPI_MOSI ────────────────────▶ SDI (data in) │
│ GPIO_CS ────────────────────▶ CS (chip select) │
│ GPIO_DC ────────────────────▶ D/C (data/command) │
│ GPIO_RST ────────────────────▶ RESET │
│ │
│ D/C pin selects: │
│ • LOW = Command mode (sending LCD commands) │
│ • HIGH = Data mode (sending pixel data) │
│ │
│ Pixel format: RGB565 (16 bits per pixel) │
│ ┌─────────────────────────────────────────┐ │
│ │ R4 R3 R2 R1 R0 │ G5 G4 G3 G2 G1 G0 │ B4 B3 B2 B1 B0 │ │
│ │ 5 bits red │ 6 bits green │ 5 bits blue │ │
│ └─────────────────────────────────────────┘ │
│ │
│ Resolution: 320 x 240 pixels │
│ Buffer size: 320 * 240 * 2 = 153,600 bytes (~150 KB) │
│ │
└─────────────────────────────────────────────────────────────────┘
Sprite System
Sprites are the visual building blocks of 2D games:
Sprite System:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Sprite Structure: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ x, y: position on screen │ │
│ │ width, height: size in pixels │ │
│ │ pixels[]: RGB565 color data │ │
│ │ transparent_color: color to skip when drawing │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Sprite Sheet: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ 0 │ │ 1 │ │ 2 │ │ 3 │ │ 4 │ │ 5 │ │ 6 │ │ 7 │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ Walk animation frames ─────▶ │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ 8 │ │ 9 │ │10 │ │11 │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ │ │
│ │ Jump animation frames ─────▶ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Blitting (drawing sprite to framebuffer): │
│ for each pixel in sprite: │
│ if pixel != transparent: │
│ framebuffer[y * 320 + x] = pixel │
│ │
└─────────────────────────────────────────────────────────────────┘
Audio Mixing
Playing multiple sounds simultaneously:
Audio Mixing:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Sound Channels: │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Channel 0 │ │ Channel 1 │ │ Channel 2 │ │
│ │ (music) │ │ (jump SFX) │ │ (coin SFX) │ │
│ │ sample: 100 │ │ sample: 50 │ │ sample: 75 │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ Mixer │ │
│ │ (100+50+75)│ │
│ │ / 3 │ │
│ └─────┬─────┘ │
│ │ │
│ ▼ │
│ ┌───────────┐ │
│ │ DAC │───▶ Speaker │
│ │ output │ │
│ └───────────┘ │
│ │
│ Mixing formula (with clipping prevention): │
│ output = clamp((ch0 + ch1 + ch2) / num_active, -32768, 32767) │
│ │
└─────────────────────────────────────────────────────────────────┘
Button Debouncing
Physical buttons need software debouncing:
Button Debouncing:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Physical button press (noisy signal): │
│ ┌──┐ ┌┐┌─────────────────────────────┐ ┌┐ ┌──┐ │
│ │ │ │││ │ ││ │ │ │
│ ┘ └─┘└┘ └─┘└─┘ └ │
│ │
│ After debouncing (clean signal): │
│ ┌────────────────────────────────────────┐ │
│ │ │ │
│ ┘ └ │
│ │
│ Algorithm: │
│ 1. Read button state │
│ 2. If changed from last reading, reset timer │
│ 3. If stable for N milliseconds (e.g., 20ms), accept change │
│ │
│ Code: │
│ if (current != last_state) { │
│ debounce_timer = get_ms(); │
│ } │
│ if (get_ms() - debounce_timer > DEBOUNCE_MS) { │
│ stable_state = current; │
│ } │
│ last_state = current; │
│ │
└─────────────────────────────────────────────────────────────────┘
Memory Budget
Managing resources on embedded:
Memory Budget Example (STM32F446):
┌─────────────────────────────────────────────────────────────────┐
│ │
│ Flash (512 KB): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Code │ ~50 KB │ Game engine, drivers │ │
│ │ Graphics assets │ ~300 KB │ Sprites, tiles, fonts │ │
│ │ Audio assets │ ~150 KB │ Music, sound effects │ │
│ │ Game data │ ~10 KB │ Levels, configurations │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ RAM (128 KB): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Framebuffer A │ ~150 KB │ 320x240 RGB565 │ │
│ │ │ │ PROBLEM: Won't fit! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Solution: Use external RAM or reduce resolution │
│ OR: Partial update (only redraw changed regions) │
│ OR: Line-by-line rendering (no framebuffer) │
│ │
│ Compact approach (128 KB RAM): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Line buffer (x2) │ 1.3 KB │ Double-buffered line │ │
│ │ Audio buffer (x2) │ 4 KB │ Double-buffered audio │ │
│ │ Game state │ 10 KB │ Player, enemies, items │ │
│ │ Working memory │ 50 KB │ Decompression, temp │ │
│ │ Stack │ 8 KB │ Call stack │ │
│ │ Free │ ~55 KB │ Available │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Project Specification
What You Will Build
A complete handheld game console with:
- Color LCD display (240x320 or similar)
- Button controls (D-pad + 2 action buttons)
- Audio output (speaker or headphones)
- Game loading from SD card
- At least one playable game (Tetris, Snake, Breakout, etc.)
- Menu system for game selection
Hardware Requirements
Core Components:
- STM32F4 board (Nucleo-F446RE recommended, or Discovery)
- ILI9341 or ST7789 LCD (SPI, 240x320)
- Micro SD card module (SPI)
- 6 tactile buttons (D-pad + A + B)
- Piezo buzzer or small speaker + amplifier
- 3.3V power supply
Optional Enhancements:
- 3D printed enclosure
- LiPo battery + charger
- Headphone jack
- Volume control potentiometer
Functional Requirements
- Display System:
- Initialize LCD via SPI
- Double-buffered rendering (or line-by-line)
- 60 FPS target frame rate
- RGB565 color format
- Basic graphics primitives (rect, line, pixel)
- Sprite blitting with transparency
- Text rendering with bitmap font
- Input System:
- Read 6 buttons (Up, Down, Left, Right, A, B)
- Button debouncing
- Button edge detection (pressed/released events)
- Responsive (< 10ms latency)
- Audio System:
- DAC or PWM audio output
- DMA-driven for CPU efficiency
- Sound effect playback
- Background music (optional)
- Volume control
- Storage System:
- SD card via SPI
- FAT filesystem (FatFs library)
- Load game assets from files
- Save/load game state
- Game Engine:
- Game loop (input → update → render)
- Fixed timestep updates
- Collision detection
- Game state management
- Score tracking
- Menu System:
- Game selection screen
- Settings (volume, brightness)
- About/credits
Non-Functional Requirements
- Performance: Consistent 60 FPS
- Responsiveness: Input latency < 16ms
- Power: Reasonable battery life (4+ hours)
- Reliability: No crashes during gameplay
- Usability: Intuitive controls
Real World Outcome
When complete, your game console will look like this:
Physical device: A handheld game console you built from scratch!
┌─────────────────────────────────┐
│ ARM Game Console v1.0 │
│ ┌───────────────────────────┐ │
│ │ │ │
│ │ ████████████████████ │ │
│ │ ████ TETRIS ████ │ │
│ │ ████████████████████ │ │
│ │ │ │
│ │ Score: 12450 │ │
│ │ Level: 5 │ │
│ │ │ │
│ │ ░░██░░ │ │
│ │ ░░██░░ │ │
│ │ ██████████ │ │
│ │ ████░░████ │ │
│ │ ██████████ │ │
│ └───────────────────────────┘ │
│ │
│ [←] [→] [↓] [A] [B] │
│ │
└─────────────────────────────────┘
Serial debug output:
FPS: 60.02 | CPU: 45% | DMA: active
Audio: 22kHz mono, 2 channels mixed
LCD: 320x240 RGB565, SPI @ 40MHz
Buttons: 0x15 (Right + A)
Console in action:
=== ARM Game Console ===
LCD: ILI9341 320x240 @ 60Hz
Audio: 22050 Hz, PWM output
SD: FAT32 mounted, 2 games found
MAIN MENU
─────────
> Tetris
Snake
Settings
[A] Select [B] Back
Loading Tetris...
Graphics: 32 sprites loaded
Audio: 5 sound effects loaded
High score: 15230
GAME RUNNING
────────────
Level: 1
Score: 0
Lines: 0
[Sound: piece_land.wav]
[Sound: line_clear.wav]
Score: 100
Lines: 1
GAME OVER
─────────
Final Score: 12450
High Score: 15230
[A] Play Again [B] Menu
Questions to Guide Your Design
Work through these questions BEFORE writing code:
-
Display Architecture: Full framebuffer or line-by-line rendering? What are the tradeoffs?
-
SPI Sharing: LCD and SD card both use SPI. How do you share the bus?
-
Timing: How do you maintain 60 FPS? What happens if a frame takes too long?
-
Audio Sync: How do you play sound effects synchronized with game events?
-
Asset Format: How do you store sprites? Raw pixels? Compressed?
-
Memory Layout: Where does each buffer go? Stack size?
-
Power Management: Can you sleep between frames? During menu?
-
Extensibility: How easy is it to add a new game?
Thinking Exercise
Before writing any code, design these scenarios on paper:
Scenario 1: Frame Timing
- Target: 60 FPS (16.67 ms per frame)
- Your render takes 12 ms
- DMA transfer to LCD takes 8 ms
- How do you overlap these?
Hint: Start DMA transfer of buffer A while rendering to buffer B.
Scenario 2: Audio Event
- Player presses A button
- Jump animation starts
- Jump sound should play
- How do you trigger the sound?
Scenario 3: Asset Loading
- New level starts
- Need to load new tileset
- Don’t want loading screen freeze
- How do you stream assets?
Solution Architecture
High-Level Design
┌─────────────────────────────────────────────────────────────────┐
│ Game Console │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │ Menu │ │ Tetris │ │ Snake │ │Settings │ │ │
│ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────┬─────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Game Engine │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │
│ │ │Graphics │ │ Audio │ │ Input │ │ Assets │ │ │
│ │ │ System │ │ System │ │ System │ │ Manager │ │ │
│ │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │ │
│ └────────┼────────────┼───────────┼────────────┼──────────┘ │
│ │ │ │ │ │
│ ┌────────┼────────────┼───────────┼────────────┼──────────┐ │
│ │ │ │ │ │ │ │
│ │ ┌─────▼────┐ ┌─────▼────┐ ┌────▼────┐ ┌─────▼────┐ │ │
│ │ │ LCD │ │ DAC │ │ GPIO │ │ SD │ │ │
│ │ │ Driver │ │ Driver │ │ Driver │ │ Driver │ │ │
│ │ └─────┬────┘ └─────┬────┘ └────┬────┘ └─────┬────┘ │ │
│ │ │ │ │ │ │ │
│ │ Hardware Abstraction Layer │ │
│ └────────┼────────────┼───────────┼────────────┼──────────┘ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ SPI1 (LCD) DAC/TIM GPIOB SPI2 (SD) │ │
│ │ DMA2 DMA1 EXTI DMA1 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
Data Structures
// Graphics structures
typedef struct {
uint16_t width;
uint16_t height;
uint16_t *pixels; // RGB565 data
uint16_t transparent; // Transparent color
} Sprite;
typedef struct {
uint16_t *buffer[2]; // Double buffer
uint8_t current; // Which buffer is being displayed
uint16_t width;
uint16_t height;
} Framebuffer;
// Audio structures
typedef struct {
int16_t *samples;
uint32_t length;
uint32_t sample_rate;
uint8_t loop;
} AudioClip;
typedef struct {
AudioClip *clip;
uint32_t position;
uint8_t playing;
uint8_t volume;
} AudioChannel;
#define MAX_AUDIO_CHANNELS 4
typedef struct {
AudioChannel channels[MAX_AUDIO_CHANNELS];
int16_t mix_buffer[AUDIO_BUFFER_SIZE];
} AudioMixer;
// Input structures
typedef struct {
uint8_t current; // Current button state (bitmap)
uint8_t previous; // Previous state (for edge detection)
uint8_t pressed; // Just pressed this frame
uint8_t released; // Just released this frame
} ButtonState;
#define BTN_UP 0x01
#define BTN_DOWN 0x02
#define BTN_LEFT 0x04
#define BTN_RIGHT 0x08
#define BTN_A 0x10
#define BTN_B 0x20
// Game state
typedef struct {
uint8_t current_screen; // MENU, GAME, SETTINGS, etc.
uint32_t score;
uint32_t high_score;
uint8_t level;
// Game-specific state...
} GameState;
// Main console structure
typedef struct {
Framebuffer framebuffer;
AudioMixer audio;
ButtonState buttons;
GameState game;
uint32_t frame_count;
uint32_t last_frame_time;
} Console;
System Initialization
void console_init(void) {
// 1. System clock (168 MHz for STM32F4)
SystemClock_Config();
// 2. GPIO for buttons
gpio_init_buttons();
// 3. SPI for LCD and SD
spi_init(SPI1, 40000000); // LCD at 40 MHz
spi_init(SPI2, 25000000); // SD at 25 MHz
// 4. LCD initialization
lcd_init();
// 5. SD card and filesystem
sd_init();
f_mount(&fs, "", 1);
// 6. Audio (DAC + Timer + DMA)
audio_init(22050);
// 7. Game engine
graphics_init(&console.framebuffer);
input_init(&console.buttons);
// 8. Load menu assets
menu_init();
}
Implementation Guide
Development Environment Setup
# Required tools
sudo apt-get install gcc-arm-none-eabi openocd stlink-tools
# Create project structure
mkdir -p game-console/{src,inc,assets,games,lib}
cd game-console
Project Structure
game-console/
├── src/
│ ├── main.c # Entry point
│ ├── console.c # Main console logic
│ ├── graphics.c # Graphics system
│ ├── audio.c # Audio system
│ ├── input.c # Input handling
│ ├── assets.c # Asset loading
│ ├── lcd_ili9341.c # LCD driver
│ ├── sd_spi.c # SD card driver
│ ├── dac_audio.c # DAC audio output
│ └── system.c # Clock, GPIO setup
├── games/
│ ├── menu.c # Main menu
│ ├── tetris.c # Tetris game
│ └── snake.c # Snake game
├── inc/
│ ├── console.h
│ ├── graphics.h
│ ├── audio.h
│ └── games.h
├── assets/
│ ├── sprites/ # Sprite images
│ ├── sounds/ # Sound effects
│ └── fonts/ # Bitmap fonts
├── lib/
│ └── fatfs/ # FatFs library
├── linker.ld
├── startup.s
└── Makefile
Implementation Phases
Phase 1: Display (Week 1-2)
Goals:
- LCD working via SPI
- Basic graphics primitives
- Display test pattern
Tasks:
- Initialize SPI for LCD
- Write LCD driver (init sequence, write commands/data)
- Implement fill_rect, draw_pixel
- Display color bars test pattern
- Implement sprite blitting
- Set up DMA for fast transfers
Checkpoint: LCD shows color bars and a moving sprite.
// LCD initialization (ILI9341)
void lcd_init(void) {
// Hardware reset
gpio_write(LCD_RST, 0); delay_ms(10);
gpio_write(LCD_RST, 1); delay_ms(120);
// Software reset
lcd_cmd(0x01); delay_ms(5);
// Exit sleep
lcd_cmd(0x11); delay_ms(120);
// Pixel format: 16-bit RGB565
lcd_cmd(0x3A); lcd_data(0x55);
// Memory access control (rotation)
lcd_cmd(0x36); lcd_data(0x48);
// Display on
lcd_cmd(0x29);
}
// Draw sprite with transparency
void draw_sprite(int x, int y, Sprite *sprite) {
uint16_t *fb = framebuffer.buffer[framebuffer.current ^ 1];
for (int row = 0; row < sprite->height; row++) {
for (int col = 0; col < sprite->width; col++) {
uint16_t color = sprite->pixels[row * sprite->width + col];
if (color != sprite->transparent) {
int fx = x + col;
int fy = y + row;
if (fx >= 0 && fx < 320 && fy >= 0 && fy < 240) {
fb[fy * 320 + fx] = color;
}
}
}
}
}
Phase 2: Input (Week 2)
Goals:
- Button reading working
- Debouncing implemented
- Edge detection for press/release
Tasks:
- Configure GPIO for buttons (input with pull-up)
- Implement button reading function
- Add debouncing logic
- Detect pressed/released events
- Test with sprite movement
Checkpoint: Sprite moves smoothly with D-pad, A/B trigger actions.
// Button reading with debouncing
void input_update(ButtonState *state) {
static uint8_t debounce_count[6] = {0};
static uint8_t raw_state = 0;
// Read raw GPIO
uint8_t current_raw = 0;
if (!gpio_read(BTN_UP_PIN)) current_raw |= BTN_UP;
if (!gpio_read(BTN_DOWN_PIN)) current_raw |= BTN_DOWN;
if (!gpio_read(BTN_LEFT_PIN)) current_raw |= BTN_LEFT;
if (!gpio_read(BTN_RIGHT_PIN)) current_raw |= BTN_RIGHT;
if (!gpio_read(BTN_A_PIN)) current_raw |= BTN_A;
if (!gpio_read(BTN_B_PIN)) current_raw |= BTN_B;
// Debounce each button
for (int i = 0; i < 6; i++) {
uint8_t mask = (1 << i);
if ((current_raw & mask) == (raw_state & mask)) {
debounce_count[i] = 0;
} else {
debounce_count[i]++;
if (debounce_count[i] >= DEBOUNCE_THRESHOLD) {
raw_state ^= mask;
debounce_count[i] = 0;
}
}
}
// Update state
state->previous = state->current;
state->current = raw_state;
state->pressed = state->current & ~state->previous;
state->released = ~state->current & state->previous;
}
Phase 3: Audio (Week 3)
Goals:
- DAC outputting audio
- DMA-driven playback
- Sound effect triggering
Tasks:
- Configure DAC and timer (from Project 13)
- Set up DMA for audio buffer
- Implement audio mixer
- Load WAV files from SD
- Test sound effect playback
Checkpoint: Sound effects play when buttons pressed.
// Play sound effect
void audio_play_sfx(AudioClip *clip) {
// Find free channel
for (int i = 1; i < MAX_AUDIO_CHANNELS; i++) { // 0 reserved for music
if (!console.audio.channels[i].playing) {
console.audio.channels[i].clip = clip;
console.audio.channels[i].position = 0;
console.audio.channels[i].playing = 1;
console.audio.channels[i].volume = 100;
return;
}
}
// No free channel, steal oldest
}
// Mix all channels (called from DMA interrupt)
void audio_mix(int16_t *output, int samples) {
memset(output, 0, samples * sizeof(int16_t));
for (int ch = 0; ch < MAX_AUDIO_CHANNELS; ch++) {
AudioChannel *chan = &console.audio.channels[ch];
if (!chan->playing) continue;
for (int i = 0; i < samples; i++) {
if (chan->position >= chan->clip->length) {
if (chan->clip->loop) {
chan->position = 0;
} else {
chan->playing = 0;
break;
}
}
int32_t sample = chan->clip->samples[chan->position++];
sample = (sample * chan->volume) / 100;
output[i] = clamp(output[i] + sample, -32768, 32767);
}
}
}
Phase 4: SD Card & Assets (Week 4)
Goals:
- SD card reading
- FAT filesystem working
- Load sprites and sounds
Tasks:
- Port FatFs library
- Configure SPI for SD (from Project 11)
- Implement asset loader
- Create sprite format and loader
- Create audio format and loader
Checkpoint: Load and display sprites from SD card.
// Load sprite from BMP file
Sprite *load_sprite(const char *filename) {
FIL file;
if (f_open(&file, filename, FA_READ) != FR_OK) {
return NULL;
}
// Read BMP header (simplified)
uint8_t header[54];
UINT read;
f_read(&file, header, 54, &read);
int width = *(int32_t *)&header[18];
int height = *(int32_t *)&header[22];
Sprite *sprite = malloc(sizeof(Sprite));
sprite->width = width;
sprite->height = height;
sprite->pixels = malloc(width * height * 2);
sprite->transparent = 0xF81F; // Magenta
// Read and convert pixels (BGR to RGB565)
for (int y = height - 1; y >= 0; y--) {
for (int x = 0; x < width; x++) {
uint8_t bgr[3];
f_read(&file, bgr, 3, &read);
uint16_t rgb565 = ((bgr[2] >> 3) << 11) |
((bgr[1] >> 2) << 5) |
(bgr[0] >> 3);
sprite->pixels[y * width + x] = rgb565;
}
}
f_close(&file);
return sprite;
}
Phase 5: Game Engine (Week 5-6)
Goals:
- Main game loop
- Frame timing
- Collision detection
- Game state management
Tasks:
- Implement fixed-timestep game loop
- Add frame rate limiting
- Create collision detection helpers
- Implement game state machine
- Create menu system
Checkpoint: Menu navigable, can start game.
// Main game loop
void console_run(void) {
uint32_t last_time = get_ms();
uint32_t accumulator = 0;
const uint32_t FRAME_TIME = 16; // ~60 FPS
while (1) {
uint32_t current_time = get_ms();
uint32_t delta = current_time - last_time;
last_time = current_time;
accumulator += delta;
// Input (once per frame)
input_update(&console.buttons);
// Fixed timestep updates
while (accumulator >= FRAME_TIME) {
game_update(FRAME_TIME);
accumulator -= FRAME_TIME;
}
// Render
game_render();
// Swap and send to LCD
swap_buffers();
// Frame counter
console.frame_count++;
}
}
// Game state machine
void game_update(uint32_t dt) {
switch (console.game.current_screen) {
case SCREEN_MENU:
menu_update(dt);
break;
case SCREEN_TETRIS:
tetris_update(dt);
break;
case SCREEN_SNAKE:
snake_update(dt);
break;
}
}
Phase 6: First Game - Tetris (Week 7-8)
Goals:
- Complete Tetris implementation
- All seven tetrominoes
- Line clearing and scoring
- Game over detection
Tasks:
- Define tetromino shapes
- Implement piece movement and rotation
- Implement collision with board
- Implement line clearing
- Add scoring and levels
- Add sound effects
Checkpoint: Fully playable Tetris with audio.
// Tetris piece definition
const uint16_t TETROMINOES[7][4] = {
{0x0F00, 0x2222, 0x00F0, 0x4444}, // I
{0x8E00, 0x6440, 0x0E20, 0x44C0}, // J
{0x2E00, 0x4460, 0x0E80, 0xC440}, // L
{0xCC00, 0xCC00, 0xCC00, 0xCC00}, // O
{0x6C00, 0x4620, 0x06C0, 0x8C40}, // S
{0x4E00, 0x4640, 0x0E40, 0x4C40}, // T
{0xC600, 0x2640, 0x0C60, 0x4C80}, // Z
};
// Game update
void tetris_update(uint32_t dt) {
// Input handling
if (console.buttons.pressed & BTN_LEFT) {
try_move(-1, 0);
}
if (console.buttons.pressed & BTN_RIGHT) {
try_move(1, 0);
}
if (console.buttons.pressed & BTN_A) {
try_rotate(1);
audio_play_sfx(&sfx_rotate);
}
if (console.buttons.current & BTN_DOWN) {
drop_timer = 0; // Fast drop
}
// Gravity
drop_timer += dt;
if (drop_timer >= drop_interval) {
if (!try_move(0, 1)) {
lock_piece();
clear_lines();
spawn_piece();
if (check_game_over()) {
console.game.current_screen = SCREEN_GAMEOVER;
}
}
drop_timer = 0;
}
}
Phase 7: Polish (Week 9-10)
Goals:
- Second game (Snake)
- Visual polish
- Sound polish
- Bug fixes
Tasks:
- Implement Snake game
- Add title screen
- Add high score saving
- Visual effects (screen shake, flash)
- Audio polish (music, more SFX)
- Testing and bug fixes
Checkpoint: Complete, polished game console.
Hints in Layers
Hint 1: LCD SPI Speed
Full framebuffer transfer at 60 FPS needs ~70 Mbit/s:
- 320 * 240 * 2 bytes * 60 FPS = 9.2 MB/s
Use DMA and high SPI speed (40+ MHz). Or use partial updates.
Hint 2: Memory Constraints
If you can’t fit a full framebuffer:
- Use line-by-line rendering (1 line = 640 bytes)
- Render each line, DMA transfer, repeat
- More CPU work but fits in small RAM
Hint 3: Audio Sync
Don’t play audio directly in game code. Instead:
- Queue sound effect request
- Audio ISR picks it up
- Starts playback on next buffer fill
Hint 4: SPI Bus Sharing
LCD and SD share SPI? Use separate CS pins:
- Deassert LCD CS
- Assert SD CS
- Do SD operations
- Deassert SD CS
- Assert LCD CS
- Continue graphics
Hint 5: Frame Timing
Use timer interrupt for frame timing:
- Timer fires every 16.67 ms
- Sets flag
- Main loop checks flag, renders
- Precise timing without busy-wait
Hint 6: Sprite Optimization
For faster sprite rendering:
- Use DMA for horizontal spans
- Skip transparent pixels in inner loop
- Clip sprites to screen bounds once
- Consider sprite sheet packed in Flash
Testing Strategy
Test Categories
| Category | Purpose | Examples |
|---|---|---|
| Component | Test each system | LCD alone, audio alone |
| Integration | Systems together | Graphics + input |
| Gameplay | Game mechanics | Collision, scoring |
| Performance | Frame rate | Profile slow areas |
| Stress | Long runs | Hours of play |
Critical Test Cases
- Display: All colors display correctly
- Input: All buttons respond, no ghost presses
- Audio: Sounds play, mix correctly
- Timing: 60 FPS maintained
- Gameplay: Game rules correct
- Edge cases: Screen boundaries, game over
- Long run: 1 hour without issues
Performance Profiling
// Simple profiler
typedef struct {
uint32_t input_time;
uint32_t update_time;
uint32_t render_time;
uint32_t transfer_time;
} FrameProfile;
void profile_frame(FrameProfile *p) {
uint32_t start = get_us();
input_update(&console.buttons);
p->input_time = get_us() - start;
start = get_us();
game_update(16);
p->update_time = get_us() - start;
start = get_us();
game_render();
p->render_time = get_us() - start;
start = get_us();
swap_buffers();
p->transfer_time = get_us() - start;
// Print every 60 frames
if (console.frame_count % 60 == 0) {
printf("I:%d U:%d R:%d T:%d\n",
p->input_time, p->update_time,
p->render_time, p->transfer_time);
}
}
Common Pitfalls & Debugging
Frequent Mistakes
| Pitfall | Symptom | Solution |
|---|---|---|
| SPI too slow | Low FPS | Increase SPI speed, use DMA |
| Wrong pixel format | Garbled colors | Check RGB565 byte order |
| No debouncing | Ghost inputs | Add debounce logic |
| Audio underrun | Clicking | Increase buffer size |
| Stack overflow | Random crashes | Increase stack, check recursion |
| DMA conflict | Corruption | Don’t share DMA channels |
Debugging Strategies
- Visual Debugging: Display debug info on screen
- Serial Logging: Printf timing, state
- LED Indicators: Toggle on key events
- Logic Analyzer: Check SPI signals
- Oscilloscope: Verify audio waveform
Performance Optimization
Optimization Checklist:
[ ] DMA for all large transfers
[ ] SPI at maximum speed
[ ] Optimize inner loops (sprite blitting)
[ ] Precompute where possible
[ ] Use lookup tables for math
[ ] Profile to find bottlenecks
[ ] Consider assembly for hot paths
Extensions & Challenges
Beginner Extensions
- More games: Breakout, Pong, Space Invaders
- Animated sprites: Frame animation system
- Background music: Loop music during gameplay
- Pause menu: Pause with B button
Intermediate Extensions
- Scrolling backgrounds: Tile-based scrolling
- Particle effects: Explosions, sparkles
- Save system: Save high scores to SD
- Multiple difficulty levels: Easy/Medium/Hard
Advanced Extensions
- Game download: Load games from SD
- Link cable: Multiplayer via UART
- Emulator: NES/GB emulator
- Wireless: Bluetooth controller
- Custom PCB: Design your own board
Self-Assessment Checklist
Understanding
- I can explain the game loop (input, update, render)
- I understand double buffering and why it’s needed
- I can describe how audio mixing works
- I know how to debounce buttons
- I understand the memory constraints
- I can explain how all subsystems integrate
Implementation
- LCD displays graphics correctly at 60 FPS
- All buttons work with no ghost presses
- Audio plays without glitches
- At least one game is fully playable
- Menu system allows game selection
- System runs for hours without issues
Quality
- Smooth, responsive gameplay
- Good visual presentation
- Clear, crisp audio
- No crashes or glitches
- Easy to add new games
The Interview Questions They’ll Ask
After completing this project, you’ll be ready for these questions:
- “Tell me about a project you’re proud of”
- This IS that project! A complete embedded system you designed and built.
- Demonstrates: hardware/software integration, real-time systems, resource constraints
- “How do you optimize for performance in embedded systems?”
- Use DMA to offload data transfer
- Profile to find bottlenecks
- Optimize critical loops
- Use hardware peripherals efficiently
- “How do you handle real-time constraints?”
- Fixed timestep for consistency
- Prioritize critical paths
- Use interrupts appropriately
- Double buffer to decouple production/consumption
- “Describe a challenging bug you solved”
- Many opportunities: audio glitches, frame drops, memory corruption
- Describe debugging process, tools used, solution
- “How would you design a system with limited resources?”
- Memory budgeting
- Feature prioritization
- Creative solutions (line-by-line rendering, etc.)
Books That Will Help
| Topic | Book | Chapter |
|---|---|---|
| Game architecture | “Game Programming Patterns” by Nystrom | Chapters 1, 8, 9 |
| Graphics | “Computer Graphics from Scratch” by Gambetta | Part 1 |
| Audio | “The Audio Programming Book” | Chapters 1-4 |
| Embedded systems | “Making Embedded Systems” by White | Chapters 8-10 |
| ARM peripherals | “Definitive Guide to ARM Cortex-M3/M4” | Chapters 8-10 |
Resources
Hardware
Software
- FatFs Library
- STemWin Graphics Library (reference)
Example Projects
- STM32 Game Console
- PicoSystem (RP2040-based)
- Gamebuino (Arduino-based)
Related Projects in This Series
- Previous: Project 15: Tiny OS
- Integrates: Projects 3, 4, 5, 7, 10, 11, 12, 13
- This is the Capstone: Congratulations on completing the ARM Deep Dive!
Congratulations!
If you’ve completed this project, you have:
- Built a complete embedded system from scratch
- Integrated multiple hardware peripherals
- Implemented real-time graphics and audio
- Created an actual playable game
- Demonstrated mastery of ARM architecture
You are now an ARM expert. This project proves you can:
- Design and implement complex embedded systems
- Handle real-time constraints
- Manage limited resources creatively
- Debug at the hardware/software interface
- Build something tangible and impressive
This is the kind of project that distinguishes you in interviews and opens doors to embedded systems, game development, and IoT positions.
Well done!
This guide was expanded from LEARN_ARM_DEEP_DIVE.md. For the complete learning path, see the project index.