Tutorial 91: Building a 2D Platformer
Architecture overview
A 2D platformer splits into: Player (physics, input, animation state), Platform (collision geometry), Level (loads and holds platform data), and a camera that follows the player. CNA provides SpriteBatch for rendering and Keyboard/GamePad for input.
AABB collision
Axis-Aligned Bounding Box (AABB) collision tests whether two rectangles overlap. Resolve by finding the minimum penetration axis and pushing the player out along that axis.
#include "Microsoft/Xna/Framework/Rectangle.hpp"
// Returns true and sets penetration vector if rects overlap
bool AABBCollide(const Rectangle& a, const Rectangle& b, Vector2& penetration) {
int dx = (a.X + a.Width/2) - (b.X + b.Width/2);
int dy = (a.Y + a.Height/2) - (b.Y + b.Height/2);
int overlapX = (a.Width/2 + b.Width/2) - std::abs(dx);
int overlapY = (a.Height/2 + b.Height/2) - std::abs(dy);
if (overlapX <= 0 || overlapY <= 0) return false;
if (overlapX < overlapY) {
penetration = Vector2(dx < 0 ? -overlapX : overlapX, 0.0f);
} else {
penetration = Vector2(0.0f, dy < 0 ? -overlapY : overlapY);
}
return true;
}
Gravity and jump physics
const float GRAVITY = 900.0f; // pixels/sec²
const float JUMP_FORCE = -420.0f; // pixels/sec (upward = negative Y)
const float MAX_FALL = 800.0f; // terminal velocity
void Player::Update(const GameTime& gt, const std::vector<Platform>& platforms) {
float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
// Gravity
velocity_.Y = std::min(velocity_.Y + GRAVITY * dt, MAX_FALL);
// Horizontal input
auto ks = Keyboard::GetState();
velocity_.X = 0.0f;
if (ks.IsKeyDown(Keys::Left) || ks.IsKeyDown(Keys::A)) velocity_.X = -200.0f;
if (ks.IsKeyDown(Keys::Right) || ks.IsKeyDown(Keys::D)) velocity_.X = 200.0f;
// Jump (with coyote time and jump buffer)
bool jumpPressed = ks.IsKeyDown(Keys::Space) || ks.IsKeyDown(Keys::Up);
if (jumpPressed) jumpBufferTimer_ = 0.12f;
else jumpBufferTimer_ = std::max(0.0f, jumpBufferTimer_ - dt);
if (coyoteTimer_ > 0.0f && jumpBufferTimer_ > 0.0f) {
velocity_.Y = JUMP_FORCE;
coyoteTimer_ = 0.0f;
jumpBufferTimer_ = 0.0f;
}
// Move and collide
position_ += velocity_ * dt;
grounded_ = false;
for (auto& p : platforms) ResolveCollision(p);
// Coyote time: briefly allow jump after walking off a ledge
if (grounded_) coyoteTimer_ = 0.1f;
else coyoteTimer_ = std::max(0.0f, coyoteTimer_ - dt);
}
One-way platforms
One-way platforms only collide when the player is above and falling downward. Check velocity_.Y > 0 and that the player's bottom was above the platform top last frame.
void Player::ResolveCollision(const Platform& p) {
Vector2 pen;
if (!AABBCollide(Bounds(), p.Bounds(), pen)) return;
if (p.OneWay) {
// Only resolve if falling and player bottom was above platform top
if (velocity_.Y <= 0.0f) return;
if (prevBottom_ > p.Bounds().Y) return;
pen = Vector2(0.0f, pen.Y > 0 ? pen.Y : 0.0f);
}
position_.X -= pen.X;
position_.Y -= pen.Y;
if (pen.Y > 0.0f) { velocity_.Y = 0.0f; grounded_ = true; }
if (pen.Y < 0.0f) velocity_.Y = 0.0f; // hit ceiling
if (pen.X != 0.0f) velocity_.X = 0.0f;
}
Animation state machine
enum class PlayerAnim { Idle, Run, Jump, Fall };
void Player::UpdateAnimation(const GameTime& gt) {
float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
PlayerAnim next =
!grounded_ ? (velocity_.Y < 0 ? PlayerAnim::Jump : PlayerAnim::Fall)
: velocity_.X != 0 ? PlayerAnim::Run
: PlayerAnim::Idle;
if (next != currentAnim_) {
currentAnim_ = next;
animFrame_ = 0;
animTimer_ = 0.0f;
}
animTimer_ += dt;
if (animTimer_ >= frameDuration_) {
animTimer_ -= frameDuration_;
animFrame_ = (animFrame_ + 1) % FrameCount(currentAnim_);
}
}
Parallax background
void Level::DrawBackground(SpriteBatch& sb, const Vector2& cameraPos) {
// Layer 0: slowest (farthest away)
sb.Draw(bgLayer0_, Vector2(-cameraPos.X * 0.1f, -cameraPos.Y * 0.05f), Color::White);
// Layer 1: medium
sb.Draw(bgLayer1_, Vector2(-cameraPos.X * 0.4f, -cameraPos.Y * 0.2f), Color::White);
// Layer 2: near (almost full-speed)
sb.Draw(bgLayer2_, Vector2(-cameraPos.X * 0.8f, -cameraPos.Y * 0.5f), Color::White);
}
Complete mini-platformer skeleton
#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 "Microsoft/Xna/Framework/Rectangle.hpp"
#include <vector>
#include <memory>
#include <algorithm>
#include <cmath>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
struct Platform {
Rectangle Bounds;
bool OneWay = false;
};
class PlatformerGame final : public Game {
public:
PlatformerGame() : 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 white = Color::White;
pixel_->SetData(&white, 1);
platforms_ = {
{ Rectangle(0, 550, 800, 50), false }, // ground
{ Rectangle(150, 430, 200, 16), true }, // one-way platform
{ Rectangle(450, 330, 200, 16), true },
};
playerPos_ = Vector2(100, 400);
}
void Update(const GameTime& gt) override {
float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
auto ks = Keyboard::GetState();
velY_ = std::min(velY_ + 900.0f * dt, 800.0f);
velX_ = 0.0f;
if (ks.IsKeyDown(Keys::Left) || ks.IsKeyDown(Keys::A)) velX_ = -200.0f;
if (ks.IsKeyDown(Keys::Right) || ks.IsKeyDown(Keys::D)) velX_ = 200.0f;
bool jump = ks.IsKeyDown(Keys::Space);
if (jump && !prevJump_ && grounded_) velY_ = -420.0f;
prevJump_ = jump;
float prevBottom = playerPos_.Y + 40;
playerPos_.X += velX_ * dt;
playerPos_.Y += velY_ * dt;
grounded_ = false;
for (auto& p : platforms_) {
Rectangle pb = { (int)playerPos_.X, (int)playerPos_.Y, 32, 40 };
if (!pb.Intersects(p.Bounds)) continue;
if (p.OneWay && (velY_ <= 0 || prevBottom > p.Bounds.Y)) continue;
// Push out on Y (floor only for simplicity)
if (velY_ >= 0) {
playerPos_.Y = (float)(p.Bounds.Y - 40);
velY_ = 0;
grounded_ = true;
}
}
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
gd.Clear(Color(50, 50, 80));
sb_->Begin();
// Draw platforms
for (auto& p : platforms_) {
sb_->Draw(*pixel_, p.Bounds,
p.OneWay ? Color(100, 200, 100) : Color(150, 100, 60));
}
// Draw player
sb_->Draw(*pixel_,
Rectangle((int)playerPos_.X, (int)playerPos_.Y, 32, 40),
Color::Yellow);
sb_->End();
gd.Present();
}
private:
GraphicsDeviceManager graphics_;
std::unique_ptr<SpriteBatch> sb_;
std::unique_ptr<Texture2D> pixel_;
std::vector<Platform> platforms_;
Vector2 playerPos_;
float velX_ = 0, velY_ = 0;
bool grounded_ = false, prevJump_ = false;
};
int main() { PlatformerGame game; game.Run(); }