This document covers every configurable aspect of Pocket RPG: the config file, API integration, WiFi, audio, input tuning, display, save system, flash storage layout, world lore, and offline mode.
- config.json Reference
- Setting Up Configuration
- API Setup
- WiFi Configuration
- Audio Settings
- Input Tuning
- Display Settings
- Save Slots
- Flash Storage
- World Lore
- Offline Mode
Configuration is loaded from /flash/config.json at boot. If the file is missing or contains invalid JSON, all defaults are used silently. Unknown keys are ignored. Out-of-range numeric values are clamped to the nearest valid bound.
The paths module auto-detects the base path at startup, probing /flash first, then / as a fallback. All game code uses paths.asset() to resolve file locations, so the config file is always found at the correct base path.
| Key | Type | Default | Valid Range | Description |
|---|---|---|---|---|
wifi_ssid |
string | "" |
any string | WiFi network name (SSID) |
wifi_password |
string | "" |
any string | WiFi network password |
openrouter_api_key |
string | "" |
any string | OpenRouter API key for AI narrative generation |
text_speed_ms |
int | 40 |
10 -- 200 | Delay in milliseconds between each character in the typewriter text animation |
volume |
int | 3 |
0 -- 10 | Master volume level (0 = mute, 10 = maximum) |
tilt_sensitivity_deg |
int | 25 |
5 -- 90 | Tilt angle in degrees required to trigger a tilt event from the IMU |
shake_threshold_g |
float | 2.5 |
0.5 -- 10.0 | Acceleration magnitude (in g) required to register a shake gesture |
active_slot |
int | 0 |
0 -- 2 | Which save slot is used for saving and loading |
offline_mode |
bool | false |
true/false | Force offline mode (uses offline.json adventure tree instead of API) |
world_file |
string | "world.txt" |
any string | Filename of the world lore file (relative to flash root) |
ble_enabled |
bool | false |
true/false | Enable Bluetooth Low Energy (reserved for future use) |
Numeric keys are validated and clamped automatically:
- If a value cannot be coerced to its expected type (int or float), the default is used.
- If a value falls outside the valid range, it is clamped to the nearest bound. For example,
"volume": 15becomes10, and"volume": -3becomes0. - String and boolean keys are not validated beyond type checking.
{
"wifi_ssid": "MyNetwork",
"wifi_password": "secret123",
"openrouter_api_key": "sk-or-v1-abc123...",
"text_speed_ms": 40,
"volume": 5,
"tilt_sensitivity_deg": 25,
"shake_threshold_g": 2.5,
"active_slot": 0,
"offline_mode": false,
"world_file": "world.txt",
"ble_enabled": false
}There are two ways to get your config onto the device.
The interactive deployment tool prompts for WiFi, API key, and game settings, then uploads config.json to the device over serial:
python tools/deploy.py --config-only
This writes /flash/config.json via mpremote (or raw serial REPL as a fallback). You can also specify a port explicitly:
python tools/deploy.py --config-only --port COM3
If the upload fails, deploy.py saves the config locally as config.json in the project root and prints a manual upload command.
Edit config.json in a text editor, then upload it to the device:
python -m mpremote connect <PORT> cp config.json :/flash/config.json
Or use Thonny's file manager to drag the file to /flash/config.json on the device.
Pocket RPG uses the OpenRouter API to generate AI-driven narrative, choices, and game state changes each turn.
| Setting | Value |
|---|---|
| API URL | https://openrouter.ai/api/v1/chat/completions |
| Model | arcee-ai/trinity-large-preview:free |
| Request timeout | 20 seconds |
The model and endpoint are hardcoded in src/ai_client.py. To change them, edit the _MODEL and _API_URL constants at the top of that file.
Set openrouter_api_key in /flash/config.json to your OpenRouter API key. Free keys are available at https://openrouter.ai/keys. If the key is empty or missing, AI turns are skipped and a fallback response is used instead.
When running tools/deploy.py, you are prompted for the key interactively. If no key is provided, deploy.py automatically enables offline mode.
Every API request includes:
| Header | Value |
|---|---|
Authorization |
Bearer <your_api_key> |
Content-Type |
application/json |
HTTP-Referer |
https://pocket-rpg.local |
X-Title |
Pocket RPG |
The system prompt instructs the AI to respond as a dark-fantasy RPG narrator for the world of Vaeloria. It enforces:
- Maximum 57 characters across 3 lines of 19 characters each (to fit the 320x240 display).
- Responses must be valid JSON only -- no preamble, no markdown.
- JSON must include:
narrative,choices(3 items),scene_id,enemy_id,mood, andgame_state_delta.
The system prompt is assembled dynamically each turn by build_system_prompt(), which injects:
- Character stats (HP, MP, STR, DEX, INT, level, XP, inventory, location)
- World flags (persistent state markers set by the AI)
- World lore text (from
world.txt) - Dice result (if the player shook the device to roll a d20)
AI responses go through a 3-stage JSON parser:
- Direct
json.loads()on the raw content - Strip whitespace, then
json.loads() - Extract the substring from the first
{to the last}, thenjson.loads()
If all three stages fail, a hardcoded fallback response is used.
The History class maintains up to 16 user/assistant message pairs. When the limit is exceeded, the oldest pair is dropped. History is included in every API request and is persisted in save files.
After 3 consecutive API failures (MAX_API_FAILS), the engine switches to offline fallback mode automatically (using offline.json). The failure counter resets on any successful API call.
Set wifi_ssid and wifi_password in /flash/config.json, either manually or via python tools/deploy.py --config-only. WiFi connection is attempted once during the boot sequence.
| Setting | Value |
|---|---|
| Default timeout | 15,000 ms (15 seconds) |
| Poll interval | 100 ms |
The connection function polls network.WLAN.isconnected() every 100ms until either the connection succeeds or the timeout is reached.
- Activate the station interface (
STA_IF) - Disconnect any existing connection
- Call
connect(ssid, password) - Poll for up to 15 seconds
- On success: log IP address, set WiFi status indicator to green
- On failure: log error, set WiFi status indicator to red
If WiFi fails to connect (or wifi_ssid is empty), the game still boots normally. The status strip shows a red WiFi indicator. API calls will fail, and after 3 consecutive failures the engine switches to the offline adventure tree.
If offline_mode is set to true in config, the API is never called regardless of WiFi status.
A small circle in the top-right of the status strip shows connection state:
- Green: WiFi connected
- Red: WiFi not connected
The volume config key controls master volume on a scale of 0 to 10. This maps linearly to the hardware range of 0 to 255:
| Volume Level | Hardware Value | Description |
|---|---|---|
| 0 | 0 | Mute |
| 1 | 25 | Minimum audible |
| 5 | 128 | Mid-range (approximately) |
| 10 | 255 | Maximum |
The ambient/mood channel is additionally capped at 20% of maximum (hardware value 51) to keep background drones from overpowering SFX.
| Channel | Purpose | Volume |
|---|---|---|
| 0 | SFX (button clicks, combat, items) | Full master volume |
| 1 | Ambient mood (background drones) | Capped at 20% (hardware value 51) |
Each SFX is a list of (frequency_hz, duration_ms) tuples played sequentially. A frequency of 0 means silence (rest).
| SFX Name | Pattern | When Played |
|---|---|---|
SFX_BUTTON_CLICK |
1200 Hz, 20ms | Any button press |
SFX_CONFIRM |
800 Hz 60ms, 1200 Hz 80ms | Confirm actions, boot complete |
SFX_COMBAT_HIT |
150 Hz 80ms, 120 Hz 60ms | Enemy takes damage |
SFX_COMBAT_MISS |
900 Hz 40ms, 700 Hz 40ms | Attack misses |
SFX_PLAYER_HURT |
200-160 Hz descending with rest | Player takes damage |
SFX_ENEMY_DEATH |
600-200 Hz descending | Enemy defeated |
SFX_LEVEL_UP |
C5-E5-G5-C6 arpeggio | Level up |
SFX_DEATH |
300-100 Hz slow descent | Player death |
SFX_ITEM_GET |
1047 Hz 60ms, 1319 Hz 80ms | Item acquired |
SFX_SAVE |
600 Hz 40ms, 800 Hz 60ms | Game saved |
SFX_MENU_MOVE |
900 Hz, 15ms | Menu navigation |
SFX_DICE_ROLL |
400-1200 Hz rapid ascending | Shake to roll d20 |
The mood system provides ambient background drones that loop on channel 1. The AI sets the mood each turn via the mood field in its response.
| Mood | Tone(s) | On Duration | Off Duration | Description |
|---|---|---|---|---|
calm |
80 Hz | 1000 ms | 1000 ms | Slow, steady low drone |
tense |
65 Hz | 250 ms | 250 ms | Fast pulsing sub-bass |
combat |
55 Hz | 62 ms | 62 ms | Rapid heartbeat-like pulse |
triumph |
523, 659, 784 Hz | 200 ms | 0 ms | C-E-G arpeggio, continuous |
eerie |
45 Hz | 150-550 ms | 300-1200 ms | Irregular timing via PRNG |
silent |
-- | -- | -- | No ambient sound |
The eerie mood uses a linear congruential PRNG to vary on/off durations each cycle, creating an unsettling irregular rhythm.
- Buttons: A (GPIO39), B (GPIO38), C (GPIO37) -- active LOW
- IMU: MPU6886 6-axis accelerometer/gyroscope, I2C address 0x68
| Constant | Value | Description |
|---|---|---|
DEBOUNCE_MS |
50 ms | Minimum time between accepted button events |
LONG_PRESS_MS |
1200 ms | Hold duration to trigger a long press |
IMU_POLL_INTERVAL |
Every 3rd tick | IMU reads at ~17 Hz (main loop runs at 50 Hz) |
SHAKE_SUSTAIN_MS |
150 ms | Duration acceleration must exceed threshold to register a shake |
The tilt_sensitivity_deg config key (default: 25, range: 5--90) controls how far the device must be tilted before a tilt event registers.
- Pitch angle is calculated as:
atan2(ay, sqrt(ax^2 + az^2)) - Positive pitch (tilted toward you) =
down - Negative pitch (tilted away from you) =
up - If the absolute pitch is below the threshold, tilt direction is
None
Lower values make tilt more sensitive. Higher values require a more deliberate tilt.
The shake_threshold_g config key (default: 2.5, range: 0.5--10.0) controls the acceleration magnitude required to begin tracking a shake.
- Acceleration magnitude:
sqrt(ax^2 + ay^2 + az^2) - The magnitude must exceed the threshold continuously for
SHAKE_SUSTAIN_MS(150 ms) - Once fired, the shake callback does not fire again until acceleration drops below the threshold and then exceeds it again
Lower values make shake easier to trigger. A value of 1.0 is near resting gravity and will produce false positives. Values above 5.0 require vigorous shaking.
| Context | Button A | Button B | Button C |
|---|---|---|---|
| Gameplay | Choice 1 | Choice 2 | Choice 3 |
| Gameplay (long press) | Show stats | Manual save | Show inventory |
| Name entry | Previous character | Accept character | Next character |
| Name entry (long press B) | -- | Finish name | -- |
| Class selection | Previous class | Select class | Next class |
| Main menu | New Game | Continue | Settings |
| Settings | Change value | Save settings | Next setting |
Shaking the device during gameplay triggers a d20 dice roll. The result (1--20) is injected into the next AI prompt as "DICE: player rolled d20, result: N". A d20 spin animation plays on screen if the d20 spritesheet is loaded.
The text_speed_ms config key (default: 40, range: 10--200) controls the typewriter animation speed in the dialog panel. This is the delay in milliseconds between each character appearing.
| Value | Effect |
|---|---|
| 10 | Very fast, nearly instant |
| 40 | Default, readable pace |
| 100 | Slow, deliberate reveal |
| 200 | Very slow |
The 320x240 ILI9342C display is divided into fixed zones:
| Zone | Y Range | Height | Content |
|---|---|---|---|
| Scene | 0 -- 167 | 168 px | Background BMP + sprites |
| Status Strip | 168 -- 179 | 12 px | HP/MP bars, level, WiFi icon |
| Gold Divider | 180 | 1 px | Separator line |
| Dialog Panel | 181 -- 228 | 48 px | Narrative text (3 lines x 19 chars) |
| Choice Divider | 229 | 1 px | Separator line |
| Choice Bar | 230 -- 239 | 10 px | Three button prompts: [A] [B] [C] |
| Sprite | X | Y | Size |
|---|---|---|---|
| Player | 8 | 100 | 48x48 |
| Enemy | 240 | 100 | 48x48 |
The game supports 3 save slots (0, 1, 2). The active_slot config key determines which slot is used.
Save files are stored at /flash/saves/slot_0.json, /flash/saves/slot_1.json, and /flash/saves/slot_2.json. The saves/ directory is created automatically at boot if it does not exist.
Each save file contains:
| Field | Type | Description |
|---|---|---|
version |
string | Save format version (currently "2.0") |
saved_at |
int | Unix timestamp of when the save was made |
character |
dict | Full character state (name, class, HP, MP, stats, inventory, etc.) |
world_flags |
dict | String-to-bool map of persistent flags set by the AI |
history |
list | Conversation history (list of {role, content} message dicts) |
current_scene_id |
string | Active scene background ID |
current_enemy_id |
string or null | Current enemy, if any |
current_mood |
string | Active ambient mood |
Save files use atomic writes to prevent corruption from power loss:
- Write to
slot_N.json.tmp - Remove old
slot_N.json - Rename
.tmptoslot_N.json
If power is lost mid-write, the old save file remains intact.
- Manual save: Long-press button B during gameplay. Saves to the
active_slot. - Auto-save: The engine saves automatically after significant events (level up, death/respawn).
On the main menu, pressing [B] Continue loads from the active_slot. The load function validates that all required keys are present (character, world_flags, history, current_scene_id, current_mood). Character data is further validated for required fields (name, char_class, hp, max_hp, mp, max_mp, str_stat, dex_stat, int_stat, level, xp, xp_next, inventory, location). If any key is missing, the save is treated as corrupt and ignored. History is capped at 32 messages on load to limit RAM pressure.
All game files are stored in the ESP32's internal flash. The 16MB flash chip is partitioned as 3MB for the application (firmware + frozen Python) and 12.9MB for a VFS FAT filesystem. No SD card is required.
/flash/
config.json -- Configuration file (WiFi, API key, settings)
world.txt -- World lore text
offline.json -- Offline adventure tree
scenes/ -- Scene background BMPs (320x168, RGB565)
crossroads.bmp
dungeon_01.bmp
dungeon_02.bmp
forest_day.bmp
forest_night.bmp
ruins.bmp
tavern.bmp
cave.bmp
castle.bmp
combat_dungeon.bmp
combat_forest.bmp
death.bmp
levelup.bmp
sprites/ -- Character and enemy spritesheets (288x48, RGB565)
warrior.bmp
mage.bmp
rogue.bmp
ranger.bmp
goblin.bmp
skeleton.bmp
wolf.bmp
troll.bmp
wizard.bmp
dragon.bmp
bandit.bmp
slime.bmp
ui/ -- UI overlay images
splash.bmp -- Boot splash screen (320x240)
status_strip.bmp -- Status bar background (320x12)
dialog_panel.bmp -- Dialog panel background (320x48)
choice_bar.bmp -- Choice bar background (320x10)
font_16.bmp -- Bitmap font spritesheet (1536x16)
d20_sheet.bmp -- D20 dice animation sheet (240x40)
saves/ -- Auto-created at boot
slot_0.json
slot_1.json
slot_2.json
logs/ -- Auto-created at boot
session.log
The saves/ and logs/ directories are created automatically by storage.init_storage() if they do not exist.
The paths module (src/paths.py) auto-detects the base path at startup. It probes directories in this order:
/flash-- standard ESP32 VFS mount/-- some firmware configurations mount flash at root/sdand/sdcard-- legacy SD card fallback
The first path containing a scenes/ subdirectory is chosen. All other modules use paths.asset('relative/path') to build full paths, so no module contains hardcoded absolute paths.
All BMP files must be RGB565, 16-bit, little-endian, BMP v3 header (54-byte header).
| Asset Type | Dimensions | Notes |
|---|---|---|
| Scene backgrounds | 320x168 | Fills the scene zone (y=0 to y=167) |
| Character/enemy spritesheets | 288x48 | 6 frames of 48x48, horizontal strip |
| D20 spritesheet | 240x40 | 6 frames of 40x40, horizontal strip |
| Splash screen | 320x240 | Full-screen boot image |
| Status strip | 320x12 | Background for the HP/MP bar area |
| Dialog panel | 320x48 | Background for the narrative text area |
| Choice bar | 320x10 | Background for the button prompt area |
| Font spritesheet | 1536x16 | 96 glyphs of 16x16 each in a single row |
The display (ILI9342C) uses the SPI bus on M5Stack Basic v2.7. A shared spi_lock (asyncio.Lock) in storage.py must be acquired before any file read or display write operation. All storage and renderer functions acquire this lock. Concurrent access without the lock corrupts the display.
The file specified by world_file (default: world.txt) is read from flash and injected into every AI system prompt under the heading WORLD LORE:.
This gives the AI context about the game world so its narrative responses are consistent with the setting.
The shipped world.txt describes Vaeloria -- a dark-fantasy land of crumbling ruins, haunted forests, and fading civilization. Key locations include the Crossroads, Hearthstone Tavern, goblin-infested caves, and ashen dragon peaks.
- The world lore text is appended to the system prompt in full, so keep it concise.
- The AI system prompt already specifies a 57-character narrative limit. Longer world lore does not increase the response length, but it does increase the token count of each API request.
- Aim for approximately 150 words or fewer for the world lore file to keep API costs and latency low.
To create a custom world:
- Write your lore in a plain text file (UTF-8, no special formatting needed).
- Upload it to the device (for example,
/flash/my_world.txt). - Set
"world_file": "my_world.txt"in/flash/config.json.
The AI will use your custom lore to flavor its narrative responses. You do not need to change any code.
When the API is unavailable (no WiFi, no API key, or 3+ consecutive API failures), the game falls back to a pre-authored branching adventure tree loaded from /flash/offline.json.
You can also force offline mode by setting "offline_mode": true in config.
The offline adventure is a JSON array of node objects. Each node has the same fields the AI would normally return:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique node identifier (e.g. "n01") |
narrative |
string | yes | Display text (use \n for line breaks, max 3 lines of 19 chars) |
scene_id |
string | yes | Background scene to display |
enemy_id |
string or null | no | Enemy sprite to show (null for no enemy) |
mood |
string | yes | Ambient mood: calm, tense, combat, triumph, eerie, or silent |
choices |
array | yes | List of choice objects |
Each choice object:
| Field | Type | Description |
|---|---|---|
icon |
string | Icon identifier for the choice |
verb |
string | Short action verb (max 8 characters) displayed in the choice bar |
next |
string | ID of the node to navigate to when this choice is selected |
{
"id": "n04",
"narrative": "A snarling wolf\nleaps from the\nunderbrush at you!",
"scene_id": "combat_forest",
"enemy_id": "wolf",
"mood": "combat",
"choices": [
{"icon": "sword", "verb": "Fight", "next": "n10"},
{"icon": "shield", "verb": "Defend", "next": "n10"},
{"icon": "boot", "verb": "Flee", "next": "n01"}
]
}- The first node in the array is the starting node.
- When the player selects a choice, the engine looks up the
nextID and navigates to that node. - If a
nextID does not exist in the tree, navigation wraps back to the starting node. - If the current node somehow becomes invalid, it also resets to the starting node.
- Offline nodes do not produce
game_state_delta, so HP/MP/XP do not change during offline play.
- Create a JSON array of node objects following the schema above.
- Ensure every
nextreference points to a valididin the array. - The first node in the array is the entry point.
- Use only valid
scene_idvalues:dungeon_01,dungeon_02,forest_day,forest_night,ruins,tavern,cave,castle,crossroads,combat_dungeon,combat_forest,death,levelup. - Use only valid
enemy_idvalues:goblin,skeleton,wolf,troll,wizard,dragon,bandit,slime, ornullfor no enemy. - Use only valid
moodvalues:calm,tense,combat,triumph,eerie,silent. - Keep narrative text within 3 lines of 19 characters each (57 characters total).
- Upload as
/flash/offline.json.
The shipped offline.json contains 15 nodes forming a looping adventure through the Crossroads, forest, ruins, caves, dungeons, and tavern.
crossroads dungeon_01 dungeon_02
forest_day forest_night ruins
tavern cave castle
combat_dungeon combat_forest death
levelup
goblin skeleton wolf troll
wizard dragon bandit slime
calm tense combat triumph eerie silent