using XFG.ActorController;A lightweight, deterministic, and extensible Actor–Controller framework for Unity, Godot, MonoGame, and custom engines.
Designed for gameplay systems that require clean separation between decision‑making (Controllers) and execution (Actors), with support for AI, player input, network commands, cutscenes, tools, and designer‑authored FSM states.
This module is part of the XFG Simple Game Core Library, a collection of engine‑agnostic C# systems for building reliable, deterministic gameplay foundations.
The architecture is inspired by Unreal Engine’s Actor–Controller model, applying the same clean separation of responsibilities while adding a deterministic command buffer and a strongly‑typed FSM tailored for C#‑based engines.
This design draws directly from Unreal Engine’s proven Actor–Controller pattern:
- Controller = intent + decision layer
- Pawn/Character = execution layer
- Controllers issue commands, not direct state changes
- Pawns emit events back to Controllers
- Controllers can be swapped (AI, player, network)
- Strongly‑typed commands
- Deterministic command buffer
- Lightweight, engine‑agnostic FSM
- Serializable polymorphic states (Unity)
- Clean Inform() callback channel
You preserve Unreal’s strengths while making the model portable, explicit, deterministic, and C#‑friendly.
| Concept / Behavior | Unreal Engine | XFG Actor–Controller Architecture |
|---|---|---|
| Decision Layer | Controller (PlayerController, AIController) | Controller (Input, AI, B3, Utility AI, FSM, Network, Replay) |
| Execution Layer | Pawn / Character | Actor (FSM‑driven entity) |
| How Decisions Are Sent | Input events, movement functions, ability triggers | Typed commands via ExecuteCommand(cmd, param) |
| How Execution Happens | Pawn processes input, CharacterMovement, components | Actor processes commands through FSM + command buffer |
| Feedback to Controller | Delegates, events | Inform(info, args) callback |
| Possession Model | Controller possesses Pawn | Controller attaches to Actor |
| State Management | State Trees, Behavior Trees | Strongly‑typed FSM with polymorphic states |
| Determinism | Not guaranteed | Guaranteed via command buffer + FSM |
| Engine Dependency | Unreal‑specific | Engine‑agnostic |
| Serialization | Blueprint, UObjects | SerializeReference polymorphic states (Unity) |
This framework is engine‑agnostic at its core.
The only engine‑specific dependency is the base type constraint on TMachineType, wrapped in conditional compilation:
#if UNITY_5_3_OR_NEWER
where TMachineType : MonoBehaviour
#elif GODOT
where TMachineType : Godot.Node
#elif MONOGAME
where TMachineType : Microsoft.Xna.Framework.GameComponent
#else
where TMachineType : class
#endifThis allows the same Actor–Controller architecture to run in:
- Unity
- Godot
- MonoGame
- Custom engines
The FSM, command buffer, and Actor/Controller abstractions are pure C# and require no engine APIs.
Actors never decide what to do.
They only execute commands routed to them.
Actors:
- Own the FSM
- Process commands deterministically
- Route commands to the current state
- Emit Inform events back to Controllers
- Expose a single entry point:
ExecuteCommand<T>(command, parameter)This aligns with the IActor<TCommand> interface:
Actors do not decide what to do.
Actors only execute commands deterministically.
Controllers decide what the Actor should do.
A Controller can be implemented using any decision‑making paradigm:
- Player Input Controller
- Behavior Tree (B3) Controller
- Utility AI Controller
- FSM‑Driven Controller
- Network Controller
- Scripted / Cutscene Controller
- Replay / Ghost Controller
- Tooling / Editor Controller
Controllers:
- Decide what the Actor should do
- Send typed commands
- React to Actor events via
Inform() - Never mutate Actor state directly
A FIFO queue that stores commands until the Actor’s update tick.
This ensures:
- No mid‑frame state changes
- Deterministic behavior
- Identical behavior for AI, player, and network controllers
- Clean decoupling between decision and execution
┌───────────────────────────┐
│ Controller │
│ (Input / AI / Network…) │
└───────────────┬───────────┘
│ ExecuteCommand(cmd, param)
▼
┌───────────────────────────┐
│ Actor │
│ - Command Buffer │
│ - FSM (StateMachine) │
└───────────────┬───────────┘
│ Routes command to current state
▼
┌───────────────────────────┐
│ Actor State │
│ (IActorState<TCommand>) │
└───────────────────────────┘
Your architecture is conceptually aligned with Unreal’s:
Unreal: Controller decides → Pawn executes
XFG: Controller decides → Actor executes
Unreal Controllers possess Pawns
XFG Controllers attach to Actors
Unreal Controllers issue movement/intent
XFG Controllers issue typed commands
Unreal: OnLanded, OnJumped, OnTakeDamage
XFG: Inform(PlayerInform.Landed)
Both enforce:
- Controller never mutates state directly
- Actor never makes decisions
- Communication is explicit and directional
Controller ──► ExecuteCommand(cmd)
▲ │
│ ▼
Inform(info) ◄── State ◄── Actor (FSM + Buffer)
- Controller decides → sends command
- Actor buffers → processes deterministically
- State executes → performs logic
- State informs Controller → Controller reacts
- Controller may issue new commands → loop continues
public enum PlayerStateID { Idle, Moving, Jumping }
public enum PlayerMessage { None }
public enum PlayerCommand { Move, Jump }
public enum PlayerInform { Jumped, Landed, TookDamage }public class PlayerActor
: ActorStateMachine<PlayerActor, PlayerStateID, PlayerMessage, PlayerCommand>
{
private PlayerInputController _controller;
private void Awake()
{
_controller = new PlayerInputController(this);
RegisterState(PlayerStateID.Idle, new PlayerIdleState());
RegisterState(PlayerStateID.Moving, new PlayerMovingState());
RegisterState(PlayerStateID.Jumping, new PlayerJumpState());
ChangeState(PlayerStateID.Idle);
}
protected override void Update()
{
_controller.TickInput(); // Controller decides
base.Update(); // Actor executes (FSM + command buffer)
}
}public class PlayerIdleState
: IActorState<PlayerCommand>, IState<PlayerActor, PlayerStateID, PlayerMessage>
{
public void OnEnter(PlayerActor actor) { }
public void OnExit(PlayerActor actor) { }
public void OnUpdate(PlayerActor actor, float dt) { }
public void ExecuteCommand<T>(PlayerCommand cmd, T param)
{
if (cmd == PlayerCommand.Move)
actor.ChangeState(PlayerStateID.Moving);
if (cmd == PlayerCommand.Jump)
actor.ChangeState(PlayerStateID.Jumping);
}
}public class PlayerInputController : IController<PlayerInform>
{
private readonly PlayerActor _actor;
public PlayerInputController(PlayerActor actor)
{
_actor = actor;
}
public void TickInput()
{
if (InputSystem.JumpPressed)
_actor.ExecuteCommand(PlayerCommand.Jump, null);
if (InputSystem.MoveLeftHeld)
_actor.ExecuteCommand(PlayerCommand.Move, Vector2.left);
if (InputSystem.MoveRightHeld)
_actor.ExecuteCommand(PlayerCommand.Move, Vector2.right);
}
public void Inform(PlayerInform info, params object[] args)
{
switch (info)
{
case PlayerInform.Jumped:
Logger.Log("Player jumped");
break;
case PlayerInform.Landed:
Logger.Log("Player landed");
break;
case PlayerInform.TookDamage:
int amount = (int)args[0];
Logger.Log($"Player took {amount} damage");
break;
}
}
}If you want designers to configure states visually:
- Use
ActorSerializableStateMachine<> - States become Unity‑serializable via
SerializeReference - You can assign, reorder, and edit states directly in the Inspector
- No need to declare your own
Statesarray — it is inherited
public class PlayerActorSerialized
: ActorSerializableStateMachine<
PlayerActorSerialized,
PlayerStateID,
PlayerCommand,
PlayerMessage>
{
// States[] is inherited.
}- Priority command buffers
- Interrupt commands
- Network timestamps
- Possession manager
- AI planners (B3, Utility AI, FSM)
- Cutscene controllers
- Replay/ghost controllers
