← Back to list

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…

Nisarg Jasani · 2026-01-24 00:45 · 5 claps · 10.8 min read
#game-engine #zeromq #system-programming #software-architecture #cpp
Open on Medium ↗
Wiki topics: 💻 · Programming 🎮 · Gaming 🏛️ · Architecture

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

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

Proportional (Percentage Based) Scaling

Constant Size (Pixel 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:

  1. Capture and transmit local input
  2. 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

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:

  1. Higher priority first such as Critical events (e.g., deaths, replay control, or server sync) are processed before Normal gameplay events.

  2. Earlier timestamps are processed next. If multiple events share the same priority, the one with the smaller timestamp is handled first.

  3. 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

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

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.

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.

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

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

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

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