The sidescroller's controls did nothing. `akgl_controller_handle_event` matched `event->key.which == curmap->kbid` exactly, with no way to say "whatever keyboard the player is typing on", so the game did the obvious thing and bound `SDL_GetKeyboards()[0]`. That cannot work. The id a key event *carries* is chosen by the video backend and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while `SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is 1; with XInput2 the events carry the physical slave device's `sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map matched nothing and every key press was dropped. A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend: SDL documents `which` as 0 when the source is unknown or virtual, and joystick ids start at 1, so no real device is 0. A non-zero id still matches only that device, which is what keeps two local players on two keyboards apart, and there is a test for that half too. `util/charviewer.c` already passed 0 and depended on the old behaviour by accident; it works under XInput2 now as well. Two reasons this shipped, both closed: - Every test in tests/controller.c dispatched an event whose id equalled the id the map was bound with, so none of them could see it. - The sidescroller's smoke test called the control handlers directly instead of dispatching events, so it passed against a control map that matched nothing. It now pushes synthetic events through akgl_controller_handle_event from a deliberately non-zero device id and fails if the press does not arrive. Verified by breaking the binding and watching the run exit non-zero. `test_controller_wildcard_device_ids` was written first and failed against the unfixed library with "a map bound to keyboard 0 ignored a key press from device 11", which is the whole reason to trust it now that it passes. The controls were documented, in one sentence. Chapter 19 has a proper table of them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The header said the match was exact and now says what it does. Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 KiB
15. Input
libakgl does not have an input system so much as a binding system. SDL3 owns events, keycodes, keymods, gamepad enumeration and text composition, and it documents all of them in the SDL3 wiki — start with SDL_Event, SDL_Keycode and CategoryGamepad. Nothing here replaces any of it.
What libakgl adds is two things: a table that says "when event X arrives from device Y carrying key or button Z, call this handler on this actor", and a small ring buffer of keystrokes for a caller who wants to ask "is there a key waiting" without owning the event loop.
Input is pushed, not polled
The host pumps every SDL event into akgl_controller_handle_event. There is no
akgl_input_update() that reads the keyboard state for you, and there is no internal event
loop. Whatever drains the SDL event queue — your while ( SDL_PollEvent(&e) ), or SDL's
SDL_AppEvent callback — hands each event over, unconditionally.
Unconditionally is the point: an event nothing binds is not an error. It returns success having done nothing, which is what makes the blanket call correct and keeps the host from having to know which events matter.
#include <SDL3/SDL.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* Bind WASD on top of control map 0, which akgl_controller_default has already
* filled with the four arrow keys and the four D-pad directions.
*
* Bindings are appended and scanned in push order, and the first match stops
* the scan -- so these fire only for keys the eight defaults do not claim.
* There is no way to remove one; zeroing akgl_controlmaps[id] is the only reset.
*/
akerr_ErrorContext AKERR_NOIGNORE *bind_wasd(int mapid, SDL_KeyboardID kbid)
{
akgl_Control control;
PREPARE_ERROR(errctx);
PASS(errctx, akgl_controller_default(mapid, "player", kbid, 0));
/* Copied in by value, so one stack local does for every push. */
SDL_memset(&control, 0x00, sizeof(akgl_Control));
control.event_on = SDL_EVENT_KEY_DOWN;
control.event_off = SDL_EVENT_KEY_UP;
control.key = SDLK_A;
control.handler_on = &akgl_actor_cmhf_left_on;
control.handler_off = &akgl_actor_cmhf_left_off;
PASS(errctx, akgl_controller_pushmap(mapid, &control));
control.key = SDLK_D;
control.handler_on = &akgl_actor_cmhf_right_on;
control.handler_off = &akgl_actor_cmhf_right_off;
PASS(errctx, akgl_controller_pushmap(mapid, &control));
SUCCEED_RETURN(errctx);
}
/* Every event, unconditionally. An event nothing binds is success, not a
* failure, which is what makes the unconditional call correct. */
akerr_ErrorContext AKERR_NOIGNORE *pump(void *appstate, SDL_Event *event)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_controller_handle_event(appstate, event));
SUCCEED_RETURN(errctx);
}
Two independent paths out of one call
akgl_controller_handle_event does two unrelated things with the event it is given, and
neither one suppresses the other:
akgl_controller_handle_event(appstate, event)
|
+----------------------+----------------------+
| |
(a) the keystroke ring, FIRST (b) the control maps, SECOND
| |
KEY_DOWN -> push {key, mod, ""} for map 0..7 (target != NULL):
TEXT_INPUT -> attach text to the for control 0..31, in push order:
newest press, or push event type == event_on/off ?
a text-only entry device id == kbid / jsid ?
| key/button == this binding ?
| -> call the handler and
akgl_controller_poll_key() STOP the entire scan
akgl_controller_poll_keystroke()
akgl_controller_flush_keys()
The ring is filled first, before the maps are consulted, and whether or not a map
claims the key. A key that drives an actor is still a key an embedded interpreter polling
with akgl_controller_poll_key() wants to see.
The control-map scan
/** @brief How many control maps exist -- effectively the local player limit. */
#define AKGL_MAX_CONTROL_MAPS 8
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
#define AKGL_MAX_CONTROLS 32
Eight maps means up to eight locally controlled players, each on its own keyboard and
gamepad. A map with a NULL target is skipped entirely — that is how an unused slot is
spelled.
Maps are scanned in index order, bindings within a map in push order, and the first
match wins and stops the whole scan. A key bound in two maps only ever fires in the
lower-numbered one. A key bound twice in one map only fires on the earlier push. This is
what makes akgl_controller_default followed by your own pushes behave sanely: the
defaults are already in slots 0–7 and your additions land after them.
A match requires all three of: the event type equals the binding's event_on or
event_off; the device id on the event equals the map's kbid (keyboard events) or jsid
(gamepad events); and the key or button equals the binding's. The device-id half is what
keeps two players on two keyboards apart — akgl_controller_list_keyboards logs every
attached keyboard and its id, which is how you find the number to pass.
Bind device 0 unless you mean a specific device
A kbid or jsid of 0 matches every device of that kind, and it is what a
single-player game wants.
Do not reach for SDL_GetKeyboards() to fill it in. That call reports what is attached;
the id a key event actually carries is chosen by the video backend, and the two need not
agree. On X11 without XInput2 every key event carries SDL_GLOBAL_KEYBOARD_ID, which is
0, while SDL registers the attached keyboard as SDL_DEFAULT_KEYBOARD_ID, which is 1.
With XInput2 the events carry the physical slave device's sourceid and
SDL_GetKeyboards()[0] may be the master. Binding SDL_GetKeyboards()[0] therefore gives
you a map that matches nothing at all, and controls that silently do nothing.
That is not hypothetical — the sidescroller in Chapter 19 shipped exactly that bug, and its smoke test passed anyway because the test called the handlers instead of dispatching events.
0 is the right spelling for "any" because SDL documents which as 0 when the source is
unknown or virtual, and joystick ids start at 1 — so no real device is 0 and nothing is
given up by spending it.
There is no way to remove a binding. akgl_controller_pushmap appends and that is the
whole API; the only reset is zeroing akgl_controlmaps[id] yourself. Calling
akgl_controller_default twice on one map leaves sixteen bindings, and the first eight
are the ones that fire.
The keystroke ring
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
{
int tail = 0;
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
keybuffer_awaiting_text = false;
return;
}
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
keybuffer[tail].key = key;
keybuffer[tail].mod = mod;
keybuffer[tail].text[0] = '\0';
keybuffer_count += 1;
keybuffer_awaiting_text = true;
}
Thirty-two entries (AKGL_CONTROLLER_KEY_BUFFER), one fixed array in the library's data
segment, one ring for the whole process — it is a file-scope static, not per map and
not per actor.
A full buffer drops the newest keystroke, not the oldest. A caller reading a line of input wants the characters that were typed first; overwriting the head would hand it the tail of what the user typed and silently lose the beginning.
Two pollers drain the same ring, and a keystroke taken by one is not waiting for the other:
| Call | Hands back | Discards |
|---|---|---|
akgl_controller_poll_key(&keycode, &available) |
The SDL keycode, as an int |
Entries carrying only composed text and no keycode — there is no key to report |
akgl_controller_poll_keystroke(&dest, &available) |
key, mod and the composed text |
Nothing |
akgl_controller_flush_keys() |
— | Everything waiting |
An empty ring is success, not a failure: available comes back false and the caller
polls again next frame. Check available, not the keycode against 0 — a text-only
entry legitimately has a keycode of 0.
The text field needs SDL_StartTextInput() on your window. SDL reports one keystroke
as two events — the key going down, and then the character it composed to, if it composed
to anything — and it only sends the second while text input is started. Without that call
key and mod still arrive and text is always the empty string. That is not a libakgl
limitation to work around; it is the only correct way to get a character out of SDL, and
it is what makes a keyboard layout, a compose key, a dead key and an IME commit work. ",
!, (, ), : and ; are all unreachable from a keycode alone, and so is every
lower-case letter. See
SDL_StartTextInput.
An entry with composed text and no keycode is not a degenerate case: an input method or
a dead key finishes a character with no key press of its own, and a line editor wants that
character. akgl_controller_poll_keystroke reports it; akgl_controller_poll_key cannot,
and drops it on the way past.
Text longer than AKGL_CONTROLLER_KEYSTROKE_TEXT (8 bytes) — an IME committing a whole
word at once — is truncated on a code point boundary, never through the middle of a
UTF-8 sequence.
#include <SDL3/SDL.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
* Drain the keystroke ring into a line buffer, the way an embedded interpreter
* or a name-entry screen wants it.
*
* The `text` field is only populated while SDL text input is started on the
* window, so a caller that wants punctuation, lower case, dead keys or an IME
* commit calls SDL_StartTextInput() first. Without it `key` and `mod` still
* arrive and `text` is always empty.
*/
akerr_ErrorContext AKERR_NOIGNORE *read_line(char *dest, size_t destsize, bool *done)
{
akgl_Keystroke stroke;
bool available = false;
size_t used = 0;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
FAIL_ZERO_RETURN(errctx, done, AKERR_NULLPOINTER, "done");
used = SDL_strlen(dest);
*done = false;
/* The ATTEMPT block goes outside the loop: CATCH reports failure by
* break-ing, which would bind to the loop instead of the block. */
ATTEMPT {
for ( ;; ) {
CATCH(errctx, akgl_controller_poll_keystroke(&stroke, &available));
if ( available == false ) {
break;
}
if ( stroke.key == SDLK_RETURN ) {
*done = true;
break;
}
if ( (stroke.text[0] != '\0') && ((used + 1) < destsize) ) {
dest[used] = stroke.text[0];
used += 1;
dest[used] = '\0';
}
}
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
The ring is filled on whichever thread pumps events and is not synchronized. Poll it from that thread.
The default bindings
akgl_controller_default(controlmapid, actorname, kbid, jsid) points a map at the actor
registered under actorname and pushes eight bindings — the four arrow keys and the
four D-pad directions — each wired to the matching akgl_actor_cmhf_*_on/_off pair:
| Key | Gamepad button | handler_on / handler_off |
|---|---|---|
SDLK_DOWN |
SDL_GAMEPAD_BUTTON_DPAD_DOWN |
akgl_actor_cmhf_down_on / _down_off |
SDLK_UP |
SDL_GAMEPAD_BUTTON_DPAD_UP |
akgl_actor_cmhf_up_on / _up_off |
SDLK_LEFT |
SDL_GAMEPAD_BUTTON_DPAD_LEFT |
akgl_actor_cmhf_left_on / _left_off |
SDLK_RIGHT |
SDL_GAMEPAD_BUTTON_DPAD_RIGHT |
akgl_actor_cmhf_right_on / _right_off |
It is the "just give me something that works" path. A game wanting different keys builds
its own bindings with akgl_controller_pushmap, as in the WASD example above.
The default bindings cannot produce a diagonal. Every akgl_actor_cmhf_*_on handler
clears AKGL_ACTOR_STATE_MOVING_ALL before setting its own bit, so pressing Left while Up
is held cancels Up rather than adding to it. That is fine for a four-way JRPG and wrong for
anything that wants eight-way movement — and it means the thrust-vector ellipse cap in
Chapter 14 is unreachable through these handlers. Eight-way movement wants
your own handlers that AKGL_BITMASK_ADD the movement bit without clearing the others.
akgl_controller_handle_button_down / _up are a different thing again, and are not
control-map bindings: they look the actor up by the literal name "player" in
AKGL_REGISTRY_ACTOR rather than being told which actor to act on, so they only ever drive
one. The akgl_actor_cmhf_* handlers are the general form; see
Chapter 12.
Gamepads
SDL delivers no button events from a gamepad nobody has opened. akgl_game_init calls
akgl_controller_open_gamepads() for the devices present at startup; a device plugged in
mid-session arrives as SDL_EVENT_GAMEPAD_ADDED, and akgl_controller_handle_added opens
it. Route that event (and SDL_EVENT_GAMEPAD_REMOVED, to akgl_controller_handle_removed)
or hot-plugged pads stay silent. No gamepads attached at all is success, not an error.
akgl_controller_handle_added logs the mapping it found, deliberately: a controller with
no entry in the mapping database produces no button events at all, which is otherwise
indistinguishable from a broken binding.
Which is why libakgl ships a database. include/akgl/SDL_GameControllerDB.h carries
AKGL_SDL_GAMECONTROLLER_DB_LEN (2255) mappings from the community
SDL_GameControllerDB project, and
akgl_game_init feeds every one to SDL_AddGamepadMapping before opening anything.
That header is generated, and tracked on purpose. It is the offline fallback that keeps
the library buildable when upstream is renamed, rate-limited or unreachable. Never
hand-edit it; regeneration is cmake --build build --target controllerdb and nothing else
runs the script. The full contract, including why a failed fetch used to destroy the very
file it exists to protect, is in AGENTS.md under "Generated and Vendored Sources".
Known defects
A matched binding's handler pointer is not checked. akgl_controller_handle_event
calls curcontrol->handler_on(...) the moment the event type, device id and key all match,
with no NULL test. A control pushed with a NULL handler_on or handler_off crashes
when its event arrives — and since akgl_controller_pushmap copies whatever it is given,
a memset-zeroed akgl_Control with only event_on filled in is a loaded gun. Set both
handlers on every binding you push, even if one of them does nothing. There is no
diagnostic for this at any layer.
appstate is required and never read. Every entry point in this subsystem —
akgl_controller_handle_event, handle_button_down, handle_button_up, handle_added,
handle_removed — raises AKERR_NULLPOINTER on a NULL appstate and then never looks
at it. Pass any non-NULL pointer if your program has no app state.
The analogue-axis, mouse and pen fields are declared and never consulted.
akgl_Control::axis, axis_range_min and axis_range_max, and akgl_ControlMap::mouseid
and penid, are in the structs and akgl_controller_handle_event does not read any of
them. Setting them has no effect; analogue sticks, mice and pens do not reach the control
maps at all today. Handle those events yourself before calling
akgl_controller_handle_event.
Key auto-repeat is not filtered. The ring push tests only event->type == SDL_EVENT_KEY_DOWN and does not consult event->key.repeat, so a held key fills the
32-entry ring at the platform's repeat rate. Check repeat in your event pump if you are
polling for discrete presses.
Two defects this chapter used to carry are fixed and are recorded here so nobody re-derives them from the header prose:
- A negative
controlmapidis rejected. Bothakgl_controller_pushmapandakgl_controller_defaultcheckcontrolmapid < 0as well as the upper bound and raiseAKERR_OUTOFBOUNDS.tests/controller.cpasses-1and-4096to each. The@warningblocks ininclude/akgl/controller.hsaying only the upper bound is checked are stale;TODO.md, "Known and still open" item 11. - The
akgl_controller_handle_*functions exist.controller.honce declared four names that were defined under different spellings, so they linked nowhere. Fixed in 0.5.0, andscripts/check_api_surface.shruns as theapi_surfacetest to stop that class of drift coming back.
Statuses
The ones this subsystem raises, with their libakgl meanings in Chapter 4:
| Status | When |
|---|---|
AKERR_NULLPOINTER |
A NULL appstate, event, control, or poller destination; SDL cannot enumerate or open a device; no actor registered as "player" |
AKERR_OUTOFBOUNDS |
controlmapid outside 0..AKGL_MAX_CONTROL_MAPS - 1, or the map already holds AKGL_MAX_CONTROLS bindings |
AKGL_ERR_REGISTRY |
akgl_controller_default could not find actorname — usually because the actor has not been created yet |
| anything | Whatever the matched binding's handler raises, propagated unchanged |