Tutorial 26: Tilemaps and Tile-Based Worlds
Tile-based worlds divide the game area into a regular grid of identical-size cells. Each cell stores an index into a tile atlas — a single texture containing all tile artwork arranged in a grid. CNA provides no built-in TileMap class, but the XNA API has everything you need to implement an efficient one yourself. This tutorial builds a complete, production-ready TileMap system.
Tile-based design
Advantages of tile maps over free-placed sprites:
- Memory efficient — hundreds of unique tile appearances from one small atlas texture.
- Cache-friendly — sequential access of a 2D array matches CPU cache lines.
- Trivial culling — skip tiles outside the visible rectangle (no spatial structure needed).
- Easy editor integration — Tiled, LDtk, and dozens of other tools export tile indices.
- Constant-time tile lookup by pixel coordinate (divide by tile size).
TileMap class
// TileMap.hpp
#pragma once
#include <vector>
#include <memory>
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Vector2.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
// Special tile IDs
static constexpr intcs TILE_EMPTY = -1; // transparent / no tile
class TileMap {
public:
TileMap(intcs mapWidth, intcs mapHeight,
intcs tileWidth, intcs tileHeight,
intcs atlasColumns)
: mapW_(mapWidth), mapH_(mapHeight),
tileW_(tileWidth), tileH_(tileHeight),
atlasCols_(atlasColumns),
tiles_(mapWidth * mapHeight, TILE_EMPTY),
solid_(mapWidth * mapHeight, false)
{}
// --- Tile access ---
void SetTile(intcs x, intcs y, intcs tileId, bool isSolid = false) {
if (!InBounds(x, y)) return;
tiles_[y * mapW_ + x] = tileId;
solid_[y * mapW_ + x] = isSolid;
}
intcs GetTile(intcs x, intcs y) const { return InBounds(x,y) ? tiles_[y*mapW_+x] : TILE_EMPTY; }
bool IsSolid(intcs x, intcs y) const { return InBounds(x,y) && solid_[y*mapW_+x]; }
// Convert world pixel position to tile coordinate
intcs WorldToTileX(Single worldX) const { return (intcs)(worldX / tileW_); }
intcs WorldToTileY(Single worldY) const { return (intcs)(worldY / tileH_); }
// Bounding rectangle of a tile in world space
Rectangle TileWorldRect(intcs tx, intcs ty) const {
return Rectangle(tx * tileW_, ty * tileH_, tileW_, tileH_);
}
intcs GetMapWidth() const { return mapW_; }
intcs GetMapHeight() const { return mapH_; }
intcs GetTileWidth() const { return tileW_; }
intcs GetTileHeight()const { return tileH_; }
// Source rectangle of a tile within the atlas
Rectangle GetSourceRect(intcs tileId) const {
intcs col = tileId % atlasCols_;
intcs row = tileId / atlasCols_;
return Rectangle(col * tileW_, row * tileH_, tileW_, tileH_);
}
// --- Draw only visible tiles ---
void Draw(SpriteBatch& sb, Texture2D& atlas, Rectangle viewport) const {
// Clamp tile range to map bounds
intcs startX = std::max(0, viewport.X / tileW_);
intcs startY = std::max(0, viewport.Y / tileH_);
intcs endX = std::min(mapW_, (viewport.X + viewport.Width) / tileW_ + 1);
intcs endY = std::min(mapH_, (viewport.Y + viewport.Height) / tileH_ + 1);
for (intcs ty = startY; ty < endY; ++ty) {
for (intcs tx = startX; tx < endX; ++tx) {
intcs id = GetTile(tx, ty);
if (id == TILE_EMPTY) continue;
Vector2 dest((Single)(tx * tileW_), (Single)(ty * tileH_));
Rectangle src = GetSourceRect(id);
sb.Draw(atlas, dest, src, Color::White);
}
}
}
private:
intcs mapW_, mapH_, tileW_, tileH_, atlasCols_;
std::vector<intcs> tiles_;
std::vector<bool> solid_;
bool InBounds(intcs x, intcs y) const {
return x >= 0 && x < mapW_ && y >= 0 && y < mapH_;
}
};
Tile atlas (sprite sheet)
A tile atlas is a single texture where tiles are arranged left-to-right, top-to-bottom. For a 16x16 tile atlas with 32x32 pixel tiles the texture is 512x512 pixels (16 columns × 32 px = 512). Tile IDs start at 0 (top-left) and increment left-to-right, then top-to-bottom:
// Atlas layout example (16 columns, 32x32 tiles):
// Tile 0 = grass (col 0, row 0)
// Tile 1 = dirt (col 1, row 0)
// Tile 2 = stone (col 2, row 0)
// Tile 16 = water (col 0, row 1)
// Tile 17 = sand (col 1, row 1)
// Source rectangle for tile 18 (col 2, row 1):
Rectangle src = map.GetSourceRect(18);
// = Rectangle(2*32, 1*32, 32, 32) = Rectangle(64, 32, 32, 32)
Rendering visible tiles only
The TileMap::Draw() method above already culls off-screen tiles using a viewport rectangle derived from the camera position. The viewport in world space is:
// Get visible world rectangle from camera
Vector2 camPos = camera_->getPosition();
Viewport vp = gd.getViewport();
Single zoom = camera_->getZoom();
Rectangle visibleWorld(
(intcs)(camPos.X - (Single)vp.Width / (2.0f * zoom)),
(intcs)(camPos.Y - (Single)vp.Height / (2.0f * zoom)),
(intcs)((Single)vp.Width / zoom),
(intcs)((Single)vp.Height / zoom)
);
// In Draw():
spriteBatch_->Begin(SpriteSortMode::Deferred, nullptr, nullptr, nullptr, nullptr,
nullptr, camera_->getTransform());
tileMap_->Draw(*spriteBatch_, *atlas_, visibleWorld);
spriteBatch_->End();
Tile collision map
Mark tiles as solid when populating the map, then query at runtime:
// Populate map (0 = grass, 1 = dirt solid, 2 = water solid)
for (intcs y = 0; y < 50; ++y)
for (intcs x = 0; x < 80; ++x)
tileMap_->SetTile(x, y, 0, false); // grass floor
// Add solid walls at the border
for (intcs x = 0; x < 80; ++x) {
tileMap_->SetTile(x, 0, 2, true); // top wall
tileMap_->SetTile(x, 49, 2, true); // bottom wall
}
// --- Collision query ---
bool TileCollision(const TileMap& map, Rectangle entityRect) {
intcs tx0 = map.WorldToTileX((Single)entityRect.X);
intcs ty0 = map.WorldToTileY((Single)entityRect.Y);
intcs tx1 = map.WorldToTileX((Single)(entityRect.X + entityRect.Width - 1));
intcs ty1 = map.WorldToTileY((Single)(entityRect.Y + entityRect.Height - 1));
for (intcs ty = ty0; ty <= ty1; ++ty)
for (intcs tx = tx0; tx <= tx1; ++tx)
if (map.IsSolid(tx, ty)) return true;
return false;
}
Tiled (.tmx) JSON export pattern
Tiled is the most popular free tile map editor. It can export maps as JSON. A minimal loader:
// Tiled JSON export structure (simplified):
// {
// "width": 80, "height": 50,
// "tilewidth": 32, "tileheight": 32,
// "layers": [
// { "name": "Ground", "data": [1,1,2,1,...] },
// { "name": "Solid", "data": [0,0,3,0,...] }
// ],
// "tilesets": [ { "columns": 16 } ]
// }
// Minimal JSON loader (use nlohmann/json or rapidjson in production)
std::unique_ptr<TileMap> LoadTiledJson(const std::string& path) {
// 1. Read file contents
std::ifstream f(path);
std::string json((std::istreambuf_iterator<char>(f)), {});
// 2. Parse (pseudo-code — use a real JSON library)
intcs width = JsonGet<intcs>(json, "width");
intcs height = JsonGet<intcs>(json, "height");
intcs tileW = JsonGet<intcs>(json, "tilewidth");
intcs tileH = JsonGet<intcs>(json, "tileheight");
intcs cols = JsonGet<intcs>(json, "tilesets[0].columns");
auto map = std::make_unique<TileMap>(width, height, tileW, tileH, cols);
// 3. Fill from "Ground" layer (tile IDs in Tiled are 1-based; subtract 1)
auto groundData = JsonGetArray(json, "layers[0].data");
for (intcs i = 0; i < (intcs)groundData.size(); ++i) {
intcs id = groundData[i] - 1; // Tiled uses 1-based IDs; 0 = empty
intcs tx = i % width;
intcs ty = i / width;
if (id >= 0)
map->SetTile(tx, ty, id, /* look up solid from collision layer */);
}
return map;
}
Complete working example
#include <memory>
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "TileMap.hpp"
#include "Camera2D.hpp" // from Tutorial 17
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
class TileDemo final : public Game {
public:
TileDemo() : graphics_(this) {
graphics_.setPreferredBackBufferWidth(800);
graphics_.setPreferredBackBufferHeight(600);
}
protected:
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
spriteBatch_ = std::make_unique<SpriteBatch>(gd);
atlas_ = Content.Load<Texture2D>("Tiles/world_atlas");
playerTex_ = Content.Load<Texture2D>("Sprites/player");
camera_ = std::make_unique<Camera2D>(800, 600);
// Build a 50x40 map with 32x32 tiles; atlas is 16 columns wide
tileMap_ = std::make_unique<TileMap>(50, 40, 32, 32, 16);
// Fill with grass (tile 0)
for (intcs y = 0; y < 40; ++y)
for (intcs x = 0; x < 50; ++x)
tileMap_->SetTile(x, y, 0, false);
// Stone border (tile 2, solid)
for (intcs x = 0; x < 50; ++x) {
tileMap_->SetTile(x, 0, 2, true);
tileMap_->SetTile(x, 39, 2, true);
}
for (intcs y = 0; y < 40; ++y) {
tileMap_->SetTile( 0, y, 2, true);
tileMap_->SetTile(49, y, 2, true);
}
// A water pool (tile 16, solid)
for (intcs y = 10; y < 15; ++y)
for (intcs x = 10; x < 18; ++x)
tileMap_->SetTile(x, y, 16, true);
playerPos_ = Vector2(5.0f * 32.0f, 5.0f * 32.0f);
}
void Update(GameTime& gt) override {
auto kb = Keyboard::GetState();
Single dt = (Single)gt.getElapsedGameTime().TotalSeconds();
Vector2 move;
if (kb.IsKeyDown(Keys::Left)) move.X -= 1.0f;
if (kb.IsKeyDown(Keys::Right)) move.X += 1.0f;
if (kb.IsKeyDown(Keys::Up)) move.Y -= 1.0f;
if (kb.IsKeyDown(Keys::Down)) move.Y += 1.0f;
const Single speed = 160.0f;
Vector2 newPos = playerPos_ + move * speed * dt;
Rectangle newRect((intcs)newPos.X, (intcs)newPos.Y, 28, 28);
if (!TileCollision(*tileMap_, newRect))
playerPos_ = newPos;
// Camera follows player
camera_->setPosition(playerPos_ + Vector2(14.0f, 14.0f));
camera_->ClampToWorld(50 * 32, 40 * 32);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
// Compute visible world rect for culling
Viewport vp = gd.getViewport();
Vector2 camPos = camera_->getPosition();
Rectangle vis(
(intcs)(camPos.X - vp.Width / 2),
(intcs)(camPos.Y - vp.Height / 2),
vp.Width, vp.Height);
spriteBatch_->Begin(SpriteSortMode::Deferred, nullptr, nullptr, nullptr,
nullptr, nullptr, camera_->getTransform());
tileMap_->Draw(*spriteBatch_, *atlas_, vis);
spriteBatch_->Draw(*playerTex_, playerPos_, Color::White);
spriteBatch_->End();
gd.Present();
}
private:
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> spriteBatch_;
std::unique_ptr<Texture2D> atlas_, playerTex_;
std::unique_ptr<TileMap> tileMap_;
std::unique_ptr<Camera2D> camera_;
Vector2 playerPos_;
};
int main() { TileDemo game; game.Run(); }
Optimisation notes
- Visible-range culling is the single most important optimisation — a 200×150 tile map has 30 000 tiles; culling reduces this to the ~400 tiles visible at 800×600 with 32px tiles.
- Multiple layers — add a
std::vector<std::vector<intcs>>insideTileMapfor background, foreground, and decoration layers. Draw each in a separate loop or combine into one draw loop. - Chunk streaming — for very large maps (4000×4000+ tiles) split the map into 64×64 chunks and only keep chunks near the camera in memory.
- Static render-to-texture — pre-render the visible tile region into a
RenderTarget2Dand only re-render when the camera moves more than one tile width. Eliminates tile draw calls on stationary frames.