Screenshot of Impaler

Overview of Impaler's Minimal Game Engine

Impaler is a minimal first-person arena shooter that sells for $3. The scope and complexity of the game are 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 their use.


High-level Organization

Diagram showing organization of Impaler's code

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 is unaware 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 - game-agnostic systems 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.

Animation showing various noise functions used by the game.

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 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 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, creating 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. It took significant effort to make it easy to use within game logic. The snippet below shows 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 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 (cylinders 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 later.

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 and end point?”. Ray evaluations consider both map geometry and collision volumes when checking for intersections. Ray casting is used extensively:

  • Check if the player is standing on something
  • Detect collisions for particles and projectiles
  • Calculate a monster’s line of sight to the player
  • Ensure explosions don’t damage objects behind walls

Event System

Physics runs in a separate update loop to decouple it from gameplay 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 drawing an object multiple times. Forward rendering can suffer from overdraw and limited light sources. Fortunately, the game is not demanding and runs on integrated graphics. A typical frame is only about ~20 draw calls.

Impaler screenshot

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 billboard management system 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 to control 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 the loading of OpenGL resources with 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 with an easy way to manage lights without OpenGL interactions. Game code can simply request where it wants a light to exist.

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)

To support everything above, the shader must be fed a fair amount of data and configuration. As a result, a system to streamline locating, caching, and feeding shader uniforms (variables) was created. 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 system 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, a finite number of sounds 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 sound instances exist (e.g. explosion_1.wav, explosion_2.wav, etc.). One of the more interesting tasks was creating an API for managing looping sounds 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 recycle it 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 this category will grow as new features are added to the game.

Impaler screenshot

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 is 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 strips of triangles that follow a moving object. 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 will be extensive enough to support any type of game. I plan to cover a more technical graphics topic in the next post.

If you enjoy these posts and would like to support my work, please consider buying my game on Steam!