Game Structure and Software Architecture

Software Architecture

This is a straightforward simple memo on what all game structure should be like, in the most general sense. It’s for me. It’s not for you. It hasn’t been checked very thoroughly and might contain errors, even big ones, especially the parts generated with the help of generative AI.

Project Structure

Parent-to-Child Dependencies

All the dependencies should be designed to be accessed from parent to child. By ensuring that parents handle dependencies, you avoid issues where children or siblings aren’t fully initialized. This also keeps objects modular, so each is only responsible for its own scope. Do not depend from child to parent. The parent isn’t loaded yet when the child is ready. Do not depend on siblings. They might or might not be there. Let the parent handle it. Things should be self contained. Objects should be responsible for simple things which the parent manages.

Separation of Logic and Data

Separate logic from data. It is always a good idea to split behavior from data models, ensuring a clean and maintainable codebase.

Saving State

EF Core with SQLite should be used for object relational mapping. All state saving and loading should be handled by EF Core. Standard practices should be used for organizing the state objects.

EF Core (Entity Framework Core) is an Object-Relational Mapper (ORM), which means it translates between your C# classes and database tables. It handles CRUD (Create, Read, Update, Delete) operations for you, but requires a few key elements:

  • DbContext: The class responsible for managing database connections and mapping entities (your C# objects) to tables in your database.
  • Entities: The C# classes that represent your database tables.
  • Migrations: EF Core’s way of managing changes to your database schema.

Defining Your DbContext

The DbContext class is where you configure your connection to the database and define which entities will be tracked.

Here’s a minimal example of a DbContext:

public class GameDbContext : DbContext {

    // Define DbSet properties for each entity (table) in your database
    public DbSet<Player> Players { get; set; }
    public DbSet<InventoryItem> InventoryItems { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) {
        // Configure EF Core to use SQLite
        optionsBuilder.UseSqlite("Data Source=game.db");
    }
}

This GameDbContext class will automatically map your Player and InventoryItem entities to tables in an SQLite database (game.db).

Defining Entities

Entities are C# classes that represent rows in your database tables. Each property corresponds to a column in the table.

For example, here’s a Player entity:

public class Player {
    public int Id { get; set; }     // Primary key
    public string Name { get; set; }
    public int Level { get; set; }
    public List<InventoryItem> Inventory { get; set; }  // Navigation property
}

And the InventoryItem:

public class InventoryItem {
    public int Id { get; set; }     // Primary key
    public string ItemName { get; set; }
    public int Quantity { get; set; }
    public int PlayerId { get; set; }  // Foreign key to Player
    public Player Player { get; set; } // Navigation property
}

In this example:

  • Each Player has a collection of InventoryItems.
  • The InventoryItem table will have a foreign key (PlayerId) to the Player table.

Basic CRUD Operations

Create

using (var context = new GameDbContext()) {
    var player = new Player {
        Name = "Jack",
        Level = 5
    };

    context.Players.Add(player);
    context.SaveChanges();  // Persist changes to the database
}

Read

using (var context = new GameDbContext()) {
    var player = context.Players
                         .Include(p => p.Inventory)  // Load related inventory items
                         .FirstOrDefault(p => p.Name == "Jack");

    Console.WriteLine(player.Name + " has " + player.Inventory.Count + " items.");
}

Update

using (var context = new GameDbContext()) {
    var player = context.Players.FirstOrDefault(p => p.Name == "Jack");
    if (player != null) {
        player.Level += 1;
        context.SaveChanges();  // Save the updated data
    }
}

Delete

using (var context = new GameDbContext()) {
    var player = context.Players.FirstOrDefault(p => p.Name == "Jack");
    if (player != null) {
        context.Players.Remove(player);
        context.SaveChanges();
    }
}

Tips for Effective Use

Avoid Querying the DbContext Multiple Times: When you need related data, use eager loading (.Include()) or lazy loading so you don’t make multiple database round trips.

Example of eager loading:

var playerWithInventory = context.Players.Include(p => p.Inventory).FirstOrDefault();

Use Transactions for Batch Operations: If you are performing multiple updates, wrap them in a transaction for better performance and consistency.

using (var transaction = context.Database.BeginTransaction()) {
    // Multiple operations here
    transaction.Commit();
}

Split Read and Write Contexts: If your game has a lot of read-heavy operations, you can create separate contexts for reading and writing. This can reduce lock contention in some cases.

Relationships Between the Database and C# Objects

Key Parts:

  1. List<InventoryItem> in the Player class
    This is a collection (or list) of items that a player owns. It’s purely a C# list inside the Player object. It represents all the items that the player has in their inventory.

  2. DbSet<InventoryItem> in the GameDbContext class
    This is a way for Entity Framework (EF Core) to track all the inventory items in your game. It knows how to load items from the database, save them back to the database, and query them.

Relationship Between Them:

  • The DbSet<InventoryItem> in GameDbContext manages all the InventoryItem objects stored in your database. Think of it like a “big box” where every inventory item in the entire game is kept.
  • The List<InventoryItem> inside the Player class is more specific. It only holds the items that belong to that particular player. When you retrieve a player from the database, EF Core also helps fill in the List<InventoryItem> with just the items that belong to that player.

How an Item is Created and Added

  1. Creating a New Item: When you create an InventoryItem, you are making a new C# object in memory. For example:
var newItem = new InventoryItem {
    ItemName = "Sword",
    Quantity = 1,
    PlayerId = player.Id // Foreign key linking to the player
};
  • This creates an object in memory but it’s not yet part of the database.

  • Adding it to the Player’s Inventory: Next, you add it to the player’s inventory list:

player.Inventory.Add(newItem);
  • Now the new InventoryItem is in the Player‘s list (player.Inventory), but it still doesn’t exist in the database yet.

  • Adding the Item to the Database (Making it Permanent): To save the new item to the database, you have to tell EF Core to track it and save it. Here’s how:

context.InventoryItems.Add(newItem); // Add the item to the DbSet
context.SaveChanges();               // Save changes to the database
  • context.InventoryItems.Add(newItem) tells EF Core to prepare this new item for being stored in the database.
  • context.SaveChanges() takes everything EF Core is tracking (including this new item) and writes it into the database.

Now the item exists both in memory (in player.Inventory) and in the database (inside the InventoryItems table).

Who or What is Responsible for the Objects?

  • You (the Developer) create and manage the actual objects (InventoryItem, Player, etc.) in C# code.
  • EF Core manages the communication between your code (in memory) and the database (on disk). It knows how to save and load these objects.

What is the Real Object?

  • The real object is the one in memory. For example, newItem is the actual object you create.
  • When you save it to the database, it becomes a row in the database, but when you load it back later, EF Core re-creates the object in memory.

Are There Temporary Objects?

  • Yes, the InventoryItem you create in memory is a “temporary object” until you save it to the database using context.SaveChanges().
  • After saving, it becomes permanent in the database. EF Core keeps a reference to this same object in memory, so it doesn’t create multiple copies unnecessarily.

Are They the Same Object with Multiple References?

  • Yes. Once you add an item to both player.Inventory and context.InventoryItems, these are two references to the same object.
    • player.Inventory holds a reference to the InventoryItem in the context of the player.
    • context.InventoryItems manages that same object but in the context of the database.

How Do You Use All of This?

  1. Create the item:
    Create a new item in memory like this:
var newItem = new InventoryItem {
    ItemName = "Shield",
    Quantity = 1,
    PlayerId = player.Id
};

Add the item to the player:
Add it to the player’s Inventory list:

player.Inventory.Add(newItem);

Track the item in EF Core:
Tell EF Core about this item by adding it to the DbSet:

context.SaveChanges();

Now, the item is:

  • In the player’s inventory (player.Inventory).
  • In the database, managed by EF Core (context.InventoryItems).

Quick Summary:

  • List<InventoryItem> in Player: Holds items only for that player in memory.
  • DbSet<InventoryItem> in GameDbContext: Manages all inventory items in the game and connects them to the database.
  • Creating an item: You create the item in memory, add it to the player’s list, and then save it to the database with EF Core.
  • Real object: The item is real in memory, and EF Core makes sure the object gets saved and retrieved from the database properly. Multiple references to the same object point to the same instance.

This approach helps ensure that your game objects exist in both memory and the database in a well-structured, efficient manner.

Who Should Initialize Player with Inventory Items?

Classes should not be responsible for initializing themselves with its data. That would violate the Single Responsibility Principle, where a class should only have one reason to change. The Player class, for example, should focus on modeling the player and its properties, not managing data access.

The better option is to have a controller or manager responsible for fetching the Player and its associated data from the database. This manager could be something like a PlayerService or GameDataLoader and it would query the database, populate the Player object with the data, and return the fully initialized Player object.

It might look something like:

public class PlayerService {
    private readonly GameDbContext _context;

    public PlayerService(GameDbContext context) {
        _context = context;
    }

    public Player GetPlayerWithInventory(int playerId) {
        return _context.Players
                       .Include(p => p.Inventory)  // Eager load inventory items
                       .FirstOrDefault(p => p.Id == playerId);
    }
}

This way the Player class stays clean and doesn’t know anything about the database.

Multiple Operations on the Same Object

It can feel like there are two separate operations:

  1. Adding items to the player.Inventory list.
  2. Adding items to the DbSet<InventoryItem>.

It can feel like you’re duplicating the same work by managing both the list and the database. Let’s clarify what’s going on and how to approach it.

Are There Really Two Sets of Objects?

Not exactly. When you create an InventoryItem, it exists as a single object in memory. When you add it to player.Inventory and later to context.InventoryItems, you’re just creating two references to the same object. EF Core tracks changes to the object via context.InventoryItems, but you’re free to access that object through player.Inventory.

Is It Normal?

Yes, this pattern of managing both the collection (like player.Inventory) and saving to the database separately is pretty common in EF Core and ORMs in general. However, the redundancy you’re feeling can be reduced.

How to Simplify

Instead of managing both the player’s inventory list and the database context separately, let’s clean it up so that you only interact with the Player object and let EF Core handle saving both Player and InventoryItem in one go.

  • One Object Graph: You should treat the Player and its Inventory as a single “object graph.” When you save the Player, EF Core will automatically save all related InventoryItem objects.

Here’s how you might do that:

using (var context = new GameDbContext()) {
    // Create the player and the item
    var player = new Player { Name = "Jack", Level = 5 };
    var newItem = new InventoryItem { ItemName = "Sword", Quantity = 1 };

    // Add the item to the player's inventory
    player.Inventory.Add(newItem);

    // Add the player (with inventory) to the context
    context.Players.Add(player);

    // Save everything at once
    context.SaveChanges();  // Saves both Player and InventoryItem in one go
}

In this example:

  • You’re only managing the Player object.
  • By adding the InventoryItem to the player.Inventory, EF Core will automatically track that item because it’s part of the Player’s “object graph.”
  • When you call context.SaveChanges(), EF Core will save the Player and all related InventoryItem objects in one transaction.

Is It Code Smell?

The approach where you’re managing player.Inventory and context.InventoryItems separately can feel like a code smell if you’re not leveraging EF Core’s ability to track relationships between entities. If you’re manually handling both, it feels like duplication and is error-prone.

However, when you treat the Player and InventoryItems as part of a single object graph (as shown above), it becomes simpler and more natural. EF Core can track everything with just one call to SaveChanges().

Summary

  • Who initializes the Player? A PlayerService (or similar) should be responsible for loading Player objects with their InventoryItems from the database.
  • Are two object sets necessary? No, ideally you treat Player and InventoryItem as a single object graph. Add items to player.Inventory and let EF Core handle the persistence for both in one operation.
  • Cleaner approach: Use EF Core’s relationship tracking to save both Player and InventoryItem with one SaveChanges() call, minimizing manual duplication of work.

This approach should streamline your code and make it feel less “janky.”

Performing Logic Without Becoming Tied to Database Operations

Let’s explore how the Receive(IItem givenItem, IItemGiver giver) method would fit into this scheme.

The Receive method allows the player to receive an item from another entity like an NPC. Here’s how we can integrate this with our previously discussed EF Core setup without causing duplication or breaking cohesion.

1. What Would Receive Do?

The Receive method in Player is responsible for:

  • Adding the received item (givenItem) to the player’s inventory.
  • Optionally tracking who gave the item (if needed for gameplay logic).
  • Ensuring the item is properly saved to the database.

The key here is that the method should manage the player’s inventory but let EF Core handle the database part. Receive can operate on the object graph (the player and its items) without worrying about how EF Core persists those changes.

2. Adjusting the Receive Method to Work with EF Core

Let’s start by looking at a potential implementation of the Receive method:

public class Player {

    public List<InventoryItem> Inventory { get; private set; }

    public void Receive(IItem givenItem, IItemGiver giver) {
        // Assume givenItem is a valid InventoryItem object
        InventoryItem item = (InventoryItem)givenItem;

        // Add the item to the player's inventory
        Inventory.Add(item);

        // Optionally log the giver or update some logic based on the giver
        Console.WriteLine($"{giver.Name} gave you {item.ItemName}.");

        // The item now exists in the player's inventory list
        // Saving to the database happens outside of this method
    }
}

In this method:

  • givenItem is cast to InventoryItem and added to the player’s Inventory list.
  • The method handles adding the item to the player’s memory representation of inventory but doesn’t directly deal with the database.
  • Database operations (saving the item) happen outside this method.

3. Who Manages Database Saving?

You want the Receive method to be simple and focus on the game logic, not database handling. Database saving should be handled at a higher level, probably by the game logic controller or service.

For example, an NPC gives the player an item, and after the game logic is complete, the system saves the changes to the database.

Here’s how that might look at the controller/service level:

public class GameController {
    private readonly GameDbContext _context;

    public GameController(GameDbContext context) {
        _context = context;
    }

    public void GiveItemToPlayer(Player player, IItem givenItem, IItemGiver giver) {
        // Player receives the item from the NPC
        player.Receive(givenItem, giver);

        // Track changes to the player's inventory in the database
        _context.Players.Update(player);
        
        // Since the InventoryItem is part of the Player's object graph, EF Core will
        // automatically track the new item and save it
        _context.SaveChanges();
    }
}

Here:

  • The Player handles the game logic: The player gets the item from the NPC via Receive(givenItem, giver).
  • The GameController handles the database logic: After the player has received the item, the controller updates the player’s state in the database by calling Update(player) and SaveChanges().

4. Why Does This Make Sense?

  • Separation of Concerns: The Player class focuses only on game logic (receiving items, managing its own state). It doesn’t deal with saving or database operations, which are handled at a higher level (in this case, the GameController).
  • Single Object Graph: The Player and its InventoryItem are part of a single object graph. When the GameController saves the Player, EF Core will automatically detect that a new InventoryItem was added to the player’s Inventory and will save both the Player and the new item in one operation.
  • No Duplication of Objects: You’re still only working with one set of objects in memory. There’s no separate management of the database objects and in-memory objects.

5. Handling the Giver (NPC)

If the giver (NPC) needs to be tracked (for example, if it’s important to know which NPC gave the item), you can log or store that information in the Receive method or another part of the Player class. Here’s an example:

public class Player {

    public List<InventoryItem> Inventory { get; private set; }

    public void Receive(IItem givenItem, IItemGiver giver) {
        InventoryItem item = (InventoryItem)givenItem;

        // Add to the player's inventory
        Inventory.Add(item);

        // Log or track the giver if needed
        Console.WriteLine($"{giver.Name} gave {item.ItemName}");

        // Example: Track giver info in the item
        item.GivenBy = giver.Name;

        // Database saving is handled externally
    }
}

In this case, you might store the giver’s information inside the InventoryItem or just log it. This depends on your game’s needs.

Does This Fit the Pattern?

Absolutely. This design adheres to the principles we’ve discussed:

  • Parent-to-Child Dependency: The Player manages its own inventory (InventoryItem is part of the Player object graph).
  • Single Responsibility: The Player class focuses only on its own game logic (receiving items), while the GameController is responsible for coordinating between game logic and the database.
  • No Object Duplication: You only manage one set of objects in memory. EF Core tracks the changes and handles persistence in the background.

Example Flow:

  1. The NPC (giver) wants to give an item.
  2. The GameController calls player.Receive(givenItem, giver).
  3. The Player receives the item and adds it to its Inventory.
  4. The GameController calls SaveChanges() to persist both the Player and the new InventoryItem to the database.

Summary

  • Player.Receive() manages the logic of receiving the item, while database operations are handled at a higher level (like a controller or manager).
  • EF Core automatically tracks changes to the InventoryItem through the Player’s object graph.
  • You avoid duplication by keeping everything in one object graph and only persisting the whole player state when needed.

This approach is clean, avoids code smells, and keeps your game logic and data access responsibilities well-separated.

Managing dependencies

[Export] is the Godot way of doing dependency injection. Ideally the exported node would be a child node mapped to a parent controller, as explained in [[#Parent-to-Child Dependencies]] above.

When a clear parent-child relationship cannot be used, like, for example in a situation where UI needs data to display different strategies can be applied.

Where Should Requests Originate?

The request to give an item should indeed originate from the NPC (or more precisely, from an interaction between the NPC and the Player). However, the NPC itself doesn’t need to manage the whole process—its role can remain simple, while the higher-level game logic (likely in the GameController) handles the actual mechanics.

2. The NPC as a “Dumb” Object

The NPC can be more of a “dumb” object. The NPC just holds some data and methods to initiate an action (like giving an item), but the NPC should not be responsible for complex game logic like inventory management or interacting directly with the database. This keeps the NPC clean and focused on what it represents in the game world.

In other words, the NPC initiates the process by signaling its intent to give an item, but the GameController or some other game system handles the heavy lifting.

Here’s a possible structure:

3. How the Hierarchy and Interaction Would Work

  1. NPC: The NPC triggers the action, such as giving an item, when it interacts with the player.
  2. GameController: The GameController (or a similar system) manages what actually happens when the NPC gives the item to the player, including updating the game state, calling the player’s Receive() method, and saving the changes.
  3. Player: The player receives the item, and the player’s inventory is updated. The player doesn’t handle persistence or interaction logic—that’s handled by the GameController.

4. Example Flow of Giving an Item

NPC Class

The NPC is a simple entity. It has an item to give and can trigger the GiveItemToPlayer() method in the GameController.

public class NPC : IItemGiver {
    public string Name { get; set; }
    public InventoryItem ItemToGive { get; set; }

    public void InteractWithPlayer(Player player, GameController controller) {
        // The NPC asks the GameController to give the player an item
        controller.GiveItemToPlayer(player, ItemToGive, this);
    }
}

In this case:

  • The NPC has an ItemToGive, but it doesn’t handle the details of giving the item.
  • When the player interacts with the NPC, the NPC triggers the interaction by calling the GameController.

GameController Class

The GameController handles the actual transaction, manages the logic of giving the item, and persists the changes to the database.

public class GameController {
    private readonly GameDbContext _context;

    public GameController(GameDbContext context) {
        _context = context;
    }

    public void GiveItemToPlayer(Player player, IItem givenItem, IItemGiver giver) {
        // The player receives the item
        player.Receive(givenItem, giver);

        // Update the database to reflect the new item in the player's inventory
        _context.Players.Update(player);
        _context.SaveChanges();
    }
}

Here:

  • The GameController coordinates the interaction between the NPC and the Player.
  • It ensures that the item is added to the player’s inventory and saved in the database.

Player Class

The Player is responsible for adding the item to its inventory but doesn’t deal with persistence or interaction logic.

public class Player {
    public List<InventoryItem> Inventory { get; private set; }

    public void Receive(IItem givenItem, IItemGiver giver) {
        InventoryItem item = (InventoryItem)givenItem;
        Inventory.Add(item);
        Console.WriteLine($"{giver.Name} gave you a {item.ItemName}.");
    }
}

Hierarchy and Parent-to-Child Dependency

To maintain the parent-to-child dependency, the NPC doesn’t need to directly know about the GameController or even the Player in a deep sense. It just triggers an action (like InteractWithPlayer) and passes in the necessary data.

The GameController acts as the “parent” managing the flow between the NPC and the Player. It orchestrates the logic from a higher level, ensuring that the NPC, Player, and database all interact correctly without causing any child-to-parent dependencies or complex cross-object interactions.

In this setup:

  • NPC → GameController → Player is the flow of control.
  • The NPC triggers the event, but it’s the GameController that manages the relationship between NPCs, Players, and the database.

This avoids any circular dependencies, keeps the NPC simple (it doesn’t manage the database or game state directly), and maintains a clear, hierarchical flow of control.

6. Why This Structure Makes Sense

  • Separation of Concerns: The NPC only knows about the fact that it wants to give an item. It doesn’t care about how the item is added to the player’s inventory or saved to the database.
  • Centralized Game Logic: The GameController coordinates the overall interaction and handles the persistence. This prevents messy dependencies between unrelated objects and keeps the architecture clean.
  • Single Source of Truth: There’s no risk of having multiple versions of the same data floating around. The Player manages the inventory, and EF Core handles the persistence as one unified object graph.

Summary

  • The NPC initiates the action to give an item but doesn’t handle the complex logic. It triggers the interaction by calling the GameController.
  • The GameController orchestrates the interaction, calling the player’s Receive() method and saving the changes to the database.
  • The Player receives the item, but doesn’t manage the persistence or higher-level game logic.
  • This structure avoids child-to-parent dependencies and keeps the NPC and Player focused on their respective responsibilities, while the GameController handles game logic and database interaction.

This way, everything stays clean, focused, and manageable, adhering to solid architectural principles.

What About Returning State?

you sometimes need to get state back from child objects. However, how you handle that state depends on how tightly you want to couple your objects and what kind of architecture you’re following. There are two key approaches you can consider:

  1. Fetching State When Necessary: This is the classic way where you allow some controlled flow of information back up the hierarchy.
  2. Designing Around the Principle of Always Valid State: This approach reduces the need to query state and focuses on ensuring that the system is always in a valid state by design.

Let’s explore both.

Fetching State Back from Child Objects

In many cases, it’s reasonable for a parent (like the GameController) to query child objects (like the Player or NPC) for their state. This is especially true when decisions need to be made based on real-time data (e.g., checking the player’s health, inventory capacity, etc.).

Example: Checking the Player’s Health

Imagine you need to check the player’s health to decide if they can continue in combat or if they need healing:

public class GameController {

    public void CheckPlayerHealth(Player player) {
        if (player.Health < 20) {
            Console.WriteLine("Player needs healing!");
        } else {
            Console.WriteLine("Player is healthy.");
        }
    }
}

public class Player {
    public int Health { get; private set; }

    public Player(int health) {
        Health = health;
    }
}

Here:

  • The GameController is querying the Player for its current health.
  • The flow remains parent-to-child (the GameController calls methods on Player), but it’s getting information back for decision-making.

This approach is sensible when:

  • You need real-time, up-to-date information from child objects.
  • The parent object (e.g., GameController) needs to make decisions based on child state.

2. Designing for Always Valid State

Another approach is to minimize or eliminate the need for fetching state by designing your objects to always maintain a valid state. In this approach, each object is responsible for managing its state and ensuring that it is always “correct” from the perspective of the game logic. This aligns with the tell, don’t ask principle, where instead of querying state, you tell the object to act and trust that it will maintain its own state properly.

Example: Player’s Health in an “Always Valid” System

Instead of checking the player’s health manually, you could have the Player class manage its own health logic:

public class Player {
    public int Health { get; private set; }

    public Player(int health) {
        Health = health;
    }

    public void TakeDamage(int damage) {
        Health -= damage;

        // Automatically handle death
        if (Health <= 0) {
            HandleDeath();
        }
    }

    private void HandleDeath() {
        Console.WriteLine("Player has died.");
        // Additional logic for death could go here
    }
}

In this example:

  • The Player object itself manages its state (health), and you don’t need to query it in the GameController to check if it needs healing or if it’s dead.
  • The Player ensures that its own health logic is valid at all times. If health reaches 0, it automatically handles death internally.

This approach is sensible when:

  • You want to decentralize state management and avoid state queries.
  • You prefer each object to be self-sufficient in managing its own state, reducing external checks.

When to Fetch State vs. When to Rely on “Always Valid State”

Fetching State

This approach works best when you have:

  • Complex decision-making that requires real-time information from child objects.
  • Systems where the parent (controller) needs to make decisions based on child state.

You should use this approach if:

  • You need to query conditions like the player’s inventory, health, or quest status to make game-wide decisions.
  • The flow of data needs to be flexible and responsive.

Designing for Always Valid State

This approach reduces complexity by making each object responsible for its own state, making the system less dependent on constant state checks.

You should use this approach if:

  • You want to reduce the need for querying state and want each object to maintain itself.
  • You prefer objects that react to commands and automatically handle their own logic, ensuring that no external checks are needed.

Hybrid Approach: Valid State + Occasional Queries

In practice, most games use a combination of both approaches.

  • “Always Valid State”: For simpler systems like inventory management, health, or abilities, where the player and other entities handle their own state and transitions.
  • Fetching State: For higher-level game systems or controllers that need to coordinate multiple entities or query complex data (like checking for quest completion or making decisions based on several objects’ states).

For example, the GameController might periodically query the player’s health and inventory, but rely on the player itself to always keep its inventory and health in a valid state.

Example of Combining Both Approaches

public class GameController {

    public void UpdateGameState(Player player) {
        // Query player state when needed
        if (player.Health < 20) {
            Console.WriteLine("Warning: Player health is low.");
        }

        // Assume player always maintains a valid inventory
        player.ReceiveNewItem(new InventoryItem("Health Potion"));
    }
}

public class Player {
    public int Health { get; private set; }
    public List<InventoryItem> Inventory { get; private set; }

    public Player(int health) {
        Health = health;
        Inventory = new List<InventoryItem>();
    }

    // Player manages its own health and reacts accordingly
    public void TakeDamage(int damage) {
        Health -= damage;
        if (Health <= 0) HandleDeath();
    }

    // Always valid inventory management
    public void ReceiveNewItem(InventoryItem item) {
        Inventory.Add(item);
        Console.WriteLine($"Received {item.ItemName}.");
    }

    private void HandleDeath() {
        Console.WriteLine("Player has died.");
    }
}

In this hybrid example:

  • The GameController queries the player’s health when needed.
  • The Player manages its inventory and health internally, always ensuring valid state without needing to be micromanaged by the GameController.

Conclusion

  • Fetching state is necessary when the controller needs real-time information to make decisions, and it’s totally acceptable in many cases.
  • Always valid state design reduces the need for querying and ensures objects maintain themselves in a valid state, which simplifies logic and reduces dependencies.
  • Hybrid approaches are often best in games, where certain systems (like health, inventory, or quests) manage themselves, while others (like high-level game logic) still require state checks.

In short, aim for valid state whenever possible, but don’t be afraid to query state when needed for complex game logic. Both patterns can work together effectively depending on the situation.

C# Events and Signals for Decoupling

Use the event system to create loosely coupled communication mechanism between nodes. This allows nodes that don’t have a direct relationship to communicate without being tightly bound. The UI can emit an event when it needs data, and any node (or manager) can listen to those signals and provide the needed data.

Using Godot Resources to Share Data

Godot Resource can be used to share data between various objects as long as you are aware of the limitations - only one party should ever be responsible for changing the data, while several can read it at the same time. A shared Resource gives instantaneous access to the data across multiple objects. You will also have to be careful and deploy a default strategy in case the data is not available at any given time.

Service Locator / Manager Pattern

Can be used to create a centralized manager or service locator to handle instances of shared dependencies like data required by UI. This pattern helps when the relationships is not clear or direct. For example, you can have a GameManager or DataManager node responsible for holding game state, UI data, or other global information. You’d reference this node using Godot’s GetTree().Root or a singleton (Autoload) which would allow child nodes, such as your UI elements, to fetch the necessary data without needing a direct parent-child relationship.

Godot Singleton Autoload

For truly global data (such as player stats, global settings, etc.) consider using an Autoload. This allows any node to access global state without needing a direct reference. This should be used sparingly to avoid overloading the global namespace with too many dependencies.