[Previous post](https://metrognomes.dev/game-dev/0x2006_Issue+0x4+-+Game+Struct+and+Scaffolding) described the problem: SDL's callback pattern hid control flow and forced heap allocation through `void* appstate`.
This is the resolution. What actually shipped.
---
## The Final Structure
The callback pattern required four functions (`SDL_AppInit`, `SDL_AppIterate`, `SDL_AppEvent`, `SDL_AppQuit`) and a wrapper struct to hold both `Engine` and `Game` on the heap.
The new structure has one function (`main`) and two stack-allocated structs:
```
// Before (callbacks)
typedef struct {
Engine engine;
Game game;
} AppState;
SDL_AppResult SDL_AppInit(void** appstate, int argc, char* argv[]) {
AppState *app = calloc(1, sizeof(AppState));
// ...
}
SDL_AppResult SDL_AppIterate(void* appstate) {
AppState *app = (AppState *)appstate; // ← Cast every frame
// ...
}
// After (main)
int main(int argc, char* argv[]) {
(void)argc;
(void)argv;
Engine engine; // ← Stack
Game game; // ← Stack
// ...init...
while (running) {
// ...loop...
}
// ...cleanup...
}
```
**Zero `void*` casting. Zero heap allocation. Zero wrapper struct.**
---
## What Changed
|Component|Before|After|
|---|---|---|
|**Entry point**|`SDL_AppInit/Iterate/Quit`|`int main()` + `<SDL3/SDL_main.h>`|
|**State passing**|`void* appstate` cast|Direct local variables|
|**Cleanup**|`SDL_AppQuit`|Explicit `game_shutdown` → `engine_shutdown` → `SDL_Quit`|
|**Control flow**|Hidden in SDL dispatcher|Linear loop, debuggable|
|**Input state**|Custom `InputState` (520 bytes)|SDL's native event + polling|
---
## The Input Simplification
I also scrapped a custom `InputState` struct that tracked `current[]` and `previous[]` arrays for "just pressed" detection. According to [SDL3's Best Keyboard Practices](https://wiki.libsdl.org/SDL3/BestKeyboardPractices), the hybrid approach is:
- **Events** (`SDL_EVENT_KEY_DOWN`) for discrete actions (jump, attack, pause)
- **Polling** (`SDL_GetKeyboardState()`) for continuous input (WASD movement)
The custom state tracker was 520 bytes of redundant memory and boilerplate. I replaced it with two patterns that already existed.
---
## The Cleanup Chain
One thing that required careful thought was destruction order. The original code called `SDL_Quit()` unconditionally at the end, which broke error paths.
**Correct order:**
```
game_shutdown(&game); // Clean up game resources first
engine_shutdown(&engine); // Then engine
SDL_Quit(); // Finally SDL
```
And every early return cleans up what came before:
```
if (!game_init(&game, &engine)) {
engine_shutdown(&engine); // ← Engine was allocated first, must free last
return 1;
}
```
---
## Lessons From The Refactor
|Observation|Lesson|
|---|---|
|**Callbacks forced heap**|Abstraction can hide allocation costs|
|**Hidden control flow**|If you can't see the loop, you can't debug it|
|**Custom input tracking**|Read docs before building (SDL already solved it)|
|**520 bytes wasted**|Every byte of state must earn its keep|
|**Stack allocation works**|Sometimes "simple" means "local variables"|
---
## Next Up: Issue 0x5 — Draw a square on screen
---
_[
[email protected]](mailto:
[email protected]) | Previous: [[0x2005_Issue 0x2, 0x3 - Input]] → Next: [[0x2008_Issue 0x5 - Draw a square on screen]]_
_Transparency note: I use Lumo as a technical reviewer for these posts — checking C semantics, verifying claims, and catching formatting issues. The ideas, mistakes, and fixes are my own._