Tutorial 91: Building a 2D Platformer

CNA — C++ XNA 4.0 reimplementation

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(); }