junkers devlog 01 · @martinthebad
c64.jpg ─ □ ✕
thank you
bannerz.sys ─ □ ✕
libre culture open source it all make art encrypt everything junkerz
nav.exe ─ □ ✕
junkers devlog 01: the loop and the arena

Phase 0: a clean foundation

Before there is a game there is a window, a clock, and a way to manage memory. That was the whole of Phase 0. Get those right and keep them provable: no warnings under strict flags, no leaks under Valgrind, and the tooling set up (gdb, clangd, ctags, cscope).

The fixed-timestep accumulator

The simulation has to run at the same rate whatever the monitor does, whether that is 60Hz, 144Hz, or an uneven mess. The loop keeps the sim rate separate from the render rate with an accumulator. Real elapsed time goes in, fixed 1/60s steps come out, and whatever is left over becomes an interpolation factor for the renderer.

if (frame_time > ACCUM_MAX) frame_time = ACCUM_MAX;   /* spiral-of-death cap */
accum += frame_time;

while (accum >= FIXED_DT) {                  /* step the sim in 1/60 s chunks */
    player_update(player, lvl, &app->input, FIXED_DT);
    drone_update_all(&drones, lvl, FIXED_DT);
    app->sim_time += FIXED_DT;
    accum -= FIXED_DT;
}

render(app, /* ... */ accum / FIXED_DT, frame_time);   /* alpha = leftover fraction */

alpha is accum / FIXED_DT, somewhere in [0,1). The renderer uses it to blend each body from its previous tick position toward the current one, so at 144Hz the motion stays smooth between 60Hz steps instead of the same position being drawn several times. ACCUM_MAX caps the catch-up at 0.25s. If the machine stalls for a second the loop will not try to run 60 steps at once, it just lets sim time fall behind. Timing comes from SDL_GetPerformanceCounter for sub-millisecond precision rather than SDL_GetTicks.

The bump arena

There is no raw malloc in the game code. Everything is allocated from an arena: one block of memory, one used offset, and allocating means moving that offset forward by the aligned size.

size_t mask     = align - 1;
size_t padding  = (align - (a->used & mask)) & mask;
size_t new_used = a->used + padding + size;

void *ptr = a->buf + a->used + padding;
a->used   = new_used;

The alignment is branch-free. a->used & mask is the offset within the current alignment (a modulo for power-of-two alignment, no divide). Subtracting it from align gives how many bytes to skip to reach the next aligned address, and the outer & mask turns the already-aligned case back into zero. There are two arenas. PERM is 64MB and lasts for the whole run. SCRATCH is 4MB and gets reset at the top of every frame, so per-frame temporaries cost nothing to free and never fragment. A typed macro keeps the call sites honest:

#define ARENA_ALLOC_N(arena_ptr, type, n) \
    ((type *)arena_alloc((arena_ptr), sizeof(type) * (size_t)(n), _Alignof(type)))

Since every live allocation is just a span inside a known buffer, there is no hidden heap state. Cleanup is a single arena_destroy and Valgrind stays at zero lost.

← asuka and the premise all posts next: movement and collision →

Ignorance is death
gb.log
Guestbook
rollin.ini ─ □ ✕
solarpunk.bat ─ □ ✕
garden wizard