Overview of Impaler's Minimal Game Engine

Impaler 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

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 wonderful 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.

Complexity Code Footprint Reference Open Source
Low 2100 lines

Physics

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. It is an approximation that does a good job ensuring collision volumes behave well when they collide. If two monsters’ collision volumes overlap, their positions are offset in a way that resembles an elastic collision. Like projectiles, events are created when collisions occur so game systems can react. The Verlet integration loop is an expensive operation that requires looping through all physics objects.

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?”. Rays consider both map geometry and collision volumes when looking 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.

Complexity Code Footprint Reference
Very High 2200 lines

Rendering

Impaler uses a forward single-pass renderer implemented in OpenGL. A frame is created by performing lighting calculations on each object as it is drawn to the output buffer. Unlike a deferred renderer, a new frame is generated without drawing an object multiple times into different buffers. The drawbacks of forward rendering are overdraw and limited light sources. Effects such as screen-space reflections are also not possible. Drawbacks aside, the renderer is efficient and a frame requires only ~20 draw calls. In Unity, it is common to see 100+ draw calls per frame (or worse).

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 from the installation directory at launch using the Loader component. The Loader interacts with the file system, parses the different file formats, and marshals data into the GPU. In the case of shaders, the system also compiles and links the GLSL programs. It enables the loading of OpenGL resources in a single line.

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 do the following:

  • Manage dynamic geometry such as billboards
  • Recalculate normals
  • Sort geometry
  • Assign UV coordinates based on spritesheet lookups
  • Manage per-vertex metadata that is passed to the vertex shader


Complexity Code Footprint Reference Open Source
High 2900 lines

Audio

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.

Complexity Code Footprint Reference Open Source
Medium 800 lines

Modules

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 make. 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 object. A picture is better than words here - see the rocket smoke in the previous screenshot.

Complexity Code Footprint Reference
Medium 500 lines Live coding of a particle system

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!