A pure rename plus link rewrite and nothing else. Collision is a pluggable subsystem with two implementations, shapes, a response hook and a tile binding; that is a chapter, and burying it inside the physics chapter is the shape this manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and characters and actors are 10, 11 and 12. Kept separate from writing the chapter because **nothing validates a cross-reference target.** The example harness reads fenced blocks; it does not follow links. A rename mixed into five hundred lines of new prose is not reviewable, and a broken link would land silently. As its own commit it is reviewable as a rename, and a grep for dangling `](NN-` targets is clean. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
12 KiB
03. Getting started
This chapter gets libakgl into your build and a window on your screen. It assumes you have read Chapter 4, because the first program is written in the error protocol and will not make sense otherwise.
Dependencies
| Dependency | Floor | Why libakgl needs it |
|---|---|---|
| SDL3 | 3.4.x (3.4.8 is vendored) | Window, renderer, events, properties, timing, mutexes |
| SDL3_image | vendored | Decoding spritesheet and tileset images |
| SDL3_mixer | vendored | akgl_load_start_bgm, the mixer device and track table |
| SDL3_ttf | vendored | Font loading, rasterizing and metrics |
| jansson | vendored | Parsing every JSON asset format |
| libakerror | >= 2.0.1, hard floor | The error protocol. Part of libakgl's public interface |
| libakstdlib | >= 0.2 | Checked wrappers over libc — aksl_strncpy, aksl_fclose, aksl_snprintf |
| semver | vendored, in-tree | Savegame and version comparison |
All eight are git submodules under deps/, so a recursive clone gives you a build with no
system packages at all.
The libakerror floor is enforced, not advisory. Two #error feature tests in
include/akgl/error.h fire on a stale header:
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
#endif
It is a hard floor because libakerror 2.0.0 made akerr_next_error() return a context that
already holds its reference and moved __akerr_last_ignored into thread-local storage —
and both of those expand at your call sites, through IGNORE and the FAIL_* macros,
because akerror.h is part of libakgl's public interface. A tree that mixes headers and
libraries across that line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2 to stop the library half happening by accident; the
#error catches the header half in an install tree.
Building libakgl itself
git clone --recursive https://source.starfort.tech/andrew/libakgl.git
cd libakgl
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
ctest --test-dir build --output-on-failure
If a dependency is already installed system-wide, libakgl finds it and skips the vendored
copy. The rule is if(NOT TARGET ...) per dependency, so mixing is fine: a system SDL3 with
a vendored jansson works.
Route 1 — add_subdirectory()
This is what akbasic does, and it is the route with the fewest ways to go wrong: one set
of compiler flags, one set of headers, nothing to install.
add_subdirectory(third_party/libakgl)
add_executable(mygame src/main.c)
target_link_libraries(mygame PRIVATE akgl::akgl)
akgl::akgl carries its own usage requirements, so the SDL3, libakerror and jansson include
paths reach your target without you naming them.
AKGL_WERROR is OFF by default, and leaving it off is the right thing for a consumer.
It is turned on only by two CI jobs in this repository. The reason is exactly your build: a
target-level -Werror turns every diagnostic a newer compiler invents into a broken build
for somebody else's project, and the warning set is not even stable across optimization
levels — an -O2 build reports ten -Wstringop-truncation warnings that -O0 and
-fsyntax-only do not. -Wall is on for every target this project owns, and the tree builds
clean under it at every optimization level.
If you have already vendored SDL3 yourself, declare it before the add_subdirectory and
libakgl will use yours rather than adding a second copy:
add_subdirectory(third_party/SDL) # declares SDL3::SDL3
add_subdirectory(third_party/libakgl) # sees it and does not add its own
Route 2 — pkg-config
make install puts akgl.pc in lib/pkgconfig/. Here is what it contains, in full:
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${exec_prefix}/include
Name: akgl
Description: AKLabs Game library
Version: 0.7.0
Cflags: -I${includedir}/
Libs: -L${libdir} -lakgl
akgl.pc names no dependencies at all. There is no Requires: and no
Requires.private: line, so pkg-config --cflags --libs akgl tells you nothing about
libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer, SDL3_ttf or jansson. That has always
been wrong, and it is sharper than it looks: akerror.h is part of libakgl's public
interface, so a consumer that builds against whatever akerror.h is on its include path and
links whatever libakerror.so the loader finds can get exactly the mismatch pkg-config had
every opportunity to prevent.
Until that is fixed, name them yourself. These are the modules whose headers libakgl's own public headers include:
pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansson
sdl3-image is needed at link time but appears in no public header, so it belongs in the
private half.
Recorded in TODO.md under "akgl.pc names no dependencies", along with why the fix wants
its own commit: adding a Requires: line changes what pkg-config --libs akgl emits for
every existing consumer.
There is no CMake package config. install() ships the library, the headers and
akgl.pc, and nothing generates an akglConfig.cmake, so find_package(akgl) does not
work against an install tree. Use add_subdirectory() or pkg-config.
The startup order that actually works
Verified against src/game.c, not against the header comment:
1. fill in akgl_game.name, .version and .uri
| akgl_game_init refuses to run without all three, and there are no
| defaults -- the window title, SDL's app metadata and the savegame
| compatibility check are all built from them
v
2. akgl_game_init()
| error codes, version stamp, frame clock, state mutex, pools, the eight
| registries, SDL app metadata, control maps, SDL_Init(VIDEO|GAMEPAD|
| AUDIO), controller DB, gamepads, SDL_mixer, SDL_ttf, and finally the
| four globals pointed at their default storage.
|
| What it does NOT do: create the window, choose a physics backend, or
| load any configuration. Those read properties, which you have not set
| yet.
v
3. akgl_registry_load_properties() or akgl_set_property()
| screen size, physics constants, anything else
v
4. akgl_render_2d_init(akgl_renderer) <- creates the window; reads
| game.screenwidth/screenheight
| akgl_physics_factory(akgl_physics, name) <- "null" or "arcade"
v
5. load assets, then loop on akgl_game_update()
akgl_game_init does not choose a physics backend for you. physics.h says the
physics.engine property drives akgl_physics_factory; it does not. That string appears
nowhere in src/, and akgl_game_init never calls the factory. You call it yourself, with
an akgl_String holding "null" or "arcade". The header comment is wrong and is left
alone here deliberately — this manual documents what the code does, and correcting header
prose is a separate commit.
The first window
Sixty frames of an empty world, then out. It compiles under
-std=gnu99 -Wall -Werror and runs headless.
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/staticstring.h>
#include <akgl/text.h>
int main(void)
{
akgl_String *engine = NULL;
int frame = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
/* 1. akgl_game_init refuses to run without all three of these. */
CATCH(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
"firstwindow", sizeof(akgl_game.name) - 1));
CATCH(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
"0.1.0", sizeof(akgl_game.version) - 1));
CATCH(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
"net.example.firstwindow", sizeof(akgl_game.uri) - 1));
/* 2. SDL, the pools, the registries, audio, fonts, gamepads. */
CATCH(errctx, akgl_game_init());
/* 3. Configuration. akgl_render_2d_init reads these two. */
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
/* 4. The window and the renderer, then a physics backend. */
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
CATCH(errctx, akgl_heap_next_string(&engine));
CATCH(errctx, akgl_string_initialize(engine, "null"));
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
/* 5. Load assets, then loop. */
for ( frame = 0; frame < 60; frame++ ) {
CATCH(errctx, akgl_game_update(NULL));
}
} CLEANUP {
IGNORE(akgl_heap_release_string(engine));
IGNORE(akgl_text_unloadallfonts());
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
SDL_Quit();
return 0;
}
Five things in there are worth pointing at.
aksl_strncpy, not strncpy. Every name in this library is a registry key, handed to
strcmp, realpath and SDL property calls, none of which stop at the end of the field.
strncpy(dest, src, sizeof(dest)) leaves dest unterminated whenever the source fills it.
Bound n at sizeof(dest) - 1 and let aksl_strncpy raise AKERR_OUTOFBOUNDS if the bytes
do not fit.
FINISH_NORETURN, not FINISH, in main. FINISH ends by returning the error context
from the enclosing function, and main returns int. With FINISH_NORETURN and no
HANDLE block, an unhandled error goes to libakerror's default handler, which logs it and
calls akerr_exit() — so SDL_Quit() and return 0 are reached only on the success path.
Never exit(3) an akerr status. An exit status is one byte wide and libakgl's band
starts at 256, so exit(AKGL_ERR_SDL) is a wait status of 0 and the shell sees a clean run.
akerr_exit() maps 0 to 0, 1–255 to themselves, and anything else to
AKERR_EXIT_STATUS_UNREPRESENTABLE (125). See Chapter 4, Table 3.
akgl_text_unloadallfonts() before SDL_Quit(). SDL_Quit destroys the property
registry the fonts live in and takes the last reference to every one of them with it. This
program loads no fonts, and the call is in the shutdown anyway because that is where it
belongs the moment one is loaded — Chapter 17.
Sixty akgl_game_update calls log Low FPS! 0 sixty times. akgl_game.fps is a
completed-second average, so it reads 0 for the first second of the process, which is under
the low-FPS threshold. akgl_game.lowfpsfunc is a hook for exactly this: point it at
something that sheds work, or at nothing.
Running headless
On a server, in CI, or under valgrind, force the dummy drivers:
SDL_VIDEO_DRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIO_DRIVER=dummy ./mygame
These are the SDL3 spellings, and the ones scripts/memcheck.sh exports. SDL3 also still
honours SDL2's SDL_VIDEODRIVER and SDL_AUDIODRIVER as aliases — SDL_RENDER_DRIVER was
never spelled any other way. Without them, akgl_game_init fails at SDL_Init with
AKGL_ERR_SDL and the message Couldn't initialize SDL: No available video device.
A program can set the same thing from inside itself with SDL_SetHint before
akgl_game_init, which is what most of the test suites in tests/ do:
#include <SDL3/SDL.h>
void go_headless(void)
{
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
}
The software renderer is a real rasterizer, not a stub — everything draws, nothing is
displayed, and SDL_RenderReadPixels gives you the frame back. That is how the image
assertions in tests/ work.
Where to go next
Chapter 5 for the pools you just claimed a string from,
Chapter 7 for what akgl_game_update actually does, or
Chapter 20 to start building a game.