-
Notifications
You must be signed in to change notification settings - Fork 0
Engine technical design
Miniflow is a minimal 2D game engine written in C and C++ to power our university project. In this document we'll go over the general architecture of the engine, the logical pipeline and basic concepts of the engine.
Miniflow is meant to be a very simple game engine. Hence the goals are as following:
- Simplicity (no over-engineering)
- Non-blocking behavior
- Separate input, logic and render handlers
- Data-oriented design
- Exclusively 2D (3D rendering of 2D things permitted)
While the goals of the project are important, it's also important to understand what Miniflow is not.
Miniflow is not...
- ...designed for programmer comfort
- ...designed to power any game other than the RPG project
- ...designed to have any kind of level editor (reliance on Tiled)
The engine is split from the main game code in order to facilitate code organization and to make it easier to write interoperable C/C++ code. The engine is written in C, while the game itself and its logic is written in C++.
The reason for such a split is that the simplicity of C makes it harder to make naive mistakes in the threading and rendering code, and allows for better performing things to be developed.
C++ will be used for the game itself for its inheritance, however by design dynamic dispatch (the virtual methods) should be avoided in loops of any kind. Dynamic dispatch by itself is fine, but only if used sparingly and not in hot code. This means that the rendering and logic methods must use static dispatch.
| Path | Purpose |
|---|---|
src/ |
All C/C++ source code |
src/engine/ |
Engine code (C executable) |
src/engine/input/ |
Handling of I/O (keyboard, mouse, files, etc) |
src/engine/render/ |
Vulkan middleware and rendering pipelines for game object rendering |
src/engine/logic/ |
Bootstrap structure for logic processing |
src/engine/state/ |
Game state, logic state, render state |
src/engine/main.c |
Entry point |
src/engine/platform.h / .c |
Platform-specific wrappers for cross-platform development |
src/game/ |
Game logic and code (C++ staticlib) |
src/game/game.h / .cpp |
Static library exports for the game hooks |
| Path | Purpose |
|---|---|
assets/ |
All asset files |
assets/dialogues/ |
Dialogue tree files |
assets/sprites/ |
Sprite/2D image files |
assets/sounds/ |
Sounds/Audio files |
levels/ |
Room and level files |
| Path | Purpose |
|---|---|
_build/ |
Build output |
vendor/ |
Third-party libraries |
The engine is written in C and is designed to provide a decent foundation for the game itself. This is also the part of the project that produces the actual executable. It expects certain functions to be provided by the game static library, such as those that control the logic, rendering, and such. The structure of said functions is more akin to "hooks" or "events".
The input system reads user input and continuously writes it to the input buffer. The buffer is then intended to be read by the logic thread, where actions are performed based on that.
The input system is supposed to read keyboard, mouse and gamepad input, and resolve key bindings. It should provide a raw list of keystrokes and movements, as well as a list of key bindings that were pressed.
struct Input {
uint16_t *raw_chars;
InputBinding *commands;
Vector2 mouse_pos;
Vector2 mouse_delta;
int32_t mouse_wheel_delta;
};
struct InputBinding {
uint8_t pressed;
char *command;
}Since all keystrokes are resolved to key bindings, this structure doesn't actually need to provide any data about which specific device generated the command. The only distinction that might need to exist is between mouse and gamepad movements.
The input update rate should be 2x that of the tickrate (configurable).
The input system isn't only responsible for keyboard, mouse and gamepad interactions, but also for parsing files and arguments provided to the game. It's responsible for decoding package files, loading assets and parsing startup flags of the game.
Startup flags are used to influence how the initial GameState is initialized. They're used to enable things like debug mode, the console window, and many other things.
Decoding package files would involve using manifest to perform decoding of the asset binary blob. In essense, just to address, read length, output binary blob.
The system needs to also be responsible for handling loading of the assets, such as models and textures. It would also be responsible for cleaning things out of RAM and VRAM as they become no longer necessary (such as between level jumps). Based on the level data, it would load all of the necessary things into RAM, and into the GPU memory as specified by the GPU memory budget. It should have two modes of operation, one where all data is loaded at once and cleaned at the end of the level, and one where the data is loaded on-demand and cleaned as soon as it's unnecessary based on what is currently going on.
In general, the course of action for the input loop is as follows:
- Check for inputs
- Ensure inputs aren't duplicated
- Write inputs to the structure
- If dynamic loading of entities is enabled...
- Check entity array for things that no longer need to be loaded into memory
- Unload things that can be unloaded
The sound system would run in the input loop, and play sounds that have been sent to it by the game logic.
The logic system is responsible for actually making things move in the game. It flushes the input buffer and uses commands provided by it to determine player actions. Based on that data, it would perform actions such as using / attacking, or moving the player character.
Movement is accomplished by altering the acceleration vector, and then applying it to the velocity vector. This is necessary for continuous interpolation in the rendering system to work, as rendering may update at a much faster pace than the main game itself.
Logic would also be responsible for all AI-related events, such as movement of NPCs. Random events and general logic of the game is also within the responsibilities of the logic system.
- Read inputs from the inputs buffer and flush it
- Execute commands from the buffer sequentially (giving game an opportunity to do things too)
- If the time is right, perform lazy_think (think event once every 10 ticks)
- Perform think (where game itself would decide how to proceed about that)
- Clean up entities that no longer need to exist
- Perform Z sorting of all entities
The render system is responsible for actually displaying things on the screen. The game itself should have the littlest amount of control over this system. It performs rendering and interpolation in-between ticks.
Rendering is accomplished by reading the renderables structure and drawing them in the right place on the map. The GPU should do a good enough job at occlusion so we don't even need to worry about drawing stuff we don't particularly care about. The game itself would normally only know the ID of the renderable, but still be able to adjust them before each render pass in a special function.
Interpolation is a fun one. We use the acceleration vector to keep moving the entity based on current time without modifying the structure itself. There should be basic collision checks here, for which the game must be referenced.
Text is a special case of the renderable. It's basically a single square repeated multiple times, squeezed and repositioned as necessary, using UV offsets of a font texture. It should be possible to use indexed draw calls for this.
Text rendering loop runs after all the normal renderables have been drawn, and has the same Z priority as the GUI elements. It's drawn after other UI elements have been drawn.
Text renderables push UV offsets as push constants and redraw the same geometry over and over with different translation and scale.
GUI elements are drawn before the text elements, and use indexed draw calls as well. They use simple textures or colors, and since everything is designed for a specific unified resolution, no consideration regarding scaling must be done.
- Clear background
- Interpolate camera movement if necessary
- Draw entities:
- First, calculate their interpolated position and collision
- Then, draw the sprite based on that data
- Draw overlays
- Draw GUI
For release builds, the assets would be packaged into a single binary blob. A manifest file would be included with it pointing at position and size of every given path. The blob would be uncompressed, as it simply doesn't need to be.
The engine will include a minimalistic JSON parser for reading the level files. It could be used by the game to read other things like dialogue trees.
Level loader would be responsible for interpreting the level and loading that into gamestate struct. Game itself should be involved in entity spawning, as the engine itself wouldn't have context for entities.
The engine will also provide various utilities, wrappers for platform-specific code, and such. It'll handle threading code, initialization of the game, loading of the levels and so on.
The game code is where most of the magic happens. Its responsibilities include:
- Creating and managing logical "entities"
- Updating each entity, checking collisions and such
- Managing which level is currently loaded
- Interpreting and performing the level logic
- Translating level ID strings into actual code structures
- Managing savegame states
- Reacting to user input
- Managing GUI
This document will focus on structuring the implementation of the game and its features, providing general specification and expectations of the code. First, the description of each "backend" system will be provided. Then, general specs and implementation hints for gameplay features ("frontend") would be provided.
The bare minimum implementation of the game code must include everything described in the art design document. That is, to reiterate:
- Basic WASD + Mouse-only movement
- Overworld
- Dungeon
- Basic combat system
- Inventory system and items
- Basic interactions
- Quests system
Each of these systems will be described in detail later in this document. These systems are considered "frontend" and rely on the "backend" to work correctly.
The backend is responsible for lower level management of the game state, managing save states, and communicating with the engine systems.
The entity system is the most important system of the entire game. Entities are the building blocks of each game level and will be plentiful everywhere. The base entity class performs all of the basic functions that may be required by other systems, such as managing renderables, rotation, physics checks, and such.
Extending entities is done by the way of composition. Each further entity in the game would simply have a member variable of type "Entity", which it would be able to manipulate in any way it desires via event functions, which are called during key events in the gameplay (e.g. interaction, click, creation, destruction, etc). We are not using polymorphism or class inheritance, as it goes against the Data-Oriented Design paradigm, and introduces CPU inefficiencies.
New entity types/classes would be registered within events called by the game, and would need to be explicitly added to each place in the code that's responsible for managing entities or their logic in any way. The tricky part would be to avoid usage of dynamic dispatch (virtual methods, or void object/function pointers), however it should be possible to achieve with clever composition and software design. An ideal implementation of this would simply update each entity type by itself in its own loop, while the base "Entity" objects are sent to the main "big" loop to update physics and drawables. Sure, this makes the code verbose and perhaps a bit clunky, however the goal of this exercise is to create the most efficient code we possibly can.
The game class should have N (where N = [amount of entity classes]) lists of objects of each entity class. It would iterate over each one of them, and update their logic, as well as perform collision checks on those which require it. It would call methods back on these classes, and since we have an explicit list for each entity class, we can very straightforwardly define custom updating behavior for each one.
Collision checks, in order to optimize the process, would only be performed on objects that can actively be affected by a collision, such as the player themselves (to limit their movement, etc). Passive objects with collision properties would not get their own updates, however they would be involved in collision checks on "active" colliders. This should hopefully limit the amount of physics updates to just a couple each loop, with the amount of collidable objects reduced to just a few each loop. A collission update loop would only check the entity against things it could potentially collide with, explicitly. As such, an update loop for a player would look something like the following (theoretical):
for (Player ply : player_list) {
for (Wall w : wall_list) {
if (ply.get_entity().collides_with(w.get_entity()))
ply.collided_with_wall(w);
}
// Repeat for every other class it could possibly collide with
}Yes, this is verbose, but this is purpose-built code. Deal with it~
The engine handles only parsing the Tiled data into an intermediate data structure, translating positions, and such. It's up to the game to actually react to this and create relevant entities.
The game is supposed to iterate over the level structure and create entities. Each entity would have its class name as a string, as well as a list of properties to be set, and the game is expected to read this data and spawn an entity up to that specification. Triggers in level are achieved by spawning a trigger class, which would have an event name attached to it, and arguments of that event. Each tile may also have conditions attached to it, which controls its appearance, whether it should spawn or not, and such. The implementation would simply be a very rudimentary JSON node tree.
The structure of the JSON node tree would be something rudimentary, such as:
{
"kind": "if",
"op": "lte",
"left": "enemies_required",
"right": ":enemies_killed",
"next": {
"kind": "delete_self"
}
}Variables beginning with a colon : are global game state variables.
The entity class would have a function that translates string names of each member variable into the member variable themselves. These would be getters and setters. As such (very rough and inefficient pseudocode, do not copy):
EntityVariable Entity::variable(std::string name) {
if (name == "foo")
return EntityVariable<type>(this->foo);
}
void Entity::set_variable(std::string key, std::string value) {
if (key == "foo")
this->foo = string_to_type(value);
}A savegame is nothing but a reproducible snapshot of the current game state. Saves may only be performed in the overworld. A savegame would contain the following:
- Player's inventory, current equipment, health, and other variables that may be interesting
- Quest progression stats
- General world stats (enemies killed, quests done, etc)
- Current quest and its progression stage
- NPC interaction data (relationships, which dialogues were already seen, etc)
- World interaction data (which parts of the world are unlocked, which changes have been made, etc)
The save state would be read and restored whenever relevant entities are created in the game. It would also be consulted whenever something needs to be checked. Variables in the save state are akin to global game class variables.
The game would create bindings between key and its state, and abstract commands. Once the input system receives these commands, the game would react to them accordingly. On click events, position of the mouse cursor would be considered. On gamepad stick inputs, the axes would be considered.
The engine would only provide extremely rudimentary building blocks of the GUI. The game is expected to manage the GUIs itself. A suggested implementation would use special entity classes specifically for GUI things, which would specifically react to click events or keyboard events whilst they are focused. They would also follow the position of the camera, having their own positions be camera-relative. They would also have the highest Z priority of all of the game objects.
The frontend are the higher-level features that rely on proper function of all the lower level systems. This section will attempt to describe what each element is supposed to be and give general hints as to the implementation.
We simply create key bindings and then interpret commands. Movement implementation can be dead simple, as in movement commands (forward/back/left/right) simply set player velocity to a specific value. Commands would be separated into "adders" (positive sign) and "removers" (negative sign). Adders would set the velocity, removers would undo what the adders set, by resetting it to zero.
For mouse-based movement, we simply create a normalized vector originating from the center of the window and pointing in the direction of the mouse, and set the velocity to that vector scaled by the movement speed.
Additionally, for move fluid movement, setting acceleration instead of velocity could be considered, with friction force affecting the entity to decrease its speed.
The overworld is a tile-based map with NPCs, which give you quests or allow you to buy/sell things. This is also the only place where player is able to manage their inventory. Saving the game is only possible here.
Overworld is a pre-defined map, not procedurally-generated.
In the dungeon levels, saving the game is disabled, and the only way out of it and back to the overworld is by either suffering a gameover, or exiting it prematurely. Dungeons are special, as all changes to player inventory are only committed to the save state if and only if the player successfully clears the dungeon.
Dungeons may have triggers for enemies, which in turn trigger the battle level.
Battle level is a separate procedurally generated level based on the current game state. It's populated with the required enemies and upon successful completion returns the player back to the dungeon, with the encounter trigger disabled.
It's styled after a battle menu, however it resides on its own level for organization purposes.
Inventory is a simple list of items that the player has. It may be accessed at any time via a button. Player may scroll through items and read their descriptions and stats. Player may only manage their inventory (throw away items, etc) in the overworld. Within the dungeon, these functions are restricted.
Each item carries an ID and basic stats about itself. An item can have a category, and during battles items of certain types would be usable. Items of weapon types would be used in the battles as equipment.
If the player presses the interaction key and they are facing an interactable entity (such as NPC), it may trigger actions like dialogues.
The save state will have a list of all quests and their progression, whether the quest is complete, how far, and other such information. One quest may be marked as the currently active quest.