Skip to content

Latest commit

 

History

History
578 lines (408 loc) · 22.8 KB

File metadata and controls

578 lines (408 loc) · 22.8 KB

Pocket RPG -- Developer Guide

This document covers the codebase structure, development environment, deployment methods, coding patterns, and how to extend Pocket RPG. The game runs on an M5Stack Basic v2.7 with a custom UIFlow2 MicroPython firmware that stores everything in flash -- no SD card required.


1. Project Structure

Pocket-RPG/
  src/
    main.py            Entry point. Boot sequence, 50Hz async game loop, input callbacks.
    engine.py          Game engine state machine. AI turn cycle, choice handling, state transitions.
    renderer.py        Display controller. Scene backgrounds, sprites, status strip, dialog, choice bar.
    animator.py        Animation queue. Base Animation class, all animation types, Animator manager.
    sprite.py          Sprite compositor. BMP loading, frame extraction, transparency keying (0xF81F).
    font_engine.py     16x16 bitmap font renderer. Loads font spritesheet, draws dialog text.
    storage.py         SPI mutex, atomic file I/O, save/load game slots. Works with flash or SD.
    ai_client.py       OpenRouter API client. System prompt, HTTP POST, response parsing, History.
    world.py           Game data. Classes, SCENE_IDS, ENEMY_IDS, MOODS, XP tables, delta application.
    config.py          Configuration loader. Reads config.json via paths.asset(), merges defaults.
    paths.py           Path abstraction. Auto-detects /flash, /, or /sd base path for all assets.
    input_handler.py   Button polling (A/B/C) and IMU shake/tilt detection at 50Hz.
    audio.py           SFX playback via ESP32 DAC (GPIO25). Tone sequences and ambient mood.
    wifi.py            WiFi connection helper.
    fallback.py        Offline adventure tree navigator. Loads offline.json for API-free play.
    world.txt          Default world lore text for Vaeloria.
    offline.json       Default offline adventure tree (15 nodes).
  firmware/
    build.sh           Custom firmware build script (ESP-IDF + UIFlow2 + game code).
    create_game_main.py  Transforms src/main.py into frozen ROM version.
    partitions_16mb_game.csv  Flash partition table: 3MB app + 12.9MB VFS.
  tools/
    deploy.py          Interactive device setup: flash firmware, upload config via serial.
    convert_assets.py  PNG to BMP RGB565 conversion for scenes, sprites, font, d20.
    generate_builtin_assets.py  Generates complete BMP asset pack from procedural pixel art.
  assets/
    scenes/            Pre-built scene background BMPs (320x168, RGB565).
    sprites/           Pre-built character/enemy spritesheets (288x48, RGB565).
    ui/                Pre-built UI overlays: splash, status strip, dialog, choice bar, font, d20.
  docs/
    configuration.md   Configuration and setup guide.
    development.md     This file.
    architecture.md    System architecture overview.
    game-mechanics.md  Game mechanics reference.

Flash Layout (at runtime)

/flash/
  config.json          Game configuration (WiFi, API key, volume, etc.)
  world.txt            World lore file loaded by the AI client.
  offline.json         Branching adventure tree for offline mode.
  scenes/              Scene background BMPs (320x168, RGB565).
  sprites/             Character and enemy spritesheets (288x48, RGB565).
  ui/
    font_16.bmp        Font spritesheet (1536x16, RGB565).
    d20_sheet.bmp      D20 dice spritesheet (240x40, RGB565).
    splash.bmp         Boot splash screen (320x240, RGB565).
    status_strip.bmp   Status bar background (320x12, RGB565).
    dialog_panel.bmp   Dialog panel background (320x48, RGB565).
    choice_bar.bmp     Choice bar background (320x10, RGB565).
  saves/
    slot_0.json        Save slot 0.
    slot_1.json        Save slot 1.
    slot_2.json        Save slot 2.
  logs/
    session.log        Runtime log output.

2. Development Environment

Hardware

  • M5Stack Basic v2.7 -- ESP32 with ILI9342C 320x240 display, 3 face buttons (A/B/C), MPU6886 IMU, mono DAC on GPIO25, 16MB flash.

Firmware

  • UIFlow2 MicroPython -- The custom firmware is built from the UIFlow2 source with game code frozen into ROM and assets stored in the 12.9MB VFS FAT partition.
  • The stock UIFlow2 firmware can also be used for development (download from https://flow.m5stack.com), but game assets must then be uploaded to the device flash manually.
  • For firmware builds from source, ESP-IDF v5.4.2 is required. The firmware/build.sh setup command installs this automatically.

Desktop Development

You can run and test portions of the code on a desktop Python 3.x installation. The codebase uses try/except ImportError guards around all M5-specific imports (M5, machine, urequests, uasyncio). When M5 is None, hardware calls are skipped.


3. Deploying to Device

There are three deployment methods, from simplest to most involved.

Method A: deploy.py with prebuilt firmware (recommended)

This is the standard path for end users and developers who do not need to modify the firmware itself.

  1. Connect the M5Stack via USB.
  2. Run the interactive deployment tool:
    python tools/deploy.py
    
  3. The tool will:
    • Detect the serial port (or prompt you for it).
    • Ask whether to flash the firmware binary (firmware/pocket_rpg_firmware.bin).
    • Prompt for WiFi SSID, password, API key, volume, text speed, and other settings.
    • Flash the firmware via esptool (if selected).
    • Upload config.json to /flash/config.json via mpremote.

Useful flags:

Flag Effect
--config-only Skip firmware flash, only upload config
--flash-only Skip config prompts, only flash firmware
--port COM3 Specify serial port explicitly

Dependencies (esptool, mpremote, pyserial) are installed automatically if missing.

Method B: firmware/build.sh from source

For developers who need to modify the firmware, freeze new modules into ROM, or change the partition layout.

  1. Install the toolchain (first time only):

    ./firmware/build.sh setup
    

    This clones ESP-IDF v5.4.2 and uiflow-micropython, installs submodules, and builds tools. Requires Linux or WSL and about 4GB of disk space.

  2. Build the firmware:

    ./firmware/build.sh build
    

    This copies all src/*.py files into the UIFlow2 build tree as a frozen pocket_rpg package, copies BMP assets from assets/ into the VFS filesystem image, creates the firmware entry point (game_main.py via create_game_main.py), and builds the combined binary.

  3. Flash to device:

    ./firmware/build.sh flash
    

    Or do all three steps at once:

    ./firmware/build.sh all
    

The output binary lands at firmware/pocket_rpg_firmware.bin.

Method C: Thonny / mpremote for individual files

For quick iteration on a single module without rebuilding the entire firmware.

  1. Upload individual Python files to the device:
    python -m mpremote connect <PORT> cp src/engine.py :/flash/engine.py
    
  2. Upload assets:
    python -m mpremote connect <PORT> cp assets/scenes/crossroads.bmp :/flash/scenes/crossroads.bmp
    
  3. Or use Thonny's file manager to drag files to the device filesystem.
  4. Reset the device to pick up changes.

This method is useful during development but does not freeze code into ROM, so startup will be slightly slower and use more RAM.

Boot Entry Point

The firmware's boot.py sets boot_option to 0 via NVS, which tells UIFlow2 to execute main.py directly on power-on (skipping the network/menu flow). Hold BtnA during boot to force the UIFlow2 startup menu if recovery is needed.

main.py creates a Game instance and enters the async game loop via asyncio.run(game.run()).


4. MicroPython Constraints

No f-strings

MicroPython on ESP32 does not support f-strings. Use string concatenation:

# Wrong
print(f"HP: {hp}")

# Correct
print('HP: ' + str(hp))

Limited stdlib

Many CPython standard library modules are missing. Available: json, os, struct, time, gc, machine, uasyncio. No pathlib, no typing, no dataclasses.

gc.collect() Is Critical

The ESP32 has approximately 200-250KB of usable heap. Large allocations fragment memory fast. Call gc.collect() after:

  • Loading a BMP or font into a bytearray.
  • Parsing a JSON API response.
  • Releasing sprite buffers.
  • Any operation that creates temporary bytearrays > 1KB.

The boot sequence calls gc.collect() after every major step. Follow this pattern.

ASCII Only

The 16x16 bitmap font covers printable ASCII (0x20 through 0x7F) only. Do not use non-ASCII characters in narrative text, world lore, or config string values that will be displayed on screen. The font renderer silently skips any character outside this range.

Memory Budget

Allocation Size Notes
Font spritesheet ~49 KB Loaded once at boot, held for lifetime. Largest single allocation.
Sprite idle frames (per sheet) ~9.2 KB 2 frames x 4608 bytes. Player + enemy = ~18.4 KB when both loaded.
Sprite action frame (on demand) ~4.6 KB Extracted from flash per use, freed after animation completes.
D20 sheet (all 6 frames) ~19.2 KB 6 frames x 3200 bytes. Loaded once.
Scene background BMP Streamed Never loaded entirely into RAM. Drawn strip-by-strip from flash.
AI response JSON ~1-3 KB Parsed, consumed, then freed.
Conversation history ~2-4 KB Capped at 32 messages in the History object.
Free heap target >50 KB Below this, things start failing.

Load the font early in boot before other allocations fragment the heap.

Integer Arithmetic

MicroPython's int and float have limited precision on ESP32. The codebase uses fixed-point math where needed (e.g., current_hp_x100 = old_hp * 100 in HPDrainAnim).


5. Coding Patterns

Pending Flags Pattern

Synchronous button callbacks cannot call await. The engine uses "pending" flags that are checked on the next async tick_engine() call:

# In a sync callback (called from input_handler):
self.engine._pending_save_slot = slot

# In the async tick (called from main loop):
async def tick_engine(self):
    if self._pending_save_slot is not None:
        slot = self._pending_save_slot
        self._pending_save_slot = None
        await async_save_game(slot, ...)

This pattern appears throughout main.py and engine.py for saves, dice rolls, and state transitions.

Atomic File Writes

All JSON writes go through storage.write_json(), which uses a three-step atomic write:

  1. Write to path + '.tmp'.
  2. Remove the old file at path.
  3. Rename .tmp to path.

If power is lost mid-write, the old file remains intact. The .tmp is the only thing at risk.

SPI Lock for Display Access

The ILI9342C display uses the SPI bus on M5Stack Basic v2.7. A shared async lock in storage.py must be acquired before any file read or display write:

from storage import spi_lock

async with spi_lock:
    # Safe to read files or write to display

The renderer acquires spi_lock before drawing. Storage async wrappers (async_read_json, async_write_json, async_save_game, async_load_game) acquire it internally.

RGB888 Color Format

UIFlow2's M5.Lcd methods accept RGB888 (24-bit) color values, even though the display hardware is RGB565. The API converts internally. All color constants in renderer.py are RGB888:

COL_BG = 0x0A0A1A
COL_PARCHMENT = 0xE8DCC8
COL_GOLD = 0xC8960A

Sprite pixel data stored in BMPs is RGB565. Use sprite._rgb565_to_rgb888() and sprite.rgb888_to_rgb565() for conversion when interfacing between BMP data and the LCD API.

Int Coercion for AI Values

AI responses may return stat deltas as strings or floats. Always coerce to int before applying:

delta_hp = int(response.get('hp_delta', 0))

This is handled in the response parser in ai_client.py and the delta application logic in world.py.

paths.asset() for All File Access

Never hardcode absolute paths like /flash/scenes/crossroads.bmp. Always use:

import paths
path = paths.asset('scenes/crossroads.bmp')

This returns the correct full path regardless of whether the device uses /flash, /, or an SD card fallback.

gc.collect() After Large Operations

Repeated throughout the codebase. Example from boot:

font_ok = self.font.load()
gc.collect()

6. Adding New Features

Adding a New Animation

  1. Create a new class in src/animator.py that extends Animation:
class MyAnim(Animation):
    def __init__(self, ...):
        super().__init__()
        # Store parameters

    def tick(self, dt_ms):
        self.elapsed_ms += dt_ms
        # Update state
        if self.elapsed_ms >= DURATION:
            self.done = True

    def render(self, lcd):
        # Draw to lcd
  1. Import it in engine.py and trigger it via self.animator.add(MyAnim(...)).
  2. If the animation should block input, add it to the is_blocking() check in the Animator class.

Adding a New Enemy Type

  1. Add the enemy ID string to ENEMY_IDS in src/world.py:
ENEMY_IDS = {
    "goblin", "skeleton", "wolf", "troll",
    "wizard", "dragon", "bandit", "slime",
    "my_new_enemy",  # <-- add here
}
  1. Create the spritesheet BMP: assets/sprites/my_new_enemy.bmp (288x48, RGB565, 6 frames of 48x48).
  2. Rebuild the firmware (./firmware/build.sh build) to include the new asset in the flash VFS. Alternatively, upload it manually via mpremote to /flash/sprites/my_new_enemy.bmp.
  3. The engine resolves enemy sprites by ID: paths.asset('sprites/<enemy_id>.bmp'). No code changes needed beyond step 1.

Adding a New Scene

  1. Add the scene ID string to SCENE_IDS in src/world.py:
SCENE_IDS = {
    "dungeon_01", "dungeon_02",
    # ...
    "my_new_scene",  # <-- add here
}
  1. Create the scene background BMP: assets/scenes/my_new_scene.bmp (320x168, RGB565).
  2. Rebuild firmware or upload manually to /flash/scenes/my_new_scene.bmp.
  3. The renderer resolves scene BMPs by ID: paths.asset('scenes/<scene_id>.bmp'). No additional code changes needed unless you want special behavior for this scene.

Adding a New Game State

The engine uses a string-based state machine (self.state). To add a new state:

  1. Define the state string constant (follow existing naming like 'idle', 'char_create_name', 'combat').
  2. Add handling in engine.py's tick_engine() method for the new state.
  3. Add any button handling in main.py's _on_button_press() / _on_button_long_press() if the state needs unique input handling.
  4. Add rendering logic in renderer.py if the state needs a unique screen layout.

7. Asset Requirements

All assets are 16-bit RGB565 BMP files with a standard 54-byte BMP v3 header. BMP row order is bottom-up by default (positive height in header). The code handles both bottom-up and top-down.

Scene Backgrounds

  • Dimensions: 320x168 pixels
  • Format: 16-bit RGB565, little-endian
  • File size: 54 (header) + 320 x 168 x 2 = 107,574 bytes
  • Location: assets/scenes/<scene_id>.bmp (repo) -> /flash/scenes/<scene_id>.bmp (device)
  • Notes: Streamed from flash in strips -- never loaded fully into RAM.

Character/Enemy Spritesheets

  • Dimensions: 288x48 pixels (6 frames of 48x48 each)
  • Format: 16-bit RGB565, little-endian
  • Transparency: Magenta (RGB565 value 0xF81F) is the transparent color
  • Frame order: idle_A(0) | idle_B(1) | action_A(2) | action_B(3) | hurt(4) | dead(5)
  • File size: 54 + 288 x 48 x 2 = 27,702 bytes
  • Location: assets/sprites/<entity_id>.bmp (repo) -> /flash/sprites/<entity_id>.bmp (device)
  • Notes: Idle frames (0, 1) are pre-cached in RAM (~9.2 KB per sheet). Action/hurt/death frames are read from flash on demand.

Font Spritesheet

  • Dimensions: 1536x16 pixels (96 glyphs of 16x16 each)
  • Format: 16-bit RGB565, little-endian
  • Coverage: ASCII 0x20 (space) through 0x7F
  • Glyph lookup: Character c starts at x = (ord(c) - 0x20) * 16
  • File size: 54 + 1536 x 16 x 2 = 49,206 bytes
  • Location: assets/ui/font_16.bmp (repo) -> /flash/ui/font_16.bmp (device)
  • Notes: Loaded entirely into RAM (~49 KB). This is the largest single allocation.

D20 Dice Spritesheet

  • Dimensions: 240x40 pixels (6 frames of 40x40 each)
  • Format: 16-bit RGB565, little-endian
  • File size: 54 + 240 x 40 x 2 = 19,254 bytes
  • Location: assets/ui/d20_sheet.bmp (repo) -> /flash/ui/d20_sheet.bmp (device)
  • Notes: All 6 frames pre-cached in RAM (~19.2 KB total).

Creating Assets

Use tools/convert_assets.py for PNG-to-BMP conversion:

# Convert a scene background
python tools/convert_assets.py bg input.png --size 320x168 --out assets/scenes/my_scene.bmp

# Convert a spritesheet with transparency
python tools/convert_assets.py sprite warrior.png --size 288x48 --transparent "#FF00FF" --out assets/sprites/warrior.bmp

# Convert the font spritesheet
python tools/convert_assets.py font font_16.png --glyph-size 16x16 --out assets/ui/font_16.bmp

# Batch convert all PNGs in a directory
python tools/convert_assets.py batch scenes_src/ --size 320x168 --out assets/scenes/

To regenerate the full built-in asset pack from procedural pixel art:

python tools/generate_builtin_assets.py

This writes all BMPs to the assets/ directory.

If creating assets manually, ensure:

  1. BMP version 3 header (54 bytes, BITMAPINFOHEADER).
  2. 16 bits per pixel, no compression (BI_RGB).
  3. Row data padded to 4-byte boundaries (the code handles stride calculation).
  4. Magenta transparency: in RGB565, this is 0xF81F. In RGB888, that is approximately (248, 0, 248).

8. Memory Budget

Allocation Size Notes
Font spritesheet ~49 KB Loaded once at boot. Largest single allocation. Must load before other allocations fragment the heap.
Sprite idle frames (per sheet) ~9.2 KB 2 frames x 4608 bytes each. Player + enemy = ~18.4 KB.
Sprite action frame (on demand) ~4.6 KB Single frame extracted from flash, freed after animation.
D20 sheet (all 6 frames) ~19.2 KB 6 frames x 3200 bytes. Loaded once at setup.
Scene background Streamed Drawn strip-by-strip from flash. Never fully in RAM.
AI response JSON ~1-3 KB Temporary. Call gc.collect() after parsing.
Conversation history ~2-4 KB Capped at 32 messages. Persisted in save files.
Free heap target >50 KB Below this, MemoryError becomes likely.

Boot sequence allocation order matters. The font (~49 KB contiguous) must be allocated early before fragmentation makes a large contiguous block unavailable.


9. Testing

Desktop Testing (No Device)

The codebase guards all hardware imports:

try:
    import M5
    from M5 import *
except ImportError:
    M5 = None

You can run individual modules on desktop Python 3.x for logic testing. Hardware calls are skipped when M5 is None, Speaker is None, etc.

For more thorough desktop testing, mock the M5 module:

# mock_m5.py
class MockLcd:
    def fillRect(self, *a): pass
    def drawPixel(self, *a): pass
    # ... stub all used methods

class M5:
    Lcd = MockLcd()
    @staticmethod
    def begin(): pass
    @staticmethod
    def update(): pass

Then run with the mock on the import path before main.py.

On-Device Testing

  1. Upload all files to the M5Stack (via deploy.py, build.sh, or Thonny).
  2. Open a serial monitor (115200 baud) to see print() output from the boot sequence and game loop.
  3. The boot sequence prints heap usage at each step -- watch for unexpectedly low values.
  4. Check /flash/logs/session.log for runtime log entries written by storage.append_log().

Asset Validation

Before deploying, verify BMPs have the correct dimensions and bit depth:

import struct
with open('my_asset.bmp', 'rb') as f:
    header = f.read(54)
    sig = header[0:2]                                    # b'BM'
    w = struct.unpack_from('<i', header, 18)[0]          # width
    h = struct.unpack_from('<i', header, 22)[0]          # height
    bpp = struct.unpack_from('<H', header, 28)[0]        # bits per pixel
    print(sig, w, h, bpp)                                # expect: b'BM' <W> <H> 16

10. Common Pitfalls

RGB565 vs RGB888 Confusion

BMP pixel data is RGB565. The UIFlow2 LCD API expects RGB888. If you pass raw BMP pixel values to lcd.drawPixel(), colors will be wrong. Always convert via sprite._rgb565_to_rgb888().

Forgetting gc.collect()

After loading a sprite, font, or parsing a large JSON response, call gc.collect(). Without it, fragmentation builds up and eventually a MemoryError crashes the game. The ESP32 does not garbage-collect automatically under memory pressure in all cases.

Blocking in Async Context

The main loop is async at 50Hz. Any blocking call (network I/O, large flash reads) without await will freeze the display and input. Use await asyncio.sleep_ms(0) to yield if doing long loops, and always use the async storage wrappers for file access during gameplay.

SPI Bus Conflicts

Reading from flash while the display is mid-draw can corrupt the display. Always acquire spi_lock before file reads or display writes. The async wrappers in storage.py handle this, but if you add new direct file reads, you must acquire the lock yourself.

Sync Callbacks Cannot Await

Button press callbacks from input_handler.py are synchronous. You cannot call await inside them. Use the pending flags pattern (set a flag, process it in the next tick_engine() call).

BMP Row Order

BMPs store rows bottom-up by default. Row 0 in the file is the bottom of the image. The sprite and font loaders flip rows during extraction. If you add new BMP-reading code, handle this correctly by checking the height field sign (positive = bottom-up, negative = top-down).

No f-strings, No .format() Safely

No f-strings. The .format() method works on some MicroPython builds but not all. Stick to + concatenation with str() casts. All existing code follows this pattern.

Non-ASCII Characters

The bitmap font only covers ASCII 0x20-0x7F. Non-ASCII characters (accented letters, emoji, special symbols) will not render. Keep all player-facing text within printable ASCII.

Font Must Load First

The font spritesheet is ~49 KB. If other allocations happen first, heap fragmentation may prevent a contiguous 49 KB allocation from succeeding. The boot sequence loads the font after hardware init but before sprite loading. Maintain this order.

API Response Validation

AI responses may contain unexpected types, missing keys, or out-of-range values. The engine and ai_client.py validate and clamp all values. When adding new fields to the AI prompt/response format, always provide defaults and type coercion.

Save File Corruption

Power loss during a write can corrupt save files. The atomic write pattern (write .tmp, remove old, rename) in storage.write_json() mitigates this. Never write save data directly -- always go through storage.write_json() or storage.async_save_game().