A game is a program with a deadline it can never miss. Every 16.7 milliseconds it has to read the controller, move everything in the world, work out what touched what, draw a fresh picture and keep the speakers fed, and then do it all again. An engine is the code that makes that deadline routine, so the game on top can think about jumping and not about vertex buffers.
Don't try to build Unreal. Build a 2D engine you can finish: a window, a loop that keeps honest time, sprites on the GPU, entities, collisions and sound, with a small game on top to prove it works. Once that's done you can grow it toward 3D. Your loop, entity system, input and audio carry over as they are; only the renderer and the physics have to get bigger.
01Why build this
Game engines are where performance stops being a nice-to-have and becomes a hard real-time budget. That changes how you think about code you write anywhere:
- Memory layout becomes visible. Iterating 50,000 entities stored as a pointer-chasing object graph versus a packed array is the difference between missing the frame and not. Chapter 02 stops being theory.
- Time gets harder than it looks. Fixed timesteps, interpolation and determinism are the same problems you meet in simulations, replay systems and anything that has to agree on "what happened when".
- The GPU stops being a black box. You'll see why draw calls cost what they cost, why batching matters, and what a shader actually receives.
- Real-time audio teaches the strictest threading rule there is. The audio callback can't block, allocate or wait on a lock, or the user hears it.
- Data-oriented design makes sense. An entity-component system is, in practice, a small in-memory column store with a query language. Once you've built one, a lot of database and analytics code looks familiar.
It's also a project with a built-in reviewer. Either the game feels right when you play it or it doesn't, and you can tell within about ten seconds.
02What you're building
Every frame, the finished engine runs these stages in order:
?Why does the simulation run on a different clock from the renderer?
Physics code is only stable and repeatable when it advances by the same dt
every time. Displays refresh at 60, 120 or 144 Hz, and frames sometimes take
longer than planned. So the simulation ticks at a fixed rate, the renderer draws
as often as the display allows, and it blends the last two simulation states to
hide the difference. Glenn Fiedler's "Fix Your Timestep!" is the classic
explanation, and it's probably the single most useful page for this project.
03Before you start
| You need | Why | Where to get it |
|---|---|---|
| A language that lets you control memory layout | Entity storage and batching depend on packed arrays | C, C++, Rust or Zig |
| A platform layer | Windows, input and a GPU context on every OS | SDL3, GLFW or sokol_app; raylib if you want to postpone the GPU |
| A graphics API you can learn from | Drawing sprites and, later, meshes | OpenGL 3.3 core with LearnOpenGL, or wgpu / sokol_gfx for a modern API |
| An audio output library | Talking to the OS sound device | SDL's audio API or miniaudio |
| Vectors and matrices | Transforms, cameras and collision | Any game maths primer; you need 2D first, 4x4 matrices for 3D |
| A tiny game to build | A target keeps the scope honest | Pong, Breakout or a one-screen platformer |
04The roadmap
Eight milestones. After each one you have something that runs, and the order is chosen so that nothing has to be torn out later.
A window and an honest frame timer
1 eveningOpen a window, create a GPU context, and write the loop: pump events, clear the screen, swap buffers. Measure each frame with a monotonic, high-resolution clock, never wall-clock time, which can jump when the system adjusts it.
Log the frame times and look at them. You'll see that vsync on and vsync off behave very differently, and that even an empty frame isn't perfectly regular. Keep that log; you'll use it in every later milestone.
Fixed timestep with interpolation
1 eveningSplit the loop in two: update(dt) with a constant dt, and render(alpha)
where alpha is how far you are between the last two steps. Its core is
a few lines:
acc += min(frame_time, 0.25);
while (acc >= DT) { prev = curr; update(&curr, DT); acc -= DT; }
render(lerp(prev, curr, acc / DT));Don't drop the min. Without it, one slow frame makes the next frame run extra
steps, which makes it slower, which asks for more steps. That's the "spiral of
death", and clamping the frame time is the standard way out.
Sprites on the GPU
1–2 weekendsWrite one vertex shader and one fragment shader, load a PNG into a texture, and draw a textured quad through an orthographic projection so one unit is one pixel. Then do the naive thing, one draw call per sprite, and measure it.
Now fix it. Put your sprites on a texture atlas, write every quad for the frame into one big vertex buffer, and draw it in a single call. Sort by texture and layer first. That jump in throughput is probably the most useful lesson about GPUs in the project: they're fast at big batches and slow at many small requests.
Input as actions
1 eveningKeep two snapshots of every key and button: this frame and last frame. From those you get held, pressed and released. Games care about the edges far more than the state; a jump that fires while the key is held feels broken immediately.
Then add a layer of actions. Game code asks for jump or a move_x axis,
and a small table maps keys, mouse buttons and gamepad inputs onto them. Be
careful with fixed steps here: a press that happens between two updates has to
be seen by exactly one of them.
jump fires exactly once per press at any frame rate, and rebinding it from Space to a gamepad button changes no game code.Entities and components
1–2 weekendsAn entity is just an ID. Components are plain data, like Position,
Velocity or Sprite, stored in packed arrays. Systems are functions that
iterate every entity with a given set of components. Choose a storage model:
sparse sets are simpler and make adding components cheap, while
archetypes group entities by their exact component set and iterate faster.
Make IDs generational: an index plus a counter that bumps when the slot is reused. Otherwise a system holding the ID of a dead enemy will quietly start steering whatever spawned into its slot.
Collision and a little physics
2 weekendsStart with axis-aligned boxes and move one axis at a time, resolving overlaps after each. That alone probably handles most platformers. Then add a broad phase, such as a uniform grid or spatial hash, so you only test pairs that share a cell. Checking every pair against every other is quadratic and falls over surprisingly early.
Fast objects will tunnel: in one step they're in front of a wall, in the next they're behind it, and they never overlapped. Fix it with swept tests or by sub-stepping fast bodies. Keep this separate from game logic, which should only hear about collisions as events.
Sound that never clicks
1 weekendYour OS calls the audio callback on its own thread, asking for the next few milliseconds of samples. Mix every playing voice into that buffer, apply volume and panning, and clamp the result so it can't clip past full scale.
Inside it there's a hard rule: no locks, no allocation, no file I/O. Your game thread sends commands like "play this sound" through a single-producer, single-consumer ring buffer, and all the sound data is decoded before it's needed. If you've read Chapter 15, this is a real use for everything in it.
Ship a game, then add a dimension
2–4 weekendsFinish the game. Add a title screen, a restart, and a way to load levels from a file. This is where you'll find which parts of the engine are awkward, because you'll be the one fighting them.
Then take the first step into 3D: a perspective projection, a camera, a depth buffer, and one mesh loaded from glTF. Your loop, ECS, input and audio shouldn't change at all. If they do, that's a design problem worth fixing now, before lighting and shadows pile on.
05Traps that catch everyone
| Symptom | Cause | Fix |
|---|---|---|
| Your game runs faster on a 144 Hz monitor | Movement is counted per frame, not per unit of time | Fixed timestep from milestone 2 |
| Motion stutters even at a high frame rate | You render fixed-step state without interpolation, or you time frames with a coarse clock | Interpolate between the last two states; use a monotonic high-resolution timer |
| One hitch freezes the game for seconds | The accumulator runs ever more steps to catch up | Clamp frame time before adding it to the accumulator |
| Thin lines appear between tiles | Texture filtering samples neighbouring atlas cells, or sprites sit on fractional pixels | Pad atlas cells, use nearest filtering for pixel art, snap to whole pixels |
| Audio crackles or pops | The callback blocks on a lock, allocates, or underruns | Preallocate, pass commands through a lock-free queue, raise the buffer size |
| Bullets pass through walls | Discrete collision at high speed | Swept tests or sub-steps for fast bodies |
| Crash when an enemy dies mid-update | Destroying entities while iterating their arrays | Queue destroys and apply them at the end of the frame |
06Stretch goals
- Hot-reload game code. Put the game in a shared library and reload it while the engine keeps running, the way Handmade Hero does.
- Deterministic replay. Record only inputs, play them back, and get the same game. It's also the foundation for rollback netcode.
- A job system. Spread systems across cores with a work-stealing scheduler, and find out which of your systems actually share data.
- Real 3D rendering. Lighting, shadow maps, a material system, and frustum culling.
- Scripting. Embed Lua and expose a few components to it. You'll learn a lot about where the engine/game boundary should be.
07References worth your time
Free online. Its chapters on the game loop, update method, component and data locality map directly onto milestones 2 and 5.
A large, detailed book on how commercial engines are put together, written by a lead programmer at Naughty Dog. Use it as a reference, not a cover-to-cover read.
Casey Muratori builds a complete game from scratch in C on stream, with no libraries. Hundreds of episodes; the early ones on the platform layer and audio are the best.
The short Gaffer On Games article behind milestone 2. Read it before you write your loop.
Joey de Vries' tutorial site. The "Getting started" section covers everything milestone 3 needs, and later sections cover the 3D stretch goals.
The standard book on collision: bounding volumes, broad phases and the geometric tests themselves.
Two open-source ECS libraries: EnTT uses sparse sets, flecs uses archetypes. Read them after milestone 5 to compare against your own.