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 →