Tutorial 92: Building a Top-Down RPG
Tilemap-based world
Represent the world as a 2D grid of tile indices. Each index maps to a source rectangle in a tileset texture.
struct TileMap {
int width, height, tileSize;
std::vector<int> tiles; // tile index, -1 = empty
Texture2D* tileset = nullptr;
void Draw(SpriteBatch& sb, const Vector2& cameraOffset) {
for (int y = 0; y < height; ++y) {
for (int x = 0; x < width; ++x) {
int idx = tiles[y * width + x];
if (idx < 0) continue;
int tilesPerRow = tileset->Width / tileSize;
Rectangle src = {
(idx % tilesPerRow) * tileSize,
(idx / tilesPerRow) * tileSize,
tileSize, tileSize
};
Vector2 dest = Vector2(
x * tileSize - cameraOffset.X,
y * tileSize - cameraOffset.Y);
sb.Draw(*tileset, dest, &src, Color::White);
}
}
}
};
Player movement (8-directional)
void Player::Update(const GameTime& gt, const TileMap& map) {
float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
auto ks = Keyboard::GetState();
Vector2 dir;
if (ks.IsKeyDown(Keys::W) || ks.IsKeyDown(Keys::Up)) dir.Y -= 1;
if (ks.IsKeyDown(Keys::S) || ks.IsKeyDown(Keys::Down)) dir.Y += 1;
if (ks.IsKeyDown(Keys::A) || ks.IsKeyDown(Keys::Left)) dir.X -= 1;
if (ks.IsKeyDown(Keys::D) || ks.IsKeyDown(Keys::Right)) dir.X += 1;
// Normalize diagonal movement
float len = std::sqrt(dir.X*dir.X + dir.Y*dir.Y);
if (len > 0.0f) dir = dir / len;
Vector2 newPos = position_ + dir * speed_ * dt;
// Tile collision (solid tiles block movement)
if (!map.IsSolid(newPos + Vector2(4, 4)) &&
!map.IsSolid(newPos + Vector2(20, 4)) &&
!map.IsSolid(newPos + Vector2(4, 28)) &&
!map.IsSolid(newPos + Vector2(20, 28))) {
position_ = newPos;
}
if (dir.X != 0 || dir.Y != 0) facing_ = dir;
}
Z-ordering sprites by Y
In a top-down RPG, sprites closer to the bottom of the screen should appear in front of sprites higher up. Sort entities by their Y position before drawing.
void Level::DrawEntities(SpriteBatch& sb) {
// Collect all drawable entities
std::vector<Entity*> sorted;
sorted.push_back(&player_);
for (auto& npc : npcs_) sorted.push_back(&npc);
// Sort by Y (bottom of sprite for correct overlap)
std::sort(sorted.begin(), sorted.end(), [](Entity* a, Entity* b) {
return (a->Position().Y + a->Height()) < (b->Position().Y + b->Height());
});
for (auto* e : sorted) e->Draw(sb);
}
Dialogue system
struct DialogueLine { std::string speaker, text; };
struct DialogueTree { std::vector<DialogueLine> lines; };
class DialogueSystem {
public:
void Start(const DialogueTree& tree) {
tree_ = &tree;
lineIdx_ = 0;
active_ = true;
}
void Advance() {
if (!active_) return;
++lineIdx_;
if (lineIdx_ >= (int)tree_->lines.size()) active_ = false;
}
bool Active() const { return active_; }
const DialogueLine& Current() const { return tree_->lines[lineIdx_]; }
private:
const DialogueTree* tree_ = nullptr;
int lineIdx_ = 0;
bool active_ = false;
};
Inventory basics
struct Item { std::string name; int quantity; };
class Inventory {
public:
void Add(const std::string& name, int qty = 1) {
for (auto& item : items_) {
if (item.name == name) { item.quantity += qty; return; }
}
items_.push_back({ name, qty });
}
bool Remove(const std::string& name, int qty = 1) {
for (auto& item : items_) {
if (item.name == name && item.quantity >= qty) {
item.quantity -= qty;
if (item.quantity == 0)
items_.erase(std::remove_if(items_.begin(), items_.end(),
[&](const Item& i){ return i.quantity == 0; }),
items_.end());
return true;
}
}
return false;
}
const std::vector<Item>& Items() const { return items_; }
private:
std::vector<Item> items_;
};
Camera following player
void UpdateCamera(const Vector2& playerPos, int screenW, int screenH,
int worldW, int worldH, Vector2& cameraPos) {
// Center camera on player
cameraPos.X = playerPos.X - screenW / 2.0f;
cameraPos.Y = playerPos.Y - screenH / 2.0f;
// Clamp to world bounds
cameraPos.X = std::max(0.0f, std::min(cameraPos.X, (float)(worldW - screenW)));
cameraPos.Y = std::max(0.0f, std::min(cameraPos.Y, (float)(worldH - screenH)));
}
Top-down player controller + tile collision
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/Color.hpp"
#include <vector>
#include <memory>
#include <cmath>
#include <algorithm>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
class TopDownRPG final : public Game {
public:
TopDownRPG() : graphics_(this) {
graphics_.setPreferredBackBufferWidth(800);
graphics_.setPreferredBackBufferHeight(600);
}
protected:
void LoadContent() override {
sb_ = std::make_unique<SpriteBatch>(getGraphicsDeviceProperty());
pixel_ = std::make_unique<Texture2D>(getGraphicsDeviceProperty(), 1, 1);
Color w = Color::White;
pixel_->SetData(&w, 1);
// Simple 20x15 tile map (0 = grass, 1 = wall)
mapW_ = 20; mapH_ = 15; tileSize_ = 48;
tiles_.assign(mapW_ * mapH_, 0);
// Surround with walls
for (int x = 0; x < mapW_; ++x) { tiles_[x] = 1; tiles_[(mapH_-1)*mapW_+x] = 1; }
for (int y = 0; y < mapH_; ++y) { tiles_[y*mapW_] = 1; tiles_[y*mapW_+mapW_-1] = 1; }
playerPos_ = Vector2(3*tileSize_, 3*tileSize_);
}
bool IsSolid(Vector2 pt) {
int tx = (int)(pt.X / tileSize_), ty = (int)(pt.Y / tileSize_);
if (tx < 0 || ty < 0 || tx >= mapW_ || ty >= mapH_) return true;
return tiles_[ty * mapW_ + tx] == 1;
}
void Update(const GameTime& gt) override {
float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
auto ks = Keyboard::GetState();
Vector2 dir;
if (ks.IsKeyDown(Keys::W)) dir.Y -= 1;
if (ks.IsKeyDown(Keys::S)) dir.Y += 1;
if (ks.IsKeyDown(Keys::A)) dir.X -= 1;
if (ks.IsKeyDown(Keys::D)) dir.X += 1;
float len = std::sqrt(dir.X*dir.X + dir.Y*dir.Y);
if (len > 0) dir = dir / len;
Vector2 np = playerPos_ + dir * 120.0f * dt;
if (!IsSolid(np+Vector2(2,2)) && !IsSolid(np+Vector2(22,2)) &&
!IsSolid(np+Vector2(2,26)) && !IsSolid(np+Vector2(22,26)))
playerPos_ = np;
// Camera
camera_.X = std::max(0.0f, std::min(playerPos_.X - 400.0f,
(float)(mapW_*tileSize_ - 800)));
camera_.Y = std::max(0.0f, std::min(playerPos_.Y - 300.0f,
(float)(mapH_*tileSize_ - 600)));
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(20, 60, 20));
sb_->Begin();
for (int y = 0; y < mapH_; ++y)
for (int x = 0; x < mapW_; ++x) {
Color c = tiles_[y*mapW_+x] == 1 ? Color(80,60,40) : Color(60,120,60);
sb_->Draw(*pixel_,
Rectangle((int)(x*tileSize_-camera_.X),
(int)(y*tileSize_-camera_.Y),
tileSize_-1, tileSize_-1), c);
}
sb_->Draw(*pixel_,
Rectangle((int)(playerPos_.X-camera_.X),
(int)(playerPos_.Y-camera_.Y), 24, 28),
Color::Yellow);
sb_->End();
gd.Present();
}
private:
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> sb_;
std::unique_ptr<Texture2D> pixel_;
std::vector<int> tiles_;
int mapW_, mapH_, tileSize_;
Vector2 playerPos_, camera_;
};
int main() { TopDownRPG game; game.Run(); }