Match 3 action system
Code language : C#
Software : Unity engine, Github an Trello
Match 3 system
First I created the match 3 gameplay flow with Ienumarator. It worked, however I needed a way to have full control of the flow to add or even cancel logic.
When I was talking to a lead developer he show me his thought process on this problem. After discussing the problem with a lead developer, I explored his action-based architecture where gameplay flow is represented as chained actions. Every action has it`s own logic and can trigger other actions and chain them.
This was perfect solution for my problem, so I started to design and make my own action system.
Actions architecture
The system consists of two main components:
• GridActionProcessor
• GridActions
Actions are implemented as pure C# classes that can be instantiated and submitted to the GridActionProcessor, which manages execution flow and action chaining.
GridActionProcessor
GridActionProcessor acts as the controller of the action system. When a new action is given to the processor:
• Adds the action to the stack
• Checks whether another action is currently active
• Adds newly created actions to the active action`s chained action list
• Executes the action
• Invokes the OnParentActionCompleted event when the root action has finished
ProcessAction()
public void ProcessAction<Tparameters>(BaseAction<Tparameters> action, Action<BaseAction> onComplete = null)
{
if(_actionStack.TryPeek(out var parentAction))
{
if (parentAction.IsCanceled) return;
parentAction.SetActionState(BaseAction.ActionState.Waiting);
action.Parent = parentAction;
parentAction.ChainedActions.Add(action);
}
_actionStack.Push(action);
action.SetActionState(BaseAction.ActionState.Running);
ActionDebugRegistry.ActiveActions.Add(action);
action.Execute(completedAction =>
{
OnActionComplete(completedAction);
onComplete?.Invoke(completedAction);
});
}
OnActionCOmplete()
private void OnActionComplete(BaseAction source)
{
if (source.Root == source) OnParentActionComplete?.Invoke();
source.SetActionState(BaseAction.ActionState.Completed);
source.Parent?.SetActionState(BaseAction.ActionState.Running);
_actionStack.Pop();
}
CancelCurrentChain()
public void CancelCurrentChain()
{
if (!_actionStack.TryPeek(out var current)) return;
current.Root.Cancel();
_actionStack.Clear();
}
Actions
The actions holds the logic. For example the swap action:
• Request a data swap of two grid objects
• Triggers a tween animation between the corresponding visuals.
Actions can trigger additional actions during execution. Newly created actions are pushed onto the processor stack while the parent action enters a waiting state until all chained actions are completed.
Once a chained action is completed, the action before that will continue with their logic.
Base action class
using System;
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine;
public abstract class BaseAction
{
protected ActionContext action_context;
protected Action<BaseAction> on_action_complete;
public ActionState State { get; private set; }
public BaseAction Parent;
public readonly List<BaseAction> ChainedActions = new List<BaseAction>();
public bool IsCanceled => State == ActionState.Canceled;
public BaseAction Root
{
get
{
var current = this;
while (current.Parent != null)
current = current.Parent;
return current;
}
}
public virtual void Execute(Action<BaseAction> OnActionComplete) { }
public virtual void Cancel()
{
if (State == ActionState.Canceled || State == ActionState.Completed) return;
State = ActionState.Canceled;
foreach (var child in ChainedActions)
{
child.Cancel();
}
}
protected void CompleteAction()
{
if (IsCanceled) return;
State = ActionState.Completed;
on_action_complete?.Invoke(this);
}
public void SetActionState(ActionState actionState) => this.State = actionState;
public enum ActionState
{
Waiting,
Running,
Completed,
Canceled
}
}
public abstract class BaseAction<Tparameters> : BaseAction
{
public Tparameters parameters { get; private set; }
public BaseAction(Tparameters parameters) : base() => this.parameters = parameters;
}
Swap action class
using DG.Tweening;
using System;
public class SwapAction : BaseAction<SwapActionParameters>
{
private Sequence _sequence;
public SwapAction(SwapActionParameters parameters) : base(parameters) { }
public override void Execute(Action<BaseAction> OnActionComplete)
{
on_action_complete = OnActionComplete;
action_context = parameters.Context;
if (IsCanceled) return;
_sequence = DOTween.Sequence();
HandleForwardSwap();
}
private void HandleForwardSwap()
{
if (IsCanceled) return;
AudioManager.Instance.PlaySound("StoneSwitch");
var from = parameters.From;
var to = parameters.To;
action_context.GridSystem.SwapGridObjectsData(from, to);
var tweens = action_context.BlockVisualManager.SwapVisualTweens(
from,
to,
action_context.GridSystem.ConvertGridPositionToWorldPosition,
action_context.LevelGridData.VisualSwapSpeed,
Ease.InOutQuad
);
foreach (var t in tweens)
{
if (IsCanceled) break;
_sequence.Join(t);
}
_sequence.AppendCallback(OnForwardSwapComplete);
}
private void OnForwardSwapComplete()
{
if (IsCanceled) return;
var matches = action_context.MatchDetector.CheckForAllMatches(
action_context.GridSystem.GetGridObjectArray,
action_context.LevelGridData.GridWidth,
action_context.LevelGridData.GridHeight
);
if (matches.Count <= 0)
{
HandleReverseSwap();
return;
}
action_context.GridActionProcessor.ProcessAction(
new MatchAction(new MatchActionParameters
{
Matches = matches,
Context = action_context
}),
_ => CompleteAction()
);
}
private void HandleReverseSwap()
{
if (IsCanceled) return;
AudioManager.Instance.PlaySound("StoneSwitch");
var from = parameters.From;
var to = parameters.To;
action_context.GridSystem.SwapGridObjectsData(from, to);
var tweens = action_context.BlockVisualManager.SwapVisualTweens(
from,
to,
action_context.GridSystem.ConvertGridPositionToWorldPosition,
action_context.LevelGridData.VisualSwapSpeed,
Ease.InOutQuad
);
var reverseSequence = DOTween.Sequence();
foreach (var t in tweens)
{
if (IsCanceled) break;
reverseSequence.Join(t);
}
reverseSequence.OnComplete(OnReverseSwapComplete);
_sequence = reverseSequence;
}
private void OnReverseSwapComplete()
{
if (IsCanceled) return;
CompleteAction();
}
public override void Cancel()
{
base.Cancel();
if (_sequence != null && _sequence.IsActive())
{
_sequence.Kill();
}
}
}
Reshuffle action class
using System;
using System.Collections.Generic;
using DG.Tweening;
using UnityEngine;
public class ReshuffleAction : BaseAction<ReshuffleActionParameters>
{
private GridObject[,] _grid;
private float _spawnOffset = 5f;
private Sequence _sequence;
private List<Tween> _tweens;
public ReshuffleAction(ReshuffleActionParameters parameters) : base(parameters) { }
public override void Execute(Action<BaseAction> OnActionComplete)
{
on_action_complete = OnActionComplete;
action_context = parameters.Context;
_grid = action_context.GridSystem.GetGridObjectArray;
_tweens = new List<Tween>();
ReshuffleGrid();
}
private void ReshuffleGrid()
{
_tweens.Clear();
_sequence = DOTween.Sequence();
foreach (var gridObject in _grid)
{
if (IsCanceled) break;
action_context.BlockVisualManager.TryDisableVisualOnGridObject(gridObject);
gridObject.SetMatch3BlockProfile(null);
}
for (var x = 0; x < action_context.LevelGridData.GridWidth; x++)
{
if (IsCanceled) break;
for (int y = 0; y < action_context.LevelGridData.GridHeight; y++)
{
if (IsCanceled) break;
var newTileAction = new CreateTileAction(new CreateTileActionParameters
{
Context = action_context,
TargetGridPosition = new GridPosition(x, y),
SpawnYOffset = _spawnOffset,
TargetGridObject = _grid[x, y]
});
action_context.GridActionProcessor.ProcessAction(newTileAction);
if (newTileAction.TileTween == null) continue;
_tweens.Add(newTileAction.TileTween);
}
}
CheckForPossibleMoves();
}
private void CheckForPossibleMoves()
{
if (!action_context.MatchDetector.PlayerHasPossibleMoves(_grid, action_context.GridSystem.SwapGridObjectsData)) ReshuffleGrid();
foreach (var t in _tweens)
{
if (IsCanceled) break;
_sequence.Join(t);
}
AudioManager.Instance.PlaySound("StoneSwitch");
_sequence.OnComplete(() =>
{
CompleteAction();
});
}
public override void Cancel()
{
base.Cancel();
if (_sequence != null && _sequence.IsActive()) _sequence.Kill();
}
}
ActionDebuggerWindow
Because the actions are pure C# and that they can chain with each other it can be difficult to see what is exactly happening. This is why I created a debug window to see the actions. The actions and there chained actions are visualized with the following:
• Actions and their stacked actions are visible in a tree structure
• Action execution order
• Color-coded statuses: Running (Blue), Waiting (Yellow), Completed (Green) and Cancelled (Red)
Match effects and channels
In the game, matching blocks can trigger combat effects such as dealing damage or applying modifiers like double damage. I wanted a scalable way to support additional match effects without tightly coupling gameplay systems together.
To achieve this, I used the channel design pattern. This helps with two major problems. One that we can add any type of effect a profile and the second is that we decouple the system.
At this point we have two effects:
• Attack effect
• Double damage effect
Every effect can have some own logic and data, but it always have the ActivateEffect() method. This method raise an event on the effect channel. Every class can subscribe to the effect channel and when we make a match on the board the script can react to it.
ActivateMatchEffect() on the match action
private void ActivateMatchEffect(HashSet<Match> matches)
{
foreach (var match in matches)
{
if (IsCanceled) break;
if (match.MatchEffect == null) continue;
foreach (var matchProfile in match.MatchedObjectGroup)
{
var targetPos = matchProfile.GetWorldPosition(action_context.LevelGridData.GridCellWidth, action_context.LevelGridData.GridCellHeight);
targetPos.z = targetPos.z - 2f;
match.MatchEffect.PlayBreakEffectOnPosition(targetPos);
}
match.MatchEffect.ActivateEffect();
}
}
OnEnable() on the attack energy class
private void OnEnable()
{
matchAttackEffectChannel.OnEventRaised += HandleMatchAttack;
matchDoubleDamageEffectChannel.OnEventRaised += HandleDoubleDamage;
}
HandleMatchAttack() on the attack energy class
private void HandleMatchAttack(BaseAttack attack)
{
if (attack == null) return;
foreach (var pair in _energyByEffect)
{
if (pair.Key is not MatchAttackEffect attackEffect || attackEffect.Attack != attack) continue;
OnMatch(pair.Key);
break;
}
}

