Developer Guide
This guide covers the patterns and conventions used in Super Mango and explains how to extend the game safely and consistently.
For a guided sequence, start with Sandbox School. The Entity Walkthrough is the step-by-step route for adding an entity; this page covers the conventions around it.
Coding Conventions
Language and Standard
- C11 (
-std=c11) - Compiler:
clang(default),gcccompatible - Keep builds warning-free under
-Wall -Wextra -Wpedantic; fix warnings rather than suppressing them.
Comments and Module Layout
The source is a learning resource for C and raylib. Readable layout and teaching comments are intentional, even when shorter code could do the same job:
- Start headers and source files with a short module summary. Headers use
#pragma onceand expose constants, types, and public function declarations. - Explain nontrivial raylib calls, including important arguments, return values, and resource ownership.
- Give numeric constants their units and origin, especially logical pixels, pixels per second, and animation durations.
- Document pointer ownership, float-to-integer rendering casts, and cleanup order where they matter.
- Keep separate actions and nontrivial
switchcases on separate lines. Prefer an explicit loop or early return to a dense expression when it helps a learner trace the state change. Share genuinely repeated behavior without hiding each small step behind a new helper. - Explain unfamiliar C idioms when useful: designated initializers, borrowed pointers, bounds-before-casts, and frame-time unit conversions. Do not remove a useful explanation solely because the implementation is now shorter.
raylib APIs use names such as LoadTexture and DrawTexturePro. The snake-case
helpers in src/shared/ and src/input/input_backend.* belong to this project;
they preserve explicit ownership, logical coordinates and saved-binding IDs.
Naming
| Category | Convention | Example |
|---|---|---|
| Files | snake_case |
player.c, coin.h |
| Functions | module_verb |
player_init, coins_render |
| Struct types | PascalCase via typedef |
Player, GameState, Coin |
| Enum values | UPPER_SNAKE_CASE |
ANIM_IDLE, ANIM_WALK |
Constants (#define) |
UPPER_SNAKE_CASE |
FLOOR_Y, TILE_SIZE |
| Local variables | snake_case |
dt, frame_ms, elapsed |
| Assets | snake_case under assets/sprites/<category>/ |
player/player.png, collectibles/coin.png, entities/spider.png |
| Sounds | component_descriptor.qoa under assets/sounds/<category>/ |
player/player_jump.qoa, collectibles/coin.qoa, entities/bird.qoa |
Memory and Safety Rules
- Clear each owning pointer after releasing its resource. A NULL guard only protects an already-NULL pointer; aliases and borrowed pointers require explicit lifetime discipline.
- Error paths identify the failing operation and asset path; raylib warnings provide backend detail.
- Required initialization failures return failure to the caller for cleanup; only the top-level runner returns
EXIT_FAILURE. Optional sound-effect loads warn and continue;sound_playaccepts an emptySoundEffectslot. - Release dependents before owners: aliases before samples, cached labels before fonts, and all screen resources before the graphics/audio context. Reverse initialization order is a useful way to achieve this, not a reason to free a borrowed resource twice.
- A file a level names, or one every game needs, is acquired from the session’s asset cache, not loaded directly:
asset_cache_texture_acquire(gs->cache, path)and oneasset_cache_texture_releasefor it at cleanup. The cache owns it and decides when to unload, so never pass an acquired texture, sound, track or font totexture_unload,sound_unload,music_unloadorfont_unload. The start menu gets its font, logo and confirm sound the same way. Directtexture_loadremains right for something with one owner for its whole life, such as the rows of the shared sprite table insrc/core/game_resources.c. - Use
floatfor positions and velocities. Preserve integerIntRecthitbox construction and edge rules; convert to raylibRectangleat drawing boundaries. - Asset and level paths stay relative (
"assets/...","levels/..."). Startup moves a foreign working folder to the asset root once (src/shared/asset_root.c), so no module needs to build absolute asset paths. A new command-line path option must be made absolute inmain.cbefore that move, as--profileis.
Coordinate System
Game-object positions and sizes use logical pixels. The viewport is 400×300,
but world X can extend across screen_count screens; rendering subtracts camera X.
Never use WINDOW_W / WINDOW_H for game math. The shared presentation helper scales the logical render target to the OS window with nearest filtering and an inverse pointer transform.
See Constants Reference for all defined constants.
Project Documents and Ownership
| File | Purpose |
|---|---|
PRODUCT.md |
Product direction and feature framing. |
DESIGN.md |
How the website looks: built from the game’s own sprites, palette and fonts. |
docs/wiki/developer-guide.md |
Coding conventions, entity integration, resource ownership, and verification. |
CODEOWNERS |
GitHub ownership hints for review routing. |
Treat source and workflows as authoritative. When project documents, README, or GH Pages copy drift from implementation, update the docs and run Testing & Smoke Matrix checks before shipping.
Verification and Runtime Controls
Run the 17-test make test suite (17 native binaries plus Python and JavaScript host checks) for runtime/editor changes. make validate-levels checks level and campaign data; make docs-drift checks semantic docs drift, generated catalog freshness, and roadmap quality. For documentation changes, also run bun run lint, bun run build and bun run check-site from docs/; compilation alone does not verify links. The game requires an audio device to start; on a machine with a display but no sound hardware, build the test-only RAYLIB_AUDIO=null variant (miniaudio’s null playback device; release targets refuse it). See Build System and Testing & Smoke Matrix for the full gates.
Terminal overlays use Up/Down or D-pad to select, Enter/Space/Start to confirm (A also confirms), and Esc/Back to exit (B also exits). Completion offers Next Level when configured, Replay, Level Select, and Exit; game over offers Retry, Level Select, and Exit. See Controls for the full input reference.
Adding a New Entity
Adding an entity touches the file format, the runtime, the editor, undo and the tests. The Entity Walkthrough is the one place that lists every step, with the module pattern, the resource table row, the editor files and a checklist. Follow it rather than copying an existing entity by eye.
In the game itself, a kind is a module plus one row in each table it takes
part in. The tables are plain arrays of structs with a function pointer, and
each is walked by one for loop, top to bottom:
| Table | File | Its rows, in order, are |
|---|---|---|
s_level_loaders |
src/levels/level_loader.c |
what a level load (and a lost life) places in the world |
s_update_steps |
src/core/game_update.c |
one fixed step of the simulation, after the player has moved |
s_damage_sources |
src/collision/game_collision.c |
the enemies and hazards tested against the player; the first hit counts |
s_render_layers |
src/render/game_render.c |
the frame’s 33 render layers, back to front |
A row names a small function written beside the others like it
(load_spiders, update_spiders, spider_touches, render_spiders). The
update and render ones call the entity’s module with the inputs that kind
needs. To change when something happens, move its row. To change what
happens, edit the module. Moving a row in s_level_loaders or
s_update_steps can change what a seeded run does, and then recorded
experiments and the replay tests no longer match.
Adding Physics to an Entity
Use the same pattern as player_update. dt is the simulation step passed
down from game_update_active: always the fixed 1/60 s GAME_FIXED_STEP, in
live play and in every kind of replay, never the measured frame time:
/* Apply gravity while airborne */
if (!entity->on_ground) {
entity->vy += GRAVITY * dt;
}
/* Integrate position */
entity->x += entity->vx * dt;
entity->y += entity->vy * dt;
/* Floor collision */
if (entity->y + entity->h >= FLOOR_Y) {
entity->y = (float)(FLOOR_Y - entity->h);
entity->vy = 0.0f;
entity->on_ground = 1;
} else {
entity->on_ground = 0;
}
/* Horizontal clamp to the active level width */
if (entity->x < 0.0f) entity->x = 0.0f;
if (entity->x > world_w - entity->w) entity->x = (float)(world_w - entity->w);
GRAVITY, FLOOR_Y, GAME_W, and GAME_H are all defined in game_constants.h. An entity .c file that needs only these numbers includes that header; game.h includes it too, so files that use GameState already have them. See Constants Reference for values.
This example is a simplified solid-floor integrator. Pass the active
gs->world.runtime.world_w as world_w; real player movement also resolves gaps,
one-way surfaces and the sprite’s inset foot position in player_surfaces.c.
Adding a New Sound Effect
All sound files are .qoa format, named with the convention component_descriptor.qoa. They are synthesized by tools/gen_sounds.py (12 mono 22050 Hz files that raylib decodes to 16-bit samples); make docs-drift runs gen_sounds.py --check to catch committed files that drift from the generator. Sounds lists every file and where the game plays it.
Steps to add a new sound:
- Add a generator for the sound to
tools/gen_sounds.pyand runmake sounds; it writesassets/sounds/<category>/<name>.qoa. - Add
SoundEffect *<name>;toAudioResourcesingame.h. - Add a row to
s_optional_chunksinsrc/core/game_resources.c. Loading is non-fatal (a missing file warns and leaves the slot NULL). The session loads the table once and lends the sounds to every game, and frees it in reverse order when it ends:
{ CHUNK_FIELD(<name>), "assets/sounds/<category>/<name>.qoa", "<name>.qoa" },
- Play wherever needed:
sound_play(gs->assets.audio.<name>, 128); // null-safe; per-play volume in authored units
// A source on screen: fade with distance and pan to its side (-1 left .. 1 right).
sound_play_at(snd, vol, sound_pan_for_screen_x(source_x, cam_x, (float)GAME_W));
Adding Background Music
Background music uses raylib streams through the project MusicTrack owner. Runtime levels provide the active music path through TOML:
// Acquire from the session's asset cache, by the current LevelDef's path
gs->world.music = asset_cache_music_acquire(gs->cache, def->music_path);
// Play (looping)
music_play(gs->world.music);
music_set_volume(64); // 50%; normal sessions combine level/user/mute settings
// Cleanup: give the reference back. The cache stops the track and keeps
// it loaded for the next game on this level.
asset_cache_music_release(gs->cache, gs->world.music);
gs->world.music = NULL;
music_load / music_unload are still what the cache itself calls; game
code goes through the cache so a Replay does not open the file again.
Adding HUD / Text Rendering
All text uses raylib’s built-in bitmap font, so no font file is loaded. raylib
creates it inside InitWindow and frees it in CloseWindow; TextFont only
borrows it and must never pass it to UnloadFont. Text is drawn at the font’s
10 px base size (TEXT_FONT_SIZE) with point filtering, so every glyph pixel
lands on one canvas pixel. Characters outside ASCII/Latin-1 draw as ?.
Labels are drawn straight from the font’s atlas texture (font_draw, raylib’s
DrawTextEx), so no per-label texture exists to own or free.
// Borrow the default font (requires a live graphics context)
TextFont *font = font_load();
if (!font) return -1;
// Draw while the frame's render target is active
font_draw(font, "Score: 0", 10, 10, WHITE);
// Centre a label on x = 200 (measures the text for you)
font_draw_centered(font, "Paused", GAME_W / 2, 92, WHITE);
// Cleanup before closing the graphics context (frees only the handle)
font_unload(font);
The HUD takes its TextFont handle and coin icon from the session’s asset
cache instead (asset_cache_font_acquire, asset_cache_texture_acquire in
hud_init; one release each in hud_cleanup), so a Replay’s new HUD gets
the same two back.
The HUD renders hearts (health), life counter and score. It is drawn after game entities; terminal/settings overlays can cover it. hud_layout() computes every HUD rectangle once; hud_render draws into them and the --debug hitbox view outlines the same ones, so a layout change needs one edit.
Draw labels with font_draw/font_draw_centered every frame. raylib’s default
font is a single atlas texture and DrawTextEx batches its glyphs with the rest
of the frame, so there is nothing to rasterize, cache or release per label.
Widget colours come from the UIStyle in UIState (src/shared/ui.h), a table
of colour roles in the manner of raygui’s style properties: panel, title, input,
button, hot, selected, text, dim text, accent, warning, error and border, plus a
control border_width. Widgets pick a role by state (hovered, selected,
disabled), and panels an owner draws itself should ask ui_color(ui, role) rather
than use a fixed colour, so a style change reaches them too. UI_STYLE_DEFAULT
is the editor look; UI_STYLE_HIGH_CONTRAST is used by the editor’s Contrast
toggle and by the game’s settings menu when the high-contrast setting is on.
editor_ui_test checks that the default still matches the UI_* colours, that
its dim text keeps 4.5:1 (WCAG AA) on the panel, title and input faces, and that
every high-contrast text colour keeps 7:1 on every face.
ui_toggle is a button for something on or off (the editor’s tools, Grid,
Campaign, Contrast): while on it draws UI_COLOR_SELECTED, outlined in
UI_COLOR_ACCENT when the style has outlines, instead of a label that
changes.
ui_button_icon and ui_toggle_icon put one of raygui’s 16 × 16 icons before
the label, the two centred together; the editor toolbar uses them for every
button. The icons are raygui’s own bitmaps, generated into
vendor/raygui/raygui_icons.h by tools/import_raygui_icons.py (see
vendor/raygui/README.md), and ui_draw_icon draws them in the label’s
colour, so they follow the style like the text does. Only the icons come from
raygui; the widgets stay in ui.c.
ui_label_fit(ui, x, y, max_w, text, colour) draws a label cut to max_w
with ..., and when it had to cut, registers itself so the full text shows as
its tooltip; the editor’s status message and validation rows use it. Text
fields and dropdown headers cut their text the same way.
ui_tooltip(ui, text), called right after a widget, gives that widget a
tooltip: once the pointer has rested on it for UI_TOOLTIP_DELAY_MS (500 ms),
ui_draw_overlays draws the text in a UI_COLOR_TITLE box by the pointer
(with the style’s border, when it has one), kept inside view_w × view_h.
The delay is timed with UIState.time_ms, which the owner sets each frame
(editor_run_frame from clock_millis), so a test can step the clock by hand.
The mouse cursor is collected the same immediate-mode way: ui_begin_frame
resets cursor_wish to the arrow, editable fields ask for the I-beam while the
pointer is on them, an owner can ask with ui_set_cursor, and ui_apply_cursor
at the end of the frame (the up-down arrows win while a scrollbar thumb is
dragged) calls SetMouseCursor only when the cursor changes. Upstream raylib’s
GLFW backend created a new system cursor on every call and never freed it; the
pinned build is patched to create each shape once (see
vendor/raylib/README.md), and the editor still only calls it on a change. The editor’s editor_update_cursor adds the
four-way arrows for an entity drag.
Clip drawing with ui_clip_begin / ui_clip_end (or ui_scroll_begin /
ui_scroll_end), never BeginScissorMode directly: UIState then knows the
active clip, which the editor’s in-window prompts (dialog_choice.c) end and
set again when they open in the middle of a frame.
A scrolled panel or list keeps a UIScroll (offset plus the wheel’s leftover
fraction), modelled on raygui’s GuiScrollPanel. ui_scroll_wheel turns wheel
notches into whole pixels or rows and keeps a trackpad’s fractions for the next
event; ui_scroll_begin/ui_scroll_end clamp the offset, clip drawing to the
view and say where the content starts; ui_scroll_reveal scrolls a field into
view. ui_scrollbar_input (before the content, so a click on the bar is not
also a click on the row under it) and ui_scrollbar_draw (after) add a
scrollbar whose thumb can be dragged and whose track pages by one view. The
editor’s palette, Level Config, validation list and Campaign rows all use
UIScroll; all but the validation list (which says “Rows 1-8 of 12”) show a
scrollbar.
Render Layer Order
Draw back to front (the painter’s algorithm). The full 33-layer order, with
the function that draws each layer, is in
Architecture. That table and
s_render_layers in src/render/game_render.c are the same list: give a new
entity a render_<things>() function and a row at the matching place in
both. make docs-drift fails while they differ.
Sprite Sheet Workflow
To analyze a new sprite sheet:
python3 tools/analyze_sprite.py assets/sprites/<category>/<sprite>.png
Frame math:
source_x = (frame_index % num_cols) * frame_w
source_y = (frame_index / num_cols) * frame_h
Standard animation row layout (most assets in this pack):
| Row | Animation | Notes |
|---|---|---|
| 0 | Idle | 1-4 frames, subtle |
| 1 | Walk / Run | 6-8 frames, looping |
| 2 | Jump (up) | 2-4 frames, one-shot |
| 3 | Fall / Land | 2-4 frames |
| 4 | Attack | 4-8 frames, one-shot |
| 5 | Death / Hurt | 4-6 frames, one-shot |
See Assets for sprite sheet dimensions and Player Module for animation state machine details.
Measure each sheet rather than assuming a common frame size or row layout. Advance animation using accumulated elapsed time (a float millisecond timer, so the 0.67 ms fraction of each 16.67 ms step is not truncated away); reset the frame on state entry, loop repeating states, and clamp one-shot animations to their last frame. Reuse right-facing art with sprite_draw and SPRITE_FLIP_X for left-facing rendering.
Related Pages
- Overview – project overview
- Architecture – system design and game loop
- Build System – compiling and running
- Source Files – module-by-module reference
- Assets – sprite sheets and textures
- Sounds – audio files and music
- Player Module – player-specific details
- Constants Reference – all defined constants