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.
src/shared/serializer_parse.cdeclares the schema entryROOT_TABLE_ARRAY("coins", XY_FIELDS, MAX_COINS): each element must be a table whose only keys arexandy, each a finite number (an omitted one reads as 0), and the array may not exceedMAX_COINS. Unknown fields are rejected.serializer_load_collectibles()(src/shared/serializer_load_collectibles.c) copies those values intoLevelDef.coinswith itsLOAD_XY_ARRAYmacro.level_validate_runtime()(src/levels/level_validate.c) first checks every count inlevel_validate_counts(), then checks that each coin lies inside the world.load_coins()insrc/levels/level_loader.cconverts each immutableCoinPlacementinto an active runtimeCoin. Its row in thes_level_loaderstable is markedLOAD_ONCE: loading a level runs it, butlevel_reset()(a life loss) runs only theLOAD_EVERY_LIFErows, so collected coins stay gone, andgame_restart_after_game_over()insrc/collision/collision_damage.creactivates them for Retry.game_collide()insrc/collision/game_collision.ctests the player’s hitbox withrect_intersects(), deactivates the coin, callsgame_award_score(gs, gs->world.rules.coin_score)and plays the pickup sound.coins_render()insrc/collectibles/coin.cdraws only active entries, subtracting camera x at draw time.render_coins()insrc/render/game_render.ccalls it; that function is row 15 of thes_render_layerstable, so coins are drawn over the ropes (row 14) and under the stars (row 16).- The editor saves the same
LevelDefthroughsrc/shared/serializer_save.c, wherewrite_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, insrc/core/game_update.c, write a smallupdate_<things>()besideupdate_circular_saws()that calls it with the inputs it needs, and add one row to thes_update_stepstable:static void update_circular_saws(GameState *gs, const StepInputs *in) { circular_saws_update(gs->world.circular_saws, gs->world.circular_saw_count, in->dt); }StepInputscarries 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 fromgs. The row’s place in the table is when your type moves in the step: above thecollisionrow 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-driftcompares the two. See Developer Guide for the physics pattern. -
Damage. In
src/collision/game_collision.c, write a<thing>_touches()function besidecircular_saw_touches(): skip an inactive item, build its box with your_get_hitboxhelper and test it against the player. Then add one row to thes_damage_sourcestable: 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 toapply_damage()insrc/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 ins_render_layersat 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>.hand.cwith render (and update) functions over the whole array, plus a_get_hitboxhelper if it collides - Placement struct, array and count in
LevelDef(src/levels/level.h); runtime array and count inGameWorld(src/game.h), stored by value - Schema entry, loader, saver and C/Python validation (section 3)
-
load_<entities>()inlevel_loader.cand itss_level_loadersrow:LOAD_EVERY_LIFEunless it awards score (thenLOAD_ONCE) - If it moves:
update_<entities>()insrc/core/game_update.cand itss_update_stepsrow, abovecollisionif it can hurt - If it hurts: a
_touches()function and itss_damage_sourcesrow insrc/collision/game_collision.c; if it is collected, pickup code ingame_collide() -
render_<entities>()insrc/render/game_render.cand itss_render_layersrow 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-driftcompares them with the code) - Texture row in
game_resources.c(andgame_resources_require_level_textures()if optional) - Hitbox in
src/core/debug.canddebug_log()calls for significant events - Editor integration (section 5): the
s_entity_metarow 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