Architecture
Overview
Super Mango follows a classic init → loop → cleanup pattern. AppSession owns the application runtime and one active screen (start menu or game); GameState owns resources and state for the active game screen. The session consumes explicit routes after a screen frame, so native and WebAssembly builds share one transition model.
Startup Sequence
main()
└── session_create(config)
├── initialise one raylib window/context, semantic input and audio device
│ └── device polling and platform teardown stay on the application thread
├── no `--level` → start_menu_create(), its font, logo and confirm
│ sound acquired from the session's asset cache
└── `--level` / `--sandbox` → session_open_game()
├── first game only: load the shared sprites and sounds (GameAssets)
└── game_init(gs), with a copy of those GameAssets and a pointer
│ to the session's asset cache
├── create a screen-owned logical render target
├── load TOML level data and entities; acquire the player
│ sheet, HUD icon and font and the level's own files
│ (background, floor, water, fog, tiles, music) from the cache
├── trim the cache: unload what the previous level left idle
└── arm release latch and repair browser keyboard state
session_run(session)
├── native: while active, call session_frame(session)
└── WebAssembly: register one Emscripten callback for session_frame(session)
├── start menu frame → Play or Exit route
└── game_frame(gs) → Next Level, Replay, Level Select, or Exit route
├── Next Level: load resolved phase in current GameState; only
│ the files the new level adds are read
├── Replay (native and browser): close game, open same path in place,
│ reusing the session's GameAssets and every cached file
└── Level Select: close game only, open start menu; the level's
files stay in the cache, idle, until another level opens
session_destroy(session) / browser terminal cleanup
├── close active menu or game screen
├── release the session's hold on the menu's files, clear the asset
│ cache, then unload the shared GameAssets, each once
├── clear input handlers and sound voices after screen assets are released
├── CloseAudioDevice → CloseWindow
└── free AppSession
App Session and Game Frame
AppSession is the sole production loop owner. It samples physical input, pumps the music stream, frames the active screen, then consumes its route. raylib’s EndDrawing owns presentation and event polling. Native visible windows use its 60 FPS limiter; hidden smoke runs are uncapped and retain fixed simulation steps. Each click on the native close button is reported once, as an Exit request; it replaces a Play, Continue, Next Level, Replay or Level Select route chosen earlier in the same frame (a fatal error still wins), so a close is never lost to a screen change.
On Web, Emscripten’s animation-frame callback owns scheduling. display_open
sets raylib’s target FPS to zero so EndDrawing returns to the browser instead
of sleeping on its main thread. Browser cadence follows animation frames; the
fixed-step accumulator turns whatever rate the browser delivers (60, 120 or
144 Hz) into 60 simulation steps per second. This does not require Asyncify or
change fixed-step replay/smoke simulation. The input
adapter also skips WindowShouldClose on Web: there is no native close button,
and raylib’s Web implementation calls emscripten_sleep, which aborts without
Asyncify. In-game exit remains an application route handled by the host.
On native macOS, the pinned dependency wakes its partial-busy sleep 1 ms earlier so the existing short busy wait can meet the same frame deadline despite sleep coalescing. This does not change simulation time or add another limiter. The debug counter rounds measured FPS to the nearest integer; genuine slow frames remain visible. A 60 FPS target cannot prevent stalls caused by the OS or work that exceeds the frame budget.
There are two distinct notions of input. GLFW callbacks capture ordered
commands (typing, clicks, pause) during raylib’s end-of-frame poll; the next
frame drains them. Held state answers whether movement remains pressed.
input_collect adds focus/gamepad changes without polling the OS again. The
editor drains text before command boundaries, so typing followed by Ctrl+S in
one frame saves the edited value instead of invoking shortcuts for those letters.
The drawing sequence is BeginDrawing → BeginTextureMode → screen draw calls
→ display_present. The final helper ends texture mode, copies the logical
image to the window and calls EndDrawing once. The window is a process resource;
a render target is a screen-owned off-screen image, not another OS window.
session_frame(session) {
if (session->screen == APP_SCREEN_MENU) {
start_menu_frame(menu);
apply MenuRoute;
} else if (session->screen == APP_SCREEN_GAME) {
game_frame(gs);
apply GameRoute;
}
}
game_frame(gs) {
1. Time — real seconds since last frame (GetTime, clamped to 0.25 s)
2. Events — drain project-owned InputEvent commands (quit / pause / overlays)
INPUT_PAD_ADDED / INPUT_PAD_REMOVED — update selected gamepad index
terminal: Up/Down or D-pad selects; Enter/Space/Start (or A) confirms
terminal: Esc/Back (or B) exits; Start toggles active-game pause
3. Update — accumulator turns real time into 0..5 fixed 1/60 s steps; pause/settings/
terminal state blocks them; each step runs, in order:
→ game_player_step (sampled input, motion, surface landing, bounce response)
→ authored checkpoint sampling → lethal floor-gap detection
→ the s_update_steps table, row by row (see Update Order below):
enemies → spike blocks → float platforms/rider carry → bridges
→ hazards → game_collide (enemy/hazard damage, coins/stars,
completion) → legacy checkpoints → water, fog, bouncepad animation
→ camera
A lost life or game over skips the remaining rows; the camera still moves.
4. Render — clear → the s_render_layers table, row by row (see Render Order below):
parallax background → platforms → floor tiles
→ float platforms → spike rows → spike platforms → bridges
→ bouncepads (medium, small, high) → rails
→ vines → ladders → ropes → coins → yellow/green/red stars → last star
→ blue flames → fire flames → fish → faster fish → water
→ spike blocks → axe traps → circular saws
→ spiders → jumping spiders → birds → faster birds
→ player → fog → hud
→ debug overlay/inspector → pause/game-over/completion → settings → present
}
Fixed Time Step
accumulator += frame_seconds; /* real time, clamped to 0.25 s */
while (accumulator >= GAME_FIXED_STEP && steps < GAME_MAX_STEPS_PER_FRAME) {
game_update_active(gs, GAME_FIXED_STEP, cam_x); /* always 1/60 s */
accumulator -= GAME_FIXED_STEP;
}
render(); /* every frame, even 0 steps */
Velocities are expressed in pixels per second, and every simulation step
multiplies them by the same dt of exactly 1 / TARGET_FPS. Real frame time
(raylib’s GetTime(), a double in seconds) only decides how many steps run
before the next picture (src/core/game_timing.c):
- Frame-rate-independent results. Discrete integration still has error
(
make timing-lab), but it is the same error at 30, 60 or 144 Hz, so jump heights and arcs no longer depend on the display. - Bounded movement per step. One step moves at most speed × 1/60 s, so a slow frame cannot carry the player past a collision test.
- Replays behave like live play. Live play, smoke tests, scripted replays and captured experiments run the very same steps.
A 60 Hz display runs one step per frame; a 120/144 Hz display (the browser
follows requestAnimationFrame) runs zero steps on some frames and just
redraws. So that those redraws are not the same picture shown twice and then a
jump (judder), the frame draws the player and the camera between the last two
steps: game_timing_note_step records both before each step, and
game_timing_render_view blends them by the accumulator’s share of a step,
so the picture runs up to one step (16.7 ms) behind the simulation but moves
every frame. It is drawing only: the renderer gets a copy of the player at the
blended position and the simulation never reads the recorded values, so the
steps, their order and their inputs are unchanged. A move of more than 48 px
in one step (a respawn, a camera snap, a new level) is shown where it lands.
Debug mode (whose hitbox outlines must sit on the sprite) and smoke, replay
and experiment runs draw the latest step itself. The time-trial ghost is
blended the same way between its last two samples (game_timing_view_point),
so a run that matches the best one draws both in the same place. Enemies,
hazards and platforms are still drawn at their latest step. The accumulator restarts half a step full after pauses and loads, which
absorbs sub-millisecond vsync jitter. Frame time is clamped to 0.25 s and at most
5 steps run per frame; excess time is dropped (below 12 FPS the game slows down
instead of spiralling). Smoke and scripted replays feed exactly one step per
frame regardless of the wall clock. Experiment captures (format_version = 3)
store one row per fixed step, [input bits, physics values...], with no
duration column; a format_version = 1 capture from the variable-timestep
engine, or a format_version = 2 one from before the camera snapped to the
level start and the respawn point (waiting spike blocks start when the camera
reveals them, so they could start on other steps), is rejected with a request
to record it again. Completion, game over or
a route stops the remaining steps of that frame. The debug inspector’s freeze,
single-step and slow-motion keys (Controls)
only change how much real time reaches the accumulator. They never override the
focus, settings or end-of-level blockers, and touch taps are discarded only
while such a screen owns input, not on zero-step frames.
Render-only timers (debug FPS readout, log ages) use real frame time.
During an active game update, authored checkpoints are sampled after player movement and before lethal collision handling. Legacy screen-boundary checkpoint sampling runs only when the active level has no authored records.
Update Order (one fixed step)
game_update_active() in src/core/game_update.c runs one step. It moves the
player first (game_player_step: sampled input, motion, landing on surfaces,
bounce response), samples authored checkpoints and tests for a fall into a
floor gap. Then it walks the s_update_steps table, top to bottom, and last
it moves the camera. A step that costs a life or ends the game skips the rows
that are left; the camera still moves.
Each row of s_update_steps is a name and a small update_<things>()
function in the same file. That function calls the one in the last column
with the inputs its kind needs, so it is the place to see what a kind
depends on. make docs-drift checks this table against s_update_steps:
the same names in the same order, each row calling the function listed here.
| Row | Name | Calls | Reads, besides its own array and the step time |
|---|---|---|---|
| 1 | spiders | spiders_update |
floor gaps |
| 2 | jumping spiders | jumping_spiders_update |
floor gaps, attack sound, player x, camera |
| 3 | birds | birds_update |
flap sound, player x, camera |
| 4 | faster birds | faster_birds_update |
flap sound, player x, camera |
| 5 | fish | fish_update |
world width |
| 6 | faster fish | faster_fish_update |
world width |
| 7 | spike blocks | spike_blocks_update |
camera (a block waits until it is on screen) |
| 8 | float platforms | game_float_platforms_update |
the float platform the player landed on; moves a rail rider |
| 9 | bridges | game_bridges_update |
the bridge the player landed on, player x |
| 10 | axe traps | axe_traps_update |
axe sound, player x, camera |
| 11 | circular saws | circular_saws_update |
nothing else |
| 12 | blue flames | blue_flames_update |
nothing else |
| 13 | fire flames | blue_flames_update |
nothing else (the same module, a second array) |
| 14 | collision | game_collide |
the whole world: damage, coins, stars, the level’s end |
| 15 | screen checkpoints | game_checkpoint_update |
player x; only levels without authored checkpoints |
| 16 | water | water_update |
whether the level has water |
| 17 | fog | fog_update |
whether the level has fog |
| 18 | bouncepads (medium) | bouncepads_update |
nothing else |
| 19 | bouncepads (small) | bouncepads_update |
nothing else |
| 20 | bouncepads (high) | bouncepads_update |
nothing else |
Rows 1 to 13 run before collision so that hitboxes are tested where this step put them. Rows 16 to 20 only animate.
Render Order (back to front)
This table is the s_render_layers table in src/render/game_render.c, row
for row. Each row there is the layer’s number, its name, and a small
render_<layer>() function in the same file (render_spiders,
render_water, …) that calls the function in the last column with the
array, count and texture it needs. game_render_frame() clears the canvas
and walks the table from row 1, so a later row covers an earlier one.
make docs-drift keeps the two tables in step: the same number of layers,
each What cell starting with the code row’s name, and each Drawn by
cell naming exactly the *_render functions that row’s render_<layer>()
calls. It also fails if game_render_frame() draws a layer without going
through the table.
| Layer | What | Drawn by |
|---|---|---|
| 1 | Background: per-level background_layers from assets/sprites/backgrounds/, tiled with each layer’s scroll speed |
parallax_render |
| 2 | Platforms: 9-slice pillar stacks, drawn before the floor so they sink into the ground | platforms_render |
| 3 | Floor: the level’s floor tile across the world at FLOOR_Y, with floor-gap openings |
render_floor, a loop in game_render.c |
| 4 | Float platforms: 3-slice hovering surfaces (static, crumble, rail) | float_platforms_render |
| 5 | Spike rows on the floor | spike_rows_render |
| 6 | Spike platforms | spike_platforms_render |
| 7 | Bridges: tiled crumble walkways | bridges_render |
| 8 | Bouncepads (medium) | bouncepads_render |
| 9 | Bouncepads (small) | bouncepads_render |
| 10 | Bouncepads (high) | bouncepads_render |
| 11 | Rails: bitmask tile tracks for spike blocks and float platforms | rails_render |
| 12 | Vines (vine_green.png / vine_brown.png) |
vines_render |
| 13 | Ladders | ladders_render |
| 14 | Ropes | ropes_render |
| 15 | Coins | coins_render |
| 16 | Health stars: yellow, then green, then red | health_stars_render |
| 17 | Last star | last_star_render |
| 18 | Blue and fire flames erupting from floor gaps | blue_flames_render |
| 19 | Fish, drawn before the water so they look submerged | fish_render |
| 20 | Faster fish | faster_fish_render |
| 21 | Water: the animated strip at the bottom | water_render |
| 22 | Spike blocks riding their rails | spike_blocks_render |
| 23 | Axe traps | axe_traps_render |
| 24 | Circular saws | circular_saws_render |
| 25 | Spiders | spiders_render |
| 26 | Jumping spiders | jumping_spiders_render |
| 27 | Birds | birds_render |
| 28 | Faster birds | faster_birds_render |
| 29 | Time-trial ghost: the best run’s Mango, translucent (see Controls) | render_ghost in game_render.c |
| 30 | Player | player_render |
| 31 | Fog: per-level fog_layers from assets/sprites/foregrounds/ |
fog_render |
| 32 | HUD: hearts, lives, score. With the high-contrast setting, the player’s outline and a dark strip behind the HUD are drawn first | hud_render |
| 33 | Debug overlay and inspector panels, with --debug |
debug_render, game_inspector_render |
Note: Per-level visual layers are split by role:
background_layersfeed the parallax renderer,foreground_layersselect the water/lava foreground strip texture, andfog_layersfeed the atmospheric fog system. Fog renders before the HUD so hearts/lives/score remain legible.
The pause, game-over and completion overlays and the settings panel are drawn after these 33 layers.
Level Completion and Terminal Actions
Collecting last_star calls game_complete_level(). The game snapshots elapsed time, coins collected, total coins, and the resolved next_phase path (if any), then shows a completion overlay. While it is active, gameplay update pauses. Its action list is Next Level, Replay, Level Select, Exit when a phase is pending; otherwise it is Replay, Level Select, Exit. Up/Down or D-pad moves the focused row with wraparound. Enter/Space/Start confirms it (controller A also confirms). Esc/Back exits immediately (controller B is equivalent).
Next Level uses game_load_next_phase() without replacing the game screen. If loading fails, the completion overlay stays visible with a “Next level failed to load” line (completion.next_phase_failed), the dead Next Level row is removed and focus moves to Replay; the current level is untouched, so the remaining actions keep working. That holds by construction: the next file is parsed and validated once into a heap copy and its sprites are checked before anything changes; only then is the active LevelDef swapped and applied, a step with no failure path. Level Select closes the game screen and opens the start menu in the same AppSession. Replay closes the game screen and opens the same TOML path in a new GameState, on native and in the browser alike; the window, audio device, frame callback and profile stay alive.
Pause Overlay Flow
During active gameplay, Esc or controller Start toggles the player pause reason through the overlay helper in src/core/game_overlay.c. Paused frames keep rendering the last camera position, skip gameplay updates, pause music, and draw a semi-transparent pause overlay with resume hints. Enter, Space, Esc, or controller Start resumes gameplay. Window focus loss uses a separate focus pause reason, so regaining focus does not clear an intentional player pause. Music follows one predicate, game_music_should_play(): it is silent while the pause overlay or the settings panel is up and plays otherwise (including under completion and game-over screens), so regaining focus cannot resume music beneath open settings. Completion and game-over overlays take priority over pause.
Game-Over Flow
When lethal damage consumes the final life, apply_damage() sets gs->screen.game_over and returns without resetting the level. The shared overlay helper reports GAME_OVERLAY_GAME_OVER, so the loop blocks gameplay updates and rendering draws a game-over overlay with the final score. Its terminal action list is Retry, Level Select, Exit. Retry calls game_restart_after_game_over() in place: it restores level-defined lives/hearts, resets score and bonus-life threshold, makes every coin collectable again, resets the current level, resumes music, and clears its input latch after held controls are released. Level Select and Exit follow the session routes above.
Coordinate System
The 2D Y-axis increases downward. The origin (0, 0) is at the top-left of the logical canvas.
(0,0) ──────────────────► x (GAME_W = 400)
│
│ LOGICAL CANVAS (400 × 300)
│
▼
y
(GAME_H = 300)
┌──────────────────────────────────────────┐
│ ←──────── GAME_W (400 px) ─────────────► │
FLOOR_Y ──►│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│
(300-48=252)│ Grass Tileset (48px tall) │
└──────────────────────────────────────────┘
The game renders to a 400×300 RenderTexture2D, configured with point
filtering when the screen creates it. display_present applies aspect-preserving
scaling and the render-texture Y correction. At 800×600 this gives a 2x pixel
scale. input_pointer_to_logical applies the inverse viewport transform once.
Both take the viewport from display_viewport: once the window holds at least
one whole canvas, it uses the largest whole-number scale that fits and centres
it on whole-pixel offsets, so every canvas pixel covers the same square of
window pixels (a 1920×1080 window shows the game at 3x, 1200×900, with black
margins). A fractional scale would make nearest-neighbour sampling draw some
columns one pixel wider than others, and the art would shimmer as it scrolls.
Only a window smaller than the canvas gets a fractional fit, so that nothing
is cut off.
On macOS display_open also asks for raylib’s FLAG_WINDOW_HIGHDPI, so a
Retina display gets a full-density framebuffer instead of a half-density one
the compositor would blur. Window sizes, GetScreenWidth and GLFW cursor
positions all stay in points there; raylib’s screen-scale matrix turns the
presentation rectangle into device pixels, so the viewport and its inverse
still agree. Windows and Linux do not set the flag: there raylib would resize
the window by the monitor’s scale and rescale the mouse through
SetMouseScale, which the raw GLFW cursor callbacks in input_backend.c do
not pass through.
Gameplay hitboxes remain integer IntRect values; raylib’s floating Rectangle
is a drawing boundary type. Two hitboxes that merely touch edges do not overlap.
GameState Struct
Defined in game.h. The single container for active-game resources; AppSession owns app-wide runtime state and the active screen. The plain numbers it and the entity code share (GAME_W, FLOOR_Y, GRAVITY, MAX_FLOOR_GAPS, camera tuning) live in game_constants.h, which game.h includes; entity, hazard, surface and effect .c files that need only those numbers include game_constants.h alone, so a GameState change does not recompile them.
GameState is split into three named parts, so the path to a value also
says what kind of state it is:
| Part | Type | Holds | Example |
|---|---|---|---|
gs->world |
GameWorld |
The level being played: player, camera, every entity array and count, score, lives, checkpoints, simulated time, the active LevelDef and the level’s scenery (parallax, water, fog, platform tiles) |
gs->world.spiders[i] |
gs->screen |
GameScreen |
The game screen around the level: render target, HUD, pause / game-over / completion overlays, the route for AppSession, frame-loop clock, input latches, replay script, debug tools, profile and ghost |
gs->screen.paused |
gs->assets |
GameAssets |
The sprites and sound effects every level uses, borrowed from AppSession |
gs->assets.textures.spider |
typedef struct {
char level_path[GAME_LEVEL_PATH_MAX];
void *level_def; /* owned active LevelDef backing storage */
LevelRuntime runtime; /* active LevelDef, world width, effect flags */
GameRules rules;
Player player; /* embedded by value */
GameCamera camera;
ParallaxSystem parallax; Water water; FogSystem fog;
Texture2D *floor_tile; /* the level's own floor; NULL = shared default */
MusicTrack *music; /* the level's music stream */
Platform platforms[MAX_PLATFORMS];
int platform_count;
Spider spiders[MAX_SPIDERS];
int spider_count;
/* ... fixed-size arrays + counts for every enemy, hazard, surface,
collectible, in that order ... */
int hearts, lives, score, score_life_next;
float respawn_x, respawn_y;
int checkpoint_index; /* -1 before the first authored record */
CheckpointFeedbackKind checkpoint_feedback_kind;
uint32_t checkpoint_feedback_until; /* deadline on game_checkpoint_clock_ms */
int legacy_checkpoint_screen;
uint32_t sim_steps; /* fixed steps since the level was applied; stops while paused */
} GameWorld;
typedef struct {
RenderTexture2D frame_target; /* screen owns target; session owns window */
Hud hud;
int controller; /* raylib index + 1; zero means none */
int running;
GameRoute route; /* request consumed by AppSession */
int game_over;
int paused;
unsigned int pause_reasons;
GameCompletionState completion;
GameLoopState loop;
int smoke_test_frames;
/* ... replay script, input latches, profile, ghost, inspector ... */
int debug_mode;
DebugOverlay debug;
} GameScreen;
typedef struct GameAssets { /* in game_assets.h */
TextureResources textures; /* Texture2D slots, owned by AppSession */
AudioResources audio; /* SoundEffect slots, owned by AppSession */
} GameAssets;
typedef struct GameState {
GameWorld world;
GameScreen screen;
GameAssets assets; /* a copy of the session's pointers */
int owns_assets; /* 1 only when game_init loaded them */
AssetCache *cache; /* the session's cache; NULL without one */
} GameState;
Key design decisions:
worldis the level itself: what is in it, where it is and how the player is doing (hearts, lives, score, checkpoints). “What happens to a spider each frame” is a walk overgs->world.spiders.screenis how this screen presents and steers that level: the HUD, the pause, game-over and completion overlays, routes, the frame clock, input, replay scripts and debug tools.- Textures are grouped in
TextureResources(gs->assets.textures.*) and sound effects inAudioResources(gs->assets.audio.*) so loading and cleanup walk one table each (src/core/game_resources.c). - Those shared sprites and sounds belong to the session, not the game:
AppSessionloads oneGameAssetswhen the first game opens, copies it into everyGameStateit creates and unloads it once when the session ends, so Replay, Level Select → Play and Next Level reuse the same textures. A copy holds the same pointers, so a game only borrows them (owns_assetsstays 0) andgame_cleanupleaves them loaded. AGameStatestarted without a session (the tests do this) loads a private set ingame_initand unloads it ingame_cleanup. Everything a level file names (background layers, floor tileset, water strip, fog, platform tiles, music) is inworldand is acquired and released with the level (src/levels/level_resources.c). - Those level files, and the player sheet, HUD coin icon and font, come from the session’s asset cache (
AssetCache,src/shared/asset_cache.h), described in the next section.gs->cacheis a borrowed pointer to it. Playeris embedded by value, not a pointer. This avoids a heap allocation and keeps the struct self-contained. The same applies toPlatform,Water,FogSystem, all entity arrays and the three parts themselves.- Owning pointers are cleared after release. Borrowed pointers and aliases still require correct lifetime handling.
- Active-game storage is heap-owned and zero-initialized before initialization; the structs above are an abridged ownership map, not complete declarations.
- The resolved respawn state is
respawn_x,respawn_y, andcheckpoint_index;legacy_checkpoint_screenis used only when the active level has no authored records.checkpoint_indexandrespawn_x/respawn_yare always written together (on load, on retry and when a record is reached), so a valid index always names the current respawn.
The asset cache: files that outlive a game
A Replay closes one GameState and opens another on the same level. Loading a level’s files with the game and freeing them with it therefore read the same PNGs and the same music again each time. AppSession owns one AssetCache (src/shared/asset_cache.h) for the whole run, and every game acquires its files from it:
player->texture = asset_cache_texture_acquire(cache, "assets/sprites/player/player.png");
/* ... the game runs ... */
asset_cache_texture_release(cache, player->texture);
player->texture = NULL;
The cache keeps one entry per file path, with a count of holders: acquires that have not been released yet. This is a reference count. Three rules follow from it:
- Acquire returns the cached texture when the path is already there (no file is read) and loads it otherwise. A file that fails to load is not remembered, so the next game tries it again.
- Release only lowers the count. An entry with no holders is idle: still loaded, because the next game very likely wants it. A released music track is also stopped and rewound.
- Trim (
asset_cache_trim) unloads the idle entries.game_level_commitcalls it once a level has finished loading: the new level then holds everything it needs, so what is still idle belonged only to the level before.
| Step | Files read from disk on 01_lugio_01 |
|---|---|
| First game of the session | 45 textures (30 shared, 15 of the level), 8 sounds, 1 music track |
| Replay, Retry, Level Select → Play the same level | none |
Next Level to 02_lugio_02 (same scenery and music) |
none |
Next Level from 00_sandbox_01 (other scenery) |
12 textures and 1 music track; the sandbox’s 7 are unloaded |
Memory stays bounded by the level, not by how many levels were visited: the cache holds the current level’s files (at most 15 textures, 3.1 MiB of pixels, in the shipped levels) plus, between closing one level and finishing the load of the next, the previous level’s. Keeping every file until the session ends would also have made Replay free, but its size would grow with each level added; the nine shipped levels together name 25 images (5.7 MiB).
The start menu uses the same cache for its font, logo and confirm sound. Nothing would hold those three while a game is on screen, so the trim after the next level load would unload them and every return to Level Select would read them again. AppSession therefore takes a reference of its own to each when the first menu opens and keeps it until the session ends (session_keep_menu_files): a holder that exists only to keep something loaded. The first menu of a session reads one texture and one sound; later menus read nothing. They cost 12 KiB of pixels and a 0.22 s sound.
The cache and GameAssets are two answers to the same question, “who frees this?”. The shared sprites never go away while the session runs, so a game simply borrows the pointers and nobody counts. A level’s files should go away when no level uses them any more, and only a count can tell when that is.
Authored Checkpoint Flow
LevelDef owns optional immutable CheckpointPlacement { x, y } records. Each active frame samples authored records after player movement and before lethal collisions. The furthest record with x <= player.x becomes the resolved respawn point, so a death in the same frame preserves a crossed checkpoint. The runtime never regresses to an earlier record.
Authored records disable automatic screen-boundary checkpoints for that level. A level with no records saves automatically when the player enters a new screen; the respawn column is the screen edge, or the nearest column to its left (over ground already crossed) with solid floor and no floor gap, spike row, spike platform or flame. If no such column exists the previous checkpoint is kept. Losing a life respawns at the resolved checkpoint and keeps collected coins collected; Retry, replay, and successful next-phase loads restore every coin and reset to the effective start of their respective level; a failed next-phase load retains the active level and its resolved checkpoint. The HUD shows brief CHECKPOINT CP n (1.2 s) and RESPAWN CP n (0.9 s) notices. They are timed in simulated time (sim_steps, a count of fixed steps turned into milliseconds with integers, restarted whenever a level is applied, F8 restarts included), so a pause does not use them up and they last exactly 72 and 54 steps, the same steps in every replay. The debug inspector exposes the stored checkpoint index; the regular HUD does not keep a permanent checkpoint label after the notice expires.
Error Handling Strategy
| Situation | Action |
|---|---|
| Window/audio initialization failure | Session creation fails and releases initialized resources; the top-level runner returns EXIT_FAILURE |
Resource load failure (shared sprites when the first game opens; player, HUD, water or level files in game_init) |
fprintf(stderr, ...) → release what was partly loaded → return -1, so the game does not open; for --level the top-level runner returns EXIT_FAILURE |
| Optional sound load failure | Warn and retain an empty slot; sound_play accepts NULL |
| Missing gameplay-critical shared sprite | Reject the level before replacing active level state; identify the required asset path |
| Optional presentation texture load failure | Warn and preserve the documented visual fallback |
Application errors identify the failing asset/path or lifecycle operation. raylib’s warnings provide backend detail. Text uses raylib’s built-in default font; the game frees only its small handle, never the borrowed atlas. Textures and sound aliases are released before their owning context/sample/device. The game requires an audio device: if none can be opened, session creation fails. Menu/game transitions retain the same window, including when a candidate level fails to load.
Version-1 profile binding numbers (USB HID keyboard usage IDs; the ones code names directly are BINDING_KEY_* in src/input/input_backend.h) are translated explicitly. Unsupported legacy
media/paddle/touchpad bindings retain their stored values and show a remap warning;
fixed keyboard navigation remains available. Native preference paths retain both
organization and application components. Browser input handlers are scoped to
Module.canvas; save namespaces and asynchronous commit/teardown ownership stay
unchanged.