| name | tile-grid |
| description | Programmatic tile grid in FlatRedBall2. Use when building a code-driven grid at runtime (city builders, dungeon generators, puzzle games, cellular automata) — NOT loading a TMX map from Tiled. Covers the data/visual split, world↔tile coordinate conversion, per-tile visual spawning, and performance guidance for large grids (128×128+). Trigger on any question about tile coordinates, world-to-tile math, programmatic maps, or large grids without TMX files. |
Programmatic Tile Grid
Use this skill when the grid is created at runtime in C#, not loaded from a .tmx file. Typical cases: city builders, procedural dungeons, puzzle games, cellular automata.
If you have a designer-authored level in Tiled, use the levels and tmx skills instead.
Data / Visual Split (Required Pattern)
Keep the authoritative state in a C# array and the visuals as a separate layer. Never derive game logic from what's drawn on screen.
public enum TileType { Empty, Road, ZoneR, ZoneC, ZoneI, PowerPlant, WaterTower }
private TileType[,] _tiles;
private int _cols, _rows;
private float _tileSize;
private Vector2 _gridOrigin;
private ColoredRectangleRuntime?[,] _visuals;
Always update _tiles first, then sync the visual. Never infer tile type from the visual's color.
Grid Setup
_cols = 128;
_rows = 128;
_tileSize = 16f;
_gridOrigin = new Vector2(-(_cols * _tileSize) / 2f + _tileSize / 2f,
-(_rows * _tileSize) / 2f + _tileSize / 2f);
_tiles = new TileType[_cols, _rows];
_visuals = new ColoredRectangleRuntime[_cols, _rows];
_gridOrigin is the center of tile (0,0). Add col * _tileSize / row * _tileSize to get centers of other tiles.
World ↔ Tile Coordinate Conversion
This is the most-called math in any tile game. Use Math.Floor — not (int) cast — to handle negative world coordinates correctly.
bool WorldToTile(Vector2 worldPos, out int col, out int row)
{
col = (int)Math.Floor((worldPos.X - _gridOrigin.X + _tileSize / 2f) / _tileSize);
row = (int)Math.Floor((worldPos.Y - _gridOrigin.Y + _tileSize / 2f) / _tileSize);
return col >= 0 && col < _cols && row >= 0 && row < _rows;
}
Vector2 TileToWorld(int col, int row)
=> new Vector2(_gridOrigin.X + col * _tileSize,
_gridOrigin.Y + row * _tileSize);
Gotcha: (int)(-0.3f) rounds toward zero and gives 0, but the tile at world X = -0.3 is tile -1. Always use Math.Floor.
Spawning Tile Visuals
For grids up to ~4,000 tiles, spawn one ColoredRectangleRuntime per tile in Gum (screen-space; camera does not move these automatically). This is Option B (canvas-space overlay) and is separate from the world-space path described in the World ↔ Tile section above. It uses a dedicated _uiGridOrigin in canvas-space:
private readonly Vector2 _uiGridOrigin = Vector2.Zero;
void SpawnVisual(int col, int row)
{
var rect = new ColoredRectangleRuntime
{
Width = _tileSize,
Height = _tileSize,
};
Add(rect);
_visuals[col, row] = rect;
SyncVisual(col, row);
}
void SyncVisual(int col, int row)
{
var rect = _visuals[col, row];
if (rect == null) return;
rect.X = _uiGridOrigin.X + col * _tileSize;
rect.Y = _uiGridOrigin.Y + row * _tileSize;
rect.Color = TileColor(_tiles[col, row]);
}
For very large grids (16,000+ tiles), spawn only the tiles visible in the canvas viewport and re-cull when your UI scroll offset changes. At 128×128 = 16,384 tiles, spawning all at once is slow but acceptable at startup (~500ms). Profile before optimizing.
If your grid lives in world-space instead, prefer world renderables (AARect/Sprite) so camera transforms are automatic.
Updating a Tile
Always go data-first, then sync visual:
void SetTile(int col, int row, TileType type)
{
if (col < 0 || col >= _cols || row < 0 || row >= _rows) return;
_tiles[col, row] = type;
SyncVisual(col, row);
}
Adjacency / Neighbor Iteration
static readonly (int dc, int dr)[] Cardinal = [(0,1),(0,-1),(1,0),(-1,0)];
bool HasRoadAccess(int col, int row)
{
foreach (var (dc, dr) in Cardinal)
{
int nc = col + dc, nr = row + dr;
if (nc >= 0 && nc < _cols && nr >= 0 && nr < _rows)
if (_tiles[nc, nr] == TileType.Road)
return true;
}
return false;
}
Radius Queries (Power, Water Coverage)
bool IsInRadius(int srcCol, int srcRow, int targetCol, int targetRow, float radius)
{
float dx = (targetCol - srcCol) * _tileSize;
float dy = (targetRow - srcRow) * _tileSize;
return dx * dx + dy * dy <= radius * radius;
}
Use integer math (column/row distances) when possible — it avoids the square root and is significantly faster for large grids.
Performance Notes
- 16,384 tiles (128×128): Initial spawn is slow (~300–600ms for
ColoredRectangleRuntime objects). Acceptable for city builders. If it's a problem, spawn only the visible viewport and lazy-spawn as camera pans.
- Per-tick full-grid scan: Scanning all 16,384 tiles each sim tick is fast (< 1ms). Do not premature-optimize. Only cache derived data (e.g., "powered tiles" bitset) if profiling reveals it's a bottleneck.
- Do not use
TileShapes for visual-only grids. TileShapes is a collision structure, not a rendering structure. Use ColoredRectangleRuntime for programmer-art visual tiles.
- Entity-per-tile is expensive at this scale. Avoid spawning a full
Entity subclass for each tile — the per-entity overhead (factory registration, physics update, etc.) at 16k tiles adds up. Use lightweight Gum visuals or ColoredRectangleRuntime instead.