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.
usingSystem.Collections.Generic;usingUnityEngine;namespaceGridSystem.BlogPost{publicclassGridSystemScript : MonoBehaviour {publicstaticGridSystemScriptInstance; [Header("Grid Dimensions")]publicintwidth = 50;publicintheight = 50;publicfloatcellSize = 1f; [Header("Terrain & Buildability")] [SerializeField] privateLayerMaskgroundLayerMask; [Tooltip("Cells with a height (absolute value) less than this are buildable.")]publicfloatbuildableHeightTolerance = 0.5f; [Tooltip("The minimum number of consecutive coastal cells for a valid coastline.")]publicintminCoastalRunLength = 3; [Header("Decor Settings")]publicGameObjectrockPrefab; // For simplicity, we'll use one rock prefab [Range(0, 1)]publicfloatrockPlacementProbability = 0.02f;privateGridCell[,] grid;voidAwake() {Instance = this;Initialize(); }publicvoidInitialize() {grid = newGridCell[width, height]; // --- PASS 1: Set basic properties and height-based buildability ---for (intx = 0; x < width; x++) {for (intz = 0; z < height; z++) {Vector3cellWorldPos = transform.position + newVector3(x * cellSize, 0, z * cellSize);floatheightSample = GetHeightSample(cellWorldPos);cellWorldPos.y = heightSample;boolisBuildable = Mathf.Abs(heightSample) < buildableHeightTolerance;grid[x, z] = newGridCell(newVector2Int(x, z), cellWorldPos, isBuildable); } }Debug.Log("Pass 1 Complete: Basic grid properties and buildability set."); // --- PASS 2: Identify all cells adjacent to water ---for (intx = 0; x < width; x++) {for (intz = 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 (intx = 0; x < width; x++) {for (intz = 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(); }privatefloatGetHeightSample(Vector3position) {if (Physics.Raycast(position + Vector3.up * 100f, Vector3.down, outRaycastHithit, 200f, groundLayerMask)) {returnhit.point.y; }return0f; } #region Coastal Generation Logic // Pass 2 HelperprivatevoidCheckIfPotentiallyCoastal(intx, intz) {GridCellcell = grid[x, z];cell.IsPotentiallyCoastal = false;Vector2Int[] neighbors = { Vector2Int.up, Vector2Int.down, Vector2Int.left, Vector2Int.right };foreach (vardirinneighbors) {GridCellneighbor = 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 3if (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 HelperprivatevoidValidateCoastalRun(intx, intz) {GridCellcell = grid[x, z];inthorizontalRun = GetRunLength(x, z, true);intverticalRun = GetRunLength(x, z, false);if (horizontalRun >= minCoastalRunLength) {cell.IsCoastalCell = true; // Water is to the North or Southcell.CoastalRotation = cell.WaterIsNorth ? 0 : 180; }elseif (verticalRun >= minCoastalRunLength) {cell.IsCoastalCell = true; // Water is to the East or Westcell.CoastalRotation = cell.WaterIsEast ? 90 : 270; } }privateintGetRunLength(intstartX, intstartZ, boolhorizontal) {intcount = 0;for (inti = -width; i < width; i++) // A simple, wide scan {intx = horizontal ? startX + i : startX;intz = horizontal ? startZ : startZ + i;GridCellcell = GetCell(x, z);if (cell != null && cell.IsPotentiallyCoastal) {count++; }elseif (count > 0) // Stop counting once the run is broken {break; } }returncount; } #endregionprivatevoidPopulateDecor() {if (rockPrefab == null) return;vardecorParent = newGameObject("Decor").transform;decorParent.SetParent(transform);for (intx = 0; x < width; x++) {for (intz = 0; z < height; z++) {GridCellcell = grid[x, z];if (cell.IsBuildable && !cell.IsCoastalCell) // Don't put rocks on the beach {if (Random.value < rockPlacementProbability) {QuaternionrandomRot = Quaternion.Euler(0, Random.Range(0, 360f), 0);cell.DecorObject = Instantiate(rockPrefab, cell.WorldPosition, randomRot, decorParent); } } } }Debug.Log("Decor populated."); } // --- Public Helper Methods ---publicGridCellGetCell(intx, intz) {if (x >= 0 && x < width && z >= 0 && z < height)returngrid[x, z];returnnull; } // ... Other methods for building placement, pathfinding, etc. would go here ...#if UNITY_EDITORvoidOnDrawGizmos() {if (grid == null) return;foreach (varcellingrid) {Gizmos.color = Color.green * 0.5f; // Simplified Gizmos for clarityif (!cell.IsBuildable) {Gizmos.color = newColor(0, 0.7f, 1, 0.5f); // WaterGizmos.DrawCube(cell.WorldPosition, newVector3(cellSize, 0.1f, cellSize)); }elseif (cell.IsCoastalCell) {Gizmos.color = newColor(1, 0, 1, 0.7f); // MagentaGizmos.DrawCube(cell.WorldPosition, newVector3(cellSize, 0.1f, cellSize)); // Draw a line to show coastal rotationVector3direction = Quaternion.Euler(0, cell.CoastalRotation, 0) * Vector3.forward;Gizmos.DrawRay(cell.WorldPosition, direction * cellSize); } } }#endif }}
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:
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:
Comments are closed