Building a Game Engine from Scratch: A Systems Journey
Building a game engine from scratch was never about rivaling mature engines like Unity or Unreal. It was about understanding the…
Building a Game Engine from Scratch: A Systems Journey
Building a game engine from scratch was never about rivaling mature engines like Unity or Unreal. It was about understanding the systems-level decisions that make real-time games work: how time flows through a simulation, how raw input becomes authoritative state, how networking interacts with determinism, and how performance tradeoffs surface only when systems are stressed under load.
Game engines are a uniquely powerful lens for systems design. Architectural decisions made early about time, ownership, threading, and data flow tend to become permanent. Mistakes compound quietly, while good abstractions enable entire classes of features almost for free. This project embraced that reality. Over five milestones, the engine evolved from a basic simulation loop into a modular, deterministic system supporting networking, replay, multithreading, and custom memory management.
Rather than optimizing prematurely, the project emphasized architectural clarity and empirical measurement. Each milestone forced explicit tradeoffs such as flexibility versus predictability, abstraction versus control, and those decisions shaped every system that followed.
Early Architecture: Separating Engine and Game
From the beginning, the codebase was divided into engine-level systems and game-specific logic. Rendering, input, simulation, timing, and networking lived in the engine layer, while scenes, entities, and gameplay rules lived in the game layer. This separation was not about cleanliness alone; it was about survival as complexity grew.
In early prototypes, gameplay logic leaked into core systems, making even small changes ripple unpredictably across the codebase. Refactoring toward a clear engine and game boundary immediately reduced coupling and clarified responsibilities. The engine became a set of reusable capabilities, while games became declarative descriptions of behavior and content.
This decision paid off later when multiple genres were implemented on top of the same engine. Core systems remained unchanged, while differences were expressed through scene factories and configuration rather than engine rewrites.
Modular Subsystems from Day One
Why separation mattered earlier than expected
Rather than building everything into a single update loop, the engine was structured as a set of modular subsystems:
- Input detection and handling
- Physics and gravity
- Collision detection and resolution
- Rendering
- Scaling and presentation
Each subsystem lived in its own files and exposed clear interfaces. For example, physics updates were handled independently of rendering, and collision detection produced results without directly mutating unrelated systems.
This modularity made debugging easier and allowed us to reason about each system in isolation. It also enabled later features such as event-driven gameplay and replay to hook into existing systems without rewriting them.
Rethinking Input: From Immediate Reactions to Declarative Control
Early versions of the engine handled input by directly mutating entity state. While simple, this approach quickly broke down. Direct input coupling made replay impossible, complicated networking, and obscured the causal chain between player actions and simulation outcomes.
Input was redesigned as data. Instead of acting immediately, input was translated into commands that could be queued, timestamped, serialized, replayed, or transmitted over the network. This single change unlocked multiple downstream features.
The engine now focuses solely on detecting and tracking input state, while the game layer decides how those inputs translate into actions. Instead of hardcoding movement logic into the engine, developers define key-to-action mappings in game code, allowing behavior to change without modifying engine internals.

Custom Input Chords Combination for Jumping
Scaling and Rendering: When Abstraction Meets Reality
Rendering itself was relatively straightforward using SDL3 but supporting multiple scaling behaviors turned out to be one of the more subtle challenges of the early engine.
We wanted to support two distinct presentation modes:
- Proportional (letterboxed) scaling, which preserves aspect ratio by rendering to a logical resolution and scaling the output uniformly.
- Constant pixel scaling, which renders using raw pixel sizes with nearest-neighbor sampling to preserve crisp pixel art.
Implementing this required careful coordination between logical rendering resolutions, texture scaling modes, and window resizing behavior. SDL provides the necessary building blocks, but combining them into a unified, developer-friendly system required additional abstraction and careful coordination. Much of the complexity came from handling edge cases around resizing, texture filtering, and maintaining consistent visual output across different resolutions.

Proportional (Percentage Based) Scaling

Constant Size (Pixel Based) Scaling
Time as a First-Class System
Time management was elevated to a first-class concern. Early variable-timestep experiments led to subtle bugs: physics divergence, inconsistent collision outcomes, and replay drift. These issues were difficult to debug precisely because they appeared nondeterministic.
The engine adopted a fixed-step simulation loop decoupled from rendering. Real time was converted into game time, accumulated, and advanced in discrete simulation steps. Rendering interpolated between states rather than driving simulation forward.
This separation ensured consistent physics, deterministic behavior, and predictable replay, regardless of frame rate. It also enabled features such as pausing, slow motion, and fast-forwarding without introducing special-case logic.
const double FIXED_DT = 1.0 / 60.0; // 60 Hz simulation
double accumulator = 0.0;
while (running) {
double frameDelta = timeline.tick();
accumulator += frameDelta;
while (accumulator >= FIXED_DT) {
simulate(FIXED_DT);
accumulator -= FIXED_DT;
}
double alpha = accumulator / FIXED_DT;
render(alpha);
}
This loop ensures that simulation advances in fixed increments while rendering remains decoupled and frame-rate independent.
Networking the Engine: From Authoritative Client-Server to Hybrid Peer-to-Peer
Networking began with an authoritative client–server model. This choice prioritized correctness and debuggability over raw responsiveness. All authoritative state lived on the server, while clients submitted input and received snapshots.
Once the baseline was stable, a hybrid peer-to-peer approach was explored to reduce perceived latency. While P2P reduced average input delay, it introduced higher variance, synchronization complexity, and failure modes that were harder to reason about.
Starting with an Authoritative Client–Server Model
We began with a classic authoritative client–server architecture, where the server owns the true state of the world and all clients act as replicas.
In this model:
- The server runs a headless simulation loop.
- Each client renders locally and collects player input.
- Clients send input commands to the server.
- The server advances the simulation in fixed steps and broadcasts authoritative snapshots back to all clients.
This architecture aligned cleanly with our time-driven simulation model. The server’s fixed-step loop became the single source of truth, and every world update was tied to a discrete simulation tick.
Communication Patterns and Responsibilities
We used ZeroMQ to implement clear, purpose-specific communication channels:
- REQ/REP sockets for client-to-server input commands and acknowledgements
- PUB/SUB sockets for broadcasting authoritative world snapshots
Each client has two responsibilities:
- Capture and transmit local input
- Apply authoritative snapshots received from the server
On the server, the simulation loop drains incoming command queues, advances the world deterministically, and publishes snapshots at regular intervals. This separation ensured that input handling, simulation, and rendering remained decoupled.
Lessons from Implementing Two Architectures
Building both architectures side-by-side clarified their tradeoffs:
- Client–server prioritizes correctness, determinism, and debuggability
- Hybrid P2P improves responsiveness at the cost of complexity
- A clean simulation boundary enables architectural experimentation
Most importantly, neither architecture would have been viable without a deterministic, fixed-step simulation loop. Time and simulation design made networking tractable — not the other way around.

Multithreading Without Breaking Determinism
Once networking was introduced, multithreading became unavoidable. Blocking network I/O, client discovery, and snapshot distribution cannot safely live on the same thread as rendering and simulation. At the same time, careless parallelism can easily destroy determinism, introduce race conditions, and make bugs nearly impossible to reproduce.
The central challenge was clear: how do you introduce concurrency without breaking the simulation model that everything else depends on?
A Simple Rule: One Thread Owns the World
The most important design rule we adopted was this:
Only one thread is allowed to mutate the world state.
In engine, the main simulation thread is the sole owner of entity state, physics, collision resolution, and event processing. All other threads are treated as producers of data, never as mutators.
This rule dramatically simplified reasoning about correctness. No matter how many threads were added for networking or I/O, the world itself remained single writer.
Event Management as the Coordination Layer
As systems multiplied, direct coupling became untenable. A centralized event manager was introduced to coordinate input, collision, spawning, death, networking, and replay.
All events were queued, timestamped, prioritized, and processed deterministically once per simulation tick. This made causality explicit and dramatically simplified reasoning about system interactions.
The event system became the backbone of the engine. Networking and replay reused the same execution path as live gameplay, eliminating special cases and increasing confidence in correctness.
A Centralized Event-Driven Architecture
At its core, the event system serves as a communication hub connecting gameplay logic, physics, replay, and networking. Rather than mutating state directly, systems emit events that describe what happened, leaving other systems to decide how to respond.
All interactions such as input, collision, spawn, death, and replay control are represented uniformly as events. These events are:
- queued
- timestamped
- prioritized
- processed deterministically within the same simulation tick
This design decouples gameplay logic from update loops and ensures that new systems can subscribe to or generate events without modifying existing engine internals.

Event Manager Architecture
Deterministic Ordering and Priority Handling
To guarantee consistent behavior across runs, clients, and replay sessions, the EventManager enforces a strict ordering mechanism.
The priority queue inside EventManager uses a custom comparator that sorts events in the following order:
-
Higher priority first such as
Criticalevents (e.g., deaths, replay control, or server sync) are processed beforeNormalgameplay events. -
Earlier timestamps are processed next. If multiple events share the same priority, the one with the smaller timestamp is handled first.
-
Sequence number last if both priority and timestamp are equal (e.g., two
CollisionEvents in the same frame), the monotonic sequence counter ensures a consistent tiebreaker, preserving deterministic ordering across runs.
This guarantees deterministic ordering even when multiple events occur within the same simulation tick.
Ordering Mechanism and Priority Definition:
enum class EventType : std::uint8_t
{
Collision = 0,
Death = 1,
Spawn = 2,
Input = 3,
Replay = 4,
};

Network Architecture for Event-driven Management System
Replay: Recording and Replaying Reality
Replay was implemented on top of the event system rather than as a separate mode. Inputs and events were recorded with timestamps and re-injected into the simulation during playback.
Because the engine was deterministic, replay reproduced gameplay exactly. Replay quickly became more than a feature — it became a debugging and validation tool for reproducing rare bugs and verifying behavioral changes across versions.

Replay Recording and Playback Timeline
Memory Management and Allocation Control
High-frequency object creation exposed the cost of general-purpose heap allocation. To address this, a fixed-size pool allocator was implemented using placement new.
Pools provided deterministic allocation, predictable memory usage, and optional adoption by performance-critical systems such as bullets, balls, and collectibles. Debug visualizations made allocator behavior observable during gameplay.
This reinforced a recurring theme: in real-time systems, predictability often matters more than peak performance.
Motivation: Why the Default Heap Was Not Enough
The standard heap allocator is general-purpose and flexible, but it is not optimized for the specific allocation patterns common in real-time games. In our engine, many entities shared similar lifetimes and sizes, making them ideal candidates for pooling.
The problems observed with heap-based allocation included:
- unpredictable allocation and deallocation cost
- memory fragmentation over long sessions
- occasional frame-time spikes during intensive object churn
While these issues were not catastrophic, they were visible enough to justify a more deterministic solution.
Design of the Memory Pool System
The memory pool system was designed to be:
- simple
- deterministic
- opt-in, not mandatory
Each pool:
- pre-allocates a contiguous block of memory
- divides it into fixed-size slots
- uses placement new to construct objects in-place
- tracks free and occupied slots internally
When an object is destroyed, its memory is returned to the pool without invoking the global allocator.
This design ensures constant-time allocation and deallocation, regardless of runtime conditions.
Integration with the Entity System
Rather than forcing all entities to use pooled allocation, the engine allows systems to opt in selectively.
High-frequency entities such as:
- bullets
- Arkanoid balls
- Pac-Man collectibles
were allocated from pools, while lower-frequency or more complex entities continued to use standard heap allocation.
This hybrid approach avoided unnecessary complexity while still addressing the most performance-critical paths.
// CustomAllocator: allocate pool once, then serve fixed-size slots
CustomAllocator::CustomAllocator(int slotSize, int slotCount)
: slotSize(slotSize), slotCount(slotCount), usedCount(0)
{
memory = new char[slotSize * slotCount]; // contiguous pool
used = new bool[slotCount]; // slot usage bitmap
std::memset(used, 0, slotCount * sizeof(bool));
}
int CustomAllocator::alloc()
{
for (int i = 0; i < slotCount; i++)
{
if (!used[i])
{
used[i] = true;
usedCount++;
return i; // slot id
}
}
return -1; // pool full
}
void* CustomAllocator::getPtr(int id)
{
return memory + (id * slotSize); // slot → address
}
// Bullet pool spawn and recycling logic
Bullet* BulletPool::spawn(float x, float y, float vx, float vy,
SDL_Texture* texture, int columns,
float frameDuration, float frameWidth, float frameHeight)
{
const int id = alloc.alloc();
if (id == -1) return nullptr;
void* mem = alloc.getPtr(id);
// Construct Bullet in-place (no heap allocation per bullet)
Bullet* bullet = new (mem) Bullet(x, y, vx, vy, texture,
columns, frameDuration, frameWidth, frameHeight);
activeIDs[activeCount++] = id;
return bullet;
}
void BulletPool::update(float dt)
{
for (int i = 0; i < activeCount; )
{
int id = activeIDs[i];
Bullet* bullet = reinterpret_cast<Bullet*>(alloc.getPtr(id));
bullet->update(dt);
if (!bullet->active)
{
bullet->~Bullet(); // explicit destructor (paired with placement new)
alloc.freeSlot(id); // return slot to pool
activeIDs[i] = activeIDs[activeCount - 1]; // swap-and-pop
activeCount--;
}
else
{
i++;
}
}
}
Ball Pool Usage HUD

Runtime visualization of the custom allocator, where the on-screen HUD bar reflects the percentage of used slots inside the ArkanoidBallPool.
Pac-Man Coin Pool Structure

Screenshot showing how the CoinPool manages coin objects, mirroring the Arkanoid ball system.
Proving Reusability Across Genres
To validate the architecture, three games were built using the same engine: a platformer, an Arkanoid-style game, and a Pac-Man–style maze game. Each stressed different subsystems, yet engine-level code required minimal changes.
Most modifications occurred in scene factories and configuration rather than engine systems, demonstrating that the architecture generalized beyond a single genre.
Dark Knight — Platformer Game

Dark Knight Game
The platformer emphasized:
- gravity-based movement
- collision-heavy gameplay
- moving platforms and checkpoints
This genre tested physics integration, collision events, and deterministic movement.
Arkanoid-Style Game

Arkanoid Game
The Arkanoid-style game focused on:
- fast-moving entities
- frequent object creation and destruction
- deterministic collision resolution
This made it an ideal candidate for memory pooling and performance evaluation.
Pac-Man–Style Maze Game

Pacman/Bomberman Game
The Pac-Man–style game introduced:
- grid-based level layouts
- ASCII-authored maps
- collectibles, enemies, and audio integration
Despite its structural differences, it reused the same simulation loop, event system, rendering pipeline, and input handling.
Final Thoughts
This project was not about building the fastest or most feature-rich engine. It was about understanding systems design under real constraints.
By building, breaking, measuring, and refining, the engine revealed how determinism enables replay and networking, how modularity compounds over time, and how disciplined concurrency outperforms uncontrolled parallelism. Most importantly, it deepened an appreciation for the invisible infrastructure that makes modern game engines work, and the tradeoffs their designers navigate every day.
메타데이터
- post_id
- f490448262df
- slug
- building-a-game-engine-from-scratch-a-systems-journey-f490448262df
- url
- https://medium.com/@jasani.nisarg01/building-a-game-engine-from-scratch-a-systems-journey-f490448262df
- canonical_url
- https://medium.com/@jasani.nisarg01/building-a-game-engine-from-scratch-a-systems-journey-f490448262df
- author_url
- https://medium.com/@jasani.nisarg01
- status
- ok
- fetched_at
- 2026-07-16 00:55:23