University of Wales Trinity Saint David
ACCB5017 - Games Architecture and Mechanics

Platformer Movement, Collision and Animation

Overview

This lab builds a complete player character controller for a side-scrolling platformer, centred on a Player class. Working across eleven tasks, eight inside Player.cpp and three building a new PlatformSpawner class plus a second game mode in Platformer.cpp, you will complete horizontal movement, gravity and position integration, a double jump with input debouncing, AABB collision response against platform geometry built up one branch at a time, and the animation state selection that decides which sprite animation plays for the character's current state, also built up one group of states at a time, before building an endless vertical-climb mode where platforms are generated procedurally above the player as they climb instead of being hand-placed. The AssetManager that supplies textures and animation definitions to this class follows the same singleton and RAII patterns built in the Singleton Pattern and RAII and Smart Pointers labs, and the physics integration in Task 2 follows the same Euler integration pattern used to drop a ball in the Gravity and Restitution lab. By the end, a Player object moves, jumps, collides with platforms, and animates correctly inside a small SFML game loop, and can also climb an endless procedurally generated tower in a second mode reachable from the start screen.

This lab does not supply any image files. Before starting, source your own terrain tile, background image and character sprite sheet using the same licensed-sourcing process from the Sourcing Game Assets lab, a site such as OpenGameArt.org, Kenney.nl or itch.io's free game-assets section that states a license per asset. Look for a platformer character pack with separate horizontal frame-strip images for at least idle, running, jumping and falling poses, note each strip's frame width and height, then update the filenames and frame dimensions inside AssetManager::setup() (in AssetManager.cpp, given to you complete) to match what you downloaded before building. If your pack has no separate double jump or wall jump pose, point those two names at whichever strip you do have instead, since the animation selection tasks expect every named animation they request to resolve to a loaded texture.

Asset attribution: this lab's own reference build, and the trial climb-mode implementation Tasks 9-11 are drawn from, were built and playtested against the free Pixel Adventure pack by Pixel Frog (pixelfrog-assets.itch.io/pixel-adventure-1), released under a Creative Commons Zero (CC0) 1.0 Universal licence, free to use, remix and redistribute, including commercially, with no attribution required. it is credited here anyway, in keeping with the licensed-sourcing habit this lab and the Sourcing Game Assets lab both ask you to build for whatever pack you personally choose: check the licence stated on the asset's own page before you rely on it, and keep a note of where each asset came from.

Learning Objectives

By the end of this lab you will be able to:

Concept

A platformer character has to satisfy several expectations at once. instant, precise horizontal control, a convincing fall under gravity, the option of a second jump in mid-air, a hard stop against level geometry instead of passing through it, and an animation that matches whatever it is currently doing. Player::handleInput sets velocity.x directly to -speed or +speed based on which key is held, resetting it to zero first so the character stops the instant a key is released rather than sliding to a halt. Player::update then runs Euler integration each frame, adding gravity to velocity.y before adding velocity to position, the same two-step pattern used to drop a ball in the Gravity and Restitution lab.

Jumping adds edge detection on top of this. a function-local static bool remembers whether the jump key was already held last frame, so a jump only fires on the not-pressed-to-pressed transition rather than once per frame the key stays down. a jumpsRemaining counter starting at two allows one ground jump and one air jump before it must be reset by landing. Collision against a platform uses an AABB (axis-aligned bounding box) overlap test, sf::FloatRect::findIntersection, then measures how far the player has penetrated on all four sides and resolves on whichever side has the smallest overlap, the axis of least penetration, the side the collision actually came from. The animation choice at the end of update reads onGround, onWall, velocity.y and hasDoubleJumped to decide which named animation AnimationManager::play() should be showing.

Tasks

Task 1 - Horizontal Movement

Open Player.cpp and find Player::handleInput. This is called once per frame and is where all keyboard input for the player is read. complete the horizontal movement.

  1. Reset velocity.x to 0.0f at the start of every call, so the player has no horizontal velocity unless a movement key is currently held.
  2. Set velocity.x to -speed and facingRight = false when A or Left is held.
  3. Otherwise, set velocity.x to speed and facingRight = true when D or Right is held.

Success condition: holding A/D or the arrow keys moves the player left or right at a constant speed and stops the instant the key is released, rather than sliding to a halt.

Player.cpp
void Player::handleInput(float dt) {
    // TODO: reset velocity.x to 0.0f, then set it to -speed (and
    // facingRight = false) when A or Left is held, or speed (and
    // facingRight = true) when D or Right is held. Resetting to zero
    // first is what makes the player stop instantly the moment no
    // movement key is held, a deliberate choice for precise platforming
    // rather than a slippery accelerate-and-decelerate feel.

    // Double jump input handling is added in a later task in this lab.
}

Task 2 - Gravity and Position Integration

Open Player.cpp and find Player::update. This is called once per frame after handleInput, and is where velocity turns into actual on-screen movement. complete the two-step Euler integration at the top of the function, the same pattern used to drop a ball in the Gravity and Restitution lab.

  1. Add gravity * dt to velocity.y, so the player accelerates downward every frame it is not otherwise supported.
  2. Read the sprite's current position with sprite->getPosition(), add velocity.x * dt and velocity.y * dt to it, then write it back with sprite->setPosition().

Success condition: a player with nothing beneath it accelerates downward frame over frame instead of staying still or falling at a fixed speed, and the horizontal velocity from Task 1 now actually moves the player sideways on screen.

Player.cpp
void Player::update(float dt, float gravity) {
    if (!initialized || !sprite) return;

    // TODO: add gravity * dt to velocity.y

    // TODO: read the sprite's current position with sprite->getPosition(),
    // add velocity.x * dt and velocity.y * dt to it, then write it back
    // with sprite->setPosition()

    // Animation state selection is added in a later task in this lab.

    // Flip the sprite to match the direction set above.
    if (!facingRight) {
        sprite->setScale({ -1.0f, 1.0f });
    }
    else {
        sprite->setScale({ 1.0f, 1.0f });
    }
}

Task 3 - Double Jump

Open Player.cpp and return to Player::handleInput. The horizontal movement from Task 1 is shown below already solved. add the jump handling directly after it, inside the same function.

  1. Declare static bool jumpKeyWasPressed = false; inside the function. because it is static, this variable is created once and keeps its value between calls, remembering whether the jump key was down on the previous frame.
  2. Read the current jump key state into bool jumpKeyPressed, true when W, Space or Up is held.
  3. If jumpKeyPressed is true, jumpKeyWasPressed is false, and jumpsRemaining > 0, this is a fresh press with a jump available. set velocity.y = -jumpForce, decrement jumpsRemaining, and set onGround = false.
  4. Inside that same branch, if jumpsRemaining has reached 0 and the player was airborne (!onGround && !onWall) when this jump triggered, set hasDoubleJumped = true, so update can pick a different animation for it later in this lab.
  5. After the branch, set jumpKeyWasPressed = jumpKeyPressed; so next frame's check compares against this frame's state.

Success condition: pressing the jump key once while grounded launches the player upward and jumpsRemaining drops from 2 to 1. pressing it again while still airborne launches a second time and jumpsRemaining drops to 0, a double jump. further presses do nothing until jumpsRemaining is restored, which the collision tasks below do on landing.

Player.cpp
void Player::handleInput(float dt) {
    // Horizontal movement (Task 1, already solved).
    velocity.x = 0.0f;
    if (sf::Keyboard::isKeyPressed(sf::Keyboard::Key::A) ||
        sf::Keyboard::isKeyPressed(sf::Keyboard::Key::Left)) {
        velocity.x = -speed;
        facingRight = false;
    }
    else if (sf::Keyboard::isKeyPressed(sf::Keyboard::Key::D) ||
        sf::Keyboard::isKeyPressed(sf::Keyboard::Key::Right)) {
        velocity.x = speed;
        facingRight = true;
    }

    // TODO: declare static bool jumpKeyWasPressed = false

    // TODO: read jumpKeyPressed as true when W, Space or Up is held

    // TODO: if jumpKeyPressed is true, jumpKeyWasPressed is false, and
    // jumpsRemaining > 0: set velocity.y = -jumpForce, decrement
    // jumpsRemaining, set onGround = false, and if jumpsRemaining is now
    // 0 and the player was airborne (!onGround && !onWall) when this
    // jump triggered, set hasDoubleJumped = true

    // TODO: set jumpKeyWasPressed = jumpKeyPressed for next frame
}

Task 4 - AABB Penetration Depth

Open Player.cpp and find Player::checkPlatformCollision. The broad-phase overlap test, playerBounds.findIntersection(platformBounds), is already given. it only tells you that the two rectangles overlap, not by how much on each side. compute that next.

  1. Inside the if (playerBounds.findIntersection(platformBounds)) block, compute four penetration depths. overlapLeft (the player's right edge past the platform's left edge), overlapRight (the platform's right edge past the player's left edge), overlapTop (the player's bottom edge past the platform's top edge), and overlapBottom (the platform's bottom edge past the player's top edge).
  2. Compute minOverlapX as the smaller of overlapLeft and overlapRight, and minOverlapY as the smaller of overlapTop and overlapBottom. these two values are what the next two tasks compare to decide which side the collision actually came from.

Success condition: the code compiles and runs unchanged from before, since nothing reads these variables yet, this task alone produces no visible behaviour. the next two tasks use minOverlapX and minOverlapY to actually resolve the collision.

Player.cpp
void Player::checkPlatformCollision(const sf::FloatRect& platformBounds) {
    if (!initialized || !sprite) return;

    sf::FloatRect playerBounds = sprite->getGlobalBounds();

    // Broad phase: do the two rectangles overlap at all?
    if (playerBounds.findIntersection(platformBounds)) {
        // TODO: compute overlapLeft, overlapRight, overlapTop and
        // overlapBottom, the penetration depth on each of the four sides

        // TODO: compute minOverlapX as the smaller of overlapLeft and
        // overlapRight, and minOverlapY as the smaller of overlapTop and
        // overlapBottom

        // Vertical collision resolution is added in the next task.

        // Horizontal collision resolution is added in the task after that.
    }
}

Platform is the class this collision test is called against once per platform in the level. it is given complete below as reference, its own job, stretching a texture to an arbitrary size and exposing a separate sf::FloatRect for collision, is not part of this lab.

Platform.h
#pragma once

#include <SFML/Graphics.hpp>

// Static level geometry: a visual sprite plus a separate collision
// rectangle, checked once per platform against the player each frame.
class Platform {
private:
    sf::Sprite sprite;
    sf::FloatRect bounds;

public:
    Platform(float x, float y, float width, float height, const sf::Sprite& platformSprite);

    const sf::FloatRect& getBounds() const;
    void draw(sf::RenderWindow& window);
};

Task 5 - Vertical Collision Resolution

Open Player.cpp and return to Player::checkPlatformCollision. The penetration depths from Task 4 are shown below already solved. add the vertical resolution branch: what happens when the player lands on top of a platform, or hits one from underneath.

  1. Add if (minOverlapY < minOverlapX) { ... }. a smaller vertical overlap than horizontal overlap means the collision came from the top or bottom, the axis of least penetration, not the side.
  2. Inside that block, if overlapTop < overlapBottom, the player landed on top of the platform. move the sprite up by overlapTop (sprite->move({ 0, -overlapTop })), set velocity.y = 0.0f, set onGround = true, and reset jumpsRemaining = 2 and hasDoubleJumped = false so the double jump is available again.
  3. Otherwise the player hit the platform from below (a ceiling bump). move the sprite down by overlapBottom and set velocity.y = 0.0f, but do not touch onGround or jumpsRemaining, hitting your head is not landing.

Success condition: a player falling onto a platform stops exactly on its surface instead of sinking into it, onGround becomes true, and jumpsRemaining resets to 2. jumping straight up into the underside of a platform stops the upward motion without granting a fresh double jump.

Player.cpp
void Player::checkPlatformCollision(const sf::FloatRect& platformBounds) {
    if (!initialized || !sprite) return;

    sf::FloatRect playerBounds = sprite->getGlobalBounds();

    // Broad phase: do the two rectangles overlap at all?
    if (playerBounds.findIntersection(platformBounds)) {
        // Penetration depth on each side (Task 4, already solved).
        float overlapLeft = playerBounds.position.x + playerBounds.size.x - platformBounds.position.x;
        float overlapRight = platformBounds.position.x + platformBounds.size.x - playerBounds.position.x;
        float overlapTop = playerBounds.position.y + playerBounds.size.y - platformBounds.position.y;
        float overlapBottom = platformBounds.position.y + platformBounds.size.y - playerBounds.position.y;

        float minOverlapX = std::min(overlapLeft, overlapRight);
        float minOverlapY = std::min(overlapTop, overlapBottom);

        // TODO: if minOverlapY < minOverlapX, this is a vertical
        // collision - landing on top (overlapTop < overlapBottom) pushes
        // the sprite up, stops velocity.y, sets onGround and resets
        // jumpsRemaining and hasDoubleJumped; otherwise (hit from below)
        // push the sprite down and stop velocity.y only

        // Horizontal collision resolution is added in the next task.
    }
}

Task 6 - Horizontal Collision Resolution

Open Player.cpp and return to Player::checkPlatformCollision one more time. The vertical branch from Task 5 is shown below already solved. add the else branch for when the collision came from the side instead.

  1. Add an else { ... } after the vertical branch's closing brace. reaching this branch means minOverlapX <= minOverlapY, the collision came from the left or right, not the top or bottom.
  2. Inside it, if overlapLeft < overlapRight, the player hit the platform from its left side. move the sprite left by overlapLeft (sprite->move({ -overlapLeft, 0 })) and set onWall = true.
  3. Otherwise the player hit the platform from its right side. move the sprite right by overlapRight and set onWall = true.

Success condition: a player walking into a platform's side stops horizontally exactly at its edge, sets onWall, and does not trigger a vertical landing. a player standing on top of a platform (Task 5) never falls into this branch, since its vertical overlap stays smaller than its horizontal overlap the whole time it is grounded.

Player.cpp
void Player::checkPlatformCollision(const sf::FloatRect& platformBounds) {
    if (!initialized || !sprite) return;

    sf::FloatRect playerBounds = sprite->getGlobalBounds();

    // Broad phase: do the two rectangles overlap at all?
    if (playerBounds.findIntersection(platformBounds)) {
        // Penetration depth on each side (Task 4, already solved).
        float overlapLeft = playerBounds.position.x + playerBounds.size.x - platformBounds.position.x;
        float overlapRight = platformBounds.position.x + platformBounds.size.x - playerBounds.position.x;
        float overlapTop = playerBounds.position.y + playerBounds.size.y - platformBounds.position.y;
        float overlapBottom = platformBounds.position.y + platformBounds.size.y - playerBounds.position.y;

        float minOverlapX = std::min(overlapLeft, overlapRight);
        float minOverlapY = std::min(overlapTop, overlapBottom);

        // Vertical collision resolution (Task 5, already solved).
        if (minOverlapY < minOverlapX) {
            if (overlapTop < overlapBottom) {
                sprite->move({ 0, -overlapTop });
                velocity.y = 0.0f;
                onGround = true;
                jumpsRemaining = 2;
                hasDoubleJumped = false;
            }
            else {
                sprite->move({ 0, overlapBottom });
                velocity.y = 0.0f;
            }
        }
        // TODO: else, this is a horizontal collision - when
        // overlapLeft < overlapRight move the sprite left by overlapLeft
        // and set onWall = true, otherwise move the sprite right by
        // overlapRight and set onWall = true
    }
}

Task 7 - Airborne Animation States

Open Player.cpp and return to Player::update. The gravity and position integration from Task 2 is shown below already solved. add the first half of the animation state selection directly after it, before the existing call to animManager->update(dt): the five conditions that cover every state where the player is airborne or touching a wall.

  1. If onWall && !onGround, call animManager->play("wall_jump") and set hasDoubleJumped = false.
  2. Otherwise, if hasDoubleJumped && !onGround && velocity.y < 0.0f, call animManager->play("double_jump"). this only matches while the double jump is still carrying the player upward.
  3. Otherwise, if !onGround && velocity.y < -50.0f, call animManager->play("jump") and set hasDoubleJumped = false. this is the ordinary rising-fast case.
  4. Otherwise, if !onGround && velocity.y > 50.0f, call animManager->play("fall") and set hasDoubleJumped = false.
  5. Otherwise, if !onGround, call animManager->play("jump") and set hasDoubleJumped = false. this covers the brief moment near the top of an arc where velocity.y is close to zero, and is why this condition must come last, everything above it is a more specific airborne case.

Success condition: a rising player shows jump, a falling player shows fall, a player who just used a double jump briefly shows double_jump until it starts falling again, and a player touching a wall in the air shows wall_jump. a grounded player currently shows nothing, since none of these five conditions match while onGround is true, the next task adds that half.

Player.cpp
void Player::update(float dt, float gravity) {
    if (!initialized || !sprite) return;

    // Gravity and position integration (Task 2, already solved).
    velocity.y += gravity * dt;

    sf::Vector2f pos = sprite->getPosition();
    pos.x += velocity.x * dt;
    pos.y += velocity.y * dt;
    sprite->setPosition(pos);

    // TODO: choose which airborne animation to play from onGround,
    // onWall, velocity.y and hasDoubleJumped, in the order described in
    // the prose above, calling animManager->play() with the matching
    // name and clearing hasDoubleJumped where noted

    // Grounded animation states (run, idle) are added in the next task.

    animManager->update(dt);

    if (!facingRight) {
        sprite->setScale({ -1.0f, 1.0f });
    }
    else {
        sprite->setScale({ 1.0f, 1.0f });
    }
}

AnimationManager is what play() and update(dt) above actually call into. it is given complete below as reference. this lab is about choosing which animation to play, not about how a chosen animation advances frame by frame.

AnimationManager.h
#pragma once

#include <SFML/Graphics.hpp>
#include <string>
#include <unordered_map>
#include "AssetManager.h"

// One loaded animation: a sprite sheet texture read frameWidth pixels at
// a time, played at frameTime seconds per frame.
struct Animation {
    const sf::Texture* texture;
    int frameWidth;
    int frameHeight;
    float frameTime;
    bool loop;
    int numFrames;

    Animation(const sf::Texture* tex, const Platformer::AnimationDef& def)
        : texture(tex),
        frameWidth(def.frameWidth),
        frameHeight(def.frameHeight),
        frameTime(def.frameTime),
        loop(def.loop) {
        numFrames = texture->getSize().x / def.frameWidth;
    }
};

// Plays sprite-sheet animations on a single sf::Sprite, switching between
// named animations and advancing frames based on elapsed time.
class AnimationManager {
private:
    sf::Sprite& sprite;
    std::unordered_map<std::string, Animation> animations;
    std::string currentAnim;
    int currentFrame;
    float elapsedTime;

public:
    AnimationManager(sf::Sprite& s);

    void loadAnimation(const std::string& name, const Platformer::AnimationDef& def, const sf::Texture* texture);
    void play(const std::string& name);
    void update(float dt);
};
AnimationManager.cpp
#include "AnimationManager.h"
#include <iostream>

AnimationManager::AnimationManager(sf::Sprite& s) : sprite(s), currentFrame(0), elapsedTime(0.0f) {
}

void AnimationManager::loadAnimation(const std::string& name, const Platformer::AnimationDef& def, const sf::Texture* texture) {
    if (!texture) {
        std::cerr << "Error: Texture not found for animation '" << name << "'" << std::endl;
        return;
    }
    animations.emplace(name, Animation(texture, def));
}

// Only switch (and restart) an animation if it actually changed. calling
// play("run") again every frame while already running "run" would reset
// currentFrame to 0 constantly and the animation would never advance.
void AnimationManager::play(const std::string& name) {
    if (currentAnim != name) {
        currentAnim = name;
        currentFrame = 0;
        elapsedTime = 0.0f;

        auto it = animations.find(currentAnim);
        if (it != animations.end()) {
            sprite.setTexture(*it->second.texture);
            sprite.setTextureRect(sf::IntRect({ 0, 0 }, { it->second.frameWidth, it->second.frameHeight }));
            sprite.setOrigin({ it->second.frameWidth / 2.0f, it->second.frameHeight / 2.0f });
        }
    }
}

// Time-based frame advancement, so animation speed does not depend on
// the game's frame rate.
void AnimationManager::update(float dt) {
    if (currentAnim.empty()) {
        return;
    }

    elapsedTime += dt;
    auto& anim = animations.at(currentAnim);

    if (elapsedTime >= anim.frameTime) {
        elapsedTime -= anim.frameTime;
        currentFrame++;

        if (currentFrame >= anim.numFrames) {
            if (anim.loop) {
                currentFrame = 0;
            }
            else {
                currentFrame = anim.numFrames - 1;
            }
        }

        sprite.setTextureRect(sf::IntRect({ currentFrame * anim.frameWidth, 0 }, { anim.frameWidth, anim.frameHeight }));
        sprite.setOrigin({ anim.frameWidth / 2.0f, anim.frameHeight / 2.0f });
    }
}

Task 8 - Grounded Animation States

Open Player.cpp and return to Player::update one more time. The five airborne conditions from Task 7 are shown below already solved. append the final two conditions, covering the two states where the player is standing on a platform.

  1. Add else if (onGround && velocity.x != 0.0f) { ... } after the airborne chain, calling animManager->play("run").
  2. Add a final else if (onGround) { ... }, calling animManager->play("idle"). this is the catch-all for a grounded player with no horizontal velocity.

Success condition: a stationary grounded player shows the idle animation, and a moving grounded player shows run. combined with Task 7, every reachable combination of onGround, onWall, velocity.y and hasDoubleJumped now selects exactly one of idle, run, jump, fall, double_jump or wall_jump, with no gaps.

Player.cpp
void Player::update(float dt, float gravity) {
    if (!initialized || !sprite) return;

    // Gravity and position integration (Task 2, already solved).
    velocity.y += gravity * dt;

    sf::Vector2f pos = sprite->getPosition();
    pos.x += velocity.x * dt;
    pos.y += velocity.y * dt;
    sprite->setPosition(pos);

    // Airborne animation states (Task 7, already solved).
    if (onWall && !onGround) {
        animManager->play("wall_jump");
        hasDoubleJumped = false;
    }
    else if (hasDoubleJumped && !onGround && velocity.y < 0.0f) {
        animManager->play("double_jump");
    }
    else if (!onGround && velocity.y < -50.0f) {
        animManager->play("jump");
        hasDoubleJumped = false;
    }
    else if (!onGround && velocity.y > 50.0f) {
        animManager->play("fall");
        hasDoubleJumped = false;
    }
    else if (!onGround) {
        animManager->play("jump");
        hasDoubleJumped = false;
    }
    // TODO: add the two grounded conditions - else if (onGround &&
    // velocity.x != 0.0f) play "run", else if (onGround) play "idle"

    animManager->update(dt);

    if (!facingRight) {
        sprite->setScale({ -1.0f, 1.0f });
    }
    else {
        sprite->setScale({ 1.0f, 1.0f });
    }
}

Task 9 - PlatformSpawner: A Reachable, Non-Overlapping Platform

The fixed level's std::vector<Platform> platforms in Platformer.cpp is a hand-placed list of seven platforms, useful for a short level with a goal zone, but it cannot make an endless one. This task and the two after it build a second game mode, an endless vertical climb, where platforms are generated procedurally above the player as they climb and forgotten once they scroll out of view below. GameState already separates StartScreen, InGame and EndScreen, a ClimbMode and a matching ClimbGameOver add a second, self-contained loop through the same state machine, reachable from the start screen with its own key, so Tasks 1-8 keep working exactly as before.

The two rules below are not guesses, they are fixes for two real bugs a first attempt at this hit during playtesting: platforms that were flat-out unreachable, and platforms that visually overlapped each other. Open PlatformSpawner.h (given below as reference, it declares the class this task and the next one implement) and PlatformSpawner.cpp, and complete PlatformSpawner::spawnNext, the function that generates a single new platform directly above the highest one generated so far.

  1. Work out how far the player can jump for THIS platform's gap, not a single flat number. Player's own physics constants, speed = 200, jumpForce = 400, gravity = 800, fix a single-jump ceiling of maxJumpHeight = jumpForce^2 / (2 * gravity) = 100px (already captured as MAX_JUMP_HEIGHT). the mistake is stopping there and picking one flat horizontal cap for every platform regardless of its gap. solving the jump's height equation for the time it first reaches a given gap on the way up (the ascending crossing, i.e. how a player naturally lands mid-jump, rather than the unintuitive alternative of catching a platform on the way back down past the peak) shows the horizontally reachable distance shrinks the closer the gap gets to the 100px ceiling: a gap of 60px leaves under 40px of natural horizontal reach, a gap of 90px leaves under 70px. compute heightFraction = gap / MAX_JUMP_HEIGHT, then ascendingCrossingTime = (1 - sqrt(1 - heightFraction)) / 2 (the smaller root of that equation), then safeHorizontalReach = PLAYER_SPEED * ascendingCrossingTime * HORIZONTAL_SAFETY_MARGIN.
  2. Sample a step magnitude from that reach, and step from an edge, not a centre or raw X. build a std::uniform_real_distribution<float> from 15.0f to safeHorizontalReach and sample a step. pick a random direction with stepDirectionDist(rng): if stepping right, the new platform's left edge is lastPlatformLeftX + lastPlatformWidth + step (just past the previous platform's right edge); if stepping left, it is lastPlatformLeftX - step - width (just before the previous platform's left edge). stepping from the previous platform's own edge, outward, guarantees the new platform's X-range never overlaps the previous one's, regardless of how wide either platform is, a step measured from a fixed reference point like a centre or raw X can land back inside a wide previous platform's own footprint, which looks and plays like two platforms stacked on top of each other.
  3. Clamp, place, and advance the generator's state. clamp x to [0, windowWidth - width] so the platform never spawns partly off-screen, compute y = highestGeneratedY - gap, push_back a new Platform(x, y, width, PLATFORM_HEIGHT, platformSprite), then update highestGeneratedY, lastPlatformLeftX and lastPlatformWidth so the next call to spawnNext continues the climb from here.

Success condition: the code compiles and, once Task 10 wires it up, every platform reset() pre-generates sits at a real, reachable gap above the one before it and never overlaps it in X, whatever width and gap happen to get sampled. this task alone has no independently observable behaviour, since nothing calls spawnNext yet outside the given, already-complete reset().

PlatformSpawner.h
#pragma once

#include <SFML/Graphics.hpp>
#include <vector>
#include <random>
#include "Platform.h"

// A Doodle-Jump-style endless climber cannot hand-place platforms the way
// a fixed level does, because the level never ends. PlatformSpawner
// generates platforms lazily, just above whatever the highest platform
// generated so far is, and only as the camera's rise actually demands
// more content. Platforms that have scrolled below the bottom of the
// camera are erased every frame, so this class's memory footprint stays
// bounded no matter how high the player climbs.
//
// Reachability: every platform must be reachable by a single ordinary
// jump (speed = 200px/s, jumpForce = 400px/s, gravity = 800px/s^2 gives
// a 100px jump ceiling). VERTICAL GAP is sampled well under that ceiling
// (60-90px) so every jump has real margin for error. HORIZONTAL STEP is
// deliberately NOT a flat +/-N cap - it is derived from the ASCENDING
// crossing time of whatever gap was actually sampled for that platform
// (see spawnNext()), since a flat cap generates platforms only
// reachable by an unintuitive "land on the way back down past the
// apex" line. Each new platform steps from the nearest EDGE of the
// previous one, not its centre or raw X, so two consecutive platforms
// can never end up overlapping in X regardless of how wide either one is.
class PlatformSpawner {
private:
    std::vector<Platform> platforms;
    sf::Sprite platformSprite;
    std::mt19937 rng;
    std::uniform_real_distribution<float> gapDist;     // vertical gap between platforms
    std::uniform_real_distribution<float> widthDist;   // platform width
    std::bernoulli_distribution stepDirectionDist;     // which side of the previous platform to step to

    float highestGeneratedY;    // Y of the topmost platform generated so far (smaller Y = higher up)
    float lastPlatformLeftX;    // left edge of the last platform generated
    float lastPlatformWidth;    // width of the last platform generated, so edges can be computed
    float windowWidth;

    static constexpr float PLATFORM_HEIGHT = 20.0f;
    static constexpr float PLAYER_SPEED = 200.0f;    // must match Player::speed
    static constexpr float MAX_JUMP_HEIGHT = 100.0f; // must match Player's jumpForce/gravity derived ceiling
    static constexpr float HORIZONTAL_SAFETY_MARGIN = 0.75f; // fraction of the raw reachable distance actually offered

    // Generates one new platform directly above highestGeneratedY, pushes
    // it into platforms, then advances highestGeneratedY and the last
    // platform's edges so the next call continues the climb from there.
    void spawnNext();

public:
    PlatformSpawner(const sf::Sprite& platformSprite, float windowWidth);

    // Clears all platforms and generates a fresh run starting just above startY.
    void reset(float startY);

    // Spawns new platforms so generated content always extends above
    // topOfViewY, and erases platforms scrolled below bottomOfViewY.
    // Call once per frame with the current camera bounds.
    void update(float topOfViewY, float bottomOfViewY);

    std::vector<Platform>& getPlatforms();
    float getHighestGeneratedY() const;
};
PlatformSpawner.cpp (constructor, given)
#include "PlatformSpawner.h"
#include <algorithm>
#include <cmath>

PlatformSpawner::PlatformSpawner(const sf::Sprite& sprite, float winWidth)
    : platforms(),
    platformSprite(sprite),
    rng(std::random_device{}()),
    gapDist(60.0f, 90.0f),
    widthDist(90.0f, 180.0f),
    stepDirectionDist(0.5),
    highestGeneratedY(0.0f),
    lastPlatformLeftX(0.0f),
    lastPlatformWidth(0.0f),
    windowWidth(winWidth) {
}
PlatformSpawner.cpp
void PlatformSpawner::spawnNext() {
    float width = widthDist(rng);
    float gap = gapDist(rng);

    // TODO: derive the safe horizontal reach for THIS gap. compute
    // heightFraction = gap / MAX_JUMP_HEIGHT, then the ascending
    // crossing time ascendingCrossingTime = (1 - sqrt(1 - heightFraction)) / 2
    // (the smaller root of the jump's height equation), then
    // safeHorizontalReach = PLAYER_SPEED * ascendingCrossingTime * HORIZONTAL_SAFETY_MARGIN

    // TODO: sample a step magnitude from a uniform_real_distribution
    // built from (15.0f, safeHorizontalReach), using rng

    // TODO: pick a random direction with stepDirectionDist(rng). if
    // stepping right, x = lastPlatformLeftX + lastPlatformWidth + step
    // (the new platform's left edge starts just past the previous
    // platform's right edge). if stepping left, x = lastPlatformLeftX -
    // step - width (the new platform's right edge ends just before the
    // previous platform's left edge). then clamp x to
    // [0, windowWidth - width]

    // TODO: compute y = highestGeneratedY - gap, push_back a new
    // Platform(x, y, width, PLATFORM_HEIGHT, platformSprite), then
    // update highestGeneratedY, lastPlatformLeftX and lastPlatformWidth
    // so the next call continues from here
}

Task 10 - PlatformSpawner: Spawning Ahead and Despawning Behind

Open PlatformSpawner.cpp and complete PlatformSpawner::update, called once per frame with the camera's current top and bottom world-Y. reset, shown below as reference, already calls the spawnNext you wrote in Task 9 to pre-generate an opening run of platforms, this task keeps that run going forever and cleans up after it.

  1. Spawn ahead of the camera, not behind it. while highestGeneratedY > topOfViewY - 200.0f, call spawnNext(). this keeps generated content a little ahead of what the camera can currently see, so a new platform is always ready before the player could see the gap where it would appear.
  2. Despawn what has scrolled out of view. erase any platform whose getBounds().position.y is greater than bottomOfViewY, using the same erase-remove idiom as std::remove_if plus platforms.erase(...). because the camera (Task 11) only ever moves up, a platform below that line can never become reachable again, so dropping it is safe and keeps memory bounded no matter how high the player climbs.

Success condition: starting a climb generates an initial run of platforms with real gaps and no overlap between them (Task 9), and as the player climbs, new platforms keep appearing above before they can see the gap where one would appear, while platforms that scroll below the bottom of the screen disappear rather than accumulating forever.

PlatformSpawner.cpp (reset, given)
void PlatformSpawner::reset(float startY) {
    platforms.clear();
    highestGeneratedY = startY;
    lastPlatformLeftX = windowWidth / 2.0f - 150.0f;
    lastPlatformWidth = 300.0f;

    // A wide starting platform under the player's spawn point.
    platforms.push_back(Platform(lastPlatformLeftX, startY, lastPlatformWidth, PLATFORM_HEIGHT, platformSprite));

    // Pre-generate enough platforms to fill roughly one screen height
    // above the ground, so the climb has content the instant it starts.
    while (highestGeneratedY > startY - 700.0f) {
        spawnNext();
    }
}
PlatformSpawner.cpp
void PlatformSpawner::update(float topOfViewY, float bottomOfViewY) {
    // TODO: while highestGeneratedY > topOfViewY - 200.0f, call
    // spawnNext()

    // TODO: erase-remove any platform whose getBounds().position.y is
    // greater than bottomOfViewY - it has fully scrolled below the
    // bottom of the camera view and can never become reachable again
}

std::vector<Platform>& PlatformSpawner::getPlatforms() {
    return platforms;
}

float PlatformSpawner::getHighestGeneratedY() const {
    return highestGeneratedY;
}

Task 11 - A Camera That Only Moves Up, Through a Deadzone

Open Platformer.cpp and find the ClimbMode case (given, alongside the rest of the climb's wiring, in the complete solution file at the end of this lab). Player::handleInput, Player::update and Player::checkPlatformCollision are called exactly as they are in InGame, against climbSpawner.getPlatforms() instead of the fixed vector, the one piece still missing is how the camera decides to follow the player.

The naive approach sets the camera's desired centre to a fixed distance above the player's current Y every single frame. That makes the camera chase the peak of every jump, including ones that fall back down without reaching a new platform. Because PlatformSpawner::update (Task 10) spawns ahead of the camera's view, not the player's real progress, that eagerness lets it generate platforms far above wherever the player has genuinely landed, and one can then pop into existence directly inside the player's current jump, registering as a spurious mid-air landing that refills the double jump, an unearned "triple jump" bug this lab's own trial implementation actually hit during playtesting.

  1. Compute deadzoneTopY = climbCameraCenterY - CLIMB_CAMERA_DEADZONE.
  2. If player.getPosition().y < deadzoneTopY (the player has risen above the deadzone), set climbCameraCenterY = player.getPosition().y + CLIMB_CAMERA_DEADZONE, which puts the player back at the deadzone's edge rather than the camera's exact centre.

Ordinary jumps and falls that stay inside the deadzone never move the camera at all, so pre-generation can only ever run as far ahead as the player has actually climbed. The "camera only ever moves up" rule still holds without needing a separate std::min: the condition only fires when the player is already above the deadzone, and the new centre it computes is always smaller (higher up) than the old one.

Success condition: the camera stays still through ordinary small jumps and only rises once real progress is made, never drops back down when the player falls a little, and a platform never appears directly in the player's current flight path mid-jump, no more free "triple jumps" from a spuriously spawned platform.

Platformer.cpp (excerpt from the ClimbMode case)
// TODO: compute deadzoneTopY = climbCameraCenterY - CLIMB_CAMERA_DEADZONE,
// and if player.getPosition().y < deadzoneTopY, set climbCameraCenterY =
// player.getPosition().y + CLIMB_CAMERA_DEADZONE

float topOfView = climbCameraCenterY - CLIMB_VIEW_HALF_HEIGHT;
float bottomOfView = climbCameraCenterY + CLIMB_VIEW_HALF_HEIGHT;
climbSpawner.update(topOfView, bottomOfView);

// Falling below the bottom of the camera's view means every platform
// the player could still reach is now off-screen below them, an
// unrecoverable fall.
if (player.getPosition().y > bottomOfView + 50.0f) {
    climbFinalHeightScore = climbHeightScore;
    gameState = GameState::ClimbGameOver;
}

camera.setCenter({ 400.0f, climbCameraCenterY });

Solution

Try the tasks above before revealing the solution.