
Overview of Impaler's Minimal Game Engine
12 Jan 2023Impaler is a minimal first person arena shooter that sells for $3 USD. The scope and complexity of the game is relatively low compared to other titles. My approach was to build only what was necessary to power this one game. As a result, the engine is feature-poor and doesn’t compare to something like Unity or Unreal. On the bright side, the limitations helped me keep the game’s scope manageable. This post will cover the major components and examples of how they are used.
High-level Organization
Impaler’s codebase is split into a hierarchy of four things: App, Menu, Game, and Engine. App represents the program as a whole and controls everything lower in the hierarchy. A component can reference the components below but not above (e.g. Engine has no awareness of the Menu). The Engine contains the most reusable parts of the codebase and will be the subject of this post.
App - window management, main loop, orchestration of other components.
Menu - everything related to the game’s user interface and navigation.
Game - game-specific code such as monsters, upgrades, and levels.
Engine - foundational functionality such as rendering, physics, and input.
Core (2000 lines)
Core is a collection of utilities used by other engine components and the game itself. Everything here is standard for a game project.
Visualization of various noise functions
Logging
The game is written in C so a better logging option than printf()
is needed. Instead of printf()
, the game uses a great little library called log.c. This library provides logging levels (debug, warn, error) and outputs to both stdout and a game.log
file. The log file is key to debugging issues in the wild. Development builds have verbose logging while production builds log sparingly. Logging is heavily used for app initialization, Steamworks, and GLFW.
Math
Math contains the floating point arithmetic used by the game. It provides vector, matrix, and quaternion math helpers (vec2
, vec3
, vec4
, mat4x4
, quat
). It is a combination of the linmath library and a collection of my own additions. Many of the functions are inlined as an optimization.
Input
The input system is a wrapper around GLFW input. Its purpose is to expose mouse, keyboard, and gamepad values in a game-friendly way. It handles the GLFW input callbacks and manages the game’s internal input state.
RNG
Generating random numbers is important for games that have procedurally generated content. Unfortunately, the stock rand()
available in C has some issues. I am not an expert on random number generation but I found this GDC video helpful. After some research, I decided to use the Xoroshiro128+
algorithm (following the provided best practices). In addition to the Xoroshiro128+ implementation, RNG provides pre-calculated random numbers and helpers for generating random vectors.
Noise
Noise functions are also useful for procedural content generation and rendering. In Impaler, Perlin noise modulates light source intensity which creates a flickering effect. In early versions of the game, Worley noise would modulate the translucency of smoke but it was later removed (despite looking cool). The Noise component provides an easy way to generate noise for animation and shaders. Most of the noise functions come from the stb_perlin library.
Physics (2500 lines)
The physics system manages movement and physical interactions between objects in the game. Physics code runs in a separate fixed timestep loop to be framerate-independent. The system produces “events” that enable game objects to respond to what’s happening (e.g. did a player touch lava?). This is the most complex system in Impaler. A lot of effort went into making sure it was easy to use within game code. In the snippet below you can see how a jump pad works (accelerating nearby objects upward).
Particles
Particles are movable volume-less points that represent things like sparks and debris. They are affected by forces in the world such as explosions, gravity, and friction. When particles intersect other objects, they reflect off of the surface and impart a force. Particles are the foundation of the physics system.
Projectiles
Projectiles are particles optimized for fast movement and colliding with other geometry. They are used for things like bullets and grenades. When a collision occurs, the outcome is determined based on the angle of incidence, velocity, and object material. A projectile will either bounce, destruct, or penetrate through the object depending. The math is not physically accurate but it produces good results for gameplay purposes. When a game object is intersected, an event is created for other systems to respond to. (e.g. play a sound when a bullet ricochets)
Collision Volumes
Impaler uses axis-aligned capsules (cylinder with round ends) to represent dynamic collision geometry. They are movable, have volume, and can interact with other volumes. Collision volumes are used for characters and large game objects. Like particles, they move based on physics and react to forces in the world.
Verlet Integration
The game uses a technique called Verlet integration to control the positions and velocities of objects in the world. It’s an approximation that helps game objects behave realistically when they collide. If two monsters’ collision volumes overlap, their positions are adjusted so they no longer overlap. Then, on the next frame, velocities are recalculated based on the change in position. The Verlet integration loop is an expensive operation that requires looping through all eligible pairs of objects. It works well for slow-moving objects like the player and monsters. And, like projectiles, events are emitted for collisions so that game logic can react at a later time.
Ray Cast
Ray casting is a method for querying the game for intersections along a line. It answers the question: “what objects are between a start point and end point?”. Ray evaluations consider both map geometry and collision volumes when checking for intersections. Ray casting is used extensively:
- Checking if the player is standing on something
- Collision detection for particles and projectiles
- Calculating a monster’s line of sight to the player
- Ensuring explosions don’t damage objects behind a wall
Event System
Physics runs in a separate update loop to decouple it from game code and leverage a fixed timestep. An event system was created to let the physics system share information with the game. As the physics routines run, events keep track of projectile intersections, collisions, and displacement of objects. This allows the game loop to consume these events and react accordingly. E.g. decrement health when a player touches lava.
Rendering (3000 lines)
Impaler uses a single-pass forward renderer implemented using OpenGL. A frame is created by lighting each object as it is drawn to the output buffer. Unlike a deferred renderer, a new frame is generated without having to draw an object multiple times. Forward rendering can suffer from overdraw and limited light sources. Fortunately, the game is not too demaining and runs on integrated graphics. A typical frame is only about ~20 draw calls.
Billboards
Much of the art in Impaler is 2D and rendered with a technique called billboarding. Essentially, each image or “sprite” is drawn on a rectangular 3D mesh that faces the viewer. Nearly everything in the game is rendered via billboards and a frame often has 500 - 2,000 visible at once. A system to manage billboards was needed to give the game a simple API. The billboard system handles spritesheet lookups, transforming billboard geometry, and feeding the renderer. A typical call looks like this:
Camera
Like any game, a camera is needed for controlling what the player sees. Impaler’s camera system handles the matrix math, frustum calculations, and coordinate transformations needed for a 3D game. Most importantly, it provides an API to let the game control the camera without complicated math.
Loader
The renderer needs textures, spritesheets, and shaders to do anything interesting. The game’s resources are loaded at launch using the Loader component. The Loader interacts with the file system, parses different file types, and marshals data into the GPU. This system also compiles and links the GLSL programs. It enables loading of OpenGL resources in a single line of code. Under the hood, it uses lodepng.
Lights
Impaler makes heavy use of dynamic lighting - things like torches and explosions are represented by point lights and provide much of the game’s eye candy. Each of these point light contributes both direct and specular lighting to the scene. To enable this, the fragment shader consumes a list of positions, colors, and radii representing the light sources. The light system provides the game a way to manage these lights without the OpenGL interactions. Game code can simply request where it wants a light to be.
Shader Management
Impaler’s shaders are somewhat complex for a retro game:
- Dynamic point lights
- Height + distance fog
- Specular + roughness + normal + height maps
- Steep parallax mapping
- Soft particles
- Faux ambient occlusion (a likely topic for a future post)
In order to support everything above, the shader must be fed a fair amount of data and configuration. A system to streamline locating, caching, and feeding shader uniforms (variables) was created as a result. This component takes a configuration struct and manages OpenGL / GLEW accordingly.
Meshes
The last major piece of the renderer is functionality for managing 3D meshes. While 2D in presentation, billboards still need to be represented by geometry. This systems is mostly a collection of utilities that:
- Manage dynamic geometry such as billboards
- Recalculate normals
- Sort geometry for alpha blending
- Assign UV coordinates based on spritesheet lookups
- Manage per-vertex metadata that is passed to the vertex shader
Audio (800 lines)
The audio system encapsulates the functionality for loading and rendering spatialized sounds and music. A great library called miniaudio does much of the heavy lifting here. Like the other components, the goal was to provide the game with a simple API without sharp corners. Below is a short snippet of how a sound is played:
Spatializer
The spatializer helps render the game’s audio in a realistic way. It handles the calculation of:
- Distance-based volume attenuation
- Sound occlusion based on ray casting (e.g. sounds behind a wall are quieter)
- Stereo panning based on the position and orientation of the listener
- Pitch shifting when the game is in a “slow motion” gameplay segment
Once the spatializer determines how a sound should be rendered, it calls the appropriate miniaudio
functions to update the active audio source.
Manager
Like most games, there is a finite number of sounds that can play concurrently. The audio manager helps prioritize which sounds should play (or be recycled for higher priority sound events). It also chooses which variation should play when multiple versions of a sound exist (e.g. explosion_1.wav
, explosion_2.wav
, etc.). One of the more interesting tasks was creating an API for managing looping sounds - ones that persist for the lifetime of an object. When a looping sound is “retained” by a game object, it will continue to play. Otherwise, the manager will know to end the loop and cycle it back into the pool.
Modules (500 lines)
Modules are higher-level systems composed of multiple engine components. The particle system module combines both rendering and physics, for example. There are only two modules today but I expect this category will grow as new features are added to the game.
Particle Cloud
Particle clouds power the game’s visual effects such as explosions and sparks. In Impaler, they are implemented as billboards attached to physics particles. First, billboards are arranged in 3D space to create the illusion of volume. Then, physics are applied to the billboards to achieve the desired visual effect (rising smoke, bouncing sparks, etc.). Particle effects contribute to overdraw but they look nice and are simple to create. Creating a particle cloud looks something like below:
Trail Renderer
Trails renderers are used to draw things like smoke trails and fireballs. Trails are a series nodes covered in a strip of triangles that allow rendering of long objects that follow a moving point. A picture is better than words here - see the rocket smoke in the previous screenshot.
Wrapping Up
The systems covered above are suitable for making a small game like Impaler. However, they would not support other games very well. Only commercial engines are going to be extensive enough to support any type of game. In the next post I hope to cover a more technical graphics topic.
If you enjoy these posts and would like to support my work, please consider buying my game on Steam!