Data driven grid system
Code language : C#
Software : Unity engine, Github an Trello
Grid system design
When designing the grid system, I wanted to ensure strong performance across mobile devices while keeping the architecture flexible for future features and content updates.
To achieve this, I separated the system into three layers: Data (truth), Logic (rules), and Visuals (representation). The grid itself is fully data-driven, allowing the logic to operate independently from the visuals, which improves performance and maintainability.
This separation also makes the system highly scalable. New block types, mechanics, or visual themes can be added without changing the core grid logic
Data architecture
The grid system is split into several focused classes: GridPosition, GridObject, GridSystem, GridHit, LevelGrid, and LevelGridData.
GridObject
GridObject represents the data stored on a specific position within the grid. Each object contains:
• A GridPosition reference to track its location within the grid
• A reference to a Match-3 block profile defining its gameplay behavior and visuals
This allows the grid to remain fully data-driven while keeping the gameplay logic independent from the visual representation.
GridObject class
using UnityEngine;
public class GridObject
{
private GridPosition _gridPosition;
private Match3BlockProfile _match3BlockProfile;
public GridObject(GridPosition gridPosition) => _gridPosition = gridPosition;
public GridPosition GetGridPosition => _gridPosition;
public Vector3 GetWorldPosition(float cellWidth, float cellHeight) => new Vector3(_gridPosition.X * cellWidth + cellWidth / 2, _gridPosition.Y * cellHeight + cellHeight / 2, 0);
public Match3BlockProfile GetMatch3BlockProfile => _match3BlockProfile;
public void SetGridPosition(GridPosition gridPosition) => _gridPosition = gridPosition;
public void SetMatch3BlockProfile(Match3BlockProfile match3BlockProfile) => _match3BlockProfile = match3BlockProfile;
}
Match-3 block profiles
The block profile is a scriptable object that contains data for a certain block profile. This data includes:
• A match effect
• A list of rule flags
The rules let the profile work with certain actions. For example if the profile does not have the CollapseAndFillRuleFlag, when the board does this action this profile and the visual block don`t fall down on the board
Each GridObject stores a reference to one profile. This allows other gameplay systems to use the profile to for example:
• If there is a match after a swap
• Detecting if there is any possible move left
• When matches activating the match effect that is hold by the profile
Also the profile is used by the BlockVisualManager to link the profile to a match 3 block visual.
GridSystem
GridSystem contains the core grid logic and is implemented as a pure C# class rather than a standard MonoBehaviour.
When initialized, the class generates the grid using the provided level data and stores all grid data inside a 2D array structure.
The system exposes multiple helper methods, including but not limit to:
• Converting touch positions into grid hits
• Swapping grid data
• Disposing grid data
• Grid bounds validation
• Calculating grid end position for click and swipe move
This approach keeps the data separated from both input handling and visual systems. The data is only the truth of the grid.
ConvertScreenPositionToGridHit()
public GridHit ConvertScreenPositionToGridHit(Vector2 worldPosition)
{
var localPos = CameraHolder.Match3Camera.ScreenToWorldPoint(worldPosition);
var rawX = localPos.x / _cellWidth;
var rawY = localPos.y / _cellHeight;
var gridX = Mathf.FloorToInt(rawX);
var gridY = Mathf.FloorToInt(rawY);
return new GridHit(new GridPosition(gridX, gridY), rawX, rawY, localPos);
}
SwapGridObjectsData()
public void SwapGridObjectsData(GridObject gridObjectA, GridObject gridObjectB)
{
var gridPositionA = gridObjectA.GetGridPosition;
var gridPositionB = gridObjectB.GetGridPosition;
_gridObjectArray[gridPositionA.X, gridPositionA.Y] = gridObjectB;
_gridObjectArray[gridPositionB.X, gridPositionB.Y] = gridObjectA;
gridObjectA.SetGridPosition(gridPositionB);
gridObjectB.SetGridPosition(gridPositionA);
}
DisposeMatchData()
public void DisposeMatchData(HashSet<Match> matches)
{
foreach (var match in matches)
{
for (int i = 0; i < match.MatchedObjectGroup.Length; i++)
{
var gridPos = new GridPosition(match.MatchedObjectGroup[i].GetGridPosition.X, match.MatchedObjectGroup[i].GetGridPosition.Y);
_gridObjectArray[gridPos.X, gridPos.Y].SetMatch3BlockProfile(null);
}
}
}
CheckGridBounds()
public GridPosition CheckGridBounds(GridPosition pos)
{
var x = Mathf.Clamp(pos.X, 0, _width - 1);
var y = Mathf.Clamp(pos.Y, 0, _height - 1);
return new GridPosition(x, y);
}
CalculateSwipeEndGridPosition() and CalculateClickedEndGridPosition()
public GridPosition CalculateSwipeEndGridPosition(GridHit beginHit, GridHit endHit, float swipeDirectionTolerance, float swipeMaxDiagonalDeviation)
{
var delta = endHit.LocalPos - beginHit.LocalPos;
var absX = Mathf.Abs(delta.x);
var absY = Mathf.Abs(delta.y);
var tolerance = swipeDirectionTolerance;
var maxDiagonalTolerance = swipeMaxDiagonalDeviation;
var ratio = absX > absY ? absY / absX : absX / absY;
if (ratio > maxDiagonalTolerance)
return beginHit.HitGridPosition;
if (ratio > tolerance)
return beginHit.HitGridPosition;
var horizontal = absX > absY;
if (horizontal)
{
var dir = delta.x > 0 ? 1 : -1;
return new GridPosition(beginHit.HitGridPosition.X + dir, beginHit.HitGridPosition.Y);
}
else
{
var dir = delta.y > 0 ? 1 : -1;
return new GridPosition(beginHit.HitGridPosition.X, beginHit.HitGridPosition.Y + dir);
}
}
public GridPosition CalculateClickedEndGridPosition(GridPosition beginGridPosition, float rawX, float rawY, float clickTolerance)
{
var startX = beginGridPosition.X;
var startY = beginGridPosition.Y;
var deltaX = rawX - startX;
var deltaY = rawY - startY;
var distanceX = Mathf.FloorToInt(rawX) - startX;
var distanceY = Mathf.FloorToInt(rawY) - startY;
if (Mathf.Abs(deltaX) >= 3f || Mathf.Abs(deltaY) >= 3f) return beginGridPosition;
if (Mathf.Abs(distanceX) >= 1 && Mathf.Abs(distanceY) >= 1) return beginGridPosition;
if (Mathf.Abs(distanceX) == 0 && Mathf.Abs(distanceY) == 0) return beginGridPosition;
if (distanceX == 1) return new GridPosition(startX + 1, startY);
if (distanceX == -1) return new GridPosition(startX - 1, startY);
if (distanceY == 1) return new GridPosition(startX, startY + 1);
if (distanceY == -1) return new GridPosition(startX, startY - 1);
if (Mathf.Abs(deltaX) > Mathf.Abs(deltaY))
{
rawY = startY;
}
else
{
rawX = startX;
}
if (rawX > startX)
{
rawX -= clickTolerance;
var newGridPositionX = Mathf.FloorToInt(rawX);
distanceX = newGridPositionX - startX;
if (distanceX >= 2) return new GridPosition(startX, startY);
return new(startX + 1, startY);
}
else if (rawX < startX)
{
rawX += clickTolerance;
var newGridPositionX = Mathf.FloorToInt(rawX);
distanceX = newGridPositionX - startX;
if (distanceX <= -2) return new GridPosition(startX, startY);
return new(startX - 1, startY);
}
if (rawY > startY)
{
rawY -= clickTolerance;
var newGridPositionY = Mathf.FloorToInt(rawY);
distanceY = newGridPositionY - startY;
if (distanceY >= 2) return new GridPosition(startX, startY);
return new(startX, startY + 1);
}
else if (rawY < startY)
{
rawY += clickTolerance;
var newGridPositionY = Mathf.FloorToInt(rawY);
distanceY = newGridPositionY - startY;
if (distanceY <= -2) return new GridPosition(startX, startY);
return new(startX, startY - 1);
}
return beginGridPosition;
}
LevelGrid
LevelGrid acts as the controller between player input and the grid logic.
The class listens for player interactions with the board and uses the helper methods from GridSystem to validate moves and trigger swap actions when valid input is detected.
OnNewFingerUpInput()
private void OnNewFingerUpInput(Vector2 fingerPosition)
{
if (!_allowInput) return;
var newGridHit = _gridSystem.ConvertScreenPositionToGridHit(fingerPosition);
var endTouchGridPosition = newGridHit;
if (endTouchGridPosition.HitGridPosition == _beginTouchGridPosition.HitGridPosition && !_currentSelectedGridPosition.HasValue)
{
_currentSelectedGridPosition = _beginTouchGridPosition;
_gridSystem.SelectTileByGridPosition(_currentSelectedGridPosition.Value.HitGridPosition);
return;
}
var isClickMove = _beginTouchGridPosition.HitGridPosition == endTouchGridPosition.HitGridPosition;
if (isClickMove)
{
var endGridPosition = _gridSystem.CalculateClickedEndGridPosition(_currentSelectedGridPosition.Value.HitGridPosition, endTouchGridPosition.RawX, endTouchGridPosition.RawY, levelGridData.ClickTolerance);
endGridPosition = _gridSystem.CheckGridBounds(endGridPosition);
if (endGridPosition == _currentSelectedGridPosition.Value.HitGridPosition)
{
ResetCurrentGridPosition();
return;
}
if (_gridSystem.IsDiagonalMove(_currentSelectedGridPosition.Value.HitGridPosition, endGridPosition))
{
ResetCurrentGridPosition();
return;
}
HandleMove(_currentSelectedGridPosition.Value.HitGridPosition, endGridPosition);
}
else
{
var endGridPosition = _gridSystem.CalculateSwipeEndGridPosition(_beginTouchGridPosition, endTouchGridPosition, levelGridData.SwipeDirectionTolerance, levelGridData.SwipeMaxDiagonalDeviation);
endGridPosition = _gridSystem.CheckGridBounds(endGridPosition);
HandleMove(_beginTouchGridPosition.HitGridPosition, endGridPosition);
}
_currentSelectedGridPosition = null;
}
HandleMove()
private void HandleMove(GridPosition beginGridPosition, GridPosition endGridPosition)
{
if (_currentSelectedGridPosition != null) _gridSystem.DeselectTileByGridPosition(_currentSelectedGridPosition.Value.HitGridPosition);
_allowInput = false;
var beginGridObject = _gridSystem.GetGridObjectByGridPosition(beginGridPosition);
var endGridObject = _gridSystem.GetGridObjectByGridPosition(endGridPosition);
if (beginGridObject == null || endGridObject == null || beginGridObject == endGridObject)
{
_allowInput = true;
return;
}
if (!beginGridObject.GetMatch3BlockProfile.HasRule("Swap") || !endGridObject.GetMatch3BlockProfile.HasRule("Swap"))
{
_allowInput = true;
return;
}
var swapParameters = new SwapActionParameters
{
Context = _actionContext,
From = beginGridObject,
To = endGridObject,
};
gridActionProcessor.ProcessAction(new SwapAction(swapParameters));
}
LevelGridData
The grid configuration is stored inside a ScriptableObject called LevelGridData.
This data container defines the base grid setup, including:
• Grid dimensions
• Swipe and click tolerance
• Tile background visual reference
Using a ScriptableObject allows the grid to be configured directly in the Unity editor while keeping the runtime logic fully data-oriented.
Visual architecture
The visual layer is managed by the BlockVisualManager. This system is responsible for:
• Enabling and disabling block visuals
• Applying visual feedback and animations using DOTween
• Maintaining the link between GridObjects and their visual representations
• Managing block profile to visual prefab mappings
• Filling and controlling the object pool for performant runtime spawning
The visual system is fully separated from the gameplay logic. This allows the grid logic to remain completely data-driven while visuals dynamically respond to changes in the grid state.
By decoupling visuals from logic, new block types, visual themes, and animation behaviours can be added without modifying the core system.
Dictionaries
private Dictionary<Match3BlockProfile, GameObject> _profileToVisualsDictionary;
private Dictionary<Match3BlockProfile, List<GameObject>> _pool;
private Dictionary<GridObject, GameObject> _activeBlockVisuals;
InitializePool()
private void InitializePool()
{
_activeBlockVisuals = new Dictionary<GridObject, GameObject>();
_profileToVisualsDictionary = new Dictionary<Match3BlockProfile, GameObject>();
_pool = new Dictionary<Match3BlockProfile, List<GameObject>>();
foreach (var profileToVisual in profileToVisuals)
{
_profileToVisualsDictionary.Add(profileToVisual.Match3BlockProfile, profileToVisual.Visual);
_pool.Add(profileToVisual.Match3BlockProfile, new List<GameObject>());
for (int i = 0; i < initialPoolSizePerMatch3Block; i++)
{
var newBlock = Instantiate(profileToVisual.Visual, transform);
newBlock.SetActive(false);
_pool[profileToVisual.Match3BlockProfile].Add(newBlock);
}
}
}
MoveVisualBinding()
public void MoveVisualBinding(GridObject from, GridObject to)
{
if (!_activeBlockVisuals.TryGetValue(from, out var visual)) return;
_activeBlockVisuals.Remove(from);
_activeBlockVisuals[to] = visual;
}
CreateVisualMoveTween()
public Tween CreateVisualMoveTween(GridObject gridObject, Vector3 newPosition, float tweenSpeed, Ease ease, float tweenStrength)
{
if(!_activeBlockVisuals.TryGetValue(gridObject, out var targetVisual)) return null;
return targetVisual.transform.DOMove(newPosition, tweenSpeed).SetEase(ease, tweenStrength).Pause();
}
TryEnableVisualByProfile()
public bool TryEnableVisualByProfile(Match3BlockProfile match3BlockProfile, GridObject gridObject, Func<GridPosition, Vector3> GetWorldPos, float yOffset = 0)
{
if (!_pool.TryGetValue(match3BlockProfile, out var listOfVisuals)) return false;
foreach (var visual in listOfVisuals)
{
if (visual.activeInHierarchy) continue;
_activeBlockVisuals.Add(gridObject, visual);
var visualPosition = GetWorldPos(gridObject.GetGridPosition);
visualPosition.y = visualPosition.y + yOffset;
visual.transform.position = visualPosition;
visual.SetActive(true);
break;
}
return true;
}

