Skip to content

Architecture overview

Nikolai Wuttke edited this page Sep 2, 2019 · 23 revisions

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:

  • platform layer and rendering backend
  • top-level infrastructure, which runs the main loop and manages different game modes
  • the in-game engine, based on the Entity-Component-System architecture
  • gameplay code.

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.

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 a pointer to a rigel::IGameServiceProvider object.

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.

Gameplay orchestration

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.

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 lives and no game over state, playing a single level continues until either the player reaches the exit, or decided 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 loader package. The ResourceLoader class provides an easy to use facade for all of this functionality.