Tutorial 94: Building a Puzzle Game
What you’ll learn
- Modelling a grid board and detecting matches on it.
- Animating swaps and falls with
Lerp. - An undo/redo stack, and particle feedback on a match.
Before you start — Tutorial 41: Vector2, Vector3, Vector4 Math (the interpolation the animations use), Tutorial 12: Moving Sprites and Basic Animation (per-frame movement) and Tutorial 28: Particle Systems in 2D (the match effects).
Grid-based game board
const int COLS = 8, ROWS = 8, CELL = 64;
const int GEM_TYPES = 5;
struct Cell { int type = -1; };
struct Board {
Cell cells[ROWS][COLS];
void Fill() {
for (int r = 0; r < ROWS; ++r)
for (int c = 0; c < COLS; ++c)
cells[r][c].type = rand() % GEM_TYPES;
}
Cell& At(int r, int c) { return cells[r][c]; }
};
Piece matching logic
struct Match { int row, col, len; bool horizontal; };
std::vector<Match> FindMatches(const Board& board) {
std::vector<Match> matches;
// Horizontal runs of 3+
for (int r = 0; r < ROWS; ++r) {
for (int c = 0; c < COLS - 2; ) {
int t = board.cells[r][c].type;
int len = 1;
while (c + len < COLS && board.cells[r][c+len].type == t) ++len;
if (len >= 3) matches.push_back({r, c, len, true});
c += len;
}
}
// Vertical runs of 3+
for (int c = 0; c < COLS; ++c) {
for (int r = 0; r < ROWS - 2; ) {
int t = board.cells[r][c].type;
int len = 1;
while (r + len < ROWS && board.cells[r+len][c].type == t) ++len;
if (len >= 3) matches.push_back({r, c, len, false});
r += len;
}
}
return matches;
}
Animations with Lerp
struct SwapAnim {
int r1,c1, r2,c2;
float t = 0.0f; // 0..1 animation progress
bool done() const { return t >= 1.0f; }
// Returns animated position of cell (r1,c1)
Vector2 PosA(float dt) {
t = std::min(1.0f, t + dt * 5.0f); // 0.2s swap
Vector2 from(c1*CELL, r1*CELL), to(c2*CELL, r2*CELL);
return Vector2::Lerp(from, to, t);
}
Vector2 PosB(float dt) {
Vector2 from(c2*CELL, r2*CELL), to(c1*CELL, r1*CELL);
return Vector2::Lerp(from, to, t);
}
};
Undo/redo stack
struct Move { int r1,c1, r2,c2; };
class UndoStack {
public:
void Push(Move m) { undos_.push_back(m); redos_.clear(); }
bool CanUndo() const { return !undos_.empty(); }
bool CanRedo() const { return !redos_.empty(); }
Move PopUndo() { auto m = undos_.back(); undos_.pop_back(); redos_.push_back(m); return m; }
Move PopRedo() { auto m = redos_.back(); redos_.pop_back(); undos_.push_back(m); return m; }
private:
std::vector<Move> undos_, redos_;
};
Particle effects on match
struct Particle {
Vector2 pos, vel;
Color color;
float life, maxLife;
bool alive() const { return life > 0; }
};
void SpawnMatchParticles(std::vector<Particle>& particles,
int row, int col, Color color) {
for (int i = 0; i < 12; ++i) {
float angle = (float)i / 12.0f * MathHelper::TwoPi;
float speed = 50.0f + (rand()%100);
particles.push_back({
Vector2(col*CELL + CELL/2.0f, row*CELL + CELL/2.0f),
Vector2(std::cos(angle)*speed, std::sin(angle)*speed),
color, 0.6f, 0.6f
});
}
}
void UpdateParticles(std::vector<Particle>& ps, float dt) {
for (auto& p : ps) {
if (!p.alive()) continue;
p.pos += p.vel * dt;
p.vel *= std::pow(0.9f, dt * 60.0f); // drag
p.life -= dt;
}
ps.erase(std::remove_if(ps.begin(), ps.end(),
[](const Particle& p){ return !p.alive(); }),
ps.end());
}
Match-3 grid + swap animation
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/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 "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/MathHelper.hpp"
#include <vector>
#include <memory>
#include <algorithm>
#include <cstdlib>
#include <cmath>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
class Match3Game final : public Game {
public:
Match3Game() : graphics_(this) {
graphics_.setPreferredBackBufferWidthProperty(COLS*CELL + 200);
graphics_.setPreferredBackBufferHeightProperty(ROWS*CELL + 60);
}
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);
board_.Fill();
selectedRow_ = selectedCol_ = -1;
}
void Update(GameTime& gt) override {
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
auto ks = Keyboard::GetState();
if (ks.IsKeyDown(Keys::Escape)) Exit();
// Cursor movement
auto Move = [&](int dr, int dc) {
if (prevKeys_.IsKeyDown(Keys::Up) || prevKeys_.IsKeyDown(Keys::Down) ||
prevKeys_.IsKeyDown(Keys::Left) || prevKeys_.IsKeyDown(Keys::Right))
return;
if (selectedRow_ < 0) { selectedRow_ = 0; selectedCol_ = 0; return; }
selectedRow_ = std::clamp(selectedRow_ + dr, 0, ROWS-1);
selectedCol_ = std::clamp(selectedCol_ + dc, 0, COLS-1);
};
if (ks.IsKeyDown(Keys::Up)) Move(-1, 0);
if (ks.IsKeyDown(Keys::Down)) Move( 1, 0);
if (ks.IsKeyDown(Keys::Left)) Move( 0,-1);
if (ks.IsKeyDown(Keys::Right)) Move( 0, 1);
// Swap with Space
if (ks.IsKeyDown(Keys::Space) && !prevKeys_.IsKeyDown(Keys::Space) && selectedRow_ >= 0) {
if (pendingR_ < 0) { pendingR_ = selectedRow_; pendingC_ = selectedCol_; }
else {
// Attempt swap
std::swap(board_.cells[pendingR_][pendingC_].type,
board_.cells[selectedRow_][selectedCol_].type);
auto m = FindMatches(board_);
if (m.empty()) std::swap(board_.cells[pendingR_][pendingC_].type,
board_.cells[selectedRow_][selectedCol_].type);
else { score_ += (int)m.size() * 30; }
pendingR_ = pendingC_ = -1;
}
}
prevKeys_ = ks;
UpdateParticles(particles_, dt);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(20, 20, 40));
sb_->Begin();
static const Color gemColors[GEM_TYPES] = {
Color::Red, Color::Blue, Color::Green, Color::Yellow, Color(200,0,200)
};
for (int r = 0; r < ROWS; ++r)
for (int c = 0; c < COLS; ++c) {
int t = board_.cells[r][c].type;
Rectangle dest(c*CELL+2, r*CELL+2, CELL-4, CELL-4);
sb_->Draw(*pixel_, dest, t >= 0 ? gemColors[t] : Color(40,40,60));
if (r == selectedRow_ && c == selectedCol_)
sb_->Draw(*pixel_, Rectangle(c*CELL, r*CELL, CELL, 2), Color::White);
}
// Particles
for (auto& p : particles_) {
if (!p.alive()) continue;
float alpha = p.life / p.maxLife;
Color c(p.color.getRProperty(), p.color.getGProperty(), p.color.getBProperty(), (bytecs)(255*alpha));
sb_->Draw(*pixel_, Rectangle((int)p.pos.X-2,(int)p.pos.Y-2,4,4), c);
}
sb_->End();
gd.Present();
}
private:
static const int COLS=8, ROWS=8, CELL=64, GEM_TYPES=5;
struct Cell { int type=-1; };
struct Board {
Cell cells[8][8];
void Fill() { for(auto& r:cells) for(auto& c:r) c.type=rand()%5; }
};
struct Particle {
Vector2 pos,vel; Color color; float life,maxLife;
bool alive() const { return life>0; }
};
struct Match { int row,col,len; bool h; };
std::vector<Match> FindMatches(const Board& b) {
std::vector<Match> out;
// (simplified: just return empty for skeleton)
return out;
}
void UpdateParticles(std::vector<Particle>& ps, float dt) {
for(auto& p:ps){if(!p.alive())continue;p.pos=p.pos+p.vel*dt;p.life-=dt;}
ps.erase(std::remove_if(ps.begin(),ps.end(),[](const Particle&p){return !p.alive();}),ps.end());
}
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> sb_;
std::unique_ptr<Texture2D> pixel_;
Board board_;
std::vector<Particle> particles_;
KeyboardState prevKeys_;
int selectedRow_=-1, selectedCol_=-1, pendingR_=-1, pendingC_=-1;
int score_=0;
};
int main() { Match3Game game; game.Run(); }