Two readers were given the library, the art, the chapter, and no reference
program, and told to compile and run. Both got a working game. These are the
defects that only came out because a compiler and a frame loop were involved --
four earlier read-only reviews had missed every one.
libakstdlib's header was never named. The chapters used aksl_strncpy,
aksl_snprintf and aksl_atoi throughout and said only "libakstdlib is documented
in deps/libakstdlib"; the obvious guess from the function prefix is <aksl.h> and
it is wrong. That was the only compile error the sidescroller reader hit, and it
was the chapter's fault. Both chapters now carry a per-file include table and say
which header it is.
The sidescroller's asset directory defaulted to ".". The chapter said the code
"falls back to `.`" without showing the #ifndef or that assetdir is initialised
from the macro, so a reader gets a game that only finds its art when launched
from exactly the right directory.
The sidescroller's summary line reported 0.0,0.0 airborne while the screenshot
showed the player standing on the ground. Teardown runs in main's CLEANUP block
and releases the actor pool before the summary prints. The chapter published an
example output a reader could not reproduce; the capture that makes it true is
now shown beside it.
The JRPG's CMake block interpolated ${JRPG_REPO_ROOT}, which is this repository's
own variable and undefined for anybody else -- so the font path came out wrong
and akgl_text_loadfont failed long after everything else had loaded. It is
${CMAKE_CURRENT_SOURCE_DIR} now, and the chapter says why both definitions are
needed rather than one.
Chapter 21 described --demo and a summary line in its build section that no step
wrote. Both are now a section: the script table, playback through
akgl_controller_handle_event rather than the handlers, the fixed clock step, and
printing the position before teardown. It also now says libakgl keeps no frame
counter, which a reader assumed it did.
Both map sections now say to draw it in Tiled and point at chapter 13, naming the
fields the loader needs that Tiled fills in automatically -- both readers
hand-wrote a .tmj before being given one and hit exactly those.
Excerpts across docs/ go from 190 to 198.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
60 KiB
20. Tutorial: a 2D sidescroller
This chapter builds the game in that picture, from an empty directory. When you finish, you will have a program that opens a window, loads a level drawn in Tiled, and runs a character who walks, jumps, lands on platforms, collects coins and dies on hazards.
The finished program is examples/sidescroller/ in this repository, and every listing below
is quoted from it by the docs_examples test — so nothing here can describe code that does
not exist. You can read that program at any point, but you should not need to: the steps
below are complete.
Before you start
You need a C compiler, CMake 3.10 or newer, and libakgl built. Chapter 3 covers getting the library on your machine. You do not need to have read any other chapter.
Two conventions used throughout, both explained where they first appear:
- Every libakgl function returns
akerr_ErrorContext AKERR_NOIGNORE *. That is how failures travel. Chapter 4 is the reference; this chapter shows you the four macros you actually need. - Every symbol this game defines is prefixed
ss_. Theakgl_prefix belongs to the library. A game built on it takes its own.
First principles: what libakgl does for you
libakgl is a library, not an engine. It does not own your main, it has no editor, and it
never calls your code except through function pointers you install. You write a program that
calls it.
Five ideas hold the whole thing up. Read these once; the rest of the chapter is built out of them.
A spritesheet is one image file. A sprite is an animation cut out of it. You give libakgl a PNG and say how big one frame is; a sprite names that sheet and lists which frames to play, in what order, at what speed.
A character is a mapping from state to sprite. An actor carries a 32-bit state word
made of bits like AKGL_ACTOR_STATE_MOVING_LEFT and AKGL_ACTOR_STATE_FACE_RIGHT. A
character says "when the state is exactly these bits, draw this sprite". One character is
shared by every goblin in the game.
An actor is one thing in the world. It has a position, a velocity, a state word, and a
pointer to the character that tells it how to look. Actors come from a fixed pool inside the
library — you never call malloc.
A tilemap is the level. It is a Tiled .tmj file. libakgl draws
its tile layers, and creates an actor for every object in its object layers.
Four things are library globals you use rather than create. You do not declare these; including the right header is enough:
| Global | Is |
|---|---|
akgl_game |
The game's own name, version, frame rate and hooks |
akgl_renderer |
The render backend. akgl_render_2d_init fills it in |
akgl_physics |
The physics backend. akgl_physics_init_arcade fills it in |
akgl_camera |
An SDL_FRect in map pixels. Moving it is scrolling |
akgl_gamemap |
The level. It already points at storage the library owns |
akgl_heap_actors |
The actor pool, as an array of AKGL_MAX_HEAP_ACTOR slots |
One call per frame does the work. akgl_game_update() updates every actor, steps the
physics, and draws the world. Your frame loop reads input, then calls it.
The steps
- Set up the project — a directory, a
CMakeLists.txt, and a header for your own declarations. - Open a window and run a frame loop — the startup order that works, and the shortest program that successfully draws nothing.
- Describe your art — the spritesheet and sprite JSON files.
- Bind sprites to states — the character JSON file.
- Draw a level — a Tiled map, its tile layers, and the objects that become actors.
- Load the level — in the one order that works, and honour the physics the map carries.
- Turn on collision — a collision world, a shape on the player, and a
collidablelayer. - Make the player walk — control maps, and the two hooks an actor gives you.
- Make the player jump — an impulse, and how to know you are on the ground.
- Scroll the camera — three lines.
- Collect coins and die on hazards — overlap tests, and how to remove an actor.
- Add moving enemies — a blob that patrols and a moth that flies.
- Tear down — the order that matters.
The steps are cumulative — each one adds to the same files rather than replacing them. Steps 3, 4 and 5 produce data files rather than code; step 1 produces a header. From step 2 onward the program builds and runs at the end of every step, and the text says what you should see. Where a step refers forward to a function a later step writes, it says so and tells you what to leave out until then.
1. Set up the project
Make a directory with four files:
sidescroller/
CMakeLists.txt
sidescroller.h your own declarations, shared between the .c files
main.c startup, the frame loop, teardown
player.c the player's behaviour and controls
actors.c everything else the map places
Splitting into three .c files is not required — it is what keeps each one readable.
A complete CMakeLists.txt for a game built beside libakgl:
cmake_minimum_required(VERSION 3.10)
project(sidescroller VERSION 1.0.0 LANGUAGES C)
# libakgl and its dependencies. add_subdirectory if you have the source beside
# you; find_package if you have it installed. Chapter 3 covers both.
add_subdirectory(../libakgl libakgl)
add_executable(sidescroller
main.c
player.c
actors.c
)
target_include_directories(sidescroller PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(sidescroller
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf
SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
target_compile_definitions(sidescroller
PRIVATE "SS_ASSET_DIR=\"${CMAKE_CURRENT_SOURCE_DIR}/assets\"")
The parts that matter:
Each .c file includes sidescroller.h plus whatever it calls directly:
| File | Adds |
|---|---|
main.c |
<string.h>, akstdlib.h, SDL3_image/SDL_image.h, SDL3_mixer/SDL_mixer.h, SDL3_ttf/SDL_ttf.h, akgl/character.h, akgl/controller.h, akgl/heap.h, akgl/registry.h, akgl/renderer.h, akgl/sprite.h, akgl/text.h, akgl/tilemap.h |
player.c |
<math.h>, akstdlib.h, akgl/controller.h, akgl/heap.h, akgl/physics.h, akgl/registry.h, akgl/util.h |
actors.c |
<math.h>, akstdlib.h, akgl/heap.h, akgl/registry.h |
libakstdlib's header is <akstdlib.h>, not <aksl.h> — its functions are prefixed
aksl_ but the file is not. libakerror's is <akerror.h>. libakgl's own headers are
self-contained, so including the one that declares what you are calling is always enough.
The build file:
- You link all four SDL libraries even though this game has no text and no sound, because libakgl itself is built against them.
SS_ASSET_DIRis baked in at compile time, so the program can be run from any working directory. Give it a fallback so the file still compiles without CMake, and use it as the default: a program that defaults to"."only finds its assets when it happens to be launched from the right directory.
#ifndef SS_ASSET_DIR
#define SS_ASSET_DIR "."
#endif
char *assetdir = SS_ASSET_DIR;
The header
sidescroller.h holds the constants and declarations the three .c files share. Start it
with the level's geometry:
#define SS_TILE_SIZE 16 /* Pixels per map cell, from level1.tmj */
#define SS_VIEW_WIDTH 480 /* Camera width in map pixels */
#define SS_VIEW_HEIGHT 240 /* Camera height; the whole map is this tall */
#define SS_WINDOW_SCALE 2 /* Integer upscale from the view to the window */
How many of each thing the level places, and how the player moves:
#define SS_COIN_COUNT 4
#define SS_HAZARD_COUNT 2
#define SS_JUMP_SPEED 420.0f
#define SS_PLAYER_BOX_X 8.0f
#define SS_PLAYER_BOX_Y 0.0f
#define SS_PLAYER_BOX_W 16.0f
#define SS_PLAYER_BOX_H 32.0f
The player's collision box is an offset into its 32×32 sprite frame — step 7 explains the inset.
Per-actor data the library has no field for. akgl_Actor::actorData is a void * the
library never reads or frees, and this is what this game hangs off it:
typedef struct {
float32_t home_x; /**< Where the map placed this actor. The moth orbits it; the player respawns at it. */
float32_t home_y;
float32_t phase; /**< Seconds of flight, for the moth's orbit. */
float32_t facing; /**< -1.0 walking left, +1.0 walking right. The blob's patrol direction. */
} ss_ActorData;
And the game state — one struct, one instance, declared extern here and defined in
main.c:
typedef struct {
akgl_Actor *player; /**< Borrowed from the actor pool; the map created it. */
akgl_Actor *coins[SS_COIN_COUNT]; /**< Cleared to NULL as each one is collected. */
akgl_Actor *hazards[SS_HAZARD_COUNT]; /**< The blob and the moth. Borrowed, never released. */
int coins_taken;
int deaths;
bool jump_requested; /**< Set by the jump binding, consumed by the movement logic. */
bool grounded; /**< Last step's verdict; what gates the next jump. */
bool autoplay; /**< Drive the player from a script instead of the keyboard. */
int frame; /**< Frames drawn so far. */
float32_t final_x; /**< Where the player finished, stamped before teardown for the summary line. */
float32_t final_y;
} ss_Game;
Fixed arrays, not allocations. The level places a known number of things.
What the header has to include, and declare
Everything the three .c files share goes here. The includes first:
#include <stdbool.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/collision.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/physics.h>
#include <akgl/tilemap.h>
#include <akgl/types.h>
Then the two globals main.c defines, and the functions the other files call:
extern ss_Game ss_game;
extern akgl_CollisionWorld ss_collision;
akerr_ErrorContext AKERR_NOIGNORE *ss_grounded(akgl_CollisionShape *shape, float32_t x, float32_t y, bool *dest);
/* player.c */
akerr_ErrorContext AKERR_NOIGNORE *ss_player_bind(akgl_Actor *obj);
akerr_ErrorContext AKERR_NOIGNORE *ss_player_controls(int controlmapid, char *actorname);
akerr_ErrorContext AKERR_NOIGNORE *ss_player_autoplay(int frame);
/* actors.c */
akerr_ErrorContext AKERR_NOIGNORE *ss_actors_bind(void);
Those five are what the steps below fill in:
| Function | Defined in | Called from | Step |
|---|---|---|---|
ss_grounded |
main.c |
player.c, actors.c |
9 |
ss_player_bind |
player.c |
main.c, after the map loads |
7, 8 |
ss_player_controls |
player.c |
main.c, after the map loads |
8 |
ss_actors_bind |
actors.c |
main.c, after the map loads |
12 |
ss_player_autoplay |
player.c |
the frame loop, only with --autoplay |
— |
AKERR_NOIGNORE on a declaration is what makes the compiler warn if a caller
throws the return value away. Put it on every function of your own that returns
an error context.
Wrap the whole file in the usual include guard.
2. Open a window and run a frame loop
How a libakgl function reports failure
Every call returns a pointer. NULL means success; anything else is an error context
carrying a status, a message and a stack trace. You never check it by hand — four macros do
that for you.
#include <akerror.h>
#include <akgl/game.h>
#include <akgl/registry.h>
/* A function that calls libakgl and can fail. */
akerr_ErrorContext AKERR_NOIGNORE *my_setup(void)
{
PREPARE_ERROR(errctx); /* declares errctx; always first */
FAIL_ZERO_RETURN(errctx, akgl_gamemap, AKERR_NULLPOINTER, "no map");
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
SUCCEED_RETURN(errctx); /* always last */
}
PREPARE_ERROR(errctx)declares the local context. It is the first line of the function.PASS(errctx, call)makes the call and, if it failed, returns the error to your caller with your function added to the trace. This is what you write most of the time.FAIL_ZERO_RETURN(errctx, ptr, status, msg)returns an error ifptrisNULL. Use it on every pointer parameter before you dereference it.SUCCEED_RETURN(errctx)returnsNULL. It is the last line of the function.
That is enough to write this entire game. Chapter 4 covers the rest, including how to handle an error rather than propagate it — which you need exactly once, in step 13.
Startup, in order
libakgl has one startup sequence that works:
- Fill in
akgl_game.name,.versionand.uri.akgl_game_initrefuses to run without all three — they become the window title and SDL's application metadata. - Call
akgl_game_init(). - Set the configuration properties. Before the renderer, because the renderer reads them.
- Call
akgl_render_2d_init(akgl_renderer). - Call
akgl_physics_init_arcade(akgl_physics).
Here is the first part:
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.name,
sizeof(akgl_game.name),
"libakgl sidescroller tutorial",
sizeof(akgl_game.name) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.version,
sizeof(akgl_game.version),
"1.0.0",
sizeof(akgl_game.version) - 1));
PASS(errctx, aksl_strncpy(
(char *)&akgl_game.uri,
sizeof(akgl_game.uri),
"net.aklabs.libakgl.sidescroller",
sizeof(akgl_game.uri) - 1));
PASS(errctx, akgl_game_init());
version is your game's, in any form you like. uri is a reverse-DNS application identifier
— SDL uses it as the application id, and libakgl stamps it into savegames so a file from
another game is refused.
aksl_strncpy rather than strncpy: it reports a truncation as an error instead of quietly
producing a shortened string. The same goes for aksl_snprintf and aksl_atoi later on.
libakstdlib is documented in deps/libakstdlib.
Then the properties and the renderer:
PASS(errctx, akgl_set_property("game.screenwidth", "960"));
PASS(errctx, akgl_set_property("game.screenheight", "480"));
PASS(errctx, akgl_render_2d_init(akgl_renderer));
An unset property defaults to the string "0", which asks SDL for a zero-sized window — so
set them before this call, not after.
Scaling up the pixels
libakgl draws in map pixels. A 16-pixel tile is 16 screen pixels, which is very small on a modern display. Ask SDL to scale the whole picture by whole multiples:
SDL_SetRenderLogicalPresentation(
akgl_renderer->sdl_renderer,
SS_VIEW_WIDTH,
SS_VIEW_HEIGHT,
SDL_LOGICAL_PRESENTATION_INTEGER_SCALE),
The game now renders a 480×240 view into a 960×480 window. Tell the camera the same thing —
the camera is what the game looks through, and akgl_render_2d_init sized it from the
window:
akgl_camera->x = 0.0f;
akgl_camera->y = 0.0f;
akgl_camera->w = (float32_t)SS_VIEW_WIDTH;
akgl_camera->h = (float32_t)SS_VIEW_HEIGHT;
Choosing a physics backend
akgl_game_init does not choose one. Do it yourself:
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
Without this line the first frame calls through a null function pointer. Making the library
pick a default is tracked in TODO.md under "Known and still open"; until it does, this call
belongs in every libakgl program.
The frame loop
Three things happen per frame: drain the event queue, position the camera, and call
akgl_game_update. The library does not clear or present the target, so you bracket that
call with the backend's own frame_start and frame_end:
static akerr_ErrorContext *frame(bool *running)
{
SDL_Event event;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, running, AKERR_NULLPOINTER, "running");
while ( SDL_PollEvent(&event) == true ) {
if ( event.type == SDL_EVENT_QUIT ) {
*running = false;
}
/* Every event, unconditionally: one that no control map binds is not an
* error, it is a call that did nothing. */
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &event));
}
Hand every event to akgl_controller_handle_event. An event nothing is bound to is not an
error; it is a call that does nothing.
The first argument is the app-state pointer SDL's callback API passes around. Nothing in
libakgl reads it; it is required only as a non-NULL token, so any non-NULL pointer will
do. &akgl_game.state is a convenient one — it is a struct of application-defined flags the
library also never reads, and it is already there.
Then the drawing half of the same function:
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
akgl_game_update(NULL) goes between frame_start and frame_end. The NULL means "no
iterator options" — update every actor on every layer:
PASS(errctx, akgl_game_update(NULL));
and then present what it drew, which ends the function:
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
SUCCEED_RETURN(errctx);
}
The outer loop calls that once per frame, and sleeps so it does not spin:
static akerr_ErrorContext *run(int frames)
{
bool running = true;
PREPARE_ERROR(errctx);
while ( running == true ) {
PASS(errctx, frame(&running));
if ( (frames > 0) && (ss_game.frame >= frames) ) {
running = false;
}
/* A crude frame limiter. A game with a window on a real display should
* ask SDL for vsync instead; this one has to work under the dummy video
* driver, where there is nothing to sync to. */
SDL_Delay(16);
}
The frame counter is incremented inside frame(), between the events and the drawing:
ss_game.frame += 1;
if ( ss_game.autoplay == true ) {
PASS(errctx, ss_player_autoplay(ss_game.frame));
}
PASS(errctx, update_camera());
update_camera is step 10 and ss_player_autoplay is the scripted-input hook described at
the end of the chapter. Leave both out for now — a frame() that polls events, calls
frame_start, akgl_game_update(NULL) and frame_end is a complete program.
Build and run now. You get a window of the renderer's clear colour and nothing else. That is correct — there is nothing in the world yet.
Silencing the frame-rate warning
akgl_game.lowfpsfunc is called on every frame the frame rate is under 30, and
akgl_game.fps reads 0 until the first second has elapsed — so the default handler logs a
line per frame for the first second of every run. Install your own:
static void ss_lowfps(void)
{
}
Assign it right after akgl_game_init():
akgl_game.lowfpsfunc = &ss_lowfps;
The hook exists so a game can shed work when it is running slowly. This one has nothing to
shed. The first-second false positive is recorded in TODO.md.
3. Describe your art
Put your PNG in the asset directory. This game uses a 32×32-per-frame sheet for the player, laid out left to right.
A sprite is a JSON file naming the sheet and listing frames:
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_run_right",
"width": 32,
"height": 32,
"speed": 90,
"loop": true,
"loopReverse": false,
"frames": [
0,
1,
2,
1
]
}
| Field | Means |
|---|---|
spritesheet.filename |
The PNG, resolved relative to this file |
spritesheet.frame_width/_height |
How the sheet is cut into numbered frames, left to right then top to bottom |
name |
The registry name. This is how a character asks for it |
width/height |
How big to draw it. Usually the same as the frame size |
speed |
Milliseconds per frame |
loop |
Restart at the end, rather than holding the last frame |
frames |
Frame numbers, in play order. 0,1,2,1 is a four-step walk cycle from three drawings |
A still image is the same file with one frame and "loop": false.
This game needs nine sprites: idle, run and jump for the player facing each way, plus one each for the coin, the blob and the moth. Load them from a list:
static char *ss_sprite_files[] = {
"sprite_ss_player_idle_left.json", /* one frame, held */
"sprite_ss_player_idle_right.json",
"sprite_ss_player_run_left.json", /* four frames at 90 ms */
"sprite_ss_player_run_right.json",
"sprite_ss_player_jump_left.json", /* one frame, held for the whole arc */
"sprite_ss_player_jump_right.json",
"sprite_ss_coin.json",
"sprite_ss_hazard_blob.json",
"sprite_ss_hazard_moth.json",
NULL
};
Two sprites naming the same PNG share one texture. The spritesheet registry is keyed on the
file path, so player.png is decoded and uploaded once no matter how many sprites use it.
4. Bind sprites to states
An actor's whole 32-bit state word is the key that picks a sprite. A character is the
table that maps one to the other:
{
"name": "ss_player",
"speedtime": 120,
"speed_x": 90.0,
"speed_y": 0.0,
"acceleration_x": 600.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_player_run_right"
}
]
}
| Field | Means |
|---|---|
name |
Registry name. A Tiled object asks for it |
speed_x/speed_y |
Top speed on each axis, pixels per second |
acceleration_x/_y |
How fast the actor gets to that speed |
speedtime |
Milliseconds between animation frame advances |
sprite_mappings[].state |
The state bits, named. They are OR'd together |
sprite_mappings[].sprite |
The sprite's registry name |
A mapping matches the whole state word, not a subset. ALIVE|FACE_RIGHT and
ALIVE|FACE_RIGHT|MOVING_RIGHT are two different keys and need two entries. An actor whose
state matches no entry is silently not drawn — if a character disappears, this is the first
thing to check.
The player needs ten entries: idle and running each way, and jumping each way, with and
without a horizontal direction held. The full file is
docs/tutorials/assets/sidescroller/character_ss_player.json.
speed_y is 0.0 deliberately. This character never thrusts upward — jumping is an
impulse, added in step 9, and a zero vertical top speed keeps that impulse out of the physics
engine's speed cap.
Load characters after sprites:
static char *ss_character_files[] = {
"character_ss_player.json",
"character_ss_coin.json",
"character_ss_hazard_blob.json",
"character_ss_hazard_moth.json",
NULL
};
A character's JSON names its sprites by registry name and the loader resolves each one as it reads. Load a character before its sprites and it fails on the first name it cannot find.
5. Draw a level
Draw the map in Tiled and save it as .tmj. The rest of this
section is what to set in the editor; Tiled writes the JSON. Chapter 13 is
the reference for the format itself, and for what libakgl reads out of it and what it
ignores — read that before hand-editing a .tmj, because the loader needs several fields
Tiled fills in automatically (a root-level width and height, an id on every layer, and
tilecount, columns, imagewidth and imageheight on every tileset).
Make a new map and set it up like this:
| Setting | Value |
|---|---|
| Orientation | Orthogonal |
| Tile layer format | CSV (libakgl does not read compressed layer data) |
| Tile size | 16 × 16 |
| Map size | 40 × 15 tiles |
Add your tileset image, then three layers:
| Layer | Type | What it is |
|---|---|---|
background |
Tile layer | Sky, clouds. Drawn, never solid |
terrain |
Tile layer | Ground and platforms |
actors |
Object layer | Where things start |
Making a layer solid
Select the terrain layer and add a custom property:
| Property | Type | Value |
|---|---|---|
collidable |
bool | true |
In the saved .tmj that is:
"properties": [
{
"name": "collidable",
"type": "bool",
"value": true
}
]
Every non-empty cell of a collidable layer is solid. A layer without the property is drawn
and nothing more.
Placing actors
On the actors layer, place a rectangle where each thing starts. Give each one:
- a Name — this is how your code finds it, so
player,coin1,blob1 - a Type of
actor— this is what tells libakgl to create one - a custom property
character(string) naming the character it uses - a custom property
state(int) — the starting state word
In the file, one object looks like this:
"height": 32,
"id": 1,
"name": "player",
"properties": [
{
"name": "character",
"type": "string",
"value": "ss_player"
},
{
"name": "state",
"type": "int",
"value": 20
}
],
"rotation": 0,
"type": "actor",
"visible": true,
"width": 32,
"x": 32,
"y": 160
20 is AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_RIGHT — 16 plus 4. Tiled has no
symbolic constants, so you write the number. 16 alone is alive and facing nowhere, which is
right for a coin.
Giving the map its physics
Add three custom properties to the map itself (Map → Map Properties):
| Property | Type | Value | Means |
|---|---|---|---|
physics.model |
string | arcade |
Which backend to build |
physics.gravity.y |
float | 900.0 |
Downward acceleration, px/s² |
physics.drag.y |
float | 1.5 |
Air resistance on the vertical axis |
Gravity of 900 px/s² with a drag of 1.5 gives a terminal fall speed of 600 px/s. Drag is what
bounds a fall, so do not set it to zero. (A terminal_velocity setting is in TODO.md under
"Arcade physics feel".)
Putting physics in the map rather than in code is what lets a swimming level and a walking level differ by data.
6. Load the level
Order matters: sprites, then characters, then the map. The map creates actors that name characters, and characters name sprites.
for ( i = 0; ss_sprite_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_sprite_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_sprite_load_json((char *)&path));
}
for ( i = 0; ss_character_files[i] != NULL; i++ ) {
PASS(errctx, asset_path(assetdir, ss_character_files[i], (char *)&path, sizeof(path)));
PASS(errctx, akgl_character_load_json((char *)&path));
}
asset_path is the helper that joins the directory and the file name:
static akerr_ErrorContext *asset_path(char *dir, char *name, char *dest, size_t size)
{
int count = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dir, AKERR_NULLPOINTER, "dir");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
PASS(errctx, aksl_snprintf(&count, dest, size, "%s/%s", dir, name));
SUCCEED_RETURN(errctx);
}
aksl_snprintf rather than snprintf, so a path that does not fit arrives as
AKERR_OUTOFBOUNDS naming both lengths. Truncated silently, it reports itself much later as
a missing file with a name nobody wrote.
Then the map:
PASS(errctx, asset_path(assetdir, "level1.tmj", (char *)&path, sizeof(path)));
PASS(errctx, akgl_tilemap_load((char *)&path, akgl_gamemap));
Load into akgl_gamemap, which already points at storage the library owns. Do not
declare an akgl_Tilemap on the stack: it is about 26 MB, several times a default thread
stack, and you get a segfault before the loader writes a byte. Shrinking it is TODO.md
targets 14 and 15.
That one call creates an actor for every actor object in the object layer, binds each to
the character its property names, and publishes it in the actor registry under its Tiled
name.
Honouring the map's physics
The loader built a backend from the map's properties but did not switch to it. That is your call, one line:
if ( akgl_gamemap->use_own_physics == true ) {
akgl_physics = &akgl_gamemap->physics;
Restamping the clock
The physics step measures elapsed time from akgl_physics->gravity_time. Loading nine
sprites, four characters and a map took real time, and the first step would otherwise try to
simulate all of it at once:
akgl_physics->gravity_time = SDL_GetTicksNS();
Do this after every load, immediately before the first frame.
Finding the actors
The map published them by name. Look each one up:
player = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, "player", NULL);
FAIL_ZERO_RETURN(errctx, player, AKERR_KEY, "The map placed no actor called player");
PASS(errctx, ss_player_bind(player));
PASS(errctx, ss_actors_bind());
PASS(errctx, ss_player_controls(0, "player"));
A miss means the map and the code disagree about what the level contains, which is worth failing on.
Those three calls are where steps 7 to 12 attach: ss_player_bind gives the player its shape
and its hooks, ss_actors_bind does the same for the coins and the hazards, and
ss_player_controls binds the keys. All three run after the map has loaded, because the
map is what creates the actors they touch.
Run now. The level draws, the actors appear, and everything falls through the floor.
7. Turn on collision
Collision is opt-in. Three lines switch it on:
PASS(errctx, akgl_collision_world_init(&ss_collision, NULL, (float32_t)SS_TILE_SIZE, (float32_t)SS_TILE_SIZE));
PASS(errctx, akgl_collision_bind_tilemap(&ss_collision, akgl_gamemap));
akgl_physics->collision = &ss_collision;
ss_collision is one global akgl_CollisionWorld, defined in main.c and declared extern
in your header. NULL for the second argument means the default spatial index, which is the
one to use. akgl_collision_bind_tilemap reads the tile size off the map and finds the
collidable layers you marked in step 5.
Do this after loading the map and after switching to the map's physics backend.
Giving the player a body
A collision shape is a box measured from the actor's position. Inset it into the sprite frame — art does not reach the edges of its cell, and a full-frame box catches on doorways the character visibly clears:
#define SS_PLAYER_BOX_X 8.0f
#define SS_PLAYER_BOX_Y 0.0f
#define SS_PLAYER_BOX_W 16.0f
#define SS_PLAYER_BOX_H 32.0f
ss_player_body is a file-scope rectangle in player.c, built from the constants in your
header:
static SDL_FRect ss_player_body = {
.x = SS_PLAYER_BOX_X,
.y = SS_PLAYER_BOX_Y,
.w = SS_PLAYER_BOX_W,
.h = SS_PLAYER_BOX_H
};
Turn it into a shape on the actor:
PASS(errctx, akgl_collision_shape_box(&obj->shape, &ss_player_body, 0.0f));
obj->shape_override = true;
akgl_collision_shape_box(dest, body, depth) takes the shape to fill in, the frame-relative
rectangle, and a z depth.
The 0.0f is the depth along z; passing 0 lets libakgl choose one.
Chapter 15 explains why a 2D shape has a depth at all.
shape_override = true says this actor's shape is its own rather than its character's.
An actor with a shape collides with map geometry and with no other actor. That is the default, and it is what you want in a level full of scenery. Actor-versus-actor is one added bit; Chapter 15 covers the masks.
Lifting a spawn point clear
Level editors round objects onto a grid, so a spawn point often overlaps a tile. Collision stops an actor entering geometry and has nothing to say about one that started inside it — such an actor is simply stuck. Lift it clear once, before the first frame:
PASS(errctx, akgl_collision_settle(&ss_collision, &obj->shape, &obj->x, &obj->y, 0));
Run now. The player and the blob stand on the ground. Nothing moves yet.
8. Make the player walk
The two hooks
An actor carries seven function pointers. akgl_actor_initialize installs a working default
on every one of them, and a game replaces the ones it wants to change on the actor it wants
to change them on:
| Hook | Called by | Default does |
|---|---|---|
updatefunc |
the actor sweep, before physics | choose the facing, then advance the animation |
movementlogicfunc |
the physics step, before gravity and the move | turn the movement bits into signed acceleration |
renderfunc |
the draw pass | draw the sprite the state selects |
facefunc |
updatefunc |
choose the facing bits from the movement bits |
changeframefunc |
updatefunc, when a frame is due |
step to the next animation frame |
collidefunc |
collision, after the move | push the actor out and stop it going further in |
addchild |
you | attach a child actor |
Two matter here, and the difference between them is when in the frame they run:
| Hook | Put here |
|---|---|
updatefunc |
per-frame game logic: picking things up, dying |
movementlogicfunc |
anything that has to affect this step's motion |
Install them after the actor exists. akgl_actor_initialize — which the map loader
already ran — overwrites all seven:
obj->movementlogicfunc = &ss_player_movement;
obj->updatefunc = &ss_player_update;
Replacing a hook does not mean reimplementing it. Call the default first and add to it:
PASS(errctx, akgl_actor_logic_movement(obj, dt));
akgl_actor_logic_movement copies the character's speeds onto the actor and turns the
movement bits into signed acceleration.
Binding keys
Control maps live in a library array, akgl_controlmaps, indexed by a small integer you
choose. A one-player game uses 0. Point one at your actor:
akerr_ErrorContext *ss_player_controls(int controlmapid, char *actorname)
{
akgl_ControlMap *controlmap = NULL;
akgl_Control control;
PREPARE_ERROR(errctx);
controlmap = &akgl_controlmaps[controlmapid];
controlmap->target = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, actorname, NULL);
Then fill in an akgl_Control and push it. Zero the struct first — a binding is copied into
the map, so a stack local is fine, but an uninitialized field matches against garbage:
PASS(errctx, aksl_memset((void *)&control, 0x00, sizeof(akgl_Control)));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_LEFT;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &ss_control_left_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
akgl_actor_cmhf_left_on is one of the library's built-in handlers: it sets
AKGL_ACTOR_STATE_MOVING_LEFT and signs the acceleration. There is a matching
akgl_actor_cmhf_left_off; step 9 explains why this game supplies its own instead.
Point the map at the actor, and set both device ids to 0, which means "any":
controlmap->kbid = 0;
controlmap->jsid = 0;
Use 0 unless you are writing a two-player game on two keyboards. The id a key event carries
is chosen by the video backend and is not the id SDL_GetKeyboards() reports.
A gamepad is the same table with different event types. Clear key first — a keyboard event
is matched on key whatever else the binding carries, and 0 is a keycode like any other:
control.key = 0;
control.event_on = SDL_EVENT_GAMEPAD_BUTTON_DOWN;
control.event_off = SDL_EVENT_GAMEPAD_BUTTON_UP;
control.button = SDL_GAMEPAD_BUTTON_DPAD_LEFT;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &ss_control_left_off;
PASS(errctx, akgl_controller_pushmap(controlmapid, &control));
SDL_GAMEPAD_BUTTON_DPAD_RIGHT and SDL_GAMEPAD_BUTTON_SOUTH — the A button — get the same
treatment for right and jump.
Bind controls after the map is loaded. The map is what creates the actor the control map targets.
Keeping the player visible
Add one line where you set up the player:
obj->movement_controls_face = false;
The default facing logic clears every facing bit and sets one from the movement bits — so
an actor that stops moving is left facing nowhere. Its state drops to bare ALIVE, which
your character has no sprite for, and it stops being drawn. Clearing this field leaves the
facing bits wherever the control handlers put them. Making the default behave is tracked in
TODO.md.
Run now. The arrow keys walk the player, the run animation plays, and the player stops at walls.
9. Make the player jump
Knowing you are on the ground
Nothing in libakgl records whether an actor is standing on something, and a collision contact does not answer it either — a contact says something pushed back this step, which is a different question from "is there a floor to push off". Ask directly:
PASS(errctx, akgl_collision_shape_bounds(shape, x, y + 1.0f, &feet));
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &feet, AKGL_COLLISION_LAYER_STATIC, dest));
akgl_collision_shape_bounds turns a shape plus a position into a rectangle;
akgl_collision_box_blocked says whether that rectangle overlaps anything solid. One pixel
down, and only one — a taller probe reports a floor the actor is still falling towards, and a
jump that fires off it looks like the player jumped out of thin air.
Call it at the top of movementlogicfunc:
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &ss_game.grounded));
The jump
An actor's velocity has two parts that are added together every step:
tx,ty— thrust. What the character is pushing itself with. Capped against the character'sspeed_x/speed_y.ex,ey— environment. What the world is doing to it. Gravity accumulates here.
A jump is an impulse into ey, not thrust:
if ( (ss_game.jump_requested == true) && (ss_game.grounded == true) ) {
obj->ey = -SS_JUMP_SPEED;
}
ss_game.jump_requested = false;
It has to be ey, because ey is where gravity accumulates and the two must cancel for the
arc to come back down. Written as thrust it would be capped against the character's speed_y
of 0 and scaled to nothing.
The key handler only asks; whether a jump is allowed is the movement function's decision:
static akerr_ErrorContext *ss_control_jump_on(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
ss_game.jump_requested = true;
SUCCEED_RETURN(errctx);
}
Every control handler has that signature: an actor, an SDL event, an error context back.
Releasing the button early cuts the jump short. That is variable jump height, for four lines:
static akerr_ErrorContext *ss_control_jump_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
if ( obj->ey < 0.0f ) {
obj->ey *= 0.4f;
}
SUCCEED_RETURN(errctx);
}
Picking the jump sprite
Set AKGL_ACTOR_STATE_MOVING_UP whenever the actor is off the ground, and your character's
mappings do the rest:
if ( ss_game.grounded == true ) {
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
} else {
AKGL_BITMASK_ADD(obj->state, AKGL_ACTOR_STATE_MOVING_UP);
}
Use AKGL_BITMASK_ADD, _DEL and _HAS rather than writing | and & by hand.
Friction
Bind your own release handlers, which clear the movement bit and the acceleration but leave the thrust alone:
static akerr_ErrorContext *ss_control_left_off(akgl_Actor *obj, SDL_Event *event)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, event, AKERR_NULLPOINTER, "event");
obj->ax = 0.0f;
AKGL_BITMASK_DEL(obj->state, AKGL_ACTOR_STATE_MOVING_LEFT);
SUCCEED_RETURN(errctx);
}
ss_control_right_off is the same function with AKGL_ACTOR_STATE_MOVING_RIGHT.
Then decay the thrust yourself, faster on the ground than in the air:
obj->tx -= obj->tx * friction * dt;
if ( fabsf(obj->tx) < 1.0f ) {
obj->tx = 0.0f;
}
#define SS_FRICTION_GROUND 12.0f
#define SS_FRICTION_AIR 1.5f
Snap to zero below a pixel per second, because an exponential decay never actually arrives.
The library's own akgl_actor_cmhf_left_off zeroes tx outright, which stops the actor dead
in one frame — right for a top-down game, wrong for a sidescroller. Friction and deceleration
in the backend are tracked in TODO.md under "Arcade physics feel"; when they land, this
whole section becomes a setting.
The whole movement function, in order
ss_player_movement now does five things, and the order is the order they were introduced:
akgl_actor_logic_movement(obj, dt)— the default logic, first.ss_grounded(...)— record whether there is a floor underfoot.- Decay
txif no direction is held. - If a jump was requested and the actor is grounded, write
ey. Clear the request either way. - Set or clear
AKGL_ACTOR_STATE_MOVING_UPfromgrounded.
Steps 2 and 3 have to come before 4: the jump reads the same grounded the friction does,
and reading it twice in one step would cost a query for nothing.
Run now. The player runs, slides to a stop, jumps, and holds the jump sprite through the arc.
10. Scroll the camera
akgl_camera is a plain SDL_FRect in map pixels that the library reads. Moving it is the
whole of scrolling:
limit = (float32_t)(akgl_gamemap->width * akgl_gamemap->tilewidth) - akgl_camera->w;
akgl_camera->x = (ss_game.player->x + 16.0f) - (akgl_camera->w / 2.0f);
if ( akgl_camera->x > limit ) {
akgl_camera->x = limit;
}
if ( akgl_camera->x < 0.0f ) {
akgl_camera->x = 0.0f;
}
akgl_camera->x = (float32_t)((int)akgl_camera->x);
Centre on the player, clamp to the level, and floor to a whole pixel. The tile drawing truncates the camera position when it works out how much of an edge tile to show, so a camera that is fractionally different every frame makes the tile grid shimmer.
Call it once per frame, before akgl_game_update.
Run now. The level scrolls as the player walks, and stops scrolling at both ends.
11. Collect coins and die on hazards
This is game logic, not physics, so it goes in updatefunc. Call the default first:
PASS(errctx, akgl_actor_update(obj));
Overlap tests
akgl_collide_rectangles answers "do these two rectangles overlap" with a bool. That is
the right tool for a pickup — you want to know, not to be pushed:
PASS(errctx, hitbox(ss_game.coins[i], 8.0f, &other));
PASS(errctx, akgl_collide_rectangles(&player, &other, &hit));
hitbox insets a rectangle into the 32×32 frame, for the same reason the collision shape was
inset — a hazard box the full size of the frame kills a player who is visibly nowhere near
it:
static akerr_ErrorContext *hitbox(akgl_Actor *obj, float32_t inset, SDL_FRect *dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
dest->x = obj->x + inset;
dest->y = obj->y + inset;
dest->w = 32.0f - (inset * 2.0f);
dest->h = 32.0f - (inset * 2.0f);
SUCCEED_RETURN(errctx);
}
The coin test uses an inset of 8, the hazard test 6 — a coin should be easy to take and a spike should be hard to hit.
Removing an actor
There is no "despawn" call. Giving the pool slot back is what unregisters the actor and stops it being drawn:
PASS(errctx, akgl_heap_release_actor(ss_game.coins[i]));
ss_game.coins[i] = NULL;
ss_game.coins_taken += 1;
Releasing another actor from inside the update sweep is safe: the sweep re-reads the reference count at the top of every iteration and skips a slot that has gone free. Clear your own pointer to it in the same breath.
Falling out of the level
Nothing stops an actor leaving the map, so check for it:
if ( obj->y > (float32_t)(akgl_gamemap->height * akgl_gamemap->tileheight) ) {
PASS(errctx, respawn(obj));
SUCCEED_RETURN(errctx);
}
Respawning
The player needs a data slot of its own, filled in when you set the player up:
static ss_ActorData ss_player_data;
ss_player_data.home_x = obj->x;
ss_player_data.home_y = obj->y;
obj->actorData = (void *)&ss_player_data;
Record it after settling the spawn point, so a respawn does not put the player back inside the geometry it was lifted out of.
Respawning clears everything the simulation carries between steps, not just the position:
static akerr_ErrorContext *respawn(akgl_Actor *obj)
{
ss_ActorData *data = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_ZERO_RETURN(errctx, obj->actorData, AKERR_NULLPOINTER, "obj->actorData");
data = (ss_ActorData *)obj->actorData;
obj->x = data->home_x;
obj->y = data->home_y;
obj->ex = 0.0f;
obj->ey = 0.0f;
obj->tx = 0.0f;
obj->ty = 0.0f;
obj->vx = 0.0f;
obj->vy = 0.0f;
ss_game.deaths += 1;
ey is where the fall accumulated. A player who respawns still holding a full-speed fall
lands dead again immediately.
Where to keep per-actor data
akgl_Actor::actorData is a void * the library never reads or frees. Point it at a fixed
table:
static ss_ActorData ss_hazard_data[SS_HAZARD_COUNT];
The player's slot is ss_player_data in player.c; the hazards share a table in actors.c.
The whole update function, in order
ss_player_update does four things:
akgl_actor_update(obj)— the default, which chooses the facing and advances the animation.- If the actor is below the bottom of the map, respawn and return.
- Test the player's hitbox against each remaining coin; take any it overlaps.
- Test it against each hazard; respawn on any it overlaps.
Run now. Walking into a coin takes it, walking into the blob or falling into the pit respawns you at the start.
12. Add moving enemies
Something that does not move at all
The coins need a movementlogicfunc of their own. Their character has no speed and no
acceleration so they cannot thrust — but gravity is not thrust, and a coin left to the
default logic falls out of the level with everything else.
Raising AKGL_ERR_LOGICINTERRUPT is how an actor opts out of the rest of its step. It is
not a failure: the physics step catches it, skips gravity, drag, the move and collision
for that actor, and carries on to the next one.
static akerr_ErrorContext *ss_static_movement(akgl_Actor *obj, float32_t dt)
{
PREPARE_ERROR(errctx);
(void)dt;
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s does not simulate", (char *)obj->name);
}
Only a movementlogicfunc may raise it. From anywhere else it aborts the whole physics
step and leaves every remaining actor unsimulated.
A blob that patrols
The blob walks under gravity like the player does and the library resolves it the same way. Its hook only decides which way to face next.
Set the facing and movement bits, then let the default logic sign the acceleration:
AKGL_BITMASK_DEL(obj->state, (AKGL_ACTOR_STATE_FACE_ALL | AKGL_ACTOR_STATE_MOVING_ALL));
if ( data->facing < 0.0f ) {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_MOVING_LEFT));
} else {
AKGL_BITMASK_ADD(obj->state, (AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_MOVING_RIGHT));
}
Then probe. step is one pixel in the direction of travel:
step = 1.0f;
if ( data->facing < 0.0f ) {
step = -1.0f;
}
PASS(errctx, akgl_collision_shape_bounds(&obj->shape, (obj->x + step), obj->y, &ahead));
PASS(errctx, akgl_collision_box_blocked(&ss_collision, &ahead, AKGL_COLLISION_LAYER_STATIC, &wall_ahead));
PASS(errctx, ss_grounded(&obj->shape, obj->x, obj->y, &grounded));
The floor probe is one pixel past the leading edge of the box and one pixel below its feet:
probe_y = obj->y + ss_blob_body.y + ss_blob_body.h + 1.0f;
if ( data->facing < 0.0f ) {
probe_x = obj->x + ss_blob_body.x - 1.0f;
} else {
probe_x = obj->x + ss_blob_body.x + ss_blob_body.w + 1.0f;
}
PASS(errctx, akgl_collision_solid_at(&ss_collision, probe_x, probe_y, &floor_ahead));
One pixel, not one tile. The probe is asking "is there ground under my next step", and a longer reach turns the blob round a tile before the ledge.
akgl_collision_solid_at is the cheapest query there is: is the tile under this one point
solid.
Turn around on either:
if ( (wall_ahead == true) || ((grounded == true) && (floor_ahead == false)) ) {
data->facing = -data->facing;
A moth that flies
A flying enemy wants no gravity, and the map has gravity because the player needs it. Rather than fighting the backend, write the position directly and then opt out:
data->phase += dt;
obj->x = data->home_x + (sinf(data->phase) * 64.0f);
obj->y = data->home_y + (sinf(data->phase * 2.0f) * 24.0f);
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "%s flies itself", (char *)obj->name);
Two sines at a 1:2 ratio is a figure eight.
Wiring them up
actors.c needs its own lookup helper. A miss means the map and the code disagree about what
the level contains, which is worth failing on rather than working around:
static akerr_ErrorContext *find_actor(char *name, akgl_Actor **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "name");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
*dest = SDL_GetPointerProperty(AKGL_REGISTRY_ACTOR, name, NULL);
FAIL_ZERO_RETURN(errctx, *dest, AKERR_KEY, "The map placed no actor called %s", name);
SUCCEED_RETURN(errctx);
}
Then find each actor by its Tiled name and install its hook:
static SDL_FRect ss_blob_body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
PASS(errctx, find_actor("blob1", &ss_game.hazards[0]));
PASS(errctx, akgl_collision_shape_box(&ss_game.hazards[0]->shape, &ss_blob_body, 0.0f));
ss_game.hazards[0]->shape_override = true;
PASS(errctx, akgl_collision_settle(&ss_collision, &ss_game.hazards[0]->shape,
&ss_game.hazards[0]->x, &ss_game.hazards[0]->y, 0));
ss_hazard_data[0].home_x = ss_game.hazards[0]->x;
ss_hazard_data[0].home_y = ss_game.hazards[0]->y;
ss_hazard_data[0].facing = -1.0f;
ss_game.hazards[0]->actorData = (void *)&ss_hazard_data[0];
ss_game.hazards[0]->movementlogicfunc = &ss_blob_movement;
The coins are found the same way, by the names Tiled gave them — coin1 through coin4:
for ( i = 0; i < SS_COIN_COUNT; i++ ) {
PASS(errctx, aksl_snprintf(&count, (char *)&name, sizeof(name), "coin%d", (i + 1)));
PASS(errctx, find_actor((char *)&name, &ss_game.coins[i]));
ss_game.coins[i]->movementlogicfunc = &ss_static_movement;
}
The moth needs no shape — it never touches terrain — but it does need its home position, since that is what it orbits:
PASS(errctx, find_actor("moth1", &ss_game.hazards[1]));
ss_hazard_data[1].home_x = ss_game.hazards[1]->x;
ss_hazard_data[1].home_y = ss_game.hazards[1]->y;
ss_hazard_data[1].facing = 1.0f;
ss_game.hazards[1]->actorData = (void *)&ss_hazard_data[1];
ss_game.hazards[1]->movementlogicfunc = &ss_moth_movement;
All of that lives in one function, ss_actors_bind, which main.c calls after the map is
loaded.
Run now. The coins hang in the air instead of falling, the blob patrols its platform and turns at the edges, and the moth traces a figure eight. That is the whole game.
13. Tear down
There is no akgl_game_shutdown. Teardown is yours:
IGNORE(akgl_text_unloadallfonts());
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount > 0 ) {
IGNORE(akgl_heap_release_actor(&akgl_heap_actors[i]));
}
}
IGNORE is the fourth macro: make the call and discard the error. It is the right thing on a
teardown path, where there is nobody left to report to. It is the wrong thing anywhere
else.
akgl_text_unloadallfonts must run before TTF_Quit, which destroys the fonts
underneath the registry that still points at them.
Do not call akgl_tilemap_release unless you are loading a second level — it has a
double-free, recorded in TODO.md under "Known and still open" item 2. A process that is
exiting can leave the textures to SDL_Quit.
Reporting a failure from main
main returns int, so the usual FINISH will not compile there. Use this shape:
ATTEMPT {
CATCH(errctx, parse_args(argc, argv, &assetdir, &frames));
CATCH(errctx, startup());
CATCH(errctx, load_level(assetdir));
CATCH(errctx, run(frames));
} CLEANUP {
shutdown_game();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "the sidescroller could not run");
parse_argsis ordinarystrcmpoverargv, filling inassetdirandframes. It exists for the headless run at the end of this chapter; a game that only ever opens a window can drop it and passSS_ASSET_DIRand0straight in.ATTEMPT…CLEANUP…PROCESS…HANDLE_DEFAULT…FINISH_NORETURNis the error-handling block.CATCHinside it is whatPASSis outside it.LOG_ERROR_WITH_MESSAGEis libakerror's, like every other macro here. It prints the status, your message and the stack trace the context accumulated on its way up.CLEANUPruns on every path, success or failure. Teardown goes there.- Never use a
*_RETURNmacro insideATTEMPT. It returns pastCLEANUP, so every release andfcloseis skipped. - Set a flag in
HANDLE_DEFAULTand return it afterFINISH_NORETURN. Returning from inside aHANDLEblock leaks the error context's pool slot.
Build it and run it
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
./build/examples/sidescroller/sidescroller
| Control | Keyboard | Gamepad |
|---|---|---|
| Walk left / right | ← → | D-pad left / right |
| Jump | Space | A (south) |
| Quit | Close the window | — |
Holding the jump key longer jumps higher.
To run it without a display — in CI, or to check that it still works:
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy \
./build/examples/sidescroller/sidescroller --frames 240 --autoplay
Those two flags are scaffolding for that run and are not part of the game. --frames N stops
after N frames, which is the if in run() above. --autoplay synthesizes key events on a
fixed schedule so the level plays itself:
if ( (frame % SS_AUTOPLAY_JUMP_PERIOD) == 0 ) {
synthetic.type = SDL_EVENT_KEY_DOWN;
synthetic.key.which = SS_AUTOPLAY_KBID;
synthetic.key.key = SDLK_SPACE;
PASS(errctx, akgl_controller_handle_event((void *)&akgl_game.state, &synthetic));
Feed synthetic events through akgl_controller_handle_event rather than calling the handlers
directly. A scripted run then exercises the binding table the same way a player does, so a
control map that matches nothing fails the smoke test instead of passing it.
It prints where the player finished:
sidescroller: 240 frames, 0 of 4 coins, 0 deaths, player at 136.0,160.0 grounded
Capture that position at the end of run(), not in main. Teardown happens in main's
CLEANUP block and releases the actor pool, so by the time the summary is printed
ss_game.player points at a slot that has been given back — and the line reports 0.0,0.0:
if ( ss_game.player != NULL ) {
ss_game.final_x = ss_game.player->x;
ss_game.final_y = ss_game.player->y;
}
That is what final_x and final_y on ss_Game are for.
grounded and a sensible y are how you know collision ran. If the player is hundreds of
pixels below the level, revisit step 7.
Where to look next
- Chapter 15 — the collision masks, the query API, and what happens when something moves faster than 1280 px/s.
- Chapter 14 — thrust versus environment, and the four gaps in the feel this chapter works around.
- Chapter 12 — the other five behaviour hooks, and parent/child actors.
- Chapter 13 — the rest of the map format and the limits that bind a level.
- Chapter 21 — the same shape of program with no gravity, NPCs and a text box.
