14 KiB
Reactive Game Template - Usage Guide
Overview
This template provides a clean, event-driven architecture for building games and interactive applications on Raspberry Pi Pico (RP2350) with displays. It's designed to be efficient, power-conscious, and optimized for e-ink displays.
Architecture
Event-Driven Design
Interrupt (Touch/Button) → Set Flag → Main Loop Wakes → Process Input → Update Game State → Refresh Display → Sleep
The template follows a reactive pattern:
- Sleep: CPU waits for interrupts using
__wfi()(very power efficient) - Wake: Interrupt handler sets a flag and wakes CPU
- Process: Main loop processes the input event
- Update: Game logic updates state based on input
- Render: Display refreshes only if needed
- Repeat: Back to sleep
Key Components
1. Input Event System
- InputEvent Structure: Unified representation of all inputs
- InputType Enum: Touch (down/move/up), buttons, gestures
- Interrupt Handlers: Minimal ISRs that only set flags
- Processing Functions:
process_touch_input()andprocess_button_input()
2. Game State Management
- GameState Structure: All game-specific data in one place
- GameConfig Structure: Configurable parameters (debounce, features, debug)
- No Global Variables: State is passed to functions explicitly
3. Game Logic Functions
- game_init(): Initialize game state at startup
- game_update(): Handle input events and update state
- game_draw(): Render UI and game graphics
Creating Your Own Game
Step 1: Define Your Game State
Modify the GameState structure to hold your game-specific data:
struct GameState {
// Example for a Snake game
int snake_x[100];
int snake_y[100];
int snake_length;
int food_x;
int food_y;
int direction;
uint32_t score;
bool game_over;
};
Step 2: Initialize Your Game
Implement game_init() to set initial values:
void game_init(GameState* state) {
// Snake starts in center
state->snake_x[0] = V_WIDTH / 2;
state->snake_y[0] = V_HEIGHT / 2;
state->snake_length = 3;
state->direction = 0; // Right
state->score = 0;
state->game_over = false;
// Place first food
place_random_food(state);
}
Step 3: Handle Input
Implement game_update() to respond to input events:
bool game_update(GameState* state, const InputEvent& input, const GameConfig& config, LowLevelRenderer* renderer) {
bool needs_refresh = false;
switch (input.type) {
case INPUT_BUTTON_0:
// Turn left
state->direction = (state->direction + 3) % 4;
needs_refresh = true;
break;
case INPUT_BUTTON_1:
// Turn right
state->direction = (state->direction + 1) % 4;
needs_refresh = true;
break;
case INPUT_TOUCH_DOWN:
// Touch to restart if game over
if (state->game_over) {
game_init(state);
needs_refresh = true;
}
break;
}
// Update snake position
update_snake_position(state);
check_collisions(state);
return needs_refresh;
}
Step 4: Draw Your Game
Implement game_draw() to render graphics:
void game_draw(const GameState* state, LowLevelRenderer* renderer, LowLevelGUI* gui) {
// Draw game board
LowLevelWindow *w1 = gui->draw_new_window(10, 10, V_WIDTH - 20, V_HEIGHT - 20, "Snake Game");
// Draw snake
for (int i = 0; i < state->snake_length; i++) {
renderer->draw_filled_rectangle(state->snake_x[i], state->snake_y[i], 10, 10, true, 1);
}
// Draw food
renderer->draw_filled_circle(state->food_x, state->food_y, 5, true);
// Draw score
char score_text[20];
snprintf(score_text, sizeof(score_text), "Score: %d", state->score);
renderer->draw_string(20, 30, score_text, true);
// Game over message
if (state->game_over) {
renderer->draw_string(V_WIDTH/2 - 40, V_HEIGHT/2, "GAME OVER", true);
renderer->draw_string(V_WIDTH/2 - 50, V_HEIGHT/2 + 20, "Touch to restart", true);
}
}
Step 5: Adjust Configuration
Modify GameConfig in main() for your game's needs:
GameConfig config = {
.touch_debounce_ms = 50, // Slower for menu navigation
.button_debounce_ms = 100, // Longer for game controls
.enable_gestures = false, // Not needed for snake
.enable_continuous_draw = false,
.debug_verbose = true // Enable during development
};
Example Game Ideas
1. Tic-Tac-Toe
- Input: Touch to place X/O, buttons to switch players
- State: 3x3 grid, current player, win condition
- Drawing: Grid lines, X and O symbols
- Ideal for: E-ink displays (few updates)
2. Pong
- Input: Buttons to move paddle up/down
- State: Paddle positions, ball position/velocity, score
- Drawing: Paddles, ball, center line, score
- Note: May need timer-based updates for ball movement
3. Memory Card Game
- Input: Touch cards to flip, buttons to navigate
- State: Card positions, flipped states, matches found
- Drawing: Card grid, symbols when flipped
- Ideal for: E-ink displays (turn-based)
4. Calculator
- Input: Touch for number buttons, physical buttons for operations
- State: Current value, operation mode, history
- Drawing: Display, button grid
- Perfect for: E-ink displays (minimal updates)
5. Drawing Board (Current Example)
- Input: Touch to draw, buttons to clear/undo
- State: Last position, stroke history
- Drawing: Lines following touch movement
- Ideal for: TFT displays (frequent updates)
Input Handling Best Practices
Touch Input
case INPUT_TOUCH_DOWN:
// First touch - capture position
state->start_x = input.x;
state->start_y = input.y;
break;
case INPUT_TOUCH_MOVE:
// Continuous drawing/dragging
if (config.enable_continuous_draw) {
draw_line(state->last_x, state->last_y, input.x, input.y);
}
break;
case INPUT_TOUCH_UP:
// Touch released - finalize action
calculate_gesture(state->start_x, state->start_y, input.x, input.y);
break;
Button Input
case INPUT_BUTTON_0:
// First button - navigation/cancel
navigate_menu_prev(state);
break;
case INPUT_BUTTON_1:
// Second button - selection/confirm
select_menu_item(state);
break;
Gesture Input
case INPUT_GESTURE:
switch(input.gesture_code) {
case 0x10: // Move Up
scroll_up(state);
break;
case 0x18: // Move Down
scroll_down(state);
break;
case 0x48: // Zoom In
increase_scale(state);
break;
}
break;
E-Ink Display Optimization
Minimize Refreshes
- Only return
truefromgame_update()when display actually changes - Batch updates: collect multiple changes before refreshing
- Use partial refresh when available
Visual Design Tips
- High Contrast: Use solid blacks and whites
- Clear Shapes: Avoid thin lines (use 2px minimum)
- Large Text: Use readable fonts (5x5 or larger)
- Simple Graphics: Minimize complex patterns
- Static Elements: Redraw only changed areas when possible
Example Pattern
bool game_update(GameState* state, const InputEvent& input, const GameConfig& config, LowLevelRenderer* renderer) {
bool needs_refresh = false;
// Collect all changes
if (input.type == INPUT_BUTTON_0) {
state->menu_index++;
needs_refresh = true;
}
// Only redraw if something changed
return needs_refresh;
}
Power Efficiency
Sleep Between Events
The template uses __wfi() to put CPU to sleep:
while (1) {
__wfi(); // CPU sleeps here until interrupt
// Process input...
}
Reduce Polling
- Use interrupt-driven input (already implemented)
- Avoid tight loops checking hardware
- Let ISRs wake the CPU only when needed
Display Power
// For e-ink: Turn off display after inactivity
if (time_since_last_input > SLEEP_TIMEOUT) {
display->sleep();
}
GUI Components Available
The template includes a full GUI system with these components:
// Windows
gui->draw_new_window(x, y, width, height, "Title");
// Buttons
gui->draw_button(window, x, y, "Label", pressed, rounded);
// Checkboxes
gui->draw_checkbox(window, x, y, "Label", checked);
// Radio Buttons
gui->draw_radio_button(window, x, y, "Label", selected);
// Sliders
gui->draw_slider(window, x, y, width, height, position, "Label");
// Status Bars
gui->draw_status_bar(window, x, y, width, "Label", "Sublabel", percentage, "Value");
// Gauges
gui->draw_circular_gauge(window, x, y, width, "Label", percentage);
// Text Boxes
gui->draw_textbox(window, x, y, width, height, "Content", focused);
// Tabs
gui->draw_tab(window, x, y, width, height, "Label", selected);
// Notifications
gui->draw_notification(window, x, y, width, "Time", "Message");
// Clock
gui->draw_large_clock(window, x, y, "12:30");
// Calendar
gui->draw_calendar(window, x, y, month, year);
Debugging Tips
Enable Verbose Mode
GameConfig config = {
.debug_verbose = true // Print debug messages
};
Monitor Input Events
if (config.debug_verbose) {
printf("Input: type=%d x=%d y=%d\n", input.type, input.x, input.y);
}
Check State Changes
if (config.debug_verbose) {
printf("Score: %d, Lives: %d\n", state->score, state->lives);
}
Serial Monitor
Connect via USB and monitor output:
screen /dev/cu.usbmodem101
Multi-Board Support
The template works across different board configurations:
- Pico 2 with TFT + Touch: Full interactive drawing
- Pico 2 with E-ink + Buttons: Button-based navigation
- Feather boards: Various display combinations
Board-specific configuration is handled automatically through board_config.h.
Advanced Patterns
Timer-Based Updates
For games needing periodic updates (not just reactive):
// In main(), before loop:
uint32_t last_game_tick = 0;
const uint32_t TICK_INTERVAL_MS = 100;
// In main loop:
uint32_t now = to_ms_since_boot(get_absolute_time());
bool needs_tick = (now - last_game_tick >= TICK_INTERVAL_MS);
if (needs_tick) {
// Update game logic (physics, AI, etc.)
update_game_tick(&game_state);
last_game_tick = now;
refresh_screen(bit_buffer, display);
}
Animation
// Smooth movement over multiple frames
void animate_sprite(GameState* state) {
state->sprite_x += state->velocity_x;
state->sprite_y += state->velocity_y;
state->frame_count++;
}
State Machines
enum GameMode {
MODE_MENU,
MODE_PLAYING,
MODE_PAUSED,
MODE_GAME_OVER
};
struct GameState {
GameMode mode;
// ... other fields
};
bool game_update(GameState* state, const InputEvent& input, ...) {
switch(state->mode) {
case MODE_MENU:
return handle_menu_input(state, input);
case MODE_PLAYING:
return handle_game_input(state, input);
case MODE_PAUSED:
return handle_pause_input(state, input);
case MODE_GAME_OVER:
return handle_gameover_input(state, input);
}
}
Common Pitfalls
1. Forgetting to Return True
// Wrong:
bool game_update(...) {
state->score++;
// No return - screen won't refresh!
}
// Right:
bool game_update(...) {
state->score++;
return true; // Signal refresh needed
}
2. Drawing in game_update()
// Wrong:
bool game_update(...) {
renderer->draw_line(...); // Drawing here!
return true;
}
// Right:
bool game_update(...) {
state->line_end_x = input.x; // Update state only
return true;
}
// Drawing happens in main loop when refresh is needed
3. Blocking in ISR
// Wrong:
void touch_interrupt_handler(...) {
touch->read_touch(&data); // Slow I2C operation in ISR!
printf("Touch!\n"); // Serial output in ISR!
}
// Right:
void touch_interrupt_handler(...) {
touch_interrupt_flag = true; // Just set flag
}
4. Not Clearing Buffer Before Redraw
// When redrawing entire UI:
memset(bit_buffer, 0, V_WIDTH * V_HEIGHT / 8); // Clear first
game_draw(&game_state, &renderer, &gui); // Then draw
Performance Considerations
Memory Usage
- Frame buffer:
V_WIDTH * V_HEIGHT / 8bytes (e.g., 13KB for 296x128) - Keep GameState small for fast copying
- Use
uint8_tinstead ofintwhere possible
CPU Usage
- Interrupt-driven design minimizes CPU usage
__wfi()puts CPU to sleep between events- Typical power draw: < 1mA while sleeping
Display Refresh Times
- E-ink: 1-4 seconds for full refresh
- TFT: < 50ms for full screen
- Partial updates much faster
Next Steps
- Start Simple: Begin with basic button navigation
- Add Features: Gradually add game mechanics
- Test on Hardware: Verify on your target board
- Optimize: Tune debounce, refresh strategy for your needs
- Polish: Add animations, sounds, save states
Example Projects
Check out these example implementations:
- Button Game (current): Focus switching and click counting
- Drawing Board: Touch-based freehand drawing
- Snake Game: Classic snake with button controls
- Calculator: Touch-based number pad with operations
Resources
- Board Configs:
board_configs/directory - Display Drivers:
display/directory - Font Files:
fonts/directory - Refactoring Plan:
REFACTORING_PLAN.md- Implementation details
Support
For issues or questions:
- Check serial output with
screen /dev/cu.usbmodem101 - Enable
debug_verbosein GameConfig - Review the refactoring plan for architecture details
- Test with minimal game logic first
Happy coding! 🎮