This is a blog post in my FCIS in Godot series.
A problem I find when architecting large game development projects is the eventual tight-coupling between game logic and individual actors.
Game logic becomes tangled with scene Nodes, making it unportable.
This leads me to a mess of systems, leaving me without a library of portable scripts I can drop into any project, this is something I hope to change.
Functional Core
In this new functional game development architecture we draw a line in the sand between our functional and importantly: non-side effecting core code base and the much more granular imperative shell where we make our connection to the real world (via drawing on the screen and taking input from the user).
We are going to make sure our core game logic has zero reference or dependence on the Godot runtime what so ever. We can then make sure our "core", core game logic is free of side-effects and made up of pure functions.
Implementation
Let's now see how we can implement FCIS in Godot.
State Event Transformation
The functional core is made up of pure functions (systems) which take an initial, immutable, state and some event and return a new state.
The most high-level example of this transformation we could implement is;
// Core/WorldState.cs
// Some record which stores information about the current state of our game.
// With a read-only initialiser to ensure immutability.
public record WorldState
{
public static readonly WorldState New = new();
}
// Core/GenericEvent.cs
// Some event we can extend later.
public abstract record GenericEvent;
// Core/Simulation.cs
// The main transformation and entry point to our core game logic.
// This system will likely call transformations on other systems,
// i.e. a player-movement system, in a pipeline-like fashion.
public static WorldState Update(WorldState state, GenericEvent event) {}
Imperative Shell
This is where we make the bridge between our game logic and the game engine.
We will keep this as minimal and as 'dumb' as possible, as this is the code most coupled with our current project and most likely the code we will need to re-write in each new game we create.
We can simply call the functions WorldState.New and Simulation.Update to enter our game logic.
// ... some script attached to a Node
private WorldState _state = WorldState.New;
public override void _Process(double delta)
{
if (Input.IsActionPressed("ui_up"))
{
_state = Simulation.Update(_state, new GenericEvent());
}
}
Counter Example
I can see how this still might also seem confusing and abstract, so let's work through a very simple game.
Our game will simply display a counter on the screen (a Label Node), and if the user clicks the ↑ arrow, the counter increments, and if the user clicks the ↓ arrow the counter decrements.
Folder Structure
In a new project I will create two new folders; core/ and shell/.
Core
In core/, I will create three new scripts; CInputEvent.cs, WorldState.cs and Simulation.cs.
CInputEvent.cs
namespace Core.Events;
public abstract record CInputEvent;
public record Increment : CInputEvent;
public record Decrement : CInputEvent;
WorldState.cs
namespace Core.State;
public record WorldState(int Counter)
{
public static readonly WorldState New = new(0);
}
Simulation.cs
using Core.Events;
using Core.State;
namespace Core;
public static class Simulation
{
public static WorldState Update(WorldState state, CInputEvent input) => input switch
{
Increment => state with { Counter = state.Counter + 1 },
Decrement => state with { Counter = state.Counter - 1 },
_ => state
};
}
Note the nice, modern pattern matching you can do in C# 7.0+
Shell
In our shell/ folder I'll create one script to attach to a root Control Node in our main scene.
GameRoot.cs
using System.Collections.Immutable;
using Core;
using Core.Events;
using Core.State;
using Godot;
namespace Shell.Nodes;
public partial class GameRoot : Control
{
private WorldState _state = WorldState.New;
[Export]
private Label _label = null;
public override void _Ready()
{
Render();
}
public override void _Process(double delta)
{
var inputs = GatherInputs();
if(inputs.IsEmpty) return;
_state = inputs.Aggregate(_state, Simulation.Update);
Render();
}
private static ImmutableList<CInputEvent> GatherInputs()
{
var events = ImmutableList.CreateBuilder<CInputEvent>();
if (Input.IsActionJustPressed("ui_up")) events.Add(new Increment());
if (Input.IsActionJustPressed("ui_down")) events.Add(new Decrement());
return events.ToImmutable();
}
private void Render()
{
_label.Text = $"Counter: {_state.Counter}";
}
}
All that is left to do is create a scene in Godot with a Label, attach the shell script.
Portability
In our example game, I now want to display a new counter with the same behaviour but in a 3D screen, and to be incremented when the user presses a different button.
In a tightly coupled implementation I would have to completely rewrite the counter's behaviour again to cater specifically to this new scenario.
However, because our game logic is completely decoupled, the counter's behaviour is ready to be reused where ever we might need it, and the more imperative action of detecting which button the user has pressed is trivial to implement.
GameRoot3D.cs
using System.Collections.Immutable;
using Core;
using Core.Events;
using Core.State;
using Godot;
namespace Shell.Nodes;
public partial class GameRoot3D : Node3D
{
private WorldState _state = WorldState.New;
[Export]
private Label3D _label = null;
public override void _Ready()
{
Render();
}
public override void _Process(double delta)
{
var inputs = GatherInputs();
if(inputs.IsEmpty) return;
_state = inputs.Aggregate(_state, Simulation.Update);
Render();
}
private static ImmutableList<CInputEvent> GatherInputs()
{
var events = ImmutableList.CreateBuilder<CInputEvent>();
if (Input.IsActionJustPressed("ui_enter")) events.Add(new Increment());
return events.ToImmutable();
}
private void Render()
{
_label.Text = $"Counter: {_state.Counter}";
}
}
Conclusion
You should be able to see how our new game architecture is helping us solve the issues of portability and extensibility by completely decoupling our game logic from whatever the current specific game project we are working on at the time.
In the above example there is clearly little time and effort saved, however if you imagine larger systems implemented in this way across an entire game the power of this architecture becomes apparent.
In the coming blog posts I will be using this framework in a real game example and document my progress and findings getting a player/enemy system up and running.
Top comments (0)