20 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
Aventures En Egipte (AEE) — a retro-style 2D game written in C++ using SDL3. The game uses a software-rendered 8-bit paletted graphics engine (320x200, 256 colors), custom audio (JailAudio), and GIF-based assets. The codebase and commit messages are in Valencian/Catalan.
Build
# Linux
cmake -B build
cmake --build build
# Windows (MinGW)
cmake -B build -G "MinGW Makefiles"
cmake --build build
Dependencies: SDL3. Uses CMake (minimum 3.10) with C++20. SPIR-V shaders compiled automatically if glslc is available; precompiled headers used as fallback.
The executable is output to the project root. The data/ folder must be in the working directory at runtime.
Architecture
New Rules (Modernization Phase)
The old "Golden Rule: Do Not Touch Gameplay" has been revoked. The original C-style code (jail engine + gameplay modules) is now a modernization target, not a sacred zone. The parallel-overlay approach has reached its ceiling: fades and cinematics are still blocking loops, audio relies on an async SDL_AddTimer, and the emulator-style game thread blocking at publishFrame is incompatible with an emscripten port.
The five current objectives are:
- Idiomatic C++: RAII,
std::vector/std::string/std::optional, classes with real constructors/destructors. No more rawmalloc/freein structs. - Zero blocking events: no
while (...) { poll; }, noSDL_Delayinside gameplay, nocv.wait()inpublishFrame. Every subsystem must be able to advance in a single tick call. - Time-based: animations, cinematics and fades measured in milliseconds, not frames.
JG_ShouldUpdate()as gameplay gate is on its way out. - Overlay integrated: overlay stops being a post-game layer painted by Director — it becomes part of the same render pass the game tick produces.
- SDL3 callbacks: main loop handed over to
SDL_AppInit/SDL_AppIterate/SDL_AppEvent/SDL_AppQuit, single-threaded, compatible with emscripten.
Iron rule: zero gameplay regressions. Each phase of the modernization must leave the game playing identically — same feel, same timings, same collisions, same scoring. See glittery-sprouting-pumpkin.md for the phased plan.
The current emulator-thread architecture (Director + game thread + publishFrame mutex/cv) is transitional. It will be dismantled in Phase 5 and replaced by a single-threaded SDL_AppIterate loop in Phase 7.
Modernization Targets
Invariants to preserve (touch these and you broke the game):
- Gameplay feel, movement speed, enemy AI behavior
- Collision detection, scoring, lives, level progression
- Visible animation cadence (once translated to ms, must look identical)
- Difficulty curves and cinematic timings
- Cheat codes (
reviu,alone,obert) — currently broken, should be restored - Original palettes, fades, music cues
Free to change (internal representation):
- Data structures (structs → classes with RAII)
- Ownership (raw pointers →
std::unique_ptr/std::vector/std::string) - Timing representation (frame counters → ms accumulators)
- Threading model (game thread → single-threaded state machine)
- Global state (the old
info::namespace is now aninlinesingletoninfo::ctxof typeGameContext; access isinfo::ctx.Xinstead ofinfo::X. Can be reset withinfo::ctx.reset()) - API shapes of jail subsystems (as long as callers are updated consistently)
Boundary: Original vs New Code
| Path | Owner | Rule |
|---|---|---|
source/core/jail/ |
Legacy engine, modernization target | Free to modify with care — preserve external behavior |
source/game/*.cpp/hpp |
Legacy gameplay, modernization target | Free to modify with care — preserve gameplay invariants |
source/core/rendering/ |
New presentation layer | Free to modify |
source/core/input/ |
New input layer | Free to modify |
source/utils/ |
New utilities | Free to modify |
source/game/options,defines,defaults |
New config system | Free to modify |
data/*.gif, *.ogg |
Original assets | Do not modify — assets remain untouchable |
data/fonts/, data/ui/, data/shaders/ |
New assets | Free to modify |
Legacy "Jail" Engine (source/core/jail/) — modernization target
Flat C-style APIs (no classes), prefixed by subsystem. Being progressively converted to idiomatic C++ (see Phase 1 of the plan). External API names are kept stable during the transition to avoid churning call sites.
- JG (
jgame) — Game loop timing: init/finalize, fixed-timestep update viaJG_ShouldUpdate() - JD8 (
jdraw8) — 8-bit paletted software renderer. 320x200 screen buffer (JD8_Surface=Uint8*), palette-indexed blitting with color-key transparency, fade effects.JD8_Flip()converts palette→ARGB and delegates toScreen::present() - JA (
jail_audio) — Custom audio mixing using SDL3 audio streams (OGG via stb_vorbis, WAV) - JI (
jinput) — Input: keyboard state polling, key debouncing, cheat code detection. Filters GUI keys from game, callsGlobalInputs::handle()andMouse::updateCursorVisibility()each update - JF (
jfile) — File I/O: filesystem folder mode (data/) or packed resource file (.jrf). Config folder at~/.config/jailgames/aee/
System Layer (source/core/system/)
- Director (
director.hpp/cpp) — Orchestrator singleton. Owns main thread. Launches game thread that runsModuleGame/ModuleSequence::Go(). Emulator-style architecture: Director runs at ~60 FPS independently, polls SDL events, updates overlay, presents frames. Game thread blocks atJD8_Flip()→Director::publishFrame()until Director consumes the frame. Director is non-blocking: if no new frame is available, it re-presents the last known game frame with fresh overlay on top
Presentation Layer (source/core/rendering/)
- Screen (
screen.hpp/cpp) — Singleton. Manages SDL_Window, SDL_Renderer, SDL_Texture. Dual rendering path: SDL3GPU with shaders (primary) or SDL_Renderer fallback. Handles fullscreen, zoom, aspect ratio 4:3, integer scaling, VSync. Counts FPS and updates render info segments - Overlay (
overlay.hpp/cpp) — Paints directly on the ARGB pixel buffer before presentation. Handles notifications (slide-in animation), animated render info (4 independent segments with per-segment anim + vertical slide state machine), persistent PAUSA indicator, and double-ESC-to-quit logic - Text (
text.hpp/cpp) — Bitmap font renderer. Loads.fnt+.gifpairs, renders UTF-8 glyphs directly onUint32*ARGB buffer. SupportsdrawClipped(x, y, text, color, clip_xmin, clip_xmax, clip_ymin, clip_ymax)for per-pixel 2D clipping (used by menu transitions) - Menu (
menu.hpp/cpp) — Floating options menu with stack-based page navigation (root → VIDEO/AUDIO/CONTROLS). Uses ItemKind enum: Toggle/Cycle/IntRange/Submenu/KeyBind. Features: vertical expand animation on open (outQuad), horizontal slide + height interpolation on page transitions (forward/backward direction), key capture mode for remapping. Callbacks delegate toScreen::*/Overlay::*/Options::applyAudio()to avoid duplication - SDL3GPUShader (
sdl3gpu/) — GPU shader backend (Vulkan/Metal). PostFX and CRT-Pi shaders with presets, supersampling (3×/6×/9×), Lanczos downscaling. Supports 4:3 aspect ratio stretch fused into the upscale pass to avoid artifacts
Input Layer (source/core/input/)
- GlobalInputs (
global_inputs.hpp/cpp) — Maps configurable function keys to presentation actions. Uses debounce. Returns whether a key was consumed (to suppress from game layer) - Mouse (
mouse.hpp/cpp) — Auto-hides cursor after 3 seconds of inactivity - Gamepad (
gamepad.hpp/cpp) — First-gamepad support with hot-plug. Poll-based each frame: D-pad/left stick (deadzone 12000) → virtual arrow keys for game movement; A/B buttons, Start, Back translate to synthetic SDL key events (F12/ESC/Enter/Backspace) when menu is open, so Director handles them exactly like keyboard. Loads extra mappings fromgamecontrollerdb.txt(next to the executable) at init viaSDL_AddGamepadMappingsFromFile, extending SDL's built-in controller database - KeyRemap (
key_remap.hpp/cpp) — Each frame, readsOptions::keys_game.*and mirrors physical keyboard state to virtual standard scancodes (SDL_SCANCODE_UP/DOWN/LEFT/RIGHT). Allows full movement key remapping without touching hardcoded game code inprota.cpp/mapa.cpp
Locale Layer (source/core/locale/)
- Locale (
locale.hpp/cpp) — Flat key → string map loaded from YAML at boot. Keys use dot notation (menu.items.zoom,notifications.pause). Returns the key itself when missing (visible fallback for debugging). Strings live in data/locale/ca.yaml (Valencian, default). Designed for future multilanguage support
Configuration System (source/game/)
Follows the pattern from jaildoctors_dilemma, persists to YAML:
- defines.hpp — Game constants:
Texts::WINDOW_TITLE,Texts::VERSION,GameScreen::WIDTH/HEIGHT - defaults.hpp — Default values:
Defaults::KeysGUI,Defaults::KeysGame,Defaults::Video,Defaults::Audio,Defaults::Window,Defaults::Game - options.hpp/cpp —
Optionsnamespace with inline globals and YAML load/save. Structs:KeysGUI,KeysGame,Video,RenderInfo,Audio,Window,Game,PostFXPreset,CrtPiPreset
Utilities (source/utils/)
- utils.hpp/cpp —
toLower()and other helpers - easing.hpp/cpp — Easing functions for animations:
linear,outQuad,inQuad,inOutQuad,outCubic,inCubic,lerp,lerpInt. Used by Menu transitions, render info slide, and segment animations
Function Key Map
| Key | Action |
|---|---|
| F1 | Decrease window zoom |
| F2 | Increase window zoom |
| F3 | Toggle fullscreen |
| F4 | Toggle shaders on/off |
| F5 | Toggle aspect ratio (square pixels ↔ 4:3 CRT) |
| F6 | Toggle supersampling |
| F7 | Cycle shader type (PostFX ↔ CRT-Pi) |
| F8 | Cycle shader presets |
| F9 | Toggle stretch filter (nearest ↔ linear) |
| F10 | Cycle render info (off → top → bottom → off) |
| F11 | Toggle pause (blocks game thread at publishFrame + JA_PauseMusic/JA_ResumeMusic) |
| F12 | Toggle floating options menu |
| ESC | Double-press to quit (with overlay notification) / close menu if open |
| Backspace | Go up one menu level / close menu if at root |
| ↑↓←→ / Enter | Menu navigation |
All key bindings are configurable via Options::keys_gui and stored in config.yaml (section controls: with SDL scancode names). Game movement keys (Options::keys_game.up/down/left/right) can be remapped via the CONTROLS submenu — the KeyRemap module mirrors custom physical keys to virtual standard scancodes so hardcoded game code keeps working.
Threading Model (Emulator Architecture — transitional)
⚠️ This architecture is transitional. It will be dismantled in Phase 5 of the modernization plan (the game thread +
publishFramemutex/cv disappear) and replaced by a single-threadedSDL_AppIterateloop in Phase 7. Document changes here when that happens.
Main thread (Director) Game thread (ModuleGame/Sequence::Go())
──────────────────── ────────────────────────────────────
loop at ~60 FPS { loop {
SDL_PollEvent() ... game logic ...
GlobalInputs, Mouse JD8_Flip():
if new_frame_available: palette→ARGB in pixel_data
copy to game_frame publishFrame(pixel_data) ⏸
signal → ────────────────────→ (blocks until Director consumes)
copy game_frame → present_buffer ←──── signal_consumed
Overlay::render(present_buffer) continue game loop
Screen::present(present_buffer) }
SDL_Delay to hit 60fps
}
Key points:
- Director is NON-BLOCKING: if no new frame, re-presents the last one with fresh overlay
- Double buffer:
game_frame(untouched copy from game) +presentation_buffer(regenerated with overlay each frame) - Game thread pauses at
JD8_Flip()waiting for Director — natural sync point - SDL events processed ONLY on main thread (SDL requirement)
JI_Update()no longer polls events — reads Director's state
Rendering Pipeline (inside Screen::present)
Screen::present(pixel_data):
1. FPS count + render info text update
2. IF GPU + shaders enabled:
- uploadPixels → scene_texture (320×200)
- [IF 4:3] stretch pass fused with upscale: scene → scaled_texture (W×factor, H×factor×1.2)
- [IF SS] upscale pass: scene → scaled_texture (W×factor, H×factor)
- PostFX or CRT-Pi shader → swapchain (with viewport letterboxing)
3. ELSE IF GPU without shaders:
- uploadPixels → clean render → swapchain
4. ELSE (fallback):
- SDL_UpdateTexture → SDL_RenderPresent
Pixel Format
JD8_Flip produces ABGR byte order: 0xFF000000 + R + (G<<8) + (B<<16). SDL texture uses SDL_PIXELFORMAT_ABGR8888. GPU textures use SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM (same byte layout on little-endian). Overlay colors are ABGR format.
Persistence Files
| File | Content |
|---|---|
~/.config/jailgames/aee/config.yaml |
Main config: video (incl. vsync, integer_scale), audio (incl. enabled master + music_* + sound_*), window, render_info (incl. show_time), game, shader selection, controls (movement keys + menu_toggle + pause_toggle) |
~/.config/jailgames/aee/postfx.yaml |
PostFX shader presets (6 defaults: CRT, NTSC, CURVED, SCANLINES, SUBTLE, CRT LIVE) |
~/.config/jailgames/aee/crtpi.yaml |
CRT-Pi shader presets (4 defaults: DEFAULT, CURVED, SHARP, MINIMAL) |
External Libraries (source/external/)
gif.h— Header-only GIF decoder. Cannot be included from more than one .cpp (no include guards on functions). Other files useexterndeclarations forLoadGif()stb_vorbis.h— stb single-header OGG decoderfkyaml_node.hpp— Header-only YAML parser (fkYAML v0.4.2)
Data Assets (data/)
*.gif,*.ogg— Original game assets (do not modify)fonts/8bithud.fnt + .gif— Bitmap font for overlay (8×8, 124 glyphs, UTF-8 with accents)shaders/— GLSL sources:postfx.vert,postfx.frag,upscale.frag,downscale.frag,crtpi_frag.glsllocale/ca.yaml— UI strings in Valencian (menu titles/items/values, notifications). Edit freely; reload at restartui/— Reserved for future UI graphics
Known Issues & Technical Debt
- gif.h cannot be included twice: Functions are not
staticorinline, causing multiple definition errors. Text class usesexternforward declarations as workaround - Cheats are broken (
reviu,alone,obert):JI_CheatActivatedin jinput.cpp:46 comparesSDL_Scancodevalues (e.g.SDL_SCANCODE_R=21) against ASCII chars ('r'=114). They never match. Regression from SDL3 migration. Now fixable — scheduled for Phase 1 of modernization since jail is no longer off-limits. - No sound effects in game: Game code never calls
JA_PlaySound*/JA_LoadSound— only music viaJA_PlayMusic/JA_FadeOutMusic. The SONS and VOL SONS menu items control volume of an empty channel pool. Infrastructure ready for future SFX. - Raw
malloc/freein gameplay structs:Sprite/EntitatusemallocforFrame[]andAnimacio[];jfile.cppuses a globalscratch[255]buffer (UB under concurrent calls);jail_audio.cppmixesnew/malloc/SDL_malloc. Scheduled for Phase 1 (RAII pass). - Blocking loops in cinematics and fades:
ModuleSequence::doIntro()has 15+while(!JG_ShouldUpdate())spin-waits;JD8_FadeOut/JD8_FadeToPalrun 32 internal iterations callingJD8_Flip. Incompatible withSDL_AppIterate. Scheduled for Phase 2 (state-machine refactor). SDL_AddTimerin audio:JA_Initregisters a 30ms timer callback for mixing/fade update. Incompatible with emscripten single-threaded model. Scheduled for Phase 3 (manualJA_Update(delta_ms)driven from Director).- Game thread +
publishFramemutex/cv: the emulator-style architecture is only tenable while native. Incompatible withSDL_AppIterate. Scheduled for Phase 5 (single-threaded state machine).
Pending / Ideas for Later
- Gamepad: map Y button (North) to
Pkey for Pepe character selection at title screen (only input path not covered by current mapping). - Menu items for game: habitacio_inicial, piramide_inicial, vides (already in
Options::game, not exposed). - Multi-language: add
data/locale/es.yaml/en.yamland a language selector item in menu.Locale::load()already handles arbitrary files. - Game keys remap: currently only UP/DOWN/LEFT/RIGHT +
menu_toggle. Could add remap forpause_toggle,keys_game.exit(needs care with ESC double-press flow). - Notification persistence: notifications clear on each new one (
showNotificationdoesnotifications_.clear()). Could queue instead. - FPS counter jitter: time segment width changes per frame (100 Hz centi updates) causes ~1-2 px horizontal jitter in centered layout. Could lock to max-width or use monospace digits.
- Notification messages partially hardcoded: overlay/global_inputs/director now use Locale, but the window title (
Texts::WINDOW_TITLE) and some game-layer strings remain hardcoded.
Previously Fixed (kept for reference)
- ESC double-press: Fixed by intercepting KEY_DOWN in Director, setting atomic
esc_blocked_immediately.JI_KeyPressed(ESCAPE)consults this flag. No race condition possible because Director's flag wins before game polls - Overlay freeze during intros: Fixed by threading model. Director runs independently at 60 FPS regardless of game delays. Double buffer avoids overlay smearing on re-presented frames
- ESC-closes-menu then closes game: When menu closes via ESC,
esc_swallow_until_release_flag blocksJI_KeyPressed(ESC)until physical key release. Cleared on ESCKEY_UP - Backspace-closes-menu skipping cinematics:
menu_keys_held_[SDL_SCANCODE_COUNT]array tracks scancodes consumed by menu on KEY_DOWN; matching KEY_UP is swallowed so game polling (JI_AnyKey) doesn't see them. Also covers F12, Backspace, cursor keys, capture-mode keys - Key remap not working after Backspace-close:
JI_SetInputBlocked(false)now also called whenMenu::handleKeycauses menu to close via Backspace (previously only cleared on ESC/F12 close paths)
Virtual Keystates (OR'd sources)
jinput.cpp maintains virtual_keystates[JI_VSRC_COUNT][SDL_SCANCODE_COUNT] with two sources: JI_VSRC_GAMEPAD (from Gamepad::update) and JI_VSRC_REMAP (from KeyRemap::update). JI_KeyPressed returns true if either physical keystate OR any virtual source has the key set. JI_SetInputBlocked still overrides everything (menu open = input suppressed). JI_SetVirtualKey(scancode, source, pressed) is the write API — sources are independent so gamepad and keymap can't clobber each other.
Variable FPS Cap
Director loop uses FRAME_MS_VSYNC = 16 (60 FPS) or FRAME_MS_NO_VSYNC = 4 (~250 FPS) depending on Options::video.vsync. Selected each iteration, so toggling VSync from the menu updates cap immediately. The GPU swapchain is also reconfigured via shader_backend_->setVSync() (IMMEDIATE/MAILBOX vs VSYNC present modes).
Main Entry (source/main.cpp)
Init order: file_setconfigfolder → Options::load → Locale::load("locale/ca.yaml") → Options::loadPostFX/CrtPi → JG_Init → Screen::init → JD8_Init → JA_Init → Options::applyAudio() → Overlay::init → Menu::init → Director::init (also calls Gamepad::init()) → Director::run() (blocks until quit). Shutdown: Options::save → Director::destroy → Menu::destroy → Overlay::destroy → JA_Quit → JD8_Quit → Screen::destroy → JG_Finalize.
The state machine (alternating ModuleSequence state=1 and ModuleGame state=0) now lives inside Director::gameThreadFunc(), running on the game thread.