Building a Grid-Based Movement System for my Tactics RPG
One of the most critical systems in any tactics RPG is the grid-based movement system. It's the foundation that enables strategic positioning, turn-based combat, and all the tactical depth that makes these games engaging. In this post, I'll walk through the architecture and implementation of the grid and movement system I built for a tactics RPG made with Godot 4.5 and C#.
Note: This is an early stage implementation that's currently working for the game, but there's definitely room for improvement—particularly in optimizing the movement calculations and pathfinding performance. I'll touch on some of these optimization opportunities throughout the post.
Architecture Overview
The grid and movement system is built on three core components that work together:
Core Components
- BattleMapLayers (
Battle/BattleMapLayers.cs) - Grid representation, pathfinding, and movement calculation - BattleUnit (
Battle/BattleUnit.cs) - Unit movement, animation, and AI - BattleMap (
Battle/BattleMap.cs) - Input handling and user interaction
These components communicate through an event-driven architecture using BattleEventBus, which keeps the systems loosely coupled while enabling clean coordination between input, logic, and animation.
Multi-Layer Grid Representation
The Three-Floor Elevation System
The grid uses Godot's TileMapLayer system with a multi-floor approach to support tactical elevation mechanics:
// From BattleMapLayers.cs [Export] private TileMapLayer? _moveGuideLayer; [Export] private TileMapLayer? _actionGuideLayer; [Export] private TileMapLayer? _thirdFloorLayer; // Highest elevation [Export] private TileMapLayer? _secondFloorLayer; [Export] private TileMapLayer? _firstFloorLayer; // Ground level [Export] private TileMapLayer? _objectsLayer; // Static obstacles
Each floor level is represented as a separate enum:
public enum FloorLevel { FirstFloor = 0, // Ground level SecondFloor = 1, // Mid-level platforms ThirdFloor = 2 // Highest points }
Why this matters: Units on higher floors can have tactical advantages (height bonuses for attacks), and the visual depth is achieved through Y-offset adjustments. The system checks floors from top to bottom for accurate hover detection—when the player hovers over a bridge, they select the bridge tile, not the ground beneath it.
Grid Coordinates
The system uses Vector2I (integer 2D vectors) for cell positions with helper methods for coordinate conversion:
// BattleUnit.cs public Vector2I CurrentCell => _battleMapLayers!.LocalToMap(Position);
LocalToMap(Vector2)- World position → Grid cellMapToLocal(Vector2I)- Grid cell → World position
Movement Range: The Diamond Algorithm
Calculating Movement Range
The system calculates movement range using a custom diamond-shaped range algorithm:
private Vector2I[] GetCellsWithinRange(Vector2I currentCell, int range) { var cells = new List<Vector2I>(); for (var i = 1; i <= range; i++) { // Cardinal directions (North, South, East, West) cells.Add(currentCell + new Vector2I(i, 0)); cells.Add(currentCell + new Vector2I(-i, 0)); cells.Add(currentCell + new Vector2I(0, i)); cells.Add(currentCell + new Vector2I(0, -i)); // Fill in the diamond shape for (var j = i - 1; j > 0; j--) { // Horizontal fill (creates diamond edges) cells.Add(currentCell + new Vector2I(i, 0) + new Vector2I(-j, j)); cells.Add(currentCell + new Vector2I(-i, 0) + new Vector2I(j, -j)); // Vertical fill cells.Add(currentCell + new Vector2I(i, 0) + new Vector2I(-j, -j)); cells.Add(currentCell + new Vector2I(-i, 0) + new Vector2I(j, j)); } } return FilterCellsWithinMap(cells.ToArray()); }
This creates a diamond-shaped pattern using Manhattan distance (no diagonal movement):
Range = 2:
X
X O X
X O U O X
X O X
X
Why diamond-shaped?
- Uses Manhattan distance for clear, measurable ranges
- No diagonal movement keeps positioning strategic
- Creates organic, realistic movement boundaries
- Natural fit for grid-based tactical gameplay
Pathfinding with A*
The Pathfinding Implementation
The system leverages Godot's built-in AStarGrid2D for efficient pathfinding:
public Vector2I[] GetUnitMovePath(BattleUnit unit, Vector2I targetCell) { var aStarGrid = CreateBattleMapAStarGrid(); // Mark obstacles as impassable foreach (var cell in ObstacleCells) { aStarGrid.SetPointSolid(cell); } return aStarGrid.GetIdPath(unit.CurrentCell, targetCell).ToArray(); }
Configuring the A* Grid
private AStarGrid2D CreateBattleMapAStarGrid() { var aStarGrid = new AStarGrid2D(); var mapOriginCell = new Vector2I(_mapOriginCell!.X, _mapOriginCell.Y); var mapSize = new Vector2I(_mapSize!.X, _mapSize.Y); aStarGrid.Region = new Rect2I(mapOriginCell, mapSize); aStarGrid.CellSize = TileSize; aStarGrid.DiagonalMode = AStarGrid2D.DiagonalModeEnum.Never; // Manhattan distance aStarGrid.Update(); return aStarGrid; }
Critical design choice: DiagonalMode = Never enforces 4-directional movement, consistent with the diamond range calculation and preventing "shortcut" diagonal paths.
Dynamic Obstacle System
The system distinguishes between two types of obstacles:
// Cells with any unit public Vector2I[] OccupiedCells => _battleManager?.BattleUnits.Select(unit => unit.CurrentCell).ToArray() ?? []; // Impassable cells public Vector2I[] ObstacleCells => _battleManager?.BattleUnits .Where(unit => _battleManager.CurrentTurnUnit!.IsEnemy != unit.IsEnemy) // Enemy units .Select(unit => unit.CurrentCell).ToArray() .Concat(GetObstacleCells()) // Static obstacles (trees) .ToArray() ?? [];
Key insight:
- Allied units are shown in move range but excluded from occupiable cells
- Enemy units block pathfinding entirely
- Static obstacles (trees, walls) always block movement
Path-Limited Movement
Two-Stage Movement Validation
An important aspect of the system is validating movement through actual pathfinding rather than simple range checks:
public Vector2I[] GetUnitMoveCells(BattleUnit unit) { var aStarGrid = CreateBattleMapAStarGrid(); var availableCells = new List<Vector2I>(); var cellsWithinRange = GetCellsWithinMoveRange(unit); // Get diamond range // Mark obstacles as solid foreach (var cell in ObstacleCells) { aStarGrid.SetPointSolid(cell); } // For each cell in diamond range... foreach (var cell in cellsWithinRange) { var movementLimit = unit.Attributes!.MovementRange + 1; var path = aStarGrid.GetIdPath(unit.CurrentCell, cell)[..movementLimit]; // Truncate path // Add all cells along the truncated path foreach (var pathCell in path) { if (!availableCells.Contains(pathCell)) { availableCells.Add(pathCell); } } } return availableCells.ToArray(); }
Why This Approach Works
-
Two-Stage Validation:
- First: Calculate theoretical diamond range
- Second: Validate each cell is actually reachable via pathfinding
-
Path Truncation:
[..movementLimit]limits path length to movement range- Prevents "out of range" cells from being reachable even if they're in the diamond
-
Path Collection:
- Adds all cells along the path to available moves
- Ensures only cells with valid routes are included
- Handles obstacles naturally through A* pathfinding
Visual example:
Range = 3, but obstacle blocks direct path:
[X] = Obstacle
[O] = Reachable
[.] = Unreachable (behind obstacle)
[U] = Unit
O
O O X
O O U X .
O O X
O
The cell marked with . is within the diamond range but unreachable because the path to it is blocked!
Smooth Movement Animation
Tween-Based Movement
Units move smoothly along paths using Godot's Tween system:
public void AnimateMove(Vector2I[] path, Tween? tween = null) { // Save previous state for potential cancellation _currentUnitPreviousPosition = Position; _currentUnitSpritePreviousOffset = _animatedSprite!.Offset; _currentUnitSpritePreviousPosition = _animatedSprite.Position; _currentUnitPreviousAnimation = _animatedSprite.Animation; tween ??= CreateTween(); // Animate through each cell in the path foreach (var cell in path.Skip(1)) // Skip starting position { // Main position movement tween.TweenProperty(this, "position", _battleMapLayers!.MapToLocal(cell), 0.4f) .SetTrans(Tween.TransitionType.Sine); // Handle elevation changes (parallel animations) if (_battleMapLayers.GetCellOffsetAdjustment(cell) is { } offsetAdjustment) { tween.Parallel().TweenProperty(_animatedSprite, "offset", offsetAdjustment, 0.3f) .SetTrans(Tween.TransitionType.Sine); tween.Parallel().TweenProperty(_turnIndicator, "offset", offsetAdjustment + new Vector2(0, TURN_INDICATOR_Y_OFFSET), 0.3f) .SetTrans(Tween.TransitionType.Sine); } // Update facing direction tween.Parallel().TweenCallback(Callable.From(() => FaceDirection(cell))); } }
Key animation features:
- Cell-by-Cell Animation: Each path cell gets a 0.4s movement tween for natural pacing
- Elevation Transitions: Parallel tweens for sprite offset create smooth vertical adjustments
- Direction Facing: Units automatically face movement direction using a 4-way system
- Undo Capability: Previous position/animation state saved for
CancelMove()
Event-Driven Integration
Communication via Events
The movement system communicates with the battle system through events:
// BattleUnit.cs _battleEventBus.MoveCellSelected += cell => { if (IsCurrentTurnUnit) { var path = _battleMapLayers!.GetUnitMovePath(this, cell); var tween = CreateMoveTween(path); _battleEventBus!.EmitUnitMoving(this, path.Last()); AnimateMove(path, tween); } };
Movement event flow:
- Player Input → Mouse click emits
MoveCellSelectedevent - Movement Execution → Calculate path, create tween, emit
UnitMoving, animate - State Transition →
UnitMoving→BattleState.Moving→UnitMoved→BattleState.Moved
State Machine Integration
The battle state machine coordinates movement flow:
public enum BattleState { Initial, // Turn start PreMove, // Showing movement guide Moving, // Unit is animating movement Moved, // Movement complete, ready for attack PreAttack, // Showing attack guide Attacking, // Executing attack Attacked, // Attack complete Finished // Battle ended }
Visual Feedback System
Guide Layers
The system provides real-time visual feedback through guide layers:
public enum TileSourceId { ActionGuide = 0, // Red - Attack range MoveGuide = 4, // Blue - Player movement EnemyGuide = 5, // Red - Enemy movement // ... other visual elements }
These layers show players where they can move (blue tiles) and attack (red tiles), with enemy movement shown in red during AI turns.
private void ShowMoveGuide(Vector2I[] cells, Vector2I[] excludeCells, bool isEnemy = false) { var sourceId = isEnemy ? (int)TileSourceId.EnemyGuide : (int)TileSourceId.MoveGuide; foreach (var cell in cells) { if (!excludeCells.Contains(cell)) { SetCellWithSourceId(_moveGuideLayer!, cell, sourceId); } } }
AI Movement System
Basic AI Implementation
Enemy AI uses a nearest-opponent strategy:
private void EnemyAIMove() { _battleEventBus!.EmitUnitPreMove(this); var targetUnit = GetOpponentWithinAttackRange(); if (targetUnit != null) { // Already in attack range, skip movement EnemyAIAttack(); } else { // Move toward nearest opponent var path = GetAvailablePathToNearOpponent(); var tween = CreateMoveTween(path); _battleMapLayers!.ShowUnitMoveGuide(this); tween.TweenCallback(Callable.From(() => { _battleEventBus.EmitUnitMoving(this, path.Last()); _battleMapLayers.ShowActionGuide([path.Last()]); })).SetDelay(1); // 1-second delay for visual clarity AnimateMove(path, tween); } }
AI decision tree:
- Find nearest opponent
- Check if already in attack range → Attack immediately
- Otherwise: Move to closest available cell toward opponent
- After move: Check attack range again
The pathfinding for AI:
private Vector2I[] GetAvailablePathToNearOpponent() { var nearestOpponentCell = GetNearestOpponentCell(); // Get all available move cells (respecting obstacles) var unitAvailableMoveCells = _battleMapLayers!.GetUnitMoveCells(this) .Where(cell => !_battleMapLayers!.ObstacleCells.Contains(cell) && !_battleMapLayers!.OccupiedCells.Contains(cell) && cell != CurrentCell); // Pick the closest available cell to nearest opponent var nearestCell = unitAvailableMoveCells .OrderBy(cell => cell.DistanceTo(nearestOpponentCell)) .FirstOrDefault(); return _battleMapLayers!.GetUnitMovePath(this, nearestCell); }
Key Design Patterns
1. Separation of Concerns
| Component | Responsibility |
|---|---|
| BattleMapLayers | Grid logic, pathfinding, range calculation |
| BattleUnit | Unit state, animation, AI |
| BattleMap | Input handling, user interaction |
| BattleManager | State machine, turn management |
Each class has a well-defined role, which has helped keep the system maintainable.
2. Resource-Driven Configuration
Grid bounds are defined via exported Resources, not hardcoded:
[Export] private BattleMapPositionData? _mapOriginCell; [Export] private BattleMapPositionData? _mapSize;
Benefits:
- No hardcoded grid sizes
- Each battle map configures its own boundaries
- Godot editor integration for easy level design
3. Top-Down Floor Detection
for (var i = TopToBottomFloorLayers.Length - 1; i >= 0; i--) // Top to bottom { if (TopToBottomFloorLayers[i].LocalToMap(pointerPosition + offset) is { } pointerCell && TopToBottomFloorLayers[i].GetCellSourceId(pointerCell) != -1) { return pointerCell; // Return highest floor hit first } }
Higher floors visually overlap lower floors, so checking top-to-bottom ensures hovering over a bridge selects the bridge, not the ground below.
Lessons Learned
What's Been Working
- Path-based movement validation helps prevent exploits and unrealistic movement
- Diamond range + A pathfinding* creates natural tactical positioning
- Event-driven design keeps systems loosely coupled
- Tween animations provide smooth movement
- Multi-floor support adds tactical depth without adding too much complexity
Future Improvements
-
Performance Optimization
- Cache movement calculations instead of recalculating every frame
- Optimize the path-finding for large maps
- Consider using Godot's AStar2D pool to reduce allocations
-
Terrain cost system - Different tiles could cost more movement points
-
Flying units - Units that ignore ground obstacles
-
Zone control - Implement zones of control that restrict enemy movement
-
Advanced AI - Target prioritization, defensive positioning, group tactics
-
Movement abilities - Teleport, leap, swap positions
Conclusion
Building a grid-based movement system requires careful consideration of pathfinding, visual feedback, and state management. I found that keeping components separated while enabling clean communication through events helped maintain clarity in the codebase. The two-stage movement validation (diamond range + path verification) was particularly helpful for preventing exploits while maintaining intuitive gameplay.
This architecture has been working well for the project so far, though I'm sure there are improvements to be made as I learn more. Whether you're building a tactics game in Godot or another engine, I hope some of these patterns and principles are useful for your own implementation.
If you're working on a similar system, I hope this writeup has been helpful! Feel free to reach out if you have questions about any part of the implementation.
Engine: Godot 4.5 Language: C# (.NET 8.0)
All code examples are from an actual tactics RPG game codebase and represent working, battle-tested implementations.
