home / blog / engine
Published: 20 February 2026 14 min read engine
Game engine architecture from scratch: six layers, one loop

Game engine architecture from scratch: six layers, one loop

Writing your own engine starts with getting the layer order right. Notes on subsystem order, fixed timestep simulation, and when to move to a ready engine.

Why the engine crashed in week eleven

I started writing my own engine from scratch in the winter of 2023. The goal was not to ship a game; it was to see where game engine architecture actually sits. The first ten weeks were quiet: a window opened, a triangle drew, a camera orbited. In week eleven the editor started crashing on shutdown — three out of ten exits ended in an access violation.

The call stack was different every time, the root cause was not. ResourceManager destroyed textures and buffers on shutdown while Renderer still held a command list carrying GPU handles to those same objects. The teardown order was not the reverse of the startup order. There was no order at all: subsystems came up in whatever sequence I had typed them into Engine::Init.

That was the first thing building a game engine taught me. The hard part of an engine is not rendering, not the scene graph, and certainly not physics. The hard part is deciding who is allowed to know about whom. When the direction of dependency is undefined, bugs surface everywhere and stop nowhere. Once that direction is broken, the fix is never moving a few files around; it is redrawing the layer boundary.

Six layers, dependencies pointing one way

I split the engine into six layers and let dependencies flow downward only: Platform, RHI, Render, Scene, Gameplay, Editor. The platform layer owns the window, input, file access and the thread pool; it is the only layer that sees operating system headers. Nothing above it includes windows.h, and a build rule enforces that. The platform layer is 3,100 lines in total and has been the least-changed part of the engine.

The RHI layer hides the graphics API behind one interface: IDevice, ICommandList, IBuffer, ITexture. Six months after finishing the D3D11 backend I added a Vulkan one and changed fewer than 40 lines above the RHI boundary, all of them about Present and synchronisation. Had I skipped the RHI in the first version and called D3D11 directly, the same job would have been measured in thousands of lines. The cost of that abstraction stayed measurable but small: the extra virtual calls came to roughly 0.1 ms per frame.

The render layer knows about passes and materials but not about which API sits underneath. The scene layer holds transforms, hierarchy and visibility; it never draws anything, it produces a list of visible objects. Gameplay reads and writes the scene, and the editor is the single layer at the top that is allowed to see everyone. The rule is simple: if I find a Renderer header inside Scene, that file was written in the wrong place.

Subsystem startup and shutdown order

Subsystems now live in an array, and the order comes from dependencies rather than typing habits: Log, Memory, JobSystem, Window, RHIDevice, ResourceManager, Renderer, SceneManager, ScriptVM, EditorUI. Shutdown walks the same array backwards. The rule lives in code, not in a comment, so nobody breaks it by accident. Because the array is declared in one place, adding a new subsystem forces you to decide where it belongs.

The hidden benefit shows up in error handling. If startup fails on the seventh subsystem, I tear down from the sixth backwards and touch only the ones that really came up. Before that, a failed init produced a second crash on objects that had never been constructed, and the original error message drowned underneath it. The rollback logic is 12 lines and I have not touched it since the day I wrote it.

One warning: Log must be the first system up and the last one down. For two weeks I had the Log object sitting in the middle of the array and saw none of my shutdown errors, because the system that would have printed them was already gone. A cheap mistake that is expensive to find.

Engine.cpp
// Subsystems come up in dependency order and go down in reverse. bool Engine::Init() { m_subsystems = { &m_log, &m_memory, &m_jobs, &m_window, &m_device, &m_resources, &m_renderer, &m_scene, &m_scripts, &m_editor }; for (size_t i = 0; i < m_subsystems.size(); ++i) { if (m_subsystems[i]->Init(*this)) continue; // Roll back only the ones that actually started. for (size_t j = i; j-- > 0; ) m_subsystems[j]->Shutdown(); return false; } return true; } void Engine::Shutdown() { for (size_t i = m_subsystems.size(); i-- > 0; ) m_subsystems[i]->Shutdown(); }
cppEngine.cpp

Fixed timestep simulation, variable render

The simulation advances on a fixed timestep: 60 steps per second, 16.667 ms per step. Rendering runs at whatever rate the display allows; on a 144 Hz monitor that is a 6.9 ms frame. If you do not separate the two, physics behaves differently depending on the player's monitor. A character clearing a gap at 30 fps and missing it at 144 fps is exactly this bug.

The main loop adds elapsed time to an accumulator and calls FixedUpdate while at least 16.667 ms remain in it. The critical detail is the cap: I take at most five steps per frame. Without a cap, a slow frame asks for more steps, more steps make the frame slower still, and the spiral of death locks the application up in about two seconds. Hitting that cap means the simulation is falling behind real time, so I count the clamped frames and print that number in the profiler output.

Whatever time is left over after the steps becomes a blend factor for rendering: alpha = accumulator / FIXED_DT. At draw time each object's previous and current transform are blended with that factor. Before interpolation, a 60 Hz simulation visibly stuttered on a 144 Hz screen; afterwards the same scene was smooth, and the added cost was 0.3 ms per frame with 5000 objects.

Two traps. First, the camera has to be interpolated as well; blend the objects but sample the camera instantly and the judder does not disappear, it just moves. Second, input must be sampled at the top of the frame rather than inside FixedUpdate, with presses accumulated into the steps; otherwise a short key press at 144 Hz is sometimes never seen at all.

What an event system actually decouples

Messages have to travel upward between layers: gameplay code must tell the editor that a scene loaded, yet Scene is forbidden from knowing Editor. The answer is an EventBus sitting in the middle. The publisher does not know who listens, the listener does not know who published, and the link is formed at runtime instead of compile time. All event types are declared in a single header, so one file tells you who listens to what.

I split events into two kinds. Immediate events are dispatched directly and are for things that must resolve inside the same frame, such as a window resize. Queued events are flushed at one point at the end of the frame; damage, death and sound triggers belong there. Before that split, publishing an event from inside a handler caused reentrancy bugs and crashes whenever the listener list mutated mid-dispatch.

The measurement: 4000 queued events per frame cost 1.1 ms in the first std::function based version. Moving event payloads into a fixed-size flat struct and listeners into a single contiguous array brought the same load down to 0.2 ms. Even so, do not route everything through events; if two systems in the same layer can call each other directly, let them. An event system exists to break dependencies, not to hide control flow.

ECS or OOP, and when to stop

The practical answer is both. Engine subsystems can happily stay OOP; Renderer, AudioDevice and ResourceManager are singular, long-lived, mutually different objects, and ECS architecture adds nothing to them. ECS pays off where the same operation repeats across thousands of objects: transform updates, visibility culling, particles, projectiles.

In my own engine, updating transforms for 20,000 moving objects took 4.8 ms through an object hierarchy with virtual calls. Moving the same data into component arrays and writing the loop flat brought it to 0.9 ms. Most of that win did not come from SIMD; it came from the data being contiguous in memory and from the branch disappearing out of the inner loop. In the same test the component arrays used 12 percent more memory; that was the price of the speed.

Writing your own engine makes sense in three cases. When learning is the actual goal; when the core requirement of the game fights the assumptions of ready engines, for example deterministic network simulation, an unusual rendering technique or a very tight memory budget; or when you need full control over a technology you will live with for years. If none of those apply, writing an engine becomes a substitute for writing a game.

For the stopping point I use a concrete threshold: if maintaining the engine eats more than half of the working week, and that holds for three weeks in a row, the game moves to a ready engine. Nothing you learned is lost — inside a commercial engine you now read frame budgets, memory layout and subsystem order differently. I still develop my own engine today, but I do not write my shipped games with it, and separating those two was the best decision I made.

← All posts