DEV Community

Cover image for Deep dive Tainted Grail [0] - Introduction
KamilVDono
KamilVDono

Posted on

Deep dive Tainted Grail [0] - Introduction

Best place to start is from beginning

Inheritance

Fall of Avalon is second digital game in Tainted Grail IP. First game in series is Conquest. Development history of Conquest could fill a book, tltr it started as small indie project and grew into medium-sized indie game. We had systems designed for small indie game, but managed to scale them up for medium-sized one. These systems were then copied over to Fall of Avalon as foundation.

Another big success we carried over from Conquest was the team. Team building is topic worthy of PhD thesis and in many ways more impactful than tech itself in determining game's overall success.

So we had two key components: systems built on top of Unity and team that performed well with Unity and the tools (a subset of those systems).

(Not so) Fresh start

You have team proficient with Unity and tools built on top of it. You have tools within Unity and systems (which tools rely on). So it made sense to take these and start new project, especially since Fall of Avalon wasn’t entirely different from Conquest. But there was catch: systems were originally built for small indie game. We already scaled them up once for Conquest, so we thought we could do it again. It kind of worked, but not without issues.

Systems

Here is list of main systems at beginning of TG:FoA:

  • MVC (custom)
  • Unnamed event bus (integrated with MVC, so I’ll treat it as part of MVC for now)
  • Story graphs (custom node system for story)
  • Addressables
  • HDRP
  • FMod
  • Visual scripting
  • A* project
  • VFX Graph

The cornerstone: MV(C)

Let’s start with core system inherited from Conquest: MV(C).

In this setup:

  • M - Model: where logical data lives.
  • V - View: prefab spawned to visualize model data.
  • C - Controller: but not existed :)
  • S(ilient) - Service: global functionality that’s too broad to fit into model.

Yes, the C part of MVC is missing but not big issue, you can get used to it.

Let’s dive deeper into actual components, starting from end.

Service

Services are straightforward: they provide globally available functionality that’s too specialized to be model. Good examples:

  • Regrowable Service: lets you register ID and time when you want to be pinged.
  • ID Service: provides next unique ID for given model type.

Service is singleton you can query by type. There’s no dependency injection, so any dependencies need to be handled manually:

  • Injected during initialization (in constructor or initialize method).
  • Queried when method that requires dependency is called.

View

View defines prefab, where to spawn it, and contains glue code between Unity objects and models. Exact nature of this glue code isn’t strictly defined, so you might see fat view paired with gameplay model or thin view with UI model and gameplay model.

This setup introduces clean separation: View handles Unity-specific elements, keeping model layer almost engine-agnostic, which is nice feature. But since this separation is more about model, let’s finish View first.

Regarding prefabs: they’re optional. If you don’t provide path, new GameObject is created and View MonoBehaviour is attached to it. If you do provide path, prefab is loaded from Resources—yes, old method, but we need synchronous loading and Addressables aren’t reliable for that. So prefabs can’t contain heavy content; heavy content is lazy-loaded via Addressables.

One thing to note: we don’t rely on Unity’s object lifetime. MVC has its own lifetime management. Spawned prefab with View MonoBehaviour is valid MVC object, even if it’s not yet "Awake" in Unity or has been "Destroyed".

Model

Model contains logic and data/state (classic OOP). Entire gameplay is built from models. Often UI creates its own intermediate models to reduce logic inside view.

Single model can spawn zero to multiple views when it’s created. For example, hero model might spawn 3D model in game world and portrait with HP and MP on HUD. Views can also be removed at any time without disposing of model. In most cases, model-view relationship is managed automatically by MVC.

Models have four main types of relationships:

  • Hierarchy - Model can be child of another model, it's main (or at least should be) main type. For example, FastTravel element is child of Location model. Parent knows when child is added or removed, and child’s lifetime is tied to parent—it can’t exist without it.
  • Hard Ref Field - Model can have direct reference to another model.
  • Weak Ref Field - Model can have field with another model’s ID, resolving reference each time and checking if it’s still valid.
  • Named Relation - It is possible to create a class that defines the relationship between models. For example, one Item can be owned by one Character; one Character can own zero, one, or multiple Items. Such relationships have automatic management by MVC. For example, a relationship will be automatically terminated if one of the sides is disposed.

While this hierarchy promotes a composition-over-inheritance approach, there's nothing to discourage you from using inheritance.

As mentioned in View section, models are designed to be engine-agnostic. In Conquest, where Unity was mostly visualization layer for logic and state, this worked well. But in TG:FoA, gameplay logic is tightly coupled with physics, visuals, and Unity’s state. So what was strength became weakness: model lifetimes are independent of Unity object lifetimes, but logic is deeply intertwined with Unity. Welcome to lifetime management hell. :)

Since the Model is a base class, there is no support for structs. While this wasn't an initial concern, it ultimately led to two massive pessimization:

  • The MVC architecture cannot be bursted at all.
  • Even temporary allocations are practically impossible with native or stack allocations; mostly only slow managed allocations or static caches are possible.

This, combined with an 'every-call-is-cache-miss' scenario, results in a massive performance hit.

Events

Event system is at core of MVC, so let’s cover it here.

Events are defined by an ID, generic constraints on the model (specifying that the event can only be triggered on a particular model type or its inheritors), and a payload type.
Any model can trigger any event on any other model (as long as generic constraints are met), and any model can listen to any event on any model. You can also listen for all events of certain type, not just on specific model.

To clarify, here’s some pseudocode:

// Event declaration
public static class Events {
    public static readonly Event<IGrounded, TeleportData> Teleported = new Event<IGrounded, TeleportData>(nameof(Teleported));
}

public class Location : Model, IGrounded {
    public void Teleport(Vector3 newPosition) {
        Move(newPosition);
        // Call event on myself
        this.Trigger(Events.Teleported, new TeleportData(this, oldPosition, newPosition));
    }
}

public class LocationTeleportListener : Model {
    public void SingularListen(Location target){
        // Listen for specific model for Events.Teleported
        target.Listen(Events.Teleported, Callback);
    }

    public void AnyListen() {
        // Listen for all event of type Events.Teleported
        EventsSystem.ListenAny(Events.Teleported, Callback);
    }

    void Callback(TeleportData data) {
        // Do something
    }
}
Enter fullscreen mode Exit fullscreen mode

The 'any event, any model, any listener' aspect might sound like a readability nightmare, but it's not so scary in practice. That said, discovering the implicit connections between models can be tedious at times.

Saving (and loading)

Models have built-in support for saving. System automatically dumps eligible models into JSON blob. It uses reflection to determine which fields to include, and then JSON engine serializes them.
Loading is straightforward: read JSON, deserialize models, and call their restoration methods. There's nothing particularly complex about it.

There's nothing fancy here, but you can probably tell that reflection and JSON are the antithesis of performance. So, you can expect a future post dedicated to saving and loading optimizations.

Other systems

Other systems are more or less publicly available, or were created as part of TG:FoA so it's not necessary to introduce them.
Only the "technological stack" built on our very proprietary baseline required an introduction.
Nevertheless, it's beneficial to show what we ended up with and what will likely be covered in future posts.

You can find a list of the initial systems at the beginning of the document, and here is a list of the final systems:

  • MVC
  • Addressables (never again)
  • HDRP (with small custom optimizations)
  • FMod (with optimizations at Unity glue-code level)
  • Visual scripting (never again)
  • A* project (with custom optimizations)
  • Entities with graphics (with custom optimizations and fixes)
  • VFX Graph
  • Leshy (custom system for vegetation streaming and rendering)
  • Medusa (custom system for static environment assets like cliffs and terrains)
  • Drake (custom runtime rendering baking to ECS, with optimizations and systems for OOP interactions)
  • Scenes baking (system to merge multiple scenes into one, then split into dynamic and static parts, and flatten hierarchy)
  • Kandra (custom system for rendering skeletal meshes with mesh-mesh culling)
  • HLODs (custom system for clustering and merging multiple renderers into single simplified mesh, ECS-based rendering)
  • Mipmaps streaming (built on Unity’s system, adapted for ECS and other custom systems)
  • Binary serialization via source generator
  • Various smaller utilities (rain textures, cheap VFX mesh sampling, debug tools, flocks with vertex animations, cheap splatmap sampling, Pix and Superluminal markers)

This isn’t order I’ll cover them in future posts. I plan to discuss custom systems chronologically, starting with Drake and then Leshy.

In addition to custom systems, I’ll also cover some key optimizations we implemented. Stay tuned for those and future posts.

Top comments (2)

Collapse
 
spqrpancho profile image
Pancho

Thanks for this introduction. However, I am wondering:
a) Why did you resign with the Controller layer when you are mimicking it within the Model layer anyway? It lowers the readability of the architecture at first glance.
b) Why did you abandon dependency injection?
c) "Addressables (never again)", why?

Collapse
 
kamilvdono profile image
KamilVDono

a) The person who established the framework just made it like this, and there was never any push to do it differently. To be honest, mixing web architecture like MVC with game development makes it complicated and lowers readability.
b) There wasn't dependency injection in the first place (one of the few good decisions). That would have made it way more complicated and slower, just for the sake of avoiding code like this:

var serviceA = Services.Register(new ServiceA(_someDependencyToMonoBehaviour))
var serviceB = Services.Register(new ServiceB(serviceA));
Enter fullscreen mode Exit fullscreen mode

c) It's slow. It allocates a ton of garbage. The code is a mess. It introduces dependencies between separate loadings. You need atomic groups to have unloading working okay-ish, but you also need massive groups to keep runtime allocations at bay; these two are contradictions.

Addressables is a glorified refcounting system, and it's the worst refcounting I can imagine.

I'd like to make a 'roast Unity's packages' post in the future :)

After hours, I'm making my own system based on ContentFiles (some quick tests showed over 300-400 MB less RAM usage). It can be bursted, has priorities, and is very simple.