# Game Engine Core Design Principles 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. Source: https://www.gamedev.net/articles/programming/general-and-gameplay-programming/making-a-game-engine-core-design-principles-r3210/ --- ## Why Build a Game Engine 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: - **Reusability** -- Use the same codebase across multiple game projects. - **Separation of engine code from game code** -- Engine developers and game designers can work independently. - **Maintainability** -- Well-structured code is easier to debug, extend, and optimize. - **Scalability** -- Add new features or platforms without rewriting existing systems. --- ## Core Design Principles ### Modularity 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:** - Swap implementations without affecting other systems (e.g., replace OpenGL renderer with Vulkan). - Test individual systems in isolation. - Allow teams to work on different modules in parallel. **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, streaming ``` ### Separation of Concerns Each system should have a single, clearly defined responsibility. Avoid mixing rendering logic with physics, or input handling with game state management. **Practical guidelines:** - The renderer should not know about game mechanics. - The physics engine should not know how entities are rendered. - Input processing should translate raw device events into abstract actions that game code can consume. - The game logic layer sits on top of the engine and uses engine services without modifying them. ### Data-Driven Design 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:** - Level layouts defined in data files (JSON, XML, binary) rather than code. - Entity properties and behaviors configured through component data. - Shader parameters exposed as material properties editable in tools. - Animation state machines defined in configuration rather than imperative code. ### Minimize Dependencies 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 / Hardware ``` Circular dependencies between modules are a sign of poor architecture and should be eliminated. --- ## The Entity-Component-System (ECS) Pattern ECS is a widely adopted architectural pattern in modern game engines that favors composition over inheritance. ### Core Concepts - **Entity** -- A unique identifier (often just an integer ID) that represents a game object. An entity has no behavior or data of its own. - **Component** -- A plain data container attached to an entity. Each component type stores one aspect of an entity's state (position, velocity, sprite, health, etc.). - **System** -- A function or object that processes all entities with a specific set of components. Systems contain the logic; components contain the data. ### Why ECS Over Inheritance Traditional object-oriented inheritance creates rigid, deep hierarchies: ``` GameObject -> MovableObject -> Character -> Player -> Enemy -> FlyingEnemy -> GroundEnemy ``` Problems with this approach: - Adding a new entity type that combines traits from multiple branches requires restructuring the hierarchy or using multiple inheritance. - Deep hierarchies are fragile; changes to base classes ripple through all descendants. - Classes accumulate unused behavior over time. ECS solves these problems through composition: ```javascript // 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()); ``` ### Systems Process Components ```javascript // 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; } } ``` ### Benefits of ECS - **Flexible composition** -- Create any entity type by mixing components without modifying code. - **Cache-friendly data layout** -- Storing components contiguously in memory improves CPU cache performance. - **Parallelism** -- Systems that operate on different component sets can run in parallel. - **Easy serialization** -- Components are plain data, making save/load straightforward. --- ## Core Engine Subsystems ### Memory Management 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:** - **Stack Allocator** -- Fast LIFO allocations for temporary, frame-scoped data. Reset the stack pointer at the end of each frame. - **Pool Allocator** -- Fixed-size block allocation for objects of the same type (entities, components, particles). Zero fragmentation. - **Frame Allocator** -- A linear allocator that resets every frame. Ideal for per-frame temporary data. - **Double-Buffered Allocator** -- Two frame allocators that alternate each frame, allowing data from the previous frame to persist. ```cpp // 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 } }; ``` ### Resource Management The resource manager handles loading, caching, and lifetime management of game assets. **Key responsibilities:** - **Asynchronous loading** -- Load assets in background threads to avoid stalling the game loop. - **Reference counting** -- Track how many systems use an asset; unload when no longer referenced. - **Caching** -- Keep recently used assets in memory to avoid redundant disk reads. - **Hot reloading** -- Detect asset changes on disk and reload them at runtime during development. - **Resource handles** -- Use handles (IDs or smart pointers) rather than raw pointers to reference assets. ```javascript 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); } } ``` ### Rendering Pipeline The rendering subsystem translates the game's visual state into pixels on screen. **Typical rendering pipeline stages:** 1. **Scene traversal** -- Walk the scene graph or query ECS for renderable entities. 2. **Frustum culling** -- Discard objects outside the camera's view. 3. **Occlusion culling** -- Discard objects hidden behind other geometry. 4. **Sorting** -- Order objects by material, depth, or transparency requirements. 5. **Batching** -- Group objects with the same material to minimize draw calls and state changes. 6. **Vertex processing** -- Transform vertices from model space to screen space (vertex shader). 7. **Rasterization** -- Convert triangles to fragments (pixels). 8. **Fragment processing** -- Compute final pixel color using lighting, textures, and effects (fragment shader). 9. **Post-processing** -- Apply screen-space effects like bloom, tone mapping, and anti-aliasing. **Render command pattern:** Rather than making draw calls directly, build a list of render commands that can be sorted and batched before submission: ```javascript 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; } } ``` ### Physics Integration The physics subsystem simulates physical behavior and detects collisions. **Key design considerations:** - **Fixed timestep** -- Physics should update at a fixed rate (e.g., 50 Hz) independent of the rendering frame rate. This ensures deterministic simulation behavior. - **Collision phases** -- Use a broad phase (spatial partitioning, bounding volume hierarchies) to quickly eliminate non-colliding pairs, followed by a narrow phase for precise intersection testing. - **Physics world separation** -- The physics world should maintain its own representation of objects (physics bodies) separate from game entities. A synchronization step maps between them. ```javascript 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(); } } ``` ### Input System The input system translates raw hardware events into game-meaningful actions. **Layered design:** 1. **Hardware Layer** -- Receives raw events from the OS (key pressed, mouse moved, button down). 2. **Mapping Layer** -- Translates raw inputs into named actions via configurable bindings (e.g., "Space" maps to "Jump", "W" maps to "MoveForward"). 3. **Action Layer** -- Exposes abstract actions that game code queries, completely decoupled from specific hardware inputs. ```javascript 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(); } ``` ### Event System An event system enables decoupled communication between engine subsystems and game code without direct references. **Publish-subscribe pattern:** ```javascript 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: ```javascript 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; } } ``` ### Scene Management The scene manager organizes game content into logical groups and manages transitions between different game states. **Common patterns:** - **Scene graph** -- A hierarchical tree of nodes where child transforms are relative to parent transforms. Moving a parent moves all children. - **Scene stack** -- Scenes can be pushed and popped. A pause menu pushes on top of gameplay; dismissing it pops back to gameplay. - **Scene loading** -- Scenes define which assets and entities to load. The scene manager coordinates loading, initialization, and cleanup. ```javascript 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); } } } ``` --- ## Platform Abstraction 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 | ```javascript // 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); } } ``` --- ## Initialization and Shutdown Order Engine subsystems must be initialized in dependency order and shut down in reverse order. **Typical initialization sequence:** 1. Core systems (logging, memory, configuration) 2. Platform layer (window creation, input devices) 3. Rendering system (graphics context, default resources) 4. Audio system 5. Physics system 6. Resource manager (load default/shared assets) 7. Scene manager 8. Scripting system 9. Game-specific initialization **Shutdown reverses this order** to ensure systems are cleaned up before the systems they depend on. ```javascript 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); } } ``` --- ## Performance Principles ### Avoid Premature Abstraction 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. ### Profile Before Optimizing Measure actual performance bottlenecks using profiling tools before spending time on optimization. Intuition about where time is spent is frequently wrong. ### Data-Oriented Design 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. ```javascript // 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]; ``` ### Minimize Allocations in Hot Paths Avoid creating new objects or allocating memory during per-frame updates. Pre-allocate buffers, use object pools, and reuse temporary objects. ### Batch Operations 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. --- ## Summary of Key Principles | 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 |