A game design pattern for creating and managing complex behaviors with parallelism at its core.
1. act-unity
2. act-godot
3. act-unreal
Need a port for a different game engine? Open a feature request to let the developer know.
The entire pattern revolves around Acts. Each act is the smallest self-contained unit of behaviour that can be performed in the game e.g. Walk Act, Run Act, Jump Act, Reload Act, Shoot Act, etc.
Since every Act is self-contained they can all perform in parallel.
However, when acts conflict (e.g. Walk Act & Run Act) or depend on other acts (e.g. Reload Act & Shoot Act) there are 2 mechanisms to resolve these: Blocking & Prologuing
Each act can block certain other acts when it performs. An act that is blocked cannot perform nor block any other act until it is unblocked. There are 2 types of blocks:
-
Persistent Block: This type of block remains persistent on the blockee till the blocker act has completed its perform.
e.g. IfSword Slash Actpersistent blocksMove Actthen the player cannot move till the sword slash has completed. -
Interrupt Block: This type only interrupts the blockee act's perform when the blocker act starts its perform. The blockee act can then be performed again even if the blocker act is still performing.
e.g. IfReload Actinterrupt blocksAim Actthen aiming will stop when reloading starts but the player can press aim again mid reloading to cancel and start aiming again.
Each act can have prologue acts i.e. acts that need to be performed before the main act can perform.
In example 1, the arrow pointing from an act denotes "who is my prologue" therefore:
Bis a prologue ofACis a prologue ofBDis a prologue ofC
and as such, first D performs then C then B then finally A.

Also keep in mind that no acts within the same prologue chain can block each other.
Terminology Note:
- Acts that come before an act are called prologue acts (e.g.
Bis a prologue ofA)- Acts that come after an act are called epilogue acts (e.g.
Cis an epilogue ofD)- Acts that are at the top of the chain are called top epilogue acts (e.g.
Ais a top epilogue ofB,C,D)
An act can have more than one prologue
In such a case both prologues work in parallel and only when both have finished does the act perform.
So in example 2, first C & D perform together in parallel then B then A.

An act can have more than one epilogue
In such a case both epilogues will wait for the prologue to complete first before performing themselves.
So in example 3, first D will perform then C will perform then A & B will perform together in parallel.

Cyclic prologues are not allowed
An act cannot be a prologue of itself or of any descendants. Therefore example 4 is invalid.

Note:
All examples are in Unity but the same concepts applies to other engines as well.
For examples in other engines take a look at Game Engine Specific ImplementationsIt is also recommended that you go through the documentation first for your desired engine specific implementation.
Creating your first act
Create a class inheriting from Act class and override the Enter() method like this:
public class MyFirstAct : Act
{
protected override Outcome Enter()
{
Debug.Log("Hello World!");
return Outcome.Success;
}
}Then initialize it and use it by invoking Perform() from wherever you'd like:
void Awake()
{
// Initialize
MyFirstAct myFirstAct = new();
myFirstAct.Init();
// Use
myFirstAct.Perform();
}That's it! Congrats you've just created & performed your first act.
How to use an act
The act follows this perform lifecycle:
You can implement the desired behaviour by overriding these methods:
Setup(): Where your one time initialization setup logic lives.CanPerform(): Define conditions that allow/disallow performingEnter(): Where the actual core logic lives on each perform. You have the following return options:Outcome.Success: Return this if core behaviour was successfully completed.Outcome.Pending: Return this if you don't want to immediately exit.Outcome.Failure: Return this if core behaviour failed to complete.Outcome.Retry: Return this if you want the act to perform again.
Tick()/PhysicsTick()/LateTick(): Incase the core logic needs continuous ticking updates while performingExit(): Used for cleanup after core logic on each performCleanup(): Where your deinitialization teardown logic lives.
You can also assign these properties in Setup() to further finetune your behaviour:
_canReperform: Set true if act is allowed to perform again while already ongoing._tickFlags: The type of ticking (if any) while performing
Ticking an act
To make an act tick you need to do 3 things:
- Make sure the act has been assigned a theater
- Assign
_tickFlagsin setup - Return
Outcome.PendinginEnter()
public class MyTickAct : Act
{
// Private
private int _tickCounter = 0;
protected override void Setup()
{
_tickFlags = TickFlags.Tick;
}
protected override Outcome Enter(){
return Outcome.Pending;
}
protected override Outcome Tick()
{
// Tick 5 times then exit
_tickCounter++;
if (5 <= _tickCounter)
{
return Outcome.Success;
}
return Outcome.Pending;
}
}Delaying exit in act
To delay exit without using tick for something like a timer simply return Outcome.Pending and make sure no tick flag is assigned.
public class MyTimerAct : Act
{
private Coroutine waitCoroutine;
private IEnumerator WaitRoutine()
{
yield return new WaitForSeconds(5.0f); // Wait for 5 seconds then exit
Finish(Outcome.Success);
}
protected override Outcome Enter()
{
waitCoroutine = GetTheater().StartCoroutine(WaitRoutine());
return Outcome.Pending;
}
protected override void Exit()
{
if (waitCoroutine != null)
{
GetTheater().StopCoroutine(waitCoroutine);
waitCoroutine = null;
}
}
}Blocking in act
You can assign which acts to block as such:
void Awake()
{
// Initialize
MyAct myAct = new();
myAct.Init();
MyBlockingAct myBlockingAct = new();
myBlockingAct.AddToBlock(new() { myAct });
myBlockingAct.Init();
// Use
myBlockingAct.Perform();
myAct.Perform(); // Will fail till myBlockingAct perform has completed
}You can also directly disable an act:
void Awake()
{
// Initialize
MyAct myAct = new();
myAct.Init();
// Use
myAct.SetEnabled(false);
myAct.Perform(); // Will fail since act is disabled
myAct.SetEnabled(true);
myAct.Perform(); // Will work since act has been re-enabled
}Prologuing in act
You can assign which acts to prologue as such:
void Awake()
{
// Initialize
MyAct myAct = new();
myAct.Init();
MyMainAct myMainAct = new();
myMainAct.prologue = (Act act) => new() { myAct };
myMainAct.Init();
// Use
myMainAct.Perform(); // This will perform myAct first then myMainAct
}If you have multiple acts you want to chain in prologue sequence you can also use Seq() as such:
void Awake()
{
// Initialize
Act actA = new();
actA.Init();
Act actB = new();
actB.Init();
Act actC = new();
actC.Init();
Act actD = new();
actD.Init();
MyMainAct myMainAct = new();
myMainAct.prologue = (Act act) => Act.Seq(new() {
new() { actA },
new() { actB, actC },
new() { actD }
});
myMainAct.Init();
// Use
myMainAct.Perform(); // Order of perform: actA -> actB & actC (in parallel) -> actD
}Using a theater
A Theater can be used if you want to organize & manage your acts together.
[SerializeField] Theater theater;
void Awake()
{
// Initialize
Act actA = new();
actA.Init("My Act A", theater);
Act actB = new();
actB.Init("My Act B", theater);
// Uses
theater.SetEnabled(false); // If a theater is disabled all it's acts will be disabled as well
theater.IsEnabled();
theater.AbortAll();
theater.AreAnyOngoing();
theater.GetAllActs();
}Top down game shooter player:
public class Player : MonoBehaviour
{
// Act Properties
[SerializeField] Theater theater;
[SerializeField] MoveAct moveAct = new();
[SerializeField] LookAct aimAct = new();
[SerializeField] ShootAct shootAct = new();
// Override Methods
void Update()
{
// Move
float horizontalInput = Input.GetAxisRaw("Horizontal");
float verticalInput = Input.GetAxisRaw("Vertical");
moveAct.direction = new Vector2(horizontalInput, verticalInput).normalized;
moveAct.Perform();
// Aim towards mouse pointer
Vector3 mouseWorldPosition = Camera.main.ScreenToWorldPoint(Input.mousePosition);
aimAct.targetPosition = mouseWorldPosition;
aimAct.Perform();
// Shoot
if (Input.GetMouseButtonDown(0))
{
Vector2 mouseWorldPos2D = mouseWorldPosition;
Vector2 playerPos2D = transform.position;
shootAct.direction = (mouseWorldPos2D - playerPos2D).normalized;
shootAct.Perform();
}
}
void Awake()
{
// Setup acts
theater = GetComponent<Theater>();
moveAct.Init("Move Act", theater);
aimAct.turnSpeed = -1.0f; // Instant turn
aimAct.Init("Aim Act", theater);
shootAct.Init("Shoot Act", theater);
}
}Move Act Class
[Serializable]
public class MoveAct : Act
{
// Public Properties
[SerializeField] public float speed = 5f;
[HideInInspector] public Vector2 direction = new();
// Private Properties
private Transform ownerTransform;
// Override Methods
protected override void Setup()
{
ownerTransform = GetOwner().transform;
}
protected override bool CanPerform()
{
return ownerTransform != null;
}
protected override Outcome Enter()
{
ownerTransform.position = (Vector2)ownerTransform.position + direction * speed * GetDelta();
return Outcome.Success;
}
protected override void Exit()
{
direction = Vector2.zero;
}
}Look Act Class
[Serializable]
public class LookAct : Act
{
// Public Properties
[SerializeField] public float turnSpeed = 150f; // Negative = instant turn
[SerializeField] public float acceptanceAngle = 0.5f;
[HideInInspector] public Vector2 targetPosition = new();
[HideInInspector] public Transform targetTransform = null;
// Private Transform
Transform ownerTransform;
// Override Methods
protected override void Setup()
{
_canReperform = true;
_tickFlags = TickFlags.PhysicsTick; // Enable ticking
ownerTransform = GetOwner().transform;
}
protected override Outcome PhysicsTick()
{
// Get rotation
Vector2 finalPosition = targetTransform != null ? (Vector2)targetTransform.position : targetPosition;
Vector2 direction = finalPosition - ownerTransform.position;
float goalRotation = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
// Rotate owner
float currentRotation = ownerTransform.eulerAngles.z;
float newRotation = turnSpeed < 0f ? goalRotation : Mathf.MoveTowardsAngle(currentRotation, goalRotation, turnSpeed * GetPhysicsDelta());
ownerTransform.rotation = Quaternion.Euler(0f, 0f, newRotation);
// Exit if reached goal rotation
float angleDiff = Mathf.Abs(Mathf.DeltaAngle(currentRotation, goalRotation));
return angleDiff <= acceptanceAngle ? Outcome.Success : Outcome.Pending;
}
}Shoot Act Class
[Serializable]
public class ShootAct : Act
{
// Public Properties
[SerializeField] public GameObject projectilePrefab;
[HideInInspector] public Vector2 direction = new();
// Override Methods
protected override bool CanPerform()
{
return projectilePrefab != null;
}
protected override Outcome Enter()
{
var spawnPosition = GetOwner().transform.position;
GameObject projectileObj = MonoBehaviour.Instantiate(
projectilePrefab,
spawnPosition,
Quaternion.identity
);
Projectile projectile = projectileObj.GetComponent<Projectile>();
projectile.direction = direction;
return Outcome.Success;
}
protected override void Exit()
{
direction = Vector2.zero;
}
}Top down web shooting spider enemy AI:
public class ShooterSpider : MonoBehaviour
{
// Act Properties
[SerializeField] Theater theater;
[SerializeField] PerpetualAct liveAct = new();
[SerializeField] GotoAct wanderAct = new();
[SerializeField] WaitAct waitAct = new();
[SerializeField] LookAct lookAct = new();
[SerializeField] LookAct aimAct = new();
[SerializeField] ShootAct shootWebAct = new();
[SerializeField] DamageAct damageAct = new();
// Override Methods
void Awake()
{
// Get player transform
Transform playerTransform = GameObject.FindGameObjectWithTag("Player").transform;
// Setup Acts
theater = GetComponent<Theater>();
liveAct.prologue = (Act act) =>
{
// Get random position
Vector2 randomOffset = UnityEngine.Random.insideUnitCircle * 100;
Vector2 randomPosition = (Vector2)transform.position + randomOffset;
wanderAct.targetPosition = randomPosition;
lookAct.targetPosition = randomPosition;
// Wander -> aim -> shoot -> wait
return Act.Seq(new() {
new() { wanderAct, lookAct },
new() { aimAct },
new() { shootWebAct },
new() { waitAct }
});
};
liveAct.Init("Live Act", theater);
wanderAct.Init("Wander Act", theater);
waitAct.duration = 2f;
waitAct.Init("Wander Wait Act", theater);
lookAct.turnSpeed = 200f;
lookAct.Init("Look Act", theater);
aimAct.turnSpeed = 200f;
aimAct.targetTransform = playerTransform;
aimAct.Init("Aim Act", theater);
shootWebAct.OnPreEnter += (Act act) =>
{
Vector2 spiderPosition = transform.position;
Vector2 playerPosition = playerTransform.position;
shootWebAct.direction = (playerPosition - spiderPosition).normalized;
};
shootWebAct.Init("Shoot Web Act", theater);
damageAct.AddToBlock(new() { liveAct }); // Stop AI behaviour while damaged
damageAct.Init("Damage Act", theater);
}
}Perpetual Act Class
[Serializable]
public class PerpetualAct : Act
{
protected override void Setup()
{
_canReperform = true;
PerformDeferred();
}
protected override void Exit()
{
PerformDeferred();
}
protected override void UnblockSelf(Act byAct)
{
base.UnblockSelf(byAct);
if (!IsBlocked())
{
PerformDeferred();
}
}
}Goto Act Class
[Serializable]
public class GotoAct : Act
{
// Public Properties
[SerializeField] public float speed = 5f;
[SerializeField] public float acceptanceRadius = 1f;
[HideInInspector] public Vector2 targetPosition = new();
[HideInInspector] public Transform targetTransform = null;
// Private Properties
private Transform ownerTransform;
// Override Methods
protected override void Setup()
{
_tickFlags = TickFlags.PhysicsTick; // Enable ticking
ownerTransform = GetOwner().transform;
}
protected override Outcome PhysicsTick()
{
Vector2 destination = targetTransform != null ? (Vector2)targetTransform.position : targetPosition;
Vector2 currentPosition = ownerTransform.position;
float distance = Vector2.Distance(currentPosition, destination);
// Exit if already within range of target
if (distance <= acceptanceRadius)
{
return Outcome.Success;
}
// Move towards destination
Vector2 direction = (destination - currentPosition).normalized;
Vector2 nextPosition = currentPosition + direction * speed * GetPhysicsDelta();
ownerTransform.position = nextPosition;
return Outcome.Pending;
}
}Wait Act Class
[Serializable]
public class WaitAct : Act
{
// Public Properties
[SerializeField] public float duration = 5f;
// Private Properties
private Coroutine waitCoroutine;
// Private Methods
private IEnumerator WaitRoutine()
{
yield return new WaitForSeconds(duration);
Finish(Outcome.Success);
}
// Override Methods
protected override Outcome Enter()
{
waitCoroutine = GetTheater().StartCoroutine(WaitRoutine());
return Outcome.Pending;
}
protected override void Exit()
{
if (waitCoroutine != null)
{
GetTheater().StopCoroutine(waitCoroutine);
waitCoroutine = null;
}
}
}Damage Act Class
[Serializable]
public class DamageAct : Act
{
// Public Properties
[SerializeField] public float stunTime = 0.5f;
// Private Properties
private Coroutine stunCoroutine;
// Private Methods
private IEnumerator StunRoutine()
{
yield return new WaitForSeconds(stunTime);
Finish(Outcome.Success);
}
// Override Methods
protected override Outcome Enter()
{
// Damage logic here
// ...
// Stun
stunCoroutine = GetTheater().StartCoroutine(StunRoutine());
return Outcome.Pending;
}
protected override void Exit()
{
if (stunCoroutine != null)
{
GetTheater().StopCoroutine(stunCoroutine);
stunCoroutine = null;
}
}
}If this has been useful in your projects consider supporting its development.
Any support motivates to keep the project well maintained, documented and growing.
MIT © Manas Ravindra Makde