Tutorial 26: Tilemaps and Tile-Based Worlds
What you’ll learn
- Representing a level as a tile grid backed by one atlas texture.
- Culling to the visible tile range instead of drawing the whole map.
- A parallel collision layer.
- Consuming a Tiled map exported as JSON.
Before you start — Tutorial 13: Sprite Sheets and Frame Animation (the atlas and source rectangles), Tutorial 17: 2D Camera and Viewport (the Camera2D class this scrolls with) and Tutorial 16: Basic Collision Detection (the tile collision layer).
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 <algorithm>
#include <cmath>
#include <vector>
#include <memory>
#include "Microsoft/Xna/Framework/Color.hpp"
#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 int TILE_EMPTY = -1; // transparent / no tile
class TileMap {
public:
TileMap(int mapWidth, int mapHeight,
int tileWidth, int tileHeight,
int atlasColumns)
: mapW_(mapWidth), mapH_(mapHeight),
tileW_(tileWidth), tileH_(tileHeight),
atlasCols_(atlasColumns),
tiles_(mapWidth * mapHeight, TILE_EMPTY),
solid_(mapWidth * mapHeight, false)
{}
// --- Tile access ---
void SetTile(int x, int y, int tileId, bool isSolid = false) {
if (!InBounds(x, y)) return;
tiles_[y * mapW_ + x] = tileId;
solid_[y * mapW_ + x] = isSolid;
}
int GetTile(int x, int y) const { return InBounds(x, y) ? tiles_[y * mapW_ + x] : TILE_EMPTY; }
bool IsSolid(int x, int y) const { return InBounds(x, y) && solid_[y * mapW_ + x]; }
// Convert world pixel position to tile coordinate.
// (floor, not a cast: a cast truncates toward zero, so -0.5f would land in tile 0)
int WorldToTileX(float worldX) const { return static_cast<int>(std::floor(worldX / tileW_)); }
int WorldToTileY(float worldY) const { return static_cast<int>(std::floor(worldY / tileH_)); }
// Bounding rectangle of a tile in world space
Rectangle TileWorldRect(int tx, int ty) const {
return Rectangle(tx * tileW_, ty * tileH_, tileW_, tileH_);
}
int GetMapWidth() const { return mapW_; }
int GetMapHeight() const { return mapH_; }
int GetTileWidth() const { return tileW_; }
int GetTileHeight() const { return tileH_; }
// Source rectangle of a tile within the atlas
Rectangle GetSourceRect(int tileId) const {
int col = tileId % atlasCols_;
int row = tileId / atlasCols_;
return Rectangle(col * tileW_, row * tileH_, tileW_, tileH_);
}
// --- Draw only visible tiles ---
void Draw(SpriteBatch& sb, const Texture2D& atlas, Rectangle visible) const {
// Clamp tile range to map bounds
int startX = std::max(0, visible.X / tileW_);
int startY = std::max(0, visible.Y / tileH_);
int endX = std::min(mapW_, (visible.X + visible.Width) / tileW_ + 1);
int endY = std::min(mapH_, (visible.Y + visible.Height) / tileH_ + 1);
for (int ty = startY; ty < endY; ++ty) {
for (int tx = startX; tx < endX; ++tx) {
int id = GetTile(tx, ty);
if (id == TILE_EMPTY) continue;
Vector2 dest(static_cast<float>(tx * tileW_), static_cast<float>(ty * tileH_));
sb.Draw(atlas, dest, GetSourceRect(id), Color::White);
}
}
}
private:
int mapW_, mapH_, tileW_, tileH_, atlasCols_;
std::vector<int> tiles_;
std::vector<bool> solid_;
bool InBounds(int x, int y) const {
return x >= 0 && x < mapW_ && y >= 0 && y < mapH_;
}
};
// Collision query (explained in "Tile collision map" below)
inline bool TileCollision(const TileMap& map, Rectangle entityRect) {
int tx0 = map.WorldToTileX(static_cast<float>(entityRect.X));
int ty0 = map.WorldToTileY(static_cast<float>(entityRect.Y));
int tx1 = map.WorldToTileX(static_cast<float>(entityRect.X + entityRect.Width - 1));
int ty1 = map.WorldToTileY(static_cast<float>(entityRect.Y + entityRect.Height - 1));
for (int ty = ty0; ty <= ty1; ++ty)
for (int tx = tx0; tx <= tx1; ++tx)
if (map.IsSolid(tx, ty)) return true;
return false;
}
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_->getPositionProperty();
Viewport vp = gd.getViewportProperty();
float zoom = camera_->getZoom();
Rectangle visibleWorld(
static_cast<int>(camPos.X - static_cast<float>(vp.getWidthProperty()) / (2.0f * zoom)),
static_cast<int>(camPos.Y - static_cast<float>(vp.getHeightProperty()) / (2.0f * zoom)),
static_cast<int>(static_cast<float>(vp.getWidthProperty()) / zoom),
static_cast<int>(static_cast<float>(vp.getHeightProperty()) / zoom)
);
// In Draw():
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend, 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 (int y = 0; y < 50; ++y)
for (int x = 0; x < 80; ++x)
tileMap_->SetTile(x, y, 0, false); // grass floor
// Add solid walls at the border
for (int x = 0; x < 80; ++x) {
tileMap_->SetTile(x, 0, 2, true); // top wall
tileMap_->SetTile(x, 49, 2, true); // bottom wall
}
// --- Collision query (this is the TileCollision() function at the bottom of TileMap.hpp) ---
inline bool TileCollision(const TileMap& map, Rectangle entityRect) {
int tx0 = map.WorldToTileX(static_cast<float>(entityRect.X));
int ty0 = map.WorldToTileY(static_cast<float>(entityRect.Y));
int tx1 = map.WorldToTileX(static_cast<float>(entityRect.X + entityRect.Width - 1));
int ty1 = map.WorldToTileY(static_cast<float>(entityRect.Y + entityRect.Height - 1));
for (int ty = ty0; ty <= ty1; ++ty)
for (int tx = tx0; tx <= tx1; ++tx)
if (map.IsSolid(tx, ty)) return true;
return false;
}
Tiles outside the map count as empty (IsSolid is false out of bounds), so an entity can walk off the edge of a map unless you surround it with solid border tiles, as above. WorldToTileX/Y use std::floor rather than a plain cast, so positions slightly left of or above the map map to tile −1 instead of tile 0.
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 } ]
// }
// A sketch of a loader. JsonGet / JsonGetArray stand in for the lookups of whichever
// JSON library you use (nlohmann/json, rapidjson...); CNA does not provide them.
template <typename T> T JsonGet(const std::string& json, const char* path);
std::vector<int> JsonGetArray(const std::string& json, const char* path);
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
int width = JsonGet<int>(json, "width");
int height = JsonGet<int>(json, "height");
int tileW = JsonGet<int>(json, "tilewidth");
int tileH = JsonGet<int>(json, "tileheight");
int cols = JsonGet<int>(json, "tilesets[0].columns");
auto map = std::make_unique<TileMap>(width, height, tileW, tileH, cols);
// 3. Fill from the "Ground" layer; a non-zero entry in the "Solid" layer marks the tile solid
std::vector<int> ground = JsonGetArray(json, "layers[0].data");
std::vector<int> solid = JsonGetArray(json, "layers[1].data");
for (int i = 0; i < static_cast<int>(ground.size()); ++i) {
int id = ground[i] - 1; // Tiled uses 1-based IDs; 0 = empty
int tx = i % width;
int ty = i / width;
if (id >= 0)
map->SetTile(tx, ty, id, solid[i] != 0);
}
return map;
}
Two Tiled details this sketch skips: the high bits of a tile ID carry flip and rotation flags (mask them off, or handle them with SpriteEffects), and IDs are relative to each tileset’s firstgid, which matters as soon as a map uses more than one tileset. Tiled also exports XML (.tmx); the JSON export (.tmj/.json) is the one shown here.
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/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/Viewport.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_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
}
protected:
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
spriteBatch_ = std::make_unique<SpriteBatch>(gd);
// Load<T> returns by value; the members are plain Texture2D values.
// (Built .xnb/.cnb textures are premultiplied and suit AlphaBlend; a loose
// .png is straight alpha, so draw it with BlendState::NonPremultiplied.)
atlas_ = getContentProperty().Load<Texture2D>("Tiles/world_atlas");
playerTex_ = getContentProperty().Load<Texture2D>("Sprites/player");
camera_ = std::make_unique<Camera2D>(800, 600);
tileMap_ = std::make_unique<TileMap>(50, 40, 32, 32, 16);
for (int y = 0; y < 40; ++y)
for (int x = 0; x < 50; ++x)
tileMap_->SetTile(x, y, 0, false);
for (int x = 0; x < 50; ++x) { tileMap_->SetTile(x, 0, 2, true); tileMap_->SetTile(x, 39, 2, true); }
for (int y = 0; y < 40; ++y) { tileMap_->SetTile( 0, y, 2, true); tileMap_->SetTile(49, y, 2, true); }
for (int y = 10; y < 15; ++y)
for (int 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();
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
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 float speed = 160.0f;
Vector2 newPos = playerPos_ + move * speed * dt;
Rectangle newRect(static_cast<int>(newPos.X), static_cast<int>(newPos.Y), 28, 28);
if (!TileCollision(*tileMap_, newRect))
playerPos_ = newPos;
camera_->setPositionProperty(playerPos_ + Vector2(14.0f, 14.0f));
camera_->ClampToWorld(50 * 32, 40 * 32);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color::Black);
Viewport vp = gd.getViewportProperty();
Vector2 camPos = camera_->getPositionProperty();
Rectangle vis(
static_cast<int>(camPos.X - vp.getWidthProperty() / 2),
static_cast<int>(camPos.Y - vp.getHeightProperty() / 2),
vp.getWidthProperty(), vp.getHeightProperty());
spriteBatch_->Begin(SpriteSortMode::Deferred, BlendState::AlphaBlend, nullptr, nullptr,
nullptr, nullptr, camera_->getTransform());
tileMap_->Draw(*spriteBatch_, atlas_, vis);
spriteBatch_->Draw(playerTex_, playerPos_, Color::White);
spriteBatch_->End();
// Game::EndDraw() presents the frame after Draw() returns.
}
private:
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> spriteBatch_;
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<int>>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.