Super Mango manualPlay

Entity Walkthrough: From TOML to a Collectible

Start with Sandbox School labs 1–5. This walkthrough follows the existing coin end-to-end, then gives the complete integration map for adding a second coin-like collectible named Token. It is an exercise, not an entity already included in the game. It is also the manual’s one checklist for adding any entity: section 7 covers the extra steps for enemies and hazards, and the checklist sums everything up.

The shape of an entity module

Every entity module works on a whole array and owns no resources:

level_loader.c         -> copy validated LevelDef placements into the gs->world array
<entities>_update      -> move, animate, detect events (one fixed step of dt)
<entities>_render      -> draw every active instance with a borrowed shared texture
<entity>_get_hitbox    -> IntRect used by collision and the debug overlay

There is no per-entity _init or _cleanup. Placement happens in level_loader.c (for example load_coins(), one row of its s_level_loaders table), and the shared texture slot in gs->assets.textures is loaded and released by src/core/game_resources.c, once per session. Static entities need even less: a coin stores only its placement state in Coin and exposes just coins_render(), and collection is handled in src/collision/. Only the player has player_init() and player_cleanup(), because it gets its own sprite: it holds a reference to the sheet in the session’s asset cache (src/shared/asset_cache.h) and gives it back at cleanup.

The four tables

The module knows how its kind behaves. When that happens in a frame is written down in four tables, one per job. Each is a plain array of structs whose rows hold a function pointer, and one for loop walks it from the top, so the order of the rows is the order things happen in:

Table File A row is What the row order decides
s_level_loaders src/levels/level_loader.c load_<things>() and LOAD_ONCE or LOAD_EVERY_LIFE what is placed first (rails before the blocks that ride them)
s_update_steps src/core/game_update.c a name and update_<things>() the simulation order of one fixed step
s_damage_sources src/collision/game_collision.c a debug name, the count field and <thing>_touches() which hit counts when two happen in one step
s_render_layers src/render/game_render.c the layer number, a name and render_<layer>() what is drawn over what

Every row points at a small function written beside the others like it. That function is deliberately boring: it calls the module’s own function with the arguments that kind needs. Read update_jumping_spiders() in src/core/game_update.c to see that jumping spiders depend on the floor gaps, a sound, the player’s x and the camera, and render_vines() in src/render/game_render.c to see that vines take two textures.

A kind does not need a row in every table. A coin never moves, so it has a loader row and a render row and no update row; its pickup is written in game_collide(), because collecting is not damage. A spider has all four.

1. Follow one existing coin

Open levels/labs/01_collision.toml. Its [[coins]] record contains x/y values.

  1. src/shared/serializer_parse.c declares the schema entry ROOT_TABLE_ARRAY("coins", XY_FIELDS, MAX_COINS): each element must be a table whose only keys are x and y, each a finite number (an omitted one reads as 0), and the array may not exceed MAX_COINS. Unknown fields are rejected.
  2. serializer_load_collectibles() (src/shared/serializer_load_collectibles.c) copies those values into LevelDef.coins with its LOAD_XY_ARRAY macro.
  3. level_validate_runtime() (src/levels/level_validate.c) first checks every count in level_validate_counts(), then checks that each coin lies inside the world.
  4. load_coins() in src/levels/level_loader.c converts each immutable CoinPlacement into an active runtime Coin. Its row in the s_level_loaders table is marked LOAD_ONCE: loading a level runs it, but level_reset() (a life loss) runs only the LOAD_EVERY_LIFE rows, so collected coins stay gone, and game_restart_after_game_over() in src/collision/collision_damage.c reactivates them for Retry.
  5. game_collide() in src/collision/game_collision.c tests the player’s hitbox with rect_intersects(), deactivates the coin, calls game_award_score(gs, gs->world.rules.coin_score) and plays the pickup sound.
  6. coins_render() in src/collectibles/coin.c draws only active entries, subtracting camera x at draw time. render_coins() in src/render/game_render.c calls it; that function is row 15 of the s_render_layers table, so coins are drawn over the ropes (row 14) and under the stars (row 16).
  7. The editor saves the same LevelDef through src/shared/serializer_save.c, where write_world_and_collectibles() emits each [[coins]] table.

The parser does not live in level_loader.c: that module translates an already validated model into runtime state. The shared serializer belongs to both applications.

Compare the health stars. Yellow, green and red stars are one module, src/collectibles/health_star.c: one HealthStar struct, one health_stars_render() and one health_star_get_hitbox(), while the level file keeps three arrays ([[star_yellows]], [[star_greens]], [[star_reds]]). A single collect_health_stars() loop in game_collision.c serves all three colours. Stars award no score, so level_reset() brings them back after every life loss. Coins award score, so they stay collected until a fresh attempt.

2. Define the Token contract

For this exercise, Token is a bounded x/y collectible worth 250 points. It uses the existing coin texture with a tint and does not restore health. Put the values in named constants. Pass the tint as the sprite_draw() tint argument: raylib tints one draw call, so the shared coin texture is not recolored. Keep sprite ownership in TextureResources; a Token borrows that texture and must never free it.

/* src/collectibles/token.h */
#pragma once
#include "../shared/graphics.h"
#define MAX_TOKENS 32
#define TOKEN_SCORE 250
typedef struct { float x, y; int active; } Token;
void tokens_render(const Token *items, int count, Texture2D *texture, int camera_x);

Implement tokens_render() by following coins_render() in src/collectibles/coin.c: skip inactive entries, build a 16×16 destination, subtract camera_x and draw the borrowed texture. The Makefile discovers a new .c in src/collectibles/ automatically.

3. Add data and round-trip support

File Required change
src/levels/level.h Include the Token header (for MAX_TOKENS); add TokenPlacement { float x, y; }, a bounded placement array and a count to LevelDef
src/game.h Include the Token header; add the runtime array and count to GameWorld, the gs->world part of GameState
src/shared/serializer_parse.c Register ROOT_TABLE_ARRAY("tokens", XY_FIELDS, MAX_TOKENS)
src/shared/serializer_load_collectibles.c Add LOAD_XY_ARRAY("tokens", token_count, MAX_TOKENS, tokens)
src/shared/serializer_save.c In write_world_and_collectibles(), emit every token as [[tokens]] with x/y through the existing float formatter
src/levels/level_validate.c Add CHECK_COUNT(token_count, MAX_TOKENS) to level_validate_counts() and a world-bounds check per placement to level_validate_runtime()
tools/validate_levels.py Add "tokens" to COUNT_LIMITS (it reads MAX_TOKENS from the headers) and to the schema table as XY_FIELDS

Keep format_version = 1 at the document root. Unknown fields must still be rejected; do not loosen schema validation to make the exercise pass.

4. Add runtime behavior

In level_loader.c, add a small load_tokens() that copies placement positions into world->tokens and sets active = 1, then add its row to the s_level_loaders table just below the coins: { load_tokens, LOAD_ONCE }. Token awards score, so follow the coin rule: LOAD_ONCE, not LOAD_EVERY_LIFE, or level_reset() would bring tokens back after every lost life and a player could farm points and bonus lives by dying on purpose. Reactivate tokens beside the coins in game_restart_after_game_over() so Retry starts a fresh attempt.

Drawing is the second table. In src/render/game_render.c, write render_tokens() beside render_coins():

/* Layer 16: tokens. */
static void render_tokens(GameState *gs, int cam_x)
{
    tokens_render(gs->world.tokens, gs->world.token_count,
                  gs->assets.textures.coin, cam_x);
}

Then give it a row in s_render_layers just below the coins, { 16, "tokens", render_tokens },, and add one to the number of every row after it. The manual lists the same layers: add a Tokens row that names tokens_render as layer 16 of the render order table and renumber the rows after it there too. make docs-drift compares the two tables row by row and names each row that differs. It also checks the layer count quoted elsewhere, for example in the README, so those need the new number.

A Token does not move, so it has no row in s_update_steps.

In game_collide(), follow the coin collision loop. On overlap, clear active, call game_award_score(gs, TOKEN_SCORE) and optionally play the existing pickup sound. Use that score helper rather than adding directly: it implements bonus lives and saturation. Keep the existing early returns after death/respawn so a stale hitbox cannot collect an item at the previous location.

The simulation advances in fixed 1/60 s steps, so game_collide() runs once per step, not once per rendered frame. Keep pickup logic free of frame timing.

Add Token hitboxes to draw_collision_boxes() in src/core/debug.c. Prefer the entity’s _get_hitbox helper when it has one, so the box you see is the box collision uses:

for (int i = 0; i < gs->world.token_count; i++)
    if (gs->world.tokens[i].active)
        outline((IntRect){(int)gs->world.tokens[i].x, (int)gs->world.tokens[i].y, 16, 16},
                cam, (Color){255, 255, 0, 255});

A frozen (F2) frame must draw without mutating the Token. Log significant events with debug_log() in the module that owns them, as the coin pickup does in game_collision.c.

If you later give Token its own sprite, add one row to a texture table in src/core/game_resources.c; loading and the reverse-order release then happen in the shared loops, with no hand-written load/free code:

{ TEX_FIELD(token), "assets/sprites/collectibles/token.png", "token.png" },

s_required_textures is for sprites every level needs. A sprite only some levels use goes in s_optional_textures, plus a REQUIRE(token, def->token_count) line in game_resources_require_level_textures(), so a level that places Tokens is rejected with the asset path when the file is missing.

5. Complete editor integration

Most of what the editor knows about a type is data in one table, so start there. Add ENT_TOKEN to EntityType in src/editor/editor.h, just before ENT_COUNT, then give it a row in s_entity_meta (src/editor/entity_meta.c) modelled on the ENT_COIN row:

[ENT_TOKEN] = {
    .type = ENT_TOKEN, .type_name = "Token", .palette_name = "Token",
    .category = EDITOR_ENTITY_CATEGORY_COLLECTIBLES,
    .capacity = MAX_TOKENS, STORED_IN(tokens, token_count),
    .preview = { TEXTURE(coin), .w = COIN_DISPLAY_W, .h = COIN_DISPLAY_H },
},

That one row gives Token its name, its palette group, its capacity, where its placements live in LevelDef (STORED_IN records the array and count with offsetof) and the sprite the Place tool shows under the cursor. Reading, writing, inserting and removing placements, counts and capacity checks, undo, the palette and the status-bar entity total all work from it. Both s_entity_meta and the property-panel table are sized by their rows, so until the Token row exists a _Static_assert stops the build with “needs one row per EntityType”.

The rest is behaviour: code that does different arithmetic on each placement struct. Follow the existing ENT_COIN code in each place:

File Integration
entity_meta.c Besides the row above, put ENT_TOKEN in s_palette_order, the order the palette lists types in
undo.h Add TokenPlacement token; to the PlacementData union
properties.c Write draw_token_properties() with x/y fields that call editor_commit_change(), and point [ENT_TOKEN] at it in s_property_panels
hit_test.c Add a bounds case to editor_entity_bounds() and put ENT_TOKEN into s_hit_order, which is the exact reverse of the canvas draw order
canvas.c A render function for Tokens, called from canvas_render() beside render_coins()
tools.c Clamp case in editor_clamp_placement(), move case in editor_move_placement() (drag, nudge and paste all move through it) and defaults in default_placement()
editor_session.c Add EDITOR_HASH_ARRAY for tokens to the document hash so the dirty marker sees Token edits

You do not have to remember the switches: they have no default: case, so with -Wall the compiler names every switch that has no ENT_TOKEN case yet. The palette lists types from entity_meta.c, so palette.c needs no change. Undo needs no new code in editor_undo_apply.c: commands are built with undo_push(stack, const Command *) and store PlacementData snapshots that are applied through the shared editor_entity_* helpers.

These editor paths all operate on LevelDef, not live runtime objects. Copy/paste and undo must preserve selection indices after array insertion/removal. Do not change fields directly from widgets without the established change-tracking callbacks, or a visually successful edit may disappear from undo history.

6. Prove the whole path

Create an editor copy of the collision lab, place two Tokens, edit one, save, reopen, playtest and collect them. Undo/redo placement, deletion and a property edit; save and verify that returning to the save point clears the dirty marker.

Extend the rich serializer fixture (fill_rich_roundtrip_fixture() in tests/level_serializer_test.c) with Tokens and extend compare_rich_roundtrip() to prove the round-trip. tests/editor_validation_test.c already loops over every palette type for storage and placement, and checks that every type has a complete table row whose storage changes only its own count, so it covers ENT_TOKEN once it is in the palette. A gameplay case should prove that one pickup awards 250 points exactly once, and tests/runtime_load_test.c should prove a life-loss level_reset() keeps collected Tokens gone, as it does for coins. Reuse the count/bounds fixtures for invalid data.

make builder test CC=clang
make validate-levels
make level-catalog content-inventory
make docs-drift
make sanitize CC=clang

Add tokens to the Collectibles count group in tools/generate_level_catalog.py, then update the source map and relevant manual references. You are finished when Token works through file load, runtime, editor, undo, clipboard, validation and save/load, not merely when the sprite appears on screen.

7. Entities that move or hurt

A Token sits still. An enemy or hazard adds three things:

  • An update function. Write one that walks the whole array, in the shape of circular_saws_update(CircularSaw *saws, int count, float dt), named after your new type. Then, in src/core/game_update.c, write a small update_<things>() beside update_circular_saws() that calls it with the inputs it needs, and add one row to the s_update_steps table:

    static void update_circular_saws(GameState *gs, const StepInputs *in)
    {
        circular_saws_update(gs->world.circular_saws, gs->world.circular_saw_count, in->dt);
    }

    StepInputs carries what a step hands to every row: the step time (in->dt, always the fixed 1/60 s), the camera’s left edge (in->cam_x) and what the player landed on (in->support). Everything else comes from gs. The row’s place in the table is when your type moves in the step: above the collision row if it can hurt the player, so its hitbox is tested where this step put it. Add the row to the update order table in the manual as well; make docs-drift compares the two. See Developer Guide for the physics pattern.

  • Damage. In src/collision/game_collision.c, write a <thing>_touches() function beside circular_saw_touches(): skip an inactive item, build its box with your _get_hitbox helper and test it against the player. Then add one row to the s_damage_sources table: the name the debug log prints, WORLD_COUNT(<things>_count) and your function. The row’s place in the table is when it is tested; the first hit in a step wins. game_collide() passes that hit to apply_damage() in src/collision/collision_damage.c, which handles hearts, knockback and the hurt-immunity timer.

  • Render order. As for the Token in section 4: a render_<things>() function and a row in s_render_layers at the right depth. The render order table lists every layer. Enemies go between the moving hazards and the player.

New .c files in an existing src/ subdirectory are picked up by the Makefile automatically. A brand-new source directory needs its own wildcard line there.

Checklist

  • src/<category>/<entity>.h and .c with render (and update) functions over the whole array, plus a _get_hitbox helper if it collides
  • Placement struct, array and count in LevelDef (src/levels/level.h); runtime array and count in GameWorld (src/game.h), stored by value
  • Schema entry, loader, saver and C/Python validation (section 3)
  • load_<entities>() in level_loader.c and its s_level_loaders row: LOAD_EVERY_LIFE unless it awards score (then LOAD_ONCE)
  • If it moves: update_<entities>() in src/core/game_update.c and its s_update_steps row, above collision if it can hurt
  • If it hurts: a _touches() function and its s_damage_sources row in src/collision/game_collision.c; if it is collected, pickup code in game_collide()
  • render_<entities>() in src/render/game_render.c and its s_render_layers row at the right layer, with the rows after it renumbered
  • The same rows in the manual’s update order and render order tables (make docs-drift compares them with the code)
  • Texture row in game_resources.c (and game_resources_require_level_textures() if optional)
  • Hitbox in src/core/debug.c and debug_log() calls for significant events
  • Editor integration (section 5): the s_entity_meta row and palette slot, undo union, property panel, hit test, canvas, tools (whose move case also moves pasted copies), document hash
  • Tests (section 6), then make builder test CC=clang, make validate-levels, make docs-drift
  • Play it with make run-level-debug LEVEL=... and check the hitboxes match the sprite