Skip to content

Architecture overview

Nikolai Wuttke edited this page Jan 22, 2022 · 23 revisions

This page gives a fairly high-level overview of the project's architecture, with links to sub-pages for specific parts of the code base. If you'd like to see a more detailed look at what's involved in rendering a single frame of the game, take a look at Anatomy of a frame.

Structure and layers

RigelEngine consists of a collection of packages (directories below the src directory), and a top-level package (all the files which are directly inside the src directory). See src/README.md for an overview of what each package is about.

The architecture can be further divided into a few layers:

RigelEngine architecture: layers

Platform layer and rendering

The project is based on SDL for platform abstraction and window creation, and OpenGL for rendering. Audio is implemented using SDL Mixer.

Rendering

A 2D rendering API is implemented on top of OpenGL. See Rendering Backend for more information. The API is provided to higher layers in form of the rigel::renderer::Renderer class. There is exactly one instance of this class at run-time. The dependency is passed on to clients with a pointer to that instance.

The game service provider

Other functionality provided by the platform layer is exposed via a single dependency, the IGameServiceProvider interface. Like the renderer, this is passed down to clients via a pointer.

Audio

The audio API exposed by this layer is much simpler compared to rendering, consisting of just a few functions to play a sound effect or start playing a song. Clients get access to audio via the IGameServiceProvider.

Music to play is identified by filename. Since the original game's music comes in an AdLib-specific format, an AdLib emulator is necessary to produce audio from the music data. The emulation happens on the fly, during music playback: Every time samples are requested by SDL Mixer, pending AdLib commands are fed to the emulator and audio is rendered. This process is implemented by the ImfPlayer class.

Sound effects, on the other hand, are limited to a set of sound IDs, as defined by the SoundId enum. These IDs are used to identify which sound to play, when calling playSound. All available sound effects are converted to a readily playable format at startup, and stay in memory after that. The game has two types of sounds: AdLib-based, and sample-based (VOC files). The former are run through the same emulator as music, but rendering happens ahead of time (during game startup). The latter are converted to 16-bit PCM format and resampled.

Main loop

After startup is finished, everything is driven by the main loop. The main loop keeps running until the user quits the game. Each iteration of the loop represents one frame rendered to the screen. Aside from audio, RigelEngine is completely single-threaded - everything happens on the same thread as the main loop.

Each frame, the loop retrieves events (e.g. keyboard input) from SDL, calls into the current game mode for rendering, and then presents the results on screen by swapping OpenGL's buffers. A game mode can request switching to a new game mode. See Game mode management for more info.

Timing

At the main loop level, timing is done in a variable timestep fashion, by giving the current game mode a delta time (time elapsed since the last frame). The in-game code implements a fixed timestep scheme on top of that, though.

Fade-in and fade-out effects

The main loop layer also provides a mechanism for fading out and fading in the entire screen. This is exposed by functions fadeInScreen and fadeOutScreen in IGameServiceProvider. These functions are blocking: They return after the fade has completed. They can be called at any time during a frame.

Engine

The engine layer is used to implement the actual gameplay. It's based on the Entity-Component-System architecture, and uses the entityx library as basis.

There isn't really a single piece of code making up the engine, only a collection of components, systems, and utilities providing low-level building blocks for the rest of the game logic. For more information on these building blocks, have a look at Engine layer.

Gameplay code

On top of the engine code we can find the gameplay code, or game logic. This can roughly be split into the following parts:

  1. Pieces of game logic, like e.g. the behavior code for all the enemies, interactive objects etc.
  2. Entity creation and configuration: Creating the right kinds of entities/game objects to match what's specified in the level data file
  3. The overall orchestration of all these pieces into gameplay.

The latter is represented by the class GameWorld, which provides a simple interface facade for the game: Player input goes in, a rendered image of the game comes out. Notably, this class isn't concerned with timing of any kind - it just provides game logic and rendering of the world. It also doesn't care about the source of the player input - it could come from a real player pressing keys on the keyboard, but it could also be a pre-recorded sequence of button presses, or even an AI playing the game.

All of these aspects are handled by another class, GameRunner. It forwards the player's button presses to the GameWorld, and takes care of timing to make the game run at the right speed.

To learn more about gameplay code and the inner workings of the GameWorld class, refer to Gameplay architecture.

Logical frame rate and rendering frame rate

The original Duke Nukem II runs at a speed of roughly 14 to 17 FPS, depending on how fast the machine is. In order to keep the experience authentic, RigelEngine needs to run game logic at a similar speed. The game's graphics are not very demanding for modern hardware though, and it's easy to render the game at a much higher frame rate. Therefore, I decided on the following for RigelEngine:

  • Rendering happens at the display refresh rate by default, which is most commonly 60 Hz
  • All game logic runs at 15 FPS, which is close to the speed of running the game on period-appropriate hardware (i.e. a 486 or 386 PC), and also neatly divides 60 FPS, resulting in one game logic update every 4 frames

At the moment, this means that 3 out of 4 frames will be showing a picture that's identical to the previous frame. But in the future, optional motion interpolation will make it possible to make use of the higher rendering frame rate while keeping the original game logic speed.

Game sessions and map sessions

Both GameWorld and GameRunner are only concerned with playing a single level of the game. Since Duke Nukem II has infinite lifes and no 'game over' state, playing a single level continues until either the player reaches the exit, or decides to quit the game. I refer to this as "map session".

Of course, there is more than one level in the game. After each level, a bonus screen is shown, followed by the next level. The last level of each episode features a boss enemy. After defeating the boss, a short sequence of images is shown to advance the game's story, followed by the high score list. After that, the player is sent back to the main menu, where they can start playing the next episode if desired. This whole progression is what I call a "game session".

To recap: A "map session" is a single level, it ends when the level is finished, or when the player quits. A "game session" is a series of map sessions, progressing through all the levels in a single episode. The latter is implemented by the GameSessionMode class, which instantiates a new GameRunner for each level, and also takes care of managing the bonus screen, story sequence etc.

Starting a new map session requires a "session ID": Episode number, level number, and difficulty. Alternatively, a saved game can be loaded. Similarly, starting a game session can be done with episode number and difficulty, or from a saved game. Difficulty can't be changed once a game session has been started.

Resource management and asset loading

RigelEngine's resource management model is fairly simple. The original game stores almost all of its assets/resources in a single package file, called NUKEM2.CMP. Because this file is extremely small for today's standards (5 MB for the full version), RigelEngine loads the entire file into memory at startup, and then gives out data like textures, level data etc. from this in-memory copy of the file.

Assets are converted on the fly from their original formats into data structures that the rest of the code can work with. For example, images are converted from indexed 16-color bitmaps into 32-bit RGBA bitmaps, sound effects are converted into 16-bit signed PCM buffers, etc.

All of these conversions are handled by dedicated parsing code in the assets package. The ResourceLoader class provides an easy to use facade for all of this functionality. The following image illustrates how the various components involved interact, and where the ResourceLoader gets data from:

Resource loading architecture overview

The game path

To load resources from the original game data, RigelEngine needs to know where to find NUKEM2.CMP and a few other files. That's where the "game path" comes in. It points to an installation of the original Duke Nukem II Game. The ResourceLoader uses it to locate and open the data file, as well as some other files like the intro movies or saved games for import into the user profile on first run.

The game path is stored in the user profile. If there is no user profile yet when RigelEngine launches, or if the profile doesn't contain a game path, the game will show a file browser to allow selecting one.