Game Structure and 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 ofInventoryItems
. - The
InventoryItem
table will have a foreign key (PlayerId
) to thePlayer
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:
List<InventoryItem>
in thePlayer
class
This is a collection (or list) of items that a player owns. It’s purely a C# list inside thePlayer
object. It represents all the items that the player has in their inventory.DbSet<InventoryItem>
in theGameDbContext
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>
inGameDbContext
manages all theInventoryItem
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 thePlayer
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 theList<InventoryItem>
with just the items that belong to that player.
How an Item is Created and Added
- 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 thePlayer
‘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 usingcontext.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
andcontext.InventoryItems
, these are two references to the same object.player.Inventory
holds a reference to theInventoryItem
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?
- 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>
inPlayer
: Holds items only for that player in memory.DbSet<InventoryItem>
inGameDbContext
: 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:
- Adding items to the
player.Inventory
list. - 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 itsInventory
as a single “object graph.” When you save thePlayer
, EF Core will automatically save all relatedInventoryItem
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 theplayer.Inventory
, EF Core will automatically track that item because it’s part of thePlayer
’s “object graph.” - When you call
context.SaveChanges()
, EF Core will save thePlayer
and all relatedInventoryItem
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
? APlayerService
(or similar) should be responsible for loadingPlayer
objects with theirInventoryItems
from the database. - Are two object sets necessary? No, ideally you treat
Player
andInventoryItem
as a single object graph. Add items toplayer.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
andInventoryItem
with oneSaveChanges()
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 toInventoryItem
and added to the player’sInventory
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 viaReceive(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 callingUpdate(player)
andSaveChanges()
.
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, theGameController
). - Single Object Graph: The
Player
and itsInventoryItem
are part of a single object graph. When theGameController
saves thePlayer
, EF Core will automatically detect that a newInventoryItem
was added to the player’sInventory
and will save both thePlayer
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 thePlayer
object graph). - Single Responsibility: The
Player
class focuses only on its own game logic (receiving items), while theGameController
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:
- The NPC (giver) wants to give an item.
- The
GameController
callsplayer.Receive(givenItem, giver)
. - The
Player
receives the item and adds it to itsInventory
. - The
GameController
callsSaveChanges()
to persist both thePlayer
and the newInventoryItem
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 thePlayer
’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
- NPC: The NPC triggers the action, such as giving an item, when it interacts with the player.
- 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’sReceive()
method, and saving the changes. - 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:
- Fetching State When Necessary: This is the classic way where you allow some controlled flow of information back up the hierarchy.
- 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 onPlayer
), 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.