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 frameIn 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 secondsThe 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 stateFixed 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
renderThis 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 stateA 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: []EntityMemory layout:
position, velocity, health
position, velocity, health
position, velocity, healthA 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, healthsIf 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 rejectedThis 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 systemA 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 effectOne 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
commandsA good engine core separates memory lifetimes.
Common lifetimes:
whole program lifetime
level lifetime
frame lifetime
temporary scratch lifetime
asset lifetimeZig 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: TextureHandleThe 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 deviceThen 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 functionsBetter:
gameplay updates state
render system reads state
renderer submits draw commandsA 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 toolsTo 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 logicThe 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 dataHot 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 dataAvoid 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 startupSome are recoverable:
optional sound missing
bad config value
failed hot reload
network packet malformedUse 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
serializationDo 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 ruleBetter shape:
Engine owns the loop
World owns game state
Systems update game state
AssetManager owns assets
Renderer draws
Platform talks to the OSThe 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.