Tutorial 26: Tilemaps and Tile-Based Worlds

2D World  ·  TileMap  ·  Atlas  ·  Source Rectangles

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>> inside TileMap for 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 RenderTarget2D and only re-render when the camera moves more than one tile width. Eliminates tile draw calls on stationary frames.