Game Dev and more
Game Dev and more
  • Blog
  • Game Dev
  • AI

A Deep Dive into a Dynamic Unity City Builder Grid System

  • Home
  • Game Dev
  • A Deep Dive into a Dynamic Unity City Builder Grid System
City builder grid system debug
  • June 17, 2025
  • tsartsaris
  • 949 Views

The Unsung Hero of City-Builders

Every great city-builder — from Anno to Settlers — is powered by an invisible but essential system: the grid system. In Andara, my post-apocalyptic city builder, the grid system isn’t just for placing buildings — it governs terrain height, coastline access, decoration, and even how AI interacts with the world.

For Andara, I needed the grid to:

  • Handle terrain height and evaluate slope.
  • Determine buildable vs. non-buildable areas.
  • Identify valid coastlines for special structures.
  • Support road networks and prefab rotation.
  • Allow procedural placement of decor.
  • Feed clean data to AI and other systems via a NativeArray.

In this post, I’ll walk through the logic and structure behind our grid system, from terrain sampling to dynamic coastline detection.

Core Structure: The GridSystemScript and GridCell

Singleton Access
The GridSystemScript acts as a Singleton, giving other systems (UI, pathfinding, buildings) centralized access.

using System.Collections.Generic;
using UnityEngine;

namespace GridSystem.BlogPost
{
    public class GridSystemScript : MonoBehaviour
    {
        public static GridSystemScript Instance;

        [Header("Grid Dimensions")]
        public int width = 50;
        public int height = 50;
        public float cellSize = 1f;

        [Header("Terrain & Buildability")]
        [SerializeField] private LayerMask groundLayerMask;
        [Tooltip("Cells with a height (absolute value) less than this are buildable.")]
        public float buildableHeightTolerance = 0.5f;
        [Tooltip("The minimum number of consecutive coastal cells for a valid coastline.")]
        public int minCoastalRunLength = 3;

        [Header("Decor Settings")]
        public GameObject rockPrefab; // For simplicity, we'll use one rock prefab
        [Range(0, 1)]
        public float rockPlacementProbability = 0.02f;

        private GridCell[,] grid;

        void Awake()
        {
            Instance = this;
            Initialize();
        }

        public void Initialize()
        {
            grid = new GridCell[width, height];

            // --- PASS 1: Set basic properties and height-based buildability ---
            for (int x = 0; x < width; x++)
            {
                for (int z = 0; z < height; z++)
                {
                    Vector3 cellWorldPos = transform.position + new Vector3(x * cellSize, 0, z * cellSize);
                    float heightSample = GetHeightSample(cellWorldPos);
                    cellWorldPos.y = heightSample;

                    bool isBuildable = Mathf.Abs(heightSample) < buildableHeightTolerance;

                    grid[x, z] = new GridCell(new Vector2Int(x, z), cellWorldPos, isBuildable);
                }
            }
            Debug.Log("Pass 1 Complete: Basic grid properties and buildability set.");

            // --- PASS 2: Identify all cells adjacent to water ---
            for (int x = 0; x < width; x++)
            {
                for (int z = 0; z < height; z++)
                {
                    if (grid[x, z].IsBuildable)
                    {
                        CheckIfPotentiallyCoastal(x, z);
                    }
                }
            }
            Debug.Log("Pass 2 Complete: Potential coastal cells identified.");

            // --- PASS 3: Validate coastlines by checking for runs ---
            for (int x = 0; x < width; x++)
            {
                for (int z = 0; z < height; z++)
                {
                    if (grid[x, z].IsPotentiallyCoastal)
                    {
                        ValidateCoastalRun(x, z);
                    }
                }
            }
            Debug.Log("Pass 3 Complete: Final coastal properties set.");

            // --- Final Step: Add some procedural decoration ---
            PopulateDecor();
        }

        private float GetHeightSample(Vector3 position)
        {
            if (Physics.Raycast(position + Vector3.up * 100f, Vector3.down, out RaycastHit hit, 200f, groundLayerMask))
            {
                return hit.point.y;
            }
            return 0f;
        }

        #region Coastal Generation Logic

        // Pass 2 Helper
        private void CheckIfPotentiallyCoastal(int x, int z)
        {
            GridCell cell = grid[x, z];
            cell.IsPotentiallyCoastal = false;

            Vector2Int[] neighbors = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right };
            foreach (var dir in neighbors)
            {
                GridCell neighbor = GetCell(x + dir.x, z + dir.y);
                // A "water" cell is one that is NOT buildable.
                if (neighbor != null && !neighbor.IsBuildable)
                {
                    cell.IsPotentiallyCoastal = true;

                    // Store which direction the water is in for Pass 3
                    if (dir == Vector2Int.up) cell.WaterIsNorth = true;
                    if (dir == Vector2Int.down) cell.WaterIsSouth = true;
                    if (dir == Vector2Int.left) cell.WaterIsWest = true;
                    if (dir == Vector2Int.right) cell.WaterIsEast = true;
                }
            }
        }

        // Pass 3 Helper
        private void ValidateCoastalRun(int x, int z)
        {
            GridCell cell = grid[x, z];

            int horizontalRun = GetRunLength(x, z, true);
            int verticalRun = GetRunLength(x, z, false);

            if (horizontalRun >= minCoastalRunLength)
            {
                cell.IsCoastalCell = true;
                // Water is to the North or South
                cell.CoastalRotation = cell.WaterIsNorth ? 0 : 180;
            }
            else if (verticalRun >= minCoastalRunLength)
            {
                cell.IsCoastalCell = true;
                // Water is to the East or West
                cell.CoastalRotation = cell.WaterIsEast ? 90 : 270;
            }
        }

      
        private int GetRunLength(int startX, int startZ, bool horizontal)
        {
            int count = 0;
            for (int i = -width; i < width; i++) // A simple, wide scan
            {
                int x = horizontal ? startX + i : startX;
                int z = horizontal ? startZ : startZ + i;

                GridCell cell = GetCell(x, z);
                if (cell != null && cell.IsPotentiallyCoastal)
                {
                    count++;
                }
                else if (count > 0) // Stop counting once the run is broken
                {
                    break;
                }
            }
            return count;
        }

        #endregion

        private void PopulateDecor()
        {
            if (rockPrefab == null) return;
            var decorParent = new GameObject("Decor").transform;
            decorParent.SetParent(transform);

            for (int x = 0; x < width; x++)
            {
                for (int z = 0; z < height; z++)
                {
                    GridCell cell = grid[x, z];
                    if (cell.IsBuildable && !cell.IsCoastalCell) // Don't put rocks on the beach
                    {
                        if (Random.value < rockPlacementProbability)
                        {
                            Quaternion randomRot = Quaternion.Euler(0, Random.Range(0, 360f), 0);
                            cell.DecorObject = Instantiate(rockPrefab, cell.WorldPosition, randomRot, decorParent);
                        }
                    }
                }
            }
            Debug.Log("Decor populated.");
        }


        // --- Public Helper Methods ---
        public GridCell GetCell(int x, int z)
        {
            if (x >= 0 && x < width && z >= 0 && z < height)
                return grid[x, z];
            return null;
        }
        
        // ... Other methods for building placement, pathfinding, etc. would go here ...
        

#if UNITY_EDITOR
        void OnDrawGizmos()
        {
            if (grid == null) return;

            foreach (var cell in grid)
            {
                Gizmos.color = Color.green * 0.5f;
                // Simplified Gizmos for clarity
                if (!cell.IsBuildable)
                {
                    Gizmos.color = new Color(0, 0.7f, 1, 0.5f); // Water
                    Gizmos.DrawCube(cell.WorldPosition, new Vector3(cellSize, 0.1f, cellSize));
                }
                else if (cell.IsCoastalCell)
                {
                    Gizmos.color = new Color(1, 0, 1, 0.7f); // Magenta
                    Gizmos.DrawCube(cell.WorldPosition, new Vector3(cellSize, 0.1f, cellSize));
                    // Draw a line to show coastal rotation
                    Vector3 direction = Quaternion.Euler(0, cell.CoastalRotation, 0) * Vector3.forward;
                    Gizmos.DrawRay(cell.WorldPosition, direction * cellSize);
                }
            }
        }
#endif
    }
}

The GridCell
Each cell holds:

  • WorldPosition, TerrainHeight
  • IsBuildable, IsOccupied, OccupyingBuilding
  • RoadType, RoadVisualInstance
  • IsCoastalCell, CoastalRotation
  • DecorObject for procedural clutter
using UnityEngine;

namespace GridSystem.BlogPost
{
    public class GridCell
    {
        public Vector3 WorldPosition { get; }
        public Vector2Int GridPosition { get; }

        // --- Core Properties ---
        public bool IsBuildable { get; }
        public GameObject DecorObject { get; set; }

        // --- Coastal Properties ---
        public bool IsCoastalCell { get; set; } = false;
        public int CoastalRotation { get; set; } = 0;

        // --- Temporary Flags for Generation ---
        public bool IsPotentiallyCoastal { get; set; } = false;
        public bool WaterIsNorth, WaterIsSouth, WaterIsEast, WaterIsWest;


        public GridCell(Vector2Int gridPos, Vector3 worldPos, bool isBuildable)
        {
            GridPosition = gridPos;
            WorldPosition = worldPos;
            IsBuildable = isBuildable;
        }

        // Properties for occupancy, roads, pathfinding costs etc.
    }
}

This makes each tile aware of what it holds and how it behaves — a mini representation of the world.

Initialization with a Plan
I use Awake() to ensure the grid is fully ready before any other system starts querying it. Initialization is broken into multiple passes, which we’ll cover next.

Pass 1: Carving Out the Buildable World

The Terrain Challenge
Sampling the terrain height for each cell using raycasts:

if (Physics.Raycast(worldPos + Vector3.up * 100f, Vector3.down, out hit, 200f, groundLayerMask)) {
    heightSample = hit.point.y;
}

Then we determine buildability:

isCellBuildable = buildableHeightTolerance2 < Mathf.Abs(heightSample)
                 && Mathf.Abs(heightSample) < buildableHeightTolerance;

This avoids steep slopes and valleys, only allowing flat enough cells for construction. I keep count of how many cells are buildable for gameplay tuning.

Passes 2 & 3: Defining Coastlines

Why Coastlines Matter
Certain buildings in Andara — like Lizard Traps or future piers — require valid coastline access.

Pass 2 – Potential Coastal Cells
We flag a cell as TempIsPotentiallyCoastal if it’s buildable and adjacent to water (i.e., non-buildable and low terrain):

if (!neighborCell.IsBuildable && neighborCell.TerrainHeight < -buildableHeightTolerance)

Temporary flags like TempHasWaterNorth are set to help in the next step.

Pass 3 – Validating Coastal Runs
We ensure coastlines are usable by checking horizontal and vertical runs:

if (horizontalRun >= minCoastalRunLength) {
    cell.IsCoastalCell = true;
    cell.CoastalRotation = facingWater ? 180 : 0;
}

This avoids having one-off coastal cells that can’t fit larger buildings.

📸 Gizmo Tip: We draw magenta arrows in the editor to visualize coastal cells and their orientation.


Finding the Perfect Spot: FindRandomCoastalRun

Sometimes, we need to programmatically place a structure on the coast. FindRandomCoastalRun() lets us do that by:

  • Scanning for valid runs of a specific length.
  • Randomly picking one that fits.

We also optimize by only checking new run starts:

bool checkHorizontal = (x == 0 || !grid[x - 1, z].IsCoastalCell);

Making It Home: Occupancy, Roads, and Decoration

Marking Territory
To place buildings:

  • We use AreCellsValidForPlacement() to check for availability.
  • Then OccupyCells() marks the cells and stores a reference to the building.

Also update a FlatGrid:

FlatGird[index] = 2; // 2 means occupied

This structure helps with AI or large-scale queries without accessing MonoBehaviours.

Laying Roads
Road placement is checked with IsCellValidForRoad() and marked via RoadType and MarkRoad().

Procedural Decoration
Empty buildable tiles have a chance to spawn decor (e.g., rocks):

if (Random.value < rockPlacementProbability)

Adding random offsets and rotations to break repetition. Decor can be destroyed when a building is placed — it doesn’t block construction.


Under the Hood: Utility Functions & Debug Tools

We rely on helpers like:

  • GetCell(x, z)
  • WorldToGrid(Vector3 worldPos)
  • TryGetCellWorldPosition()

Gizmos: Our Debug HUD
Color-code cells based on state:

  • 🟩 Buildable
  • 🟨 Non-buildable (mountains)
  • 🟦 Below buildable surface (Non-buildable)
  • 🟥 Occupied
  • 🟪 Coastal (Buildable only for coastal buildings)

Conclusion

Recap: We’ve built a grid system that:

  • Evaluates terrain height
  • Flags valid coastlines
  • Supports roads and decor
  • Offers clean data to other systems

I’d love to hear how other devs approach grid design. Let me know your thoughts.

Tags:

CITY BUILDER GAME-DEV

Share:

Previus Post
Designing Survival:
Next Post
Beyond A-Move:

Comments are closed

Recent Posts

  • Learn how to build a Real-Time AI Phone Assistant that sounds Stunningly Human.
  • Beyond A-Move: Creating Tactical Formation Movement in Unity
  • A Deep Dive into a Dynamic Unity City Builder Grid System
  • Designing Survival: The Shack System in a post-apocalyptic city builder
  • Building a Smarter Neo4j AI Agent with Google’s GenAI Toolbox

Categories

  • AI
  • Blog
  • Game Dev
Linkedin-in

Copyright 2025 Tsartsaris. All Rights Reserved