Files
libakgl/docs/03-getting-started.md
Tachikoma eabb9ad376
Some checks are pending
libakgl CI Build / performance (push) Waiting to run
libakgl CI Build / memory_check (push) Waiting to run
libakgl CI Build / mutation_test (push) Waiting to run
libakgl CI Build / cmake_build (push) Successful in 9m7s
Move outstanding work from TODO.md into the issue tracker
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

Every open item is now an issue on source.starfort.tech/andrew/libakgl,
labelled by kind and blast radius and milestoned by what it can land in: 0.9.x
for anything that breaks no ABI, 0.10.0 for new or changed public symbols,
1.0.0 for the design work. Four are epics: the performance plan (#60),
coverage (#61), actor rotation (#62), and the false header comments (#63).

Verified against the tree before filing rather than transcribed. Three entries
were already fixed and were not filed: the akgl_path_relative context leak, the
akgl_draw_background test extension, and the SDL enumeration audit -- keyboards,
gamepads and mappings are all freed in CLEANUP today. Two were reworded because
the code had moved: the fonts item is a missing teardown entry point rather than
a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap
call is already bounded by numlayers, so only the per-layer actor rescan remains.

TODO.md keeps the part a tracker has no place for: why a decision went the way
it did, what the measurement was, and which arguments turned out to be wrong.

TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor
collision, actor-to-world collision, automatic facing, image layers -- and the
four that had not are #74 through #77, with the GPU renderer's research links
kept because that is the part that took the time.

Every reference that named an item number or a moved section is repointed, in
the manual, the headers, the tests and the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00

12 KiB
Raw Blame History

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 as issue #17, 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, 1255 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.