General structure and design of the Acclimate Engine [Part 1]

Published March 27, 2024
Advertisement

So this time, I've decided to talk about something a bit different. The disassembler-part is really technical and abstract, and probably a lot of people, who don't want to develop their own programming language, will not really have that much insight from it. So today, we are instead looking at the general design of the engine, what makes it really great to develop, and sets it apart from the big common engines.

Overview

So let's start with a diagram:

The general structure of how features for the engine are coded. I'll explain it in a bit

We generally have 3 different blocks, with different use-cases.

Engine

The first part is obviously the foundation of the engine. All other systems are built upon this foundation, so this dictates the design and capabilities of all the other systems. I try to keep this as slim as possible. All functionality is divided into “Packages”, for example, one for Render, another one for GUI. But I try to keep it as small as possible. and keep most in plugins.

Plugins

Plugins are DLLs, that can be loaded to extend the base functionality of the engine. Pretty much anything that isn't at the core of the engine, is a plugin - even just for my own usage, it makes it much easier to develop features in isolation. While "Rendering" might be a core functionality, the actual 3D renderer is actually a plugin. And the full 2D-game renderer is another one.

Background

Nothing so far is really groundbreaking. Both Unreal and Unity allow plugins and packages to be developed, so what am I doing differently? Well, it comes down to what those plugins are actually capabable of doing, and how they do it. Let's start with a short recap of how Unity and Unreal handle extensions:

Unity primarily has MonoBehaviour. Everything you do as a user stars with a MonoBehaviour, somewhere, somehow. You can technically just code regular C# and make your own class-structure, but you need to tie it into the Unity Engine with a MonoBehaviour at some point, being placed in some scene.

Unreal is a bit more diverse. You have things similar to behaviours (Actors and Components), that you can extend, eigther in C++ or in Blueprint. Those use more inheritance than baseline-Unity. You also have different classes you can extend - states and instances, for different parts of the game, that give you more abilities to customize semi-global state, compared to having everything scene-bound.

Both of those systems are viable, though they have some downsides. Effectively, you are a 2nd-class citicien, especially in Unity. Unitys SceneManagment is certainly not done with a MonoBehaviour that is placed in a scene (how would it?), so the Unity-Engine devs have abilities to extend the engines in ways that you as a consumer don't really have. While this will always be the case, to a certain extend (I'm not really counting source-code modifications here), here's how I differ:

Our actual API

The Acclimate Engines Core exposes two different APIs to allow extensions of the baseline features: Resources, and Runtimes (refer to the graph above). Those are provided for certain scopes - which are primarily “Engine”, “Plugin”, “Game” and “Scene”. All of them differ in some aspects, but they all share a common layout, of how they are extended:

Extensions

A resource is composed of “extensions”. Those are simply classes that derive from a common base, for example, GameExtension. By inheriting from it, you can tap into the engines load-operation, and extend it accordingly. Let me show you a basic example:

class QuestLogGameExtension final :
	public game::GameExtension<QuestLogGameExtension>
{
	using QuestMap = std::unordered_map<uint32_t, sys::SharedPtr<Quest>>;
public:
	[[nodiscard]] static Declaration GenerateDeclaration(void) noexcept
	{
		return
		{
			L"QuestLog", {}
		};
	}
private:
	void OnNew(void) override;
	void OnLoad(sys::StringViewA strData) override;
	void OnLoadBinary(file::InFileStream& file) override;
	void OnUnload(void) override;
	std::string OnSave(void) const override;
	void OnSaveBinary(file::OutFileStream& file, core::BuildTarget target) const override;

	QuestMap m_mQuests;
};

This is an extension for the QuestLog-plugin. Questlog allows you to setup quests, that you can track in an entry:

And it all starts with the GameExtension. If you look at the implemented methods, you'll see that you have hooks into both the textual serialization system (editor), as well as the binary one (builds). So a GameExtension could kind of be compared to a ScriptableObject in Unity, but it already offers benefits. For the editor-part, you can choose any format you like, so you are not really limited by what the baseline-engine offers (the engine uses a custom YAML format, but you could use XML, Json, and whatever else). The binary part is also kind of open, though we use a specific stream-format for efficient loading.

And here is the real banger: This is also how I setup the core engine. Pretty much everything, except for the systems needed to setup the meta-environment, are made with the very same interfaces that a plugin-developer or user would have access to! This means that pretty much everything that I can make, someone else could make too.
The most practical advantage to something like Unity is this right here: Unity itself exposes settings, that you can toggle in a property-page. Now you can make custom settings, but those require making a ScriptableObject, which has to be placed into a specific location in the asset-folder to be found. That causes big headaches, especially in combination with git and shared plugins. This is not a problem at all for us:

This is the settings-page. You have both a core-engine setting (Render), combined with a game-specific dialog-system plugin (Dialogue2D). And the way both of them are setup, is entirely similar:

game::GameExtentionDeclaration dialogue2d::GameExtention::GenerateDeclaration(void) noexcept
{
	return
	{
		L"Dialogue2D",
		{
			{ L"InputHandler", core::bindAttribute(&GameExtention::m_pHandler) },
			{ L"Colors", core::bindAttribute(&GameExtention::m_vColors, &GameExtention::OnColorsChanged) },
			{ L"DefaultPlayerName", std::wstring(L"Player"), core::bindAttribute(&GameExtention::m_strDefaultPlayerName) },
			{ L"Windowskin", core::bindAttribute(&GameExtention::m_pWindowSkin)},
			{ L"DefaultSound", core::bindAttribute(&GameExtention::m_pDefaultSound) },
		},
		false
	};
}

You just declare them as properties of the specified GameExtension, and the system takes care of the rest. This is kind of similar to Unreals UPROPERTY-system, only that we do not have a meta-compiler, but need to declare it ourselves. Which is actually less tedious than it sounds.

In a similar vein, they have to be globally registered, for example during a plugin-DLLs initialization:

void Module::OnInit(const core::BaseContext& context)
{
	// game extentions
	PlayerStatGameExtention::Register(context);
	InventoryGameExtention::Register(context);
	BeastiaryGameExtension::Register(context);
	MapGameExtension::Register(context);
	GameFontGameExtension::Register(context);
	QuestLogGameExtension::Register(context);
}

And from there on out, the engine takes care of the rest. All extensions are registered before the game is loaded, and when it is, a “GameResource” object is created, containing instances of all the existing extensions, which then make up that Games data. Aside from their own load-handling, they can also be queried, eigther via custom reflection (which is done to implement the properties I've shown above), or directly:

const auto& questLogExtension = QuestLogGameExtension::Get(gameContext);
questLogExtension.GetQuest("QuestA");

CRTP taking making this as simple as could be. We actually need a gameContext, which I'll explain when it comes to runtimes - just so much is to be said, those objects are all purposefully not singletons or static. Multiple instances can exist - thus we need a context to indetify which instance we want. This is usually supplied to another extension, to allow cross-accesss:

// Please forgive my misspelling of “Extension”, I didn't know itw as with an s, have to fix that across the board at some point :D 
void InventoryGameExtention::OnInit(const game::GameContext& ctx)
{
     m_stats = PlayerStatGameExtention::Get(ctx);
}

The actual lookup is pretty fast though - much faster than searching for GameObjects in eigther Unity or Unreal. CRTP gives us a static integer type-id, which we can internally use to directly lookup the object from an array. That makes it O(1) with pretty good caching behaviour, meaning we very often don't even need to cache the accesses (at least not for performance).

Wrapping it up

Ok, that's enough for now. There is still a lot open about this design, and a few cool use-cases. But this article is already long, and I'm afraid if I type any more the forum might delete my draft again. So, until next time.

0 likes 0 comments

Comments

Nobody has left a comment. You can be the first!
You must log in to join the conversation.
Don't have a GameDev.net account? Sign up!
Advertisement