Architecture of a Game Engine

Architecture of a Game Engine

The Tinker Engine

Featured on Hashnode

Prelude

My first large-scale project was a minimalistic game engine named Tinker. I don't know why I picked the name, but it captures how I felt developing the engine very well. It was the first software project that felt like a virtual place I could go and mess around with things to see what would happen.

In this article, I will discuss the architecture of Tinker at a high level to illustrate how a game engine functions and to critique the design decisions made. It will be a good overview of how a minimalistic 3D game engine works and how object-oriented concepts can be utilized in a real-time system.

Tinker Introduction

Like any game engine, Tinker is designed to enable the creation of a real-time experience that synthesizes artwork, game design, and user input into a semantic state of the world, then renders it from the user's perspective as an image to the monitor. The updating of the world's state and its rendering must happen many times per second to maintain the illusion.

Inputs

The "artwork" is a collection of static assets created by artists. This includes 3D models, 2D sprites and textures, fonts, and rendering scripts called shaders. A more advanced engine might have animations, particle systems, and other bespoke asset types. These assets are "read-only" for Tinker, and it makes no assumptions about their content other than their file type and schema. This is a data-driven element of game engines that is often taken for granted.

Game design & programming is the abstract concept of how stuff in the game behaves. It is all of the developer's decisions: where objects are, how they move, how they respond to stimulus, AI, anything that the programmer puts into the game code, and anything a designer writes in a script or configuration file.

User input is what buttons the user presses on their controller, keyboard, mouse, or whatever. The user is welcomed into the dance party with the artwork and the game design to influence how the experience will play out.

Outputs

Those inputs all influence the game such that the player ends up viewing the world from a certain position and perspective. Each UI element has a mathematical pixel location on the screen. Each 3D object has a mathematical position, rotation, and scale and may be associated with a particular texture and shader. It may have been moved by a game system, picked up by the player, or driven by an AI; it doesn't matter. But all this information needs to be transformed into what the user is supposed to see: a 2D grid of pixels on their monitor providing a concrete, projected view of this abstract world.

Tinker renders the scene through its DirectX graphics pipeline. This translates all mathematical positions into a warped view of space, relative to the player's view. Colors and image textures are ornamented with lighting and other visual effects and then doodled into the appropriate geometry. The player's view is then collapsed into a 2D grid based on their monitor's aspect ratio. Objects occluded by other objects are forgotten, and only what would be captured by a photograph is left. Each pixel in the monitor lights up its given color for that frame.

What's the deal

One thing I learned from Tinker is that all software is fundamentally a data transformation. Even something as complex as a game engine is still just taking a recipe of inputs and producing a set of outputs. An engine is just doing it very fast and with a cool recipe.

When I encountered other types of software such as autonomous robots, it seemed to be a game engine in reverse. A game engine takes design decisions, static assets, and user input, then updates a semantic 3D view of the world, then renders it as an image. A robot, on the other hand, sees a 2D image of the world (if it uses camera-based vision), interprets the image to produce a 3D semantic view of the world, analyzes the world to make decisions, and finally translates its decisions into inputs to the robotic control system.

In summary, what makes game engines magical is that they are not magic, but well-engineered. Information is being taken from the developers and the users, moved around backstage, then shown back to the audience as a highly technical trick.

What makes any game engine unique is the philosophy of how this illusion can be pulled off as effectively as possible. This includes the organization of data, the programming style, tools, and the set of services and expansion points provided to a developer. This philosophy influences not only how the game performs, but also the creative constraints of the game itself. Now let's dive into the details of my minimalistic engine, Tinker.

Architecture

Top Level

The Tinker Engine starts with a class called Tinker. Tinker is an implementation of a more general base class, which encapsulates the process of setting up a generic DirectX desktop app with a window. In Tinker, the top-level orchestration of the game engine logic is implemented.

void Tinker::Run()
{
    LoadContent();

    while (msg.quit)
    {
        CheckQuitMsg(msg);
        FrameTick();
    }

    UnLoadContent();
}

The program's main function invokes the Tinker app, which spins in a while loop until the program is exited. Tinker is a singleton because it is the top-level object for which there is only one instance. There are a few other singletons in the system, although the pattern is avoided wherever practical because of the pitfalls of global, static systems (see Scene Manager).

One singleton is the Time Manager, as time is global and centralized for the entire engine. Next is the Scene Manager, which will hold the current scene, allowing us to have scene-level state that can be swapped in and out as the game evolves. The Scene Manager will expand shortly into most of the runtime architecture. Lastly, there are singletons for each Asset Manager, as assets are single-instanced and globally accessible.

Asset Management

The Asset Managers hold onto instances of their respective type. The engine and the user can load assets with a string key, then later retrieve assets with that string key. The memory for an asset is greedily held onto for the duration of the program, ensuring that all assets loaded at the start are accessible throughout the execution of the program. These are some of the Asset Managers:

Assets such as models and textures will have a unique loaded instance. Using the key, the asset can be fetched and reused wherever required.

void Tinker::LoadAllResources()
{
    ModelManager::Load("Bullet", "Bullet.azul");
    ShaderManager::Load("ColorLight", new ShaderColorLight(Tinker::GetDevice()), 
        ShaderBase::Layer::Base, ShaderBase::Type::Lit);
}
pGraphicsObject = new GraphicObject_ColorLight(     
    (ShaderColorLight*) ShaderManager::Get("ColorLight"),     
    ModelManager::Get("Bullet") 
);

Scene Management

The majority of Tinker's behavior lies within the Scene Manager. The Scene Manager is a finite state machine, and the Scenes are the states. The Scene Manager manages a current Scene and transitions between scenes. The user defines new Scenes by deriving from the Scene class and implementing startup, update, and teardown methods.

Each Scene is an aggregation of "Scene-Level" managers. Within the context of a Scene, these systems have a single instance and are globally accessible via references to the Scene baked into each Game Object. In a sense, these objects are contextual singletons, providing all the benefits of the pattern while avoiding the downsides of static code.

Each Scene-Level system manages a particular type of object. These types are mostly described by interface classes with the "x-able" naming scheme, i.e. Updatable and Drawable.

Scene-Level Manager: Updatable Objects

These interface classes are a weird mix between a true interface and the encapsulation of a registration system with the associated manager. For example, the Updatable system is shown, which provides the interface for Game Objects to derive from, and the interface owns the registration commands that get sent to the manager (via a broker discussed soon) to change the registration state.

The Updatable manager holds the collection of active, registered objects on which a particular operation will be performed every frame. In this case, the Update() function is called. This gives objects a chance to update their state before rendering. Here is a flying bullet:

void Bullet::Update()
{
    stance.Move(Transform::forward, bulletSpeed);
}

Game Objects

Game Objects are at the heart of Tinker. They are the main interface between the gameplay code and the engine and serve as the template for all objects in the Scene.

Game Object Subclass Sandbox

The Game Object class uses multiple inheritance of the Scene object interfaces. Because of this, Game Objects have a rich toolset and can interact with the engine directly and concisely. Most user-defined code will be new types of Game Objects that override the virtual interface functions.

To implement these objects, the user derives from Game Object. This is an implementation of the subclass sandbox pattern where user-defined objects are highly empowered when interacting with the engine, even if they don't take advantage of all of the functionality provided. A similar pattern is used for user-defined Scenes. Above, the engine-to-gameplay boundary by inheritance is illustrated.

Game Object Lifetime

Game Objects have a lifetime within a given Scene that is independent of their lifetime as a C++ object. This allows an object to have a clear entry and exit point from the scene, regardless of the gameplay programmer's memory management strategy.

On birth, an entry method is called. This is effectively the scene-level constructor. On death, an exit method is called, which is likewise a scene-level destructor. Between these two steps, the Game Object may be processed by any number of scene-level managers.

Because the lifetime events utilize the command pattern, changes in lifetime status can be delayed and controlled such that no object can be both alive and dead during the same frame. This is thanks to the next system, the Registration Broker.

Registration Broker

The Registration Broker collects and executes commands to add and remove various actors from core systems. This centralizes registration to happen at the top of every frame so that the timing of add-remove events is well understood to be at the start of the next frame. This exploits the command pattern to induce a hysteresis effect where registration requests always pool up as the frame is processed, and their execution lags until it is safe to proceed.

With this broker, the object lifetime and the state of the collections within the other scene-level managers will be constant for any given frame. This is essential because we have updates, collisions, alarms, and input. Each of these event types might try to interact with, remove, or add an object.

Say a bullet collides with two walls in the same frame. One collision callback will be processed first and removes the bullet. What happens to the second collision? The problem becomes easier to think about if we decide that objects can only be removed at the boundary between frames, rather than in the middle. This removes edge cases for the engine code and makes the gameplay code easier to reason about. We can add a check in the scene exit code to prevent duplicate removals of the bullet object and react in both wall callbacks to add a bullet hole texture.

Input

Input from the player is automated by the engine and the architecture allows overriding simple key-pressed and key-released events. A subject class is set up for each relevant key. The subject reads the state of the key from the system and detects changes. Game Objects registered to the key can subscribe to pressed or released events independently. When a relevant event is detected, each registered observer is notified and the virtual callback is invoked with the specific key as a parameter.

Here is an example of a Game Object that overrides KeyPressed(key) to fire a bullet when the spacebar is pressed:

void TankCannon::SceneEntry()
{
    SubmitInputRegistration(TINKER_KEY::KEY_SPACE, KeyEvent::Pressed);
}

void TankCannon::KeyPressed(TINKER_KEY key)
{
    if (key == TINKER_KEY::KEY_SPACE) 
    {
        BulletFactory::CreateBullet(
            stance.GetTranslation(), stance.GetRotation()
        );
    }
}

This event listening functionality is essential for discrete actions like using a gameplay ability, without manually checking every frame and storing state. Continuous behavior such as movement can be handled by reading the keyboard directly in Update() .

Timer/Alarmable Objects

Game Objects are also derived from Alarmable. Alarmable objects can register timed events with the Alarmable Manager to call a specific callback when the time has elapsed. The number of possible timers is hard-coded for simplicity. Registration is modified to include the parameters for the callback id number and the delay time. The global Time Manager is used as a single source of truth such that alarm events obey pause and game slowdown logic.

The Alarm System uses a multimap to map trigger times to events. This allows the time ordering of events to be exploited to "early-out" once an un-expired event is encountered in the update loop. Because the list is sorted, most alarm events do not need to be checked every frame. They are assumed to still be waiting so long as some earlier event has not triggered.

void Bullet::SceneEntry()
{
    SubmitAlarmRegistration(AlarmableManager::AlarmID::Alarm0, lifetime);
    // ...
}

void Bullet::Alarm0()
{
    SubmitExit();
}

This bullet Game Object sets an alarm on spawn so that it has a limited lifetime. If the bullet flies for too long, the alarm triggers and the bullet removes itself from the Scene.

Collidable Objects

The collision system is one of the most complex parts of the engine in terms of mathematical implementation and software architecture. Firstly, there are several collision volume types defined. Each of these types must be able to collide with every other possible type. The math for calculating the intersection is different for each combination. To avoid complex boolean logic and to scale with more volumes, a visitor pattern is implemented.

The visitor allows the intersection check to enter a member function of the first type. This exploits inheritance to gain access to the this pointer in an overridden function (accept), which is of the first object's derived type. Then, the this pointer of derived type is used as a parameter to a visit method on the generic second object. This enters the derived method of the other object where we have the this pointer of the second object's derived type.

We started with two generic Collison Volume objects, but now we have two pointers to derived types. With this, the correct intersection algorithm can be invoked. Hence, the type determination of the volumes is a constant time complexity operation. Speed is important for these checks because the number of checks will grow exponentially with each Collidable object added to the Scene.

This volume system exists within the larger collision-checking framework:

There is a purpose to this complexity and the resulting interface is easy to use. To start, each Collidable object has a Collision Volume, and the user selects from several implementations. No matter what volume is chosen, it will be able to detect collisions with any other volume type. Each object also has a "shell" volume that is used for tiered collision optimization.

Bullet::Bullet()
{
    // use an OBB volume for collision
    SetColliderModel(pGraphicsObj->GetModel(), Collidable::VolumeType::OBB);

    // join collision group for Bullets
    SetCollidableGroup<Bullet>();
}

Each object type is grouped into a Collidable Group. The group has its own super volume that is calculated in each frame as the union of all the individual Collidables in the group. Possible collisions between objects are registered at the group level. This allows all bullets to collide with the player, or with all other bullets, etc. However, when a collision between two objects occurs, only the callback for that specific instance of the object should be triggered.

void SceneDemoThree::Initialize()
{
    SetCollisionSelf<Bullet>();
    SetCollisionPair<EnemyTank, Bullet>();
    SetCollisionPair<EnemySpawner, Bullet>();
    SetCollisionTerrain<Bullet>();
}

Collision is tested using a tiered system that can "early out" when objects are not even close to colliding. This is handled by the Test Commands which have several implementations. Lightweight checks such as the group vs group and shell vs shell are tested before the more granular, object-specific volumes.

When a true collision is eventually detected, the event is dispatched to the two objects. The dispatch uses templates such that the specific, possibly user-defined type can be used as the parameter to the object's collision callback. This is essential because it allows the user to simply overload the virtual Collision(Collidable) function with other parameter types. When the collision occurs, the most specific overload will be called by the rules of C++.

void Bullet::Collision(EnemyTank* pEnemyTank)
{
    pEnemyTank->TakeDamage(5);
    SubmitExit();
}

void Bullet::Collision(EnemySpawner* pEnemySpawner)
{
    pEnemySpawner->TakeDamage(1);
    SubmitExit();
}

void Bullet::CollisionTerrain()
{
    SubmitExit();
}

Graphics

Drawable Objects

The previous systems make up the core update functions of Tinker. After a scene is updated, it needs to be drawn. Drawable objects follow the same pattern as the other systems:

The Drawable manager invokes Draw() and Draw2D() on each object, allowing them to be drawn immediately or submit a render command to the batched Render Manager. This system renders by Shader such that the shader only needs to be set up once per frame, instead of once per object. After the shader is prepared, all objects using the shader are rendered. The shaders are broken into the base and interface groups to guarantee the correct order, as the user interface should always appear on top of the 3D world.

void Bullet::Draw()
{
    pGraphicsObj->TinkerRender();
}

The Bullet submits to the rendering pipeline during the Draw stage. This adds the Render Command to the Shader. It will be drawn as part of a batch with objects using the same Shader.

Graphics Architecture

The Draw stage described above is built on top of a 3D graphics system. The graphics system is built on top of DirectX. The architecture of this system will give context to some of the objects and specialized systems present in the Tinker Engine.

At the top level are Graphics Objects, which are abstract representations of "a thing that can be rendered". By default, Graphics Objects have a model with potentially several separate meshes. Specializations of the Graphics Object are associated with a specialized Shader, such that instance data for that shaded type is stored on the Graphics Object, while the shader is read-only. This allows one copy of the Shader object to be reused by all client objects. The architecture includes 2D graphics as well, which is illustrated by the Sprite specialization.

The Camera class is essential to the rendering process as it provides the transform specification for rendering the player's view. Cameras are managed by the engine so that multiple cameras can be used for 2D and 3D layering, and so that the gameplay code can manipulate the Camera's position and properties.

Models and Textures are mostly just adapter classes to raw data. They are instanced in the engine so that only one copy is loaded into memory, then transformed and reused by various objects. There is support for Images as sub-rectangles of Textures to enable 2D sprites.

Shaders are associated with compiled HLSL shader files that are loaded as vertex and pixel shaders in DirectX. Shaders contain various combinations of vertex transformations and complicated visual effects. As will be shown in the next section, the Shader object is tied into the lighting system so that Scene lights can automatically and consistently affect all illuminated objects.

Lights & Effects

Another aspect of the drawing stage is special effects, and there are abstractions for different types built into the engine:

The Light System ensures all Shaders that care about lights are kept up-to-date with the lights in the Scene. The lights in the Scene are represented by Tinker Light objects, which support the standard light implementations and a fog effect. The user can instantiate these types from the Light Manager at runtime, adjust their properties and positions, and automatically benefit from the correct illumination of lit objects in the game.

Object-specific light:

void Bullet::SceneEntry()
{
    Glow = GetPointLight();
    Glow->SetDiffuse(20 * TinkerColor::Turquoise);
}

Scene light and fog for environmental effect:

void SceneDemo::Initialize()
{
    // directional light with direction and ambient, diffuse, specular values
    SetDirectionalLight(Vect(-1, -1, 1).getNorm(), .01f * Vect(1, 1, 1, 1), 
        0.5f * Colors::White, Vect(0.5f, 0.3f, 0.5f, 1));

    // fog with min and max range, and color
    SetFog(0, 2000, TinkerColor::MidnightBlue * 0.4f);
}

User Interface

Tinker supports a text-based user interface using a flyweight font system:

The flyweight element in the pattern is the Tinker Sprite. The object is owned by a Font, which also associates a given Sprite with a character. The Instanced Sprite is then reused by many Sprite Strings for any text needed on screen. The string has a Font type and a collection of active Sprites to be postage stamped at the correct locations. The underlying graphics functionality is provided by the Sprite Graphics Object, Shader, and Image class.

Print flashlight status to UI text:

TankCannon::TankCannon(Tank* pParent)
{
    // ...
    pFlashText = new SpriteString(SpriteFontManager::Get("Consolas"),
        "[F] HEADLAMP: OFF ",
        (-(float)Tinker::GetWidth() / 2) + 50,
        (-(float)Tinker::GetHeight() / 2) + 100
    );
}

void TankCannon::KeyPressed(TINKER_KEY key)
{
    if (key == TINKER_KEY::KEY_F) {
        if (flash) {
            pFlashLight->SetRange(0);
            flash = false;
            pFlashText->SetText("[F] HEADLAMP: OFF ");
        }
        else {
            pFlashLight->SetRange(4000);
            flash = true;
            pFlashText->SetText("[F] HEADLAMP: ON ");
        }
    }
}

An example client within the engine is the Screen Log, which allows the user to write debug strings directly to the screen. Here is an example of debugging position values:

ScreenLog::Add("Frigate Position: %d %d %d", 
    (int)stance.GetTranslation()[x], 
    (int)stance.GetTranslation()[y], 
    (int)stance.GetTranslation()[z]
);

3D Debug Visualization

Another minor graphics system is the Visualizer, which allows 3D wireframe debug objects to be overlayed on the scene:

The singleton provides methods for drawing various things. The public methods simply generate commands to be executed next frame. The requested visualizations are flushed after execution so the user can repeatedly invoke the visualizer every frame to debug some property or volume. The debug objects are strategically rendered by the Drawable Manager in between the base layer shaded objects and the interface layer.

void Frigate::Update()
{
    if (colliding)
    {
        DrawCollisionVolume(TinkerColor::Red);
        DrawShellVolume(TinkerColor::Orange);
    }
    else if (collidingSelf)
    {
        DrawCollisionVolume(TinkerColor::Orange);
    }
    else
    {
        DrawCollisionVolume(TinkerColor::LightGray);
        DrawShellVolume(TinkerColor::Gray);
    }
}

Environmental Systems

Tinker contains a few specialized systems that make it very easy to set up a decently immersive 3D world. The skybox and terrain objects are large-scale, custom models integrated into the engine that both require minimal setup from the user.

Skybox

The Skybox is a massive, inverted box surrounding the entire game world. Combined with a fog effect, this can give the illusion of a background to the scene. Thanks to the engine architecture outlined previously, the implementation of this gameplay-specific system becomes trivial:

void Tinker::LoadAllResources()
{
    SkyboxObjectManager::Load("BlueSpace", 
        TextureManager::Get("BlueSpace"), // background image
        5000); // scale value
}
void SceneDemo::Initialize()
{
    SetCurrentSkybox(SkyboxObjectManager::Get("BlueSpace"));
}

The skybox object accepts a texture format that will be mapped to the inverted cube. It uses an unlit, textured Graphics Object because lights should not affect the background that is supposed to appear very far away.

Terrain

The Terrain object is another environmental tool but is more complex and interactive than the Skybox.

void Tinker::LoadAllResources()
{
    TerrainObjectManager::Load("MarsTerrain", 
        "marsterrain.tga",           // heightmap
        TextureManager::Get("Sand"), // tiled texture
        8000, 700.0f, 2.5f, 32, 32); // dimensions
}
void SceneDemoThree::Initialize()
{
    SetCurrentTerrain(TerrainObjectManager::Get("MarsTerrain"));
}

The Terrain is generated from an image loaded into the engine as an asset like any other texture. The image uses gray-scale pixels to indicate the height at a given position.

Terrain Heightmap

From the image, a model is generated with a vertex at each pixel position of the Heightmap. The height coordinate corresponds to the gray-scale value in the image. The result is a detailed landscape that is entirely data-driven and easy to modify.

The Terrain is tightly integrated into several gameplay systems. For example, objects can efficiently query their location on the terrain and figure out what height to be at to walk across it. The query is done by mapping raw float offsets from the world origin to cell coordinates on the terrain.

Terrain collision is done using an iterator over an area of cells underneath the colliding object. This is to minimize the number of collision checks with the terrain, as the object can only collide with the cells closest to it. The Terrain Area Iterator uses a rectangular collision volume template to represent each cell, resulting in relatively accurate and granular collision detection.

Smooth interpolation across the Terrain is accomplished via Barycentric interpolation across the vertices of the Terrain grid. This algorithm allows an arbitrary field of the vertices to have an interpolated value at every possible point in the triangle defined by those vertices. For smooth movement across the surface, height and rotation are used. These fields can be easily queried from the terrain via Game Object methods for any possible Game Object location, then used to transform the character.

void Frigate::Update()
{
    SetDistanceFromTerrain(heightOffset);
    RotateWithTerrain();
    // ...
}

Conclusion

Retrospective

Engine-Gameplay Boundary

The boundary between the engine code and gameplay code across the lines of inheritance is an interesting concept. The contract defined by the Scene and Game Object interfaces gives a clear roadmap for a developer to implement the required behaviors. It also makes it relatively effortless to tap into core engine systems just by overriding a method. In addition, the user has infinite scope to extend the system with new hierarchies, components, and whatever architecture they desire. There is a performance consideration compared to other gameplay scripting strategies because the game code is defined as an extension to the engine's C++ code.

This relationship is similar to the Unreal Engine's Object & Actor hierarchy in which all objects inherit from a common UObject base and all in-scene objects inherit from AActor (or one of its specializations). The user can then define their own base classes in C++ that extend the engine objects with custom behavior and game-specific utilities. These are then used in the Blueprint visual scripts to create concrete implementations or be derived even further.

One key difference between Tinker and Unreal (and other industry-grade engines) is how simplified and non-modular Tinker's object behaviors are. Other engines often implement a Component system, such that special responsibilities like drawing, colliding, and input are delegated to composable classes called components. This is more dynamic and well-designed because it simplifies the Game Object class and allows composing the minimal combination of behaviors required. Implementations of these components can then be defined once and reused across several object types. Adding a component system to leverage the "x-able" interfaces as separate, composable objects would be one avenue of improvement for Tinker.

Input and Alarms as Bound Function Callbacks

One thing I don't like about Tinker is how the input callback gives the key event as a parameter. The user then has to check which key was pressed to kick off the correct response.

This could be improved by allowing the Game Object client to subscribe to a specific key with a specific callback. For style purposes, Tinker avoids function pointers and binding. However, I think there is an argument to be made for allowing specific key events to be bound to member functions by name. This could increase performance by reducing checks in the callback, and it is just more intuitive.

Such a functional design should be thoroughly encapsulated to avoid abuse. It would be interesting to see what other systems might benefit from treating member functions of Game Objects as referencable function objects. Timers and alarms could also be drastically improved because you wouldn't be limited by the number of alarms predefined in the engine, and you could give each alarm event a useful name.

Collision

Tinker's collision system is actually just an overlap detection system. A real, physics-based collision system would be orders of magnitude more complicated and require, you know, physics. Tinker is designed to be a very simple engine, so this is okay.

One improvement to Collision that could be more practical is an improvement to the interface for configuring collisions between object types. Currently, the scene must define collision pairs on startup as template declarations. This is baked into the code and must be recompiled to change.

A data-driven configuration could be added where the gameplay programmer can define which objects can collide using a configuration file. This could also contain additional properties related to the collision profile of object types. Such a system would require significant changes to the template-based architecture, but it would be a much more dynamic interface.

Summary

Tinker is a small game engine, but it shows how a framework for creating an interesting game can be accomplished with a few well-designed, generic systems. The use of design patterns illustrates how object-oriented abstractions are not only compatible with real-time systems but can be exploited for performance, with flyweight text and collision visitors as good examples.

Tinker's menagerie of manager classes, both program and scene-level, show how manual memory management & raw pointers can be used safely and correctly. It illustrates how C++ is not an unmanaged language, but a manually managed one, where that management is specialized to benefit the performance of the application.

Of all of my projects, Tinker has the most classes and is the hardest to recall in its entirety because of the volume of systems. However, returning to the source code is always remarkably easy. The clear organization of the engine around enriching Game Objects is very satisfying and gives a launching-off point for exploring a given system. Overall, the architecture is an informative introduction to the workings of a minimalistic game engine, with some fun tools thrown in.