Skip to content

Writing a Game Engine Core

A game engine core is the small central layer that runs the game.

A game engine core is the small central layer that runs the game.

It does not need to contain everything. A serious engine may have rendering, physics, audio, networking, animation, scripting, asset loading, editor tools, and platform layers. The core is smaller than that.

The core usually answers these questions:

What objects exist?
How is game state stored?
What code runs each frame?
How does time move forward?
How do systems communicate?
Who owns memory?

A good engine core is boring. It gives the game a stable loop and clear data ownership.

The Game Loop

Most games run inside a loop.

Conceptually:

while the game is running:
    read input
    update simulation
    render frame

In Zig-like pseudocode:

while (running) {
    try pollInput();
    try update(dt);
    try render();
}

The variable dt usually means delta time. It is the amount of time that passed since the previous frame.

If the previous frame happened 16 milliseconds ago, then:

dt = 0.016 seconds

The update step uses dt so movement can be based on time instead of frame count.

A Minimal Engine Type

Start with one central type.

const std = @import("std");

const Engine = struct {
    allocator: std.mem.Allocator,
    running: bool,

    pub fn init(allocator: std.mem.Allocator) Engine {
        return Engine{
            .allocator = allocator,
            .running = true,
        };
    }

    pub fn deinit(self: *Engine) void {
        _ = self;
    }

    pub fn run(self: *Engine) !void {
        while (self.running) {
            try self.update(1.0 / 60.0);
            try self.render();
        }
    }

    fn update(self: *Engine, dt: f32) !void {
        _ = self;
        _ = dt;
    }

    fn render(self: *Engine) !void {
        _ = self;
    }
};

This engine does almost nothing, but the shape is useful:

init creates the engine
deinit releases resources
run owns the main loop
update changes game state
render draws the current state

Fixed Time Step

A simple loop uses the real time between frames. That is called a variable time step.

A fixed time step updates the simulation using a constant amount of time, such as 1 / 60 seconds.

const fixed_dt: f32 = 1.0 / 60.0;

Fixed time steps are useful for physics and deterministic simulation.

Conceptually:

collect elapsed time
while elapsed time >= fixed_dt:
    update simulation by fixed_dt
    subtract fixed_dt
render

This keeps simulation updates stable even when rendering speed changes.

A simplified version:

var accumulator: f32 = 0.0;
const fixed_dt: f32 = 1.0 / 60.0;

while (running) {
    const frame_time = measureFrameTime();
    accumulator += frame_time;

    while (accumulator >= fixed_dt) {
        try update(fixed_dt);
        accumulator -= fixed_dt;
    }

    try render();
}

This pattern separates simulation rate from render rate.

Game State

Game state is all the data that describes the current world.

Examples:

player position
enemy health
current map
camera state
inventory
projectiles
timers
animation state

A simple game object might look like this:

const Vec2 = struct {
    x: f32,
    y: f32,
};

const Entity = struct {
    position: Vec2,
    velocity: Vec2,
    alive: bool,
};

Then the world can store many entities:

const World = struct {
    entities: std.ArrayList(Entity),

    pub fn init(allocator: std.mem.Allocator) World {
        return World{
            .entities = std.ArrayList(Entity).init(allocator),
        };
    }

    pub fn deinit(self: *World) void {
        self.entities.deinit();
    }
};

The engine owns the world:

const Engine = struct {
    allocator: std.mem.Allocator,
    world: World,
    running: bool,
};

This makes ownership clear.

Updating Entities

A basic update moves entities according to velocity.

fn updateWorld(world: *World, dt: f32) void {
    for (world.entities.items) |*entity| {
        if (!entity.alive) continue;

        entity.position.x += entity.velocity.x * dt;
        entity.position.y += entity.velocity.y * dt;
    }
}

The |*entity| syntax gives a pointer to each entity, so the loop can modify it.

Without the pointer, the loop receives a copy.

for (world.entities.items) |entity| {
    // entity is a copy
}

With a pointer:

for (world.entities.items) |*entity| {
    // entity points to the real item
}

This is an important Zig detail for game code.

Data-Oriented Design

Game engines often process many objects every frame.

When many objects share the same fields, memory layout matters.

An array of structs looks like this:

const Entity = struct {
    position: Vec2,
    velocity: Vec2,
    health: i32,
};

entities: []Entity

Memory layout:

position, velocity, health
position, velocity, health
position, velocity, health

A struct of arrays looks like this:

const EntityStore = struct {
    positions: []Vec2,
    velocities: []Vec2,
    healths: []i32,
};

Memory layout:

positions, positions, positions
velocities, velocities, velocities
healths, healths, healths

If the update only needs positions and velocities, the second layout can be more cache-friendly.

Zig gives you enough control to choose either layout.

Start simple. Change layout when profiling shows a real problem.

Handles Instead of Raw Pointers

Game objects often come and go. If you store raw pointers to entities, those pointers can become invalid when the entity list reallocates or an entity is removed.

A safer design is to use handles.

const EntityId = struct {
    index: u32,
    generation: u32,
};

The index points into an array. The generation helps detect stale handles.

Conceptually:

create entity at slot 5, generation 1
destroy entity at slot 5
create new entity at slot 5, generation 2
old handle has generation 1, so it is rejected

This avoids many use-after-free style bugs.

Systems

A system is code that updates one part of the game.

Examples:

movement system
physics system
animation system
AI system
collision system
render system
audio system

A system usually receives the world and modifies some part of it.

fn movementSystem(world: *World, dt: f32) void {
    for (world.entities.items) |*entity| {
        if (!entity.alive) continue;

        entity.position.x += entity.velocity.x * dt;
        entity.position.y += entity.velocity.y * dt;
    }
}

The engine update can call systems in order:

fn update(self: *Engine, dt: f32) !void {
    movementSystem(&self.world, dt);
    collisionSystem(&self.world);
    animationSystem(&self.world, dt);
}

Order matters. Physics before animation may produce different results than animation before physics.

Make system order explicit.

Events

Systems sometimes need to communicate.

Example:

collision system reports that player touched coin
audio system plays sound
score system increments score
render system shows effect

One approach is an event queue.

const Event = union(enum) {
    coin_collected: struct {
        player: EntityId,
        coin: EntityId,
    },
    entity_died: struct {
        entity: EntityId,
    },
};

The world or engine can store events:

events: std.ArrayList(Event)

Systems push events. Later systems read them.

Keep events small and clear. Avoid using events as a hidden global control system.

Memory Strategy

Game engines allocate many resources:

textures
meshes
sounds
levels
entities
temporary frame data
strings
commands

A good engine core separates memory lifetimes.

Common lifetimes:

whole program lifetime
level lifetime
frame lifetime
temporary scratch lifetime
asset lifetime

Zig allocators make this explicit.

Example:

const Engine = struct {
    gpa: std.mem.Allocator,
    frame_arena: std.heap.ArenaAllocator,
    level_arena: std.heap.ArenaAllocator,
};

Frame memory can be cleared every frame.

Level memory can be cleared when changing maps.

This reduces leaks and simplifies cleanup.

Frame Arena

A frame arena is useful for temporary data.

var frame_arena = std.heap.ArenaAllocator.init(parent_allocator);
defer frame_arena.deinit();

const frame_allocator = frame_arena.allocator();

At the end of each frame, reset the arena.

Conceptually:

_ = frame_arena.reset(.retain_capacity);

Then all temporary frame allocations disappear at once.

This is often better than freeing many small allocations individually.

Resource Ownership

An engine core should make ownership boring.

If the engine loads a texture, who frees it?

If a level owns entities, when are they destroyed?

If audio owns sound buffers, can gameplay code keep pointers to them?

Define these rules early.

A simple rule:

The engine owns global systems.
The world owns entities.
The asset manager owns loaded assets.
Gameplay code uses handles, not raw asset pointers.

Handles keep ownership centralized.

Asset Handles

A texture handle might be:

const TextureHandle = struct {
    index: u32,
    generation: u32,
};

Gameplay code stores the handle:

sprite_texture: TextureHandle

The renderer resolves the handle when needed.

This avoids spreading raw graphics API objects throughout game logic.

Platform Layer

The engine core should not know too much about the operating system.

Create a platform layer for:

window creation
input
timing
file access
graphics surface
audio device

Then the core can depend on an interface rather than platform-specific code.

Example shape:

const Platform = struct {
    pollEvents: *const fn (*Platform) anyerror!void,
    shouldClose: *const fn (*Platform) bool,
    nowSeconds: *const fn (*Platform) f64,
};

The exact design depends on how low-level you want the engine to be.

Rendering Boundary

Do not mix gameplay logic with rendering API details.

Bad:

player update directly calls Vulkan or OpenGL functions

Better:

gameplay updates state
render system reads state
renderer submits draw commands

A draw command might be:

const DrawSprite = struct {
    texture: TextureHandle,
    position: Vec2,
    size: Vec2,
};

The render system builds draw commands. The renderer backend turns them into graphics API calls.

This keeps gameplay code portable.

Determinism

Some games need deterministic simulation.

That means the same inputs produce the same results.

Determinism matters for:

replays
lockstep multiplayer
tests
debugging
simulation tools

To improve determinism:

use fixed time steps
avoid unordered iteration when order matters
control random seeds
be careful with floating point
avoid reading wall-clock time inside simulation logic

The engine core should decide where nondeterminism is allowed.

Hot Reloading

Advanced engines often support hot reloading.

Examples:

reload textures when files change
reload shaders
reload scripts
reload level data

Hot reloading is useful, but it complicates ownership.

A safe first version is asset hot reload:

asset manager watches file timestamp
asset manager reloads texture
old handle now points to new data

Avoid hot reloading core Zig code until the engine architecture is already stable.

Error Handling in a Game Engine

A game engine has different kinds of errors.

Some are fatal:

graphics device cannot initialize
required asset directory missing
out of memory during startup

Some are recoverable:

optional sound missing
bad config value
failed hot reload
network packet malformed

Use Zig errors for recoverable failure.

For fatal startup failure, return an error from initialization.

pub fn init(allocator: std.mem.Allocator) !Engine {
    // fail if required systems cannot start
}

Avoid panicking for normal failures. Panic is for bugs and impossible states.

Testing the Core

A good engine core can be tested without opening a window.

Test:

entity creation and deletion
handle generation
movement system
event queue
asset handle lookup
fixed time step accumulator
serialization

Do not make every test depend on graphics or audio.

Example:

test "movement updates position" {
    var world = World.init(std.testing.allocator);
    defer world.deinit();

    try world.entities.append(.{
        .position = .{ .x = 0, .y = 0 },
        .velocity = .{ .x = 10, .y = 0 },
        .alive = true,
    });

    updateWorld(&world, 0.5);

    try std.testing.expectEqual(@as(f32, 5.0), world.entities.items[0].position.x);
}

This test checks gameplay logic without rendering anything.

Keep the Core Small

A common mistake is making the engine core too large.

Do not put everything in Engine.

Bad shape:

Engine owns every object, every renderer detail, every input detail, every asset detail, every gameplay rule

Better shape:

Engine owns the loop
World owns game state
Systems update game state
AssetManager owns assets
Renderer draws
Platform talks to the OS

The core coordinates. It should not become a dumping ground.

The Main Idea

A game engine core is the stable center of the game.

It owns the loop, time step, world update order, memory strategy, and boundaries between systems.

In Zig, this fits naturally because Zig makes ownership and allocation explicit. You can build the core from simple structs, arrays, handles, function calls, and allocators.

Start with a small loop and a clear world model. Add systems only when the game needs them.