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:
- Explain why Player::handleInput resets velocity.x to zero every frame rather than accelerating it, and implement the instant-stop horizontal movement this produces
- Implement Euler integration of gravity into vertical velocity and velocity into position inside Player::update
- Implement edge-detected double jump input handling in Player::handleInput, using a function-local static flag to trigger a jump only on a fresh key press and a jumpsRemaining counter to allow exactly one extra jump in the air
- Implement AABB collision resolution in Player::checkPlatformCollision that finds the penetration depth on all four sides of an overlap, then resolves vertical collisions (landing, hitting a ceiling) and horizontal collisions (hitting a wall) as two separate, sequential steps on the axis of least penetration
- Implement the animation state selection in Player::update that chooses among idle, run, jump, fall, double_jump and wall_jump based on the player's current velocity and ground and wall flags, built up as airborne states first and grounded states second
- Implement PlatformSpawner::spawnNext so every generated platform is reachable by a single ordinary jump, deriving the safe horizontal step from the jump's ascending-crossing time for whatever vertical gap was actually sampled rather than a single flat cap, and so consecutive platforms never overlap in X by stepping each one from the nearest edge of the last rather than its centre
- Implement PlatformSpawner::update to spawn platforms ahead of the camera's rising view and despawn ones that have scrolled out of reach below it, then implement a deadzone-based camera rule for the climb that only follows the player once they rise above it, rather than chasing the peak of every jump including ones that fall back down without landing anywhere new
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.
- Reset
velocity.xto0.0fat the start of every call, so the player has no horizontal velocity unless a movement key is currently held. - Set
velocity.xto-speedandfacingRight = falsewhenAorLeftis held. - Otherwise, set
velocity.xtospeedandfacingRight = truewhenDorRightis 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.
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.
- Add
gravity * dttovelocity.y, so the player accelerates downward every frame it is not otherwise supported. - Read the sprite's current position with
sprite->getPosition(), addvelocity.x * dtandvelocity.y * dtto it, then write it back withsprite->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.
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.
- Declare
static bool jumpKeyWasPressed = false;inside the function. because it isstatic, this variable is created once and keeps its value between calls, remembering whether the jump key was down on the previous frame. - Read the current jump key state into
bool jumpKeyPressed, true whenW,SpaceorUpis held. - If
jumpKeyPressedis true,jumpKeyWasPressedis false, andjumpsRemaining > 0, this is a fresh press with a jump available. setvelocity.y = -jumpForce, decrementjumpsRemaining, and setonGround = false. - Inside that same branch, if
jumpsRemaininghas reached0and the player was airborne (!onGround && !onWall) when this jump triggered, sethasDoubleJumped = true, soupdatecan pick a different animation for it later in this lab. - 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.
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.
- 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), andoverlapBottom(the platform's bottom edge past the player's top edge). - Compute
minOverlapXas the smaller ofoverlapLeftandoverlapRight, andminOverlapYas the smaller ofoverlapTopandoverlapBottom. 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.
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.
#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.
- 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. - Inside that block, if
overlapTop < overlapBottom, the player landed on top of the platform. move the sprite up byoverlapTop(sprite->move({ 0, -overlapTop })), setvelocity.y = 0.0f, setonGround = true, and resetjumpsRemaining = 2andhasDoubleJumped = falseso the double jump is available again. - Otherwise the player hit the platform from below (a ceiling bump). move the sprite down by
overlapBottomand setvelocity.y = 0.0f, but do not touchonGroundorjumpsRemaining, 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.
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.
- Add an
else { ... }after the vertical branch's closing brace. reaching this branch meansminOverlapX <= minOverlapY, the collision came from the left or right, not the top or bottom. - Inside it, if
overlapLeft < overlapRight, the player hit the platform from its left side. move the sprite left byoverlapLeft(sprite->move({ -overlapLeft, 0 })) and setonWall = true. - Otherwise the player hit the platform from its right side. move the sprite right by
overlapRightand setonWall = 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.
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.
- If
onWall && !onGround, callanimManager->play("wall_jump")and sethasDoubleJumped = false. - Otherwise, if
hasDoubleJumped && !onGround && velocity.y < 0.0f, callanimManager->play("double_jump"). this only matches while the double jump is still carrying the player upward. - Otherwise, if
!onGround && velocity.y < -50.0f, callanimManager->play("jump")and sethasDoubleJumped = false. this is the ordinary rising-fast case. - Otherwise, if
!onGround && velocity.y > 50.0f, callanimManager->play("fall")and sethasDoubleJumped = false. - Otherwise, if
!onGround, callanimManager->play("jump")and sethasDoubleJumped = false. this covers the brief moment near the top of an arc wherevelocity.yis 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.
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.
#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);
};
#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.
- Add
else if (onGround && velocity.x != 0.0f) { ... }after the airborne chain, callinganimManager->play("run"). - Add a final
else if (onGround) { ... }, callinganimManager->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.
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.
- 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 ofmaxJumpHeight = jumpForce^2 / (2 * gravity) = 100px(already captured asMAX_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. computeheightFraction = gap / MAX_JUMP_HEIGHT, thenascendingCrossingTime = (1 - sqrt(1 - heightFraction)) / 2(the smaller root of that equation), thensafeHorizontalReach = PLAYER_SPEED * ascendingCrossingTime * HORIZONTAL_SAFETY_MARGIN. - 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>from15.0ftosafeHorizontalReachand sample astep. pick a random direction withstepDirectionDist(rng): if stepping right, the new platform's left edge islastPlatformLeftX + lastPlatformWidth + step(just past the previous platform's right edge); if stepping left, it islastPlatformLeftX - 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. - Clamp, place, and advance the generator's state. clamp
xto[0, windowWidth - width]so the platform never spawns partly off-screen, computey = highestGeneratedY - gap,push_backa newPlatform(x, y, width, PLATFORM_HEIGHT, platformSprite), then updatehighestGeneratedY,lastPlatformLeftXandlastPlatformWidthso the next call tospawnNextcontinues 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().
#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;
};
#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) {
}
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.
- Spawn ahead of the camera, not behind it. while
highestGeneratedY > topOfViewY - 200.0f, callspawnNext(). 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. - Despawn what has scrolled out of view. erase any platform whose
getBounds().position.yis greater thanbottomOfViewY, using the same erase-remove idiom asstd::remove_ifplusplatforms.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.
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();
}
}
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.
- Compute
deadzoneTopY = climbCameraCenterY - CLIMB_CAMERA_DEADZONE. - If
player.getPosition().y < deadzoneTopY(the player has risen above the deadzone), setclimbCameraCenterY = 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.
// 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.