Subchapter 178.5
references/game-engine-core-principles.mdMarkdown22 KBView on GitHub
A comprehensive reference on the fundamental architecture and design principles behind building a game engine. Covers modularity, separation of concerns, core subsystems, and practical implementation guidance.
A game engine is a reusable software framework that abstracts the common systems needed to build games. Rather than writing rendering, physics, input, and audio code from scratch for every project, a well-designed engine provides these as modular, configurable subsystems.
Key motivations:
Every major system in the engine should be an independent module with a well-defined interface. Modules should communicate through clean APIs rather than reaching into each other’s internals.
Why it matters:
Example structure:
engine/
core/ -- Memory, logging, math, utilities
platform/ -- OS abstraction, windowing, file I/O
renderer/ -- Graphics API, shaders, materials
physics/ -- Collision, rigid body dynamics
audio/ -- Sound playback, mixing, spatial audio
input/ -- Keyboard, mouse, gamepad, touch
scripting/ -- Scripting language bindings
scene/ -- Scene graph, entity management
resources/ -- Asset loading, caching, streamingEach system should have a single, clearly defined responsibility. Avoid mixing rendering logic with physics, or input handling with game state management.
Practical guidelines:
Wherever possible, behavior should be controlled by data rather than hard-coded logic. This allows designers and artists to modify game behavior without recompiling code.
Examples of data-driven approaches:
Each module should depend on as few other modules as possible. The dependency graph should be a clean hierarchy, not a tangled web.
Game Code
|
v
Engine High-Level Systems (Scene, Entity, Scripting)
|
v
Engine Low-Level Systems (Renderer, Physics, Audio, Input)
|
v
Engine Core (Memory, Math, Logging, Platform Abstraction)
|
v
Operating System / HardwareCircular dependencies between modules are a sign of poor architecture and should be eliminated.
ECS is a widely adopted architectural pattern in modern game engines that favors composition over inheritance.
Traditional object-oriented inheritance creates rigid, deep hierarchies:
GameObject
-> MovableObject
-> Character
-> Player
-> Enemy
-> FlyingEnemy
-> GroundEnemyProblems with this approach:
ECS solves these problems through composition:
// An entity is just an ID
const player = world.createEntity();
// Attach components to define what it is
world.addComponent(player, new Position(100, 200));
world.addComponent(player, new Velocity(0, 0));
world.addComponent(player, new Sprite("player.png"));
world.addComponent(player, new Health(100));
world.addComponent(player, new PlayerInput());
// A "flying enemy" is just a different combination of components
const flyingEnemy = world.createEntity();
world.addComponent(flyingEnemy, new Position(400, 50));
world.addComponent(flyingEnemy, new Velocity(0, 0));
world.addComponent(flyingEnemy, new Sprite("bat.png"));
world.addComponent(flyingEnemy, new Health(30));
world.addComponent(flyingEnemy, new AIBehavior("patrol_fly"));
world.addComponent(flyingEnemy, new Flying());// Movement system: processes all entities with Position + Velocity
function movementSystem(world, deltaTime) {
for (const [entity, pos, vel] of world.query(Position, Velocity)) {
pos.x += vel.x * deltaTime;
pos.y += vel.y * deltaTime;
}
}
// Render system: processes all entities with Position + Sprite
function renderSystem(world, context) {
for (const [entity, pos, sprite] of world.query(Position, Sprite)) {
context.drawImage(sprite.image, pos.x, pos.y);
}
}
// Gravity system: only affects entities with Velocity but NOT Flying
function gravitySystem(world, deltaTime) {
for (const [entity, vel] of world.query(Velocity).without(Flying)) {
vel.y += 9.8 * deltaTime;
}
}Custom memory management is critical for game engine performance. The default allocator (malloc/new) is general-purpose and not optimized for game workloads.
Common allocation strategies:
// Conceptual frame allocator
class FrameAllocator {
char* buffer;
size_t offset;
size_t capacity;
public:
void* allocate(size_t size) {
void* ptr = buffer + offset;
offset += size;
return ptr;
}
void reset() {
offset = 0; // All allocations freed instantly
}
};The resource manager handles loading, caching, and lifetime management of game assets.
Key responsibilities:
class ResourceManager {
constructor() {
this.cache = new Map();
this.loading = new Map();
}
async load(path) {
// Return cached resource if available
if (this.cache.has(path)) {
return this.cache.get(path);
}
// Avoid duplicate loads
if (this.loading.has(path)) {
return this.loading.get(path);
}
// Start async load
const promise = this._loadFromDisk(path).then(resource => {
this.cache.set(path, resource);
this.loading.delete(path);
return resource;
});
this.loading.set(path, promise);
return promise;
}
unload(path) {
this.cache.delete(path);
}
}The rendering subsystem translates the game’s visual state into pixels on screen.
Typical rendering pipeline stages:
Render command pattern:
Rather than making draw calls directly, build a list of render commands that can be sorted and batched before submission:
class RenderCommand {
constructor(mesh, material, transform, sortKey) {
this.mesh = mesh;
this.material = material;
this.transform = transform;
this.sortKey = sortKey;
}
}
class Renderer {
constructor() {
this.commandQueue = [];
}
submit(command) {
this.commandQueue.push(command);
}
flush(context) {
// Sort by material to minimize state changes
this.commandQueue.sort((a, b) => a.sortKey - b.sortKey);
for (const cmd of this.commandQueue) {
this._bindMaterial(cmd.material);
this._setTransform(cmd.transform);
this._drawMesh(cmd.mesh, context);
}
this.commandQueue.length = 0;
}
}The physics subsystem simulates physical behavior and detects collisions.
Key design considerations:
class PhysicsWorld {
constructor(fixedTimestep = 1 / 50) {
this.fixedTimestep = fixedTimestep;
this.accumulator = 0;
this.bodies = [];
}
update(deltaTime) {
this.accumulator += deltaTime;
while (this.accumulator >= this.fixedTimestep) {
this.step(this.fixedTimestep);
this.accumulator -= this.fixedTimestep;
}
}
step(dt) {
// Integrate velocities
for (const body of this.bodies) {
body.velocity.y += body.gravity * dt;
body.position.x += body.velocity.x * dt;
body.position.y += body.velocity.y * dt;
}
// Detect and resolve collisions
this.broadPhase();
this.narrowPhase();
this.resolveCollisions();
}
}The input system translates raw hardware events into game-meaningful actions.
Layered design:
class InputManager {
constructor() {
this.bindings = new Map();
this.actionStates = new Map();
}
bind(action, key) {
this.bindings.set(key, action);
}
handleKeyDown(event) {
const action = this.bindings.get(event.code);
if (action) {
this.actionStates.set(action, true);
}
}
handleKeyUp(event) {
const action = this.bindings.get(event.code);
if (action) {
this.actionStates.set(action, false);
}
}
isActionActive(action) {
return this.actionStates.get(action) || false;
}
}
// Usage
const input = new InputManager();
input.bind("Jump", "Space");
input.bind("MoveLeft", "KeyA");
input.bind("MoveRight", "KeyD");
// In game update:
if (input.isActionActive("Jump")) {
player.jump();
}An event system enables decoupled communication between engine subsystems and game code without direct references.
Publish-subscribe pattern:
class EventBus {
constructor() {
this.listeners = new Map();
}
on(eventType, callback) {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
this.listeners.get(eventType).push(callback);
}
off(eventType, callback) {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index !== -1) callbacks.splice(index, 1);
}
}
emit(eventType, data) {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
for (const callback of callbacks) {
callback(data);
}
}
}
}
// Usage
const events = new EventBus();
events.on("collision", (data) => {
console.log(`${data.entityA} collided with ${data.entityB}`);
});
events.on("entityDestroyed", (data) => {
spawnExplosion(data.position);
addScore(data.points);
});
// Emit from physics system
events.emit("collision", { entityA: player, entityB: wall });Deferred events:
For performance and determinism, events can be queued during a frame and dispatched at a specific point in the update cycle:
class DeferredEventBus extends EventBus {
constructor() {
super();
this.eventQueue = [];
}
queue(eventType, data) {
this.eventQueue.push({ type: eventType, data });
}
dispatchQueued() {
for (const event of this.eventQueue) {
this.emit(event.type, event.data);
}
this.eventQueue.length = 0;
}
}The scene manager organizes game content into logical groups and manages transitions between different game states.
Common patterns:
class SceneManager {
constructor() {
this.scenes = new Map();
this.activeScene = null;
}
register(name, scene) {
this.scenes.set(name, scene);
}
async switchTo(name) {
if (this.activeScene) {
this.activeScene.onExit();
this.activeScene.unloadResources();
}
this.activeScene = this.scenes.get(name);
await this.activeScene.loadResources();
this.activeScene.onEnter();
}
update(deltaTime) {
if (this.activeScene) {
this.activeScene.update(deltaTime);
}
}
render(context) {
if (this.activeScene) {
this.activeScene.render(context);
}
}
}A well-designed engine abstracts platform-specific code behind a uniform interface. This enables the engine to run on multiple operating systems, graphics APIs, and hardware configurations.
Areas requiring abstraction:
| Concern | Examples |
|---|---|
| Windowing | Win32, X11, Cocoa, SDL, GLFW |
| Graphics API | OpenGL, Vulkan, DirectX, Metal, WebGL |
| File I/O | POSIX, Win32, virtual file systems |
| Threading | pthreads, Win32 threads, Web Workers |
| Audio output | WASAPI, CoreAudio, ALSA, Web Audio |
| Input devices | DirectInput, XInput, evdev, Gamepad API |
// Abstract file system interface
class FileSystem {
async readFile(path) { throw new Error("Not implemented"); }
async writeFile(path, data) { throw new Error("Not implemented"); }
async exists(path) { throw new Error("Not implemented"); }
}
// Web implementation
class WebFileSystem extends FileSystem {
async readFile(path) {
const response = await fetch(path);
return response.arrayBuffer();
}
}
// Node.js implementation
class NodeFileSystem extends FileSystem {
async readFile(path) {
const fs = require("fs").promises;
return fs.readFile(path);
}
}Engine subsystems must be initialized in dependency order and shut down in reverse order.
Typical initialization sequence:
Shutdown reverses this order to ensure systems are cleaned up before the systems they depend on.
class Engine {
async initialize() {
this.logger = new Logger();
this.config = new Config("engine.json");
this.platform = new Platform();
await this.platform.createWindow(this.config.window);
this.renderer = new Renderer(this.platform.canvas);
this.audio = new AudioSystem();
this.physics = new PhysicsWorld();
this.resources = new ResourceManager();
this.input = new InputManager(this.platform.window);
this.events = new EventBus();
this.scenes = new SceneManager();
this.logger.info("Engine initialized");
}
shutdown() {
this.scenes.cleanup();
this.resources.unloadAll();
this.input.cleanup();
this.physics.cleanup();
this.audio.cleanup();
this.renderer.cleanup();
this.platform.cleanup();
this.logger.info("Engine shutdown complete");
}
run() {
let lastTime = performance.now();
const loop = (currentTime) => {
const deltaTime = (currentTime - lastTime) / 1000;
lastTime = currentTime;
this.input.poll();
this.physics.update(deltaTime);
this.scenes.update(deltaTime);
this.events.dispatchQueued();
this.scenes.render(this.renderer);
this.renderer.present();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}While modularity is important, over-engineering interfaces before understanding real requirements leads to unnecessary complexity. Start with simple, concrete implementations and refactor toward abstraction when actual use cases demand it.
Measure actual performance bottlenecks using profiling tools before spending time on optimization. Intuition about where time is spent is frequently wrong.
Organize data by how it is accessed rather than by object-oriented abstractions. Storing components of the same type contiguously in memory (Structure of Arrays rather than Array of Structures) dramatically improves CPU cache hit rates.
// Array of Structures (cache-unfriendly for position-only iteration)
const entities = [
{ position: {x: 0, y: 0}, sprite: "hero.png", health: 100 },
{ position: {x: 5, y: 3}, sprite: "bat.png", health: 30 },
];
// Structure of Arrays (cache-friendly for position-only iteration)
const positions = { x: [0, 5], y: [0, 3] };
const sprites = ["hero.png", "bat.png"];
const healths = [100, 30];Avoid creating new objects or allocating memory during per-frame updates. Pre-allocate buffers, use object pools, and reuse temporary objects.
Group similar operations together to reduce overhead from context switching, draw call setup, and cache misses. Process all entities of a given type before moving to the next type.
| Principle | Description |
|---|---|
| Modularity | Independent subsystems with clean interfaces |
| Separation of concerns | Each system has a single responsibility |
| Data-driven design | Behavior controlled by data, not hard-coded logic |
| Composition over inheritance | ECS pattern for flexible entity construction |
| Minimal dependencies | Clean, hierarchical dependency graph |
| Platform abstraction | Uniform interfaces over platform-specific code |
| Fixed timestep physics | Deterministic simulation independent of frame rate |
| Event-driven communication | Decoupled interaction through publish-subscribe |
| Data-oriented performance | Optimize memory layout for access patterns |
| Measure before optimizing | Profile to identify actual bottlenecks |