What Is It?
Many game questions are spatial: which enemies are inside this explosion, which boids are close enough to flock with, which pickups can the magnet pull? The naive answer loops over every object and measures the distance — fine for 50 units, ruinous for 5,000 asking the same question each frame.
A spatial partition divides the world into regions and files each object under the region it occupies. The simplest version is a uniform grid: a Dictionary<Vector2Int, List<Unit>> keyed by cell. A query converts its bounds into a range of cells and only inspects objects stored in those cells.
Objects must tell the grid when they move to a different cell, but that is cheap — a remove and an add, and only when crossing a boundary. In return, neighbour queries go from O(n) per query to roughly the handful of objects in nearby cells. Quadtrees, octrees and BVHs are the adaptive cousins of the same idea.
When Is It Used?
Use it when many objects repeatedly query their surroundings: crowd steering, RTS target acquisition, bullet-hell collision, AI perception, proximity voice chat.
A uniform grid works best when objects are spread fairly evenly and have similar sizes. For very clumpy worlds or huge size differences, a quadtree adapts better.
If you only have a few dozen objects, or Unity physics queries like Physics.OverlapSphereNonAlloc already answer the question, the built-in broadphase is probably enough.
Interactive Demo
Click the controls and watch the objects collaborate. The console mirrors what the C# code below would log.
Code
using System.Collections.Generic;
using UnityEngine;
namespace Patterns.Optimization.SpatialPartition
{
/// <summary>
/// Spawns wandering units, then every frame finds those within a radius
/// of this object using the grid instead of scanning every unit.
/// </summary>
public class SpatialTester : MonoBehaviour
{
[SerializeField] private Unit unitPrefab;
[SerializeField] private int unitCount = 500;
[SerializeField] private float worldSize = 100f;
[SerializeField] private float cellSize = 10f;
[SerializeField] private float queryRadius = 8f;
private SpatialGrid<Unit> grid;
private readonly List<Unit> results = new();
private void Awake()
{
grid = new SpatialGrid<Unit>(cellSize);
for (int i = 0; i < unitCount; i++)
{
Vector2 p = new(Random.value * worldSize, Random.value * worldSize);
Unit unit = Instantiate(unitPrefab, new Vector3(p.x, 0f, p.y), Quaternion.identity);
unit.Init(grid, worldSize);
}
}
private void Update()
{
Vector2 center = new(transform.position.x, transform.position.z);
int checks = grid.Query(center, queryRadius, results);
foreach (Unit unit in results)
Debug.DrawLine(transform.position, unit.transform.position, Color.cyan);
if (Time.frameCount % 60 == 0)
Debug.Log($"Found {results.Count} with {checks} distance checks (brute force: {unitCount}).");
}
}
}using System.Collections.Generic;
using UnityEngine;
namespace Patterns.Optimization.SpatialPartition
{
/// <summary>
/// A uniform grid that buckets items by cell. Queries only look at the
/// cells overlapping the search circle.
/// </summary>
public class SpatialGrid<T> where T : class, ISpatialItem
{
private readonly float cellSize;
private readonly Dictionary<Vector2Int, List<T>> cells = new();
public SpatialGrid(float cellSize) => this.cellSize = cellSize;
public Vector2Int CellOf(Vector2 position) =>
new(Mathf.FloorToInt(position.x / cellSize), Mathf.FloorToInt(position.y / cellSize));
public void Add(T item, Vector2Int cell)
{
if (!cells.TryGetValue(cell, out var list))
cells[cell] = list = new List<T>();
list.Add(item);
}
public void Remove(T item, Vector2Int cell)
{
if (cells.TryGetValue(cell, out var list))
list.Remove(item);
}
public void Move(T item, Vector2Int from, Vector2Int to)
{
if (from == to) return;
Remove(item, from);
Add(item, to);
}
/// <summary>Fills results; returns how many distance checks ran.</summary>
public int Query(Vector2 center, float radius, List<T> results)
{
results.Clear();
int checks = 0;
float radiusSq = radius * radius;
Vector2Int min = CellOf(center - Vector2.one * radius);
Vector2Int max = CellOf(center + Vector2.one * radius);
for (int x = min.x; x <= max.x; x++)
for (int y = min.y; y <= max.y; y++)
{
if (!cells.TryGetValue(new Vector2Int(x, y), out var list)) continue;
foreach (T item in list)
{
checks++;
if ((item.Position - center).sqrMagnitude <= radiusSq)
results.Add(item);
}
}
return checks;
}
}
/// <summary>Anything the grid can store.</summary>
public interface ISpatialItem
{
Vector2 Position { get; }
}
}using UnityEngine;
namespace Patterns.Optimization.SpatialPartition
{
/// <summary>
/// A wandering unit that keeps the grid informed whenever it crosses
/// into a new cell.
/// </summary>
public class Unit : MonoBehaviour, ISpatialItem
{
[SerializeField] private float speed = 4f;
private SpatialGrid<Unit> grid;
private Vector2Int cell;
private Vector2 velocity;
private float bounds;
public Vector2 Position => new(transform.position.x, transform.position.z);
public void Init(SpatialGrid<Unit> owner, float worldSize)
{
grid = owner;
bounds = worldSize;
velocity = Random.insideUnitCircle.normalized * speed;
cell = grid.CellOf(Position);
grid.Add(this, cell);
}
private void Update()
{
Vector2 p = Position + velocity * Time.deltaTime;
if (p.x < 0f || p.x > bounds) velocity.x = -velocity.x;
if (p.y < 0f || p.y > bounds) velocity.y = -velocity.y;
p = new Vector2(Mathf.Clamp(p.x, 0f, bounds), Mathf.Clamp(p.y, 0f, bounds));
transform.position = new Vector3(p.x, 0f, p.y);
// Re-bucket only when the cell actually changes.
Vector2Int newCell = grid.CellOf(p);
if (newCell != cell)
{
grid.Move(this, cell, newCell);
cell = newCell;
}
}
private void OnDestroy() => grid?.Remove(this, cell);
}
}Advantages & Disadvantages
+ Advantages
- Turns neighbour searches from checking everything into checking a small local set.
- A uniform grid is tiny to implement and has predictable, cache-friendly costs.
- Works for non-physics data too — AI blackboards, audio emitters, fog-of-war reveals.
− Disadvantages
- Objects must be kept in sync; forgetting to update a moved unit makes queries silently wrong.
- Choosing a cell size is a trade-off: too small means many cells per query, too large means crowded cells.
- Uses extra memory for buckets, and dense clusters in one cell degrade back towards brute force.
Tips
- 01Pick a cell size close to your most common query radius; then a query touches at most a 3×3 block of cells.
- 02Only re-bucket a unit when its cell coordinate actually changes — compare the old and new
Vector2Intfirst. - 03Reuse a results
List<T>passed into the query instead of returning a new list, to keep the hot path allocation-free. - 04The grid narrows candidates; still do the exact distance check (
sqrMagnitudeagainst radius²) on those candidates. - 05For thousands of units, consider a flat-array grid with Burst and the Jobs system instead of a dictionary of lists.