26 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 docs/scenes-migration-plan.md for the phased plan.
Migration Status (2026-04-16)
Completat. Totes les fases del pla original (0–7) i la migració scenes:: (Steps 0–10) estan fetes, ModuleGame és una scenes::Scene tick-based, el cooperative fiber s'ha eliminat, i el build emscripten/WASM arrenca i es publica a maverick.
Arquitectura actual:
- Un sol thread (Director). Main loop via SDL3 Callback API (
SDL_MAIN_USE_CALLBACKS):SDL_AppInit/Iterate/Event/Quita main.cpp. Director::iterate()posseeix l'estat d'escena (current_scene_,game_state_) i fa input → tick de l'escena →JD8_Flip(sense yield, només converteixscreen→pixel_data) → overlay → present. Tot en línia recta, zero fibers, zero mutex.- Totes les escenes (inclòs
ModuleGame) implementenscenes::SceneambonEnter/tick(delta_ms)/done/nextState. ModuleSequence(el vell dispatcher) eliminat. Despatxa viagame_state_ == 0(gameplay →ModuleGame) ogame_state_ == 1(cinemàtica →SceneRegistry::tryCreate(num_piramide)).
Escenes migrades (totes registrades a Director::init via SceneRegistry):
MortScene(state 100) ·BannerScene(2..5) ·MenuScene(0) ·SlidesScene(1, 7)CreditsScene(8) ·SecretaScene(6) ·IntroNewLogoScene(255,use_new_logo=true)IntroScene(255,use_new_logo=false) ·IntroSpritesScene(sub-escena de les dues intros)
Files d'Options::game exposats per a tests ràpids (persistits a config.yaml):
piramide_inicial, habitacio_inicial, vides, diamants_inicial, diners_inicial, use_new_logo, show_title_credits.
La capa scenes:: (source/scenes/): scene.hpp (interfície), scene_registry.hpp/.cpp, timeline, sprite_mover, frame_animator, palette_fade, surface_handle, scene_utils (playMusic). Pures tick-based, zero while, zero JG_ShouldUpdate.
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) - 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, únic thread del runtime. Posseeix l'estat d'escena (current_scene_: unique_ptr<Scene>,game_state_,last_tick_ms_) directament com a members.iterate()fa: poll events (viaSDL_AppEvent) → input (Gamepad/KeyRemap/GlobalInputs/Mouse) →JA_Update→ transició d'escena sidone()→scene->tick(delta_ms)→JD8_Flip(converteixscreen→pixel_data) → overlay → present →SDL_Delayal frame target. Dispatcher:game_state_ == 0→new ModuleGame,game_state_ == 1→SceneRegistry::tryCreate(info::ctx.num_piramide)(amb redirectnum_piramide == 6 && diners < 200 → 7replicant el vellModuleSequence::Go).
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 (Director skips scene->tick() + 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.
Execution Model (Single-threaded, Scene-based)
Zero threads, zero fibers, zero mutex. Main loop via SDL3 Callback API (SDL_MAIN_USE_CALLBACKS) a main.cpp. Cada frame entra pel SDL_AppIterate → Director::iterate():
SDL_AppIterate → Director::iterate() {
if (quit_requested_) { scene.reset(); return false; }
if (!context_initialized_) initGameContext();
Gamepad/KeyRemap/GlobalInputs/Mouse::update
JA_Update() ← audio pump
if (!paused_) {
if (scene && (scene->done() || JG_Quitting()))
game_state_ = scene->nextState(); scene.reset();
if (!scene) {
if (game_state_ == -1 || JG_Quitting()) return false;
scene = createNextScene(); ← ModuleGame o registry.tryCreate()
scene->onEnter();
}
JI_Update()
scene->tick(now - last_tick_ms_)
JD8_Flip() ← converteix screen indexat → pixel_data
memcpy pixel_data → game_frame
}
memcpy game_frame → presentation_buffer
Overlay::render(presentation_buffer)
Screen::present(presentation_buffer)
SDL_Delay(frame_target - elapsed)
}
SDL_AppEvent → Director::handleEvent() ← events lliurats un a un per SDL
SDL_AppQuit → Director::teardown() ← Options::save + descàrrega ordenada
Key points:
Directorposseeixcurrent_scene_,game_state_,last_tick_ms_,context_initialized_com a members — abans vivien al stack del fiber.JD8_Flip()només converteix paleta +screena ARGB (pixel_data). Ja no fa yield — tot corre lineal.JG_ShouldUpdate()encara existeix ajgame.cppcom a timing-gate per aModuleGame::Update()(10 ms fix), però ja no fa yield. Cap caller fa spin-wait.- Pausa (F11) simplement salta el bloc de tick; overlay i present continuen, es re-presenta l'últim frame congelat.
- Doble buffer (
game_frame+presentation_buffer) es manté perquè el Director pot presentar múltiples frames per cada tick durant pausa; el cost (256 KB memcpy) és trivial a 320×200. - SDL3 Callback API compatible amb emscripten: el navegador posseeix el main loop i ens crida via
requestAnimationFrame. Zero canvis de codi per a portabilitat.
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 (: Fixed.reviu,alone,obert)JI_moveCheatstradueixSDL_Scancode→ ASCII viascancode_to_asciiabans de ficar-los al buffer (jinput.cpp:32-37, 55-61), iJI_CheatActivatedcompara ASCII amb ASCII.- 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: Majoritàriament arreglat.malloc/freein gameplay structsSprite/Entitatusenstd::vector<Frame>istd::vector<Animacio>(sprite.hpp).jfile.cppja no té el globalscratch[255](substituït perthread_local std::string). L'APIfile_getfilebuffer(que tornava rawchar*ambmalloc) s'ha substituït perfile_readfileque retornastd::vector<char>RAII — elimina els leaks silenciosos que hi havia aJD8_LoadPalette,ModuleGame::Go()iscenes::playMusic. Quedajail_audio.hppbarrejantnew/malloc/SDL_mallocde forma pairada i correcta (no leak), pendent de polir ambstd::unique_ptrquan toque.Blocking loops in cinematics and fades: Fixed. Migració completa deModuleSequence::do*()a la capascenes::(Steps 1–10) +ModuleGametambé tick-based (Phase A). Totwhile(!JG_ShouldUpdate())iwait_frame_or_skip()eliminat. Els fades bloquejantsJD8_FadeOut/JD8_FadeToPaltambé eliminats (Phase B.2): només queda l'API tick-stepJD8_FadeStart*+JD8_FadeTickStep, encapsulada pel wrapperscenes::PaletteFade. ModuleGame té fasesFadingIn/FadingOutpròpies.: Fixed in Phase 3.SDL_AddTimerin audiojail_audiois now a header-onlyinlinemodule (single.cppstub only hosts thestb_vorbisimplementation to avoid multiple definitions). Music uses true streaming viastb_vorbis_open_memory+JA_PumpMusicwith a 0.5s low-water-mark instead of decoding the whole OGG into RAM. Mixing/fade update runs manually viaJA_Update()called once per frame fromDirector::iterate(). Ported from thejaildoctors_dilemmacodebase.Game thread +: Fixed in Phase 4+5 via cooperativepublishFramemutex/cvGameFiber; eliminated entirely in Phase B.2.JD8_Flip()ja no fa yield — només converteixscreen→pixel_data. Director posseeix l'estat d'escena (current_scene_,game_state_) i cridascene->tick()directament des d'iterate(). Fitxerssource/core/system/fiber.{hpp,cpp}esborrats. Zero threads, zero mutex, zero fibers.: Eliminated in Step 10. Era el vell switch perModuleSequencelegacy dispatchernum_piramide, ara substituït perSceneRegistry::tryCreate()i dispatch directe des deDirector::iterate().modulesequence.{hpp,cpp}esborrats.
WebAssembly Build
make wasm genera el build WASM via Docker (emscripten/emsdk:latest) i copia els 3 fitxers (.js/.wasm/.data) a maverick:/home/sergio/gitea/web_jailgames/static/games/aee/wasm/, amb un ssh maverick './deploy.sh' final. Output local a dist/wasm/.
Diferències respecte build natiu (a CMakeLists.txt dins if(EMSCRIPTEN)):
- SDL3 compilat des de font via
FetchContent(no hi ha paquet de sistema). - Shaders SPIR-V omesos (SDL3 GPU no suportat a WebGL2).
sdl3gpu_shader.cppexclòs dels sources — el fallbackSDL_Rendererfa tota la presentació.- screen.cpp guarda
#ifndef NO_SHADERSal voltant de l'include i les crides aSDL3GPUShaderdirectes. La resta del codi va via interfície baseShaderBackend. - Link flags:
--preload-file data@/data,-fexceptions,-sALLOW_MEMORY_GROWTH=1,-sMAX_WEBGL_VERSION=2,-sINITIAL_MEMORY=67108864,-sASSERTIONS=1,-sASYNCIFY=1. - Defines:
EMSCRIPTEN_BUILD,NO_SHADERS.
Filesystem: MEMFS default — no persistent entre recàrregues. file_setconfigfolder té fallbacks robustos (getpwuid → getenv("HOME") → /tmp) perquè no pete quan emscripten no té /etc/passwd. La config es carrega per defecte cada vegada. IDBFS pendent si mai volguéssem persistència a web.
Pending / Ideas for Later
- Sound effects: infraestructura
JA_PlaySound*/JA_LoadSoundja preparada. Cablejar events de gameplay (col·lisió momia, mort, recollir objecte, trencar tomba, cheat activat). Menus SONS/VOL SONS ja controlen el volum del pool. - IDBFS persistence a WASM: montar
/home/web_user/.configcom a IDBFS a l'init iFS.syncfsdesprés de cada save. Opcional — ara per ara la config no persistix entre recàrregues de pàgina. - 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. - jail_audio
JA_Sound_tRAII:JA_Music_tja està net (vector + string), peròJA_Sound_tencara usaUint8*viaSDL_LoadWAVout-param. Petit polish per a completar la coherència RAII.
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) lives inside gameFiberEntry() in an anonymous namespace in director.cpp, invoked as the entry of the cooperative fiber (single-threaded).