Overview
This lab builds Almost Fruit Ninja, a small SFML game centred on a single Game class, one part at a time. Starting from an empty Visual Studio project, the game grows from a bare window that spawns and despawns falling targets into a fully playable game: mouse-based slicing with a live score, a limited-use super move, a lives system with a game over condition, difficulty that scales with score, and finally keyboard-based colour slicing that replaces the mouse entirely. Each part edits the same project in place, so by the end the game contains everything built across every part.
Learning Objectives
By the end of this lab you will be able to:
- Declare a class's public and private interface in a header, separate from its implementation, and open and poll a window through SFML 3's std::optional-based event API
- Manage a live pool of objects safely: gate creation behind a timer and a maximum count, and remove elements mid-iteration with an index-controlled erase loop
- Read player input both continuously, mapping a raw mouse position into view coordinates and testing it against a shape's bounds, and discretely, handling a key press inside the existing event loop
- Display and update live on-screen state through sf::Text objects constructed against a shared font, rebuilding each one's string at runtime as the value it shows changes
- Track state that changes over a run: a limited-use counter guarded by an early return, a resource that ends the game once it reaches zero, and independent score thresholds that fire once or repeatedly
- Evolve a data type in place, replacing a bare element with a small struct to carry more information, and delete code a later design change has made obsolete
Concept
Part 1 - Falling Targets and the Game Loop
A game that spawns an object every single frame quickly runs out of control, the screen fills instantly and the vector holding those objects grows without bound. What is actually needed is a steady trickle, one new target appears every so often, and only while the number currently on screen is below some ceiling. That calls for two pieces of state working together, a spawn timer that counts up every frame and a maximum count that the current number of live targets is compared against before a new one is allowed to appear.
setFramerateLimit(60) caps the window at roughly 60 frames per second, it does not guarantee that rate, so counting frames rather than measuring real elapsed time is a deliberately simplified, frame-based stand-in for a clock. It is close enough for a steady spawn cadence here, though it means spawning and movement both stay tied to however fast frames actually arrive rather than to real time. Each frame adds a fixed amount to the spawn timer, and once that timer reaches a threshold and the target count is still below the maximum, a new sf::RectangleShape is pushed into a std::vector at a random x position and the timer resets to zero. Every existing target then needs to move a little further down the window with move(), and any target whose position has passed the bottom edge needs to disappear. Removing an element from the middle of a std::vector while a loop is still walking through it is done with the index-controlled erase loop, calling erase() on the element's iterator and skipping the usual increment for that pass so the next element is not accidentally skipped.
Part 2 - Mouse Slicing and Score Display
A target falling down the window has a position measured in the same coordinate space the window draws everything in, but the mouse cursor's position, as SFML reports it, is measured in screen pixels relative to the window's corner. Those two coordinate spaces usually line up, but comparing them directly breaks the moment the window is resized, moved, or given a scrolled or zoomed view. The safe way to compare a mouse click against a target's bounds is to convert the raw pixel position into the window's own coordinate space first, with window.mapPixelToCoords().
Once the mouse position is in the right coordinate space, testing whether it lies over a specific target is a single call, target.getGlobalBounds().contains(mouseViewPosition), which returns true only when the point falls inside that target's rectangle. Combined with sf::Mouse::isButtonPressed(sf::Mouse::Button::Left), a target can be erased from the vector the instant the held mouse position overlaps it. Showing the running total on screen needs an sf::Text object, which in SFML 3 is constructed from a reference to the sf::Font it will render with, loaded ahead of time via font.openFromFile(). Because sf::Text stores a plain string rather than a number, updating the displayed score means building a fresh string every time the score changes, most simply with a std::stringstream and setString().
Part 3 - Super Move
A limited-use ability needs exactly one new piece of state, a counter that starts at some fixed number and only ever counts down. The action itself, clearing every target on screen, is a single call to std::vector::clear(), but the reward for using it depends on how many targets existed the instant before they were cleared, so that count has to be read and saved before clear() throws it away.
Making the ability limited means checking the counter before doing anything else, an early return right at the top of the function stops the whole action cold the instant the counter reaches zero, with no further code in that function able to run this call. The key press itself slots into the same updateEvents() loop already checking for sf::Event::KeyPressed, as one more if branch alongside the existing Escape check, this time matching sf::Keyboard::Key::Enter. Showing the remaining count on screen reuses the exact shape of work the score display already does, a second sf::Text object, constructed against the same font, refreshed through its own small helper function every time the underlying number it displays changes.
Part 4 - Lives and Game Over
Inside updateTargets(), a target can already leave the vector for two different reasons, the player sliced it, or it fell off the bottom of the window unsliced. Those two cases currently do exactly the same thing, erase the element and move on, but they need to mean something different to the player. Missing a target is a failure that should cost something, while slicing one is a success that already pays out through the score. The two erase sites sit in different parts of the same loop, so charging a cost only applies to the one that runs when a target crosses the bottom edge, the slicing branch stays untouched.
The cost itself is a lives counter that only ever decreases, following the same shape as the super move counter, a member variable, a paired sf::Text constructed against the shared font, and a small refresh function that rebuilds its string whenever the underlying number changes. What is different this time is what happens once the counter bottoms out. Rather than guarding an action with an early return the way triggerSuperMove() does, reaching zero lives needs to end the game outright, which in this program means calling window->close() from inside updateTargets() itself. Because running() just reports window->isOpen(), closing the window from anywhere in the code ends the main loop in main() the same way clicking the window's own close button already does.
Part 5 - Difficulty Scaling
Every target so far has fallen at the same fixed speed, written directly as a literal inside move(), and the maximum number allowed on screen has stayed fixed since initVariables() first set it. Making either of those change over time means turning the literal into a member variable that starts at the same value but can be reassigned later, once, from a completely different function, without touching the code that uses it.
Difficulty here is entirely driven by the existing score value, checked against a set of thresholds. A repeatable rule, like adding one to the maximum target count, needs its threshold to move forward every time it fires, otherwise the same crossing would trigger again on every single frame the score stays above it. That means storing the next threshold in its own member variable and advancing it by a fixed step each time the check passes, rather than testing against a hardcoded number directly. A rule that should only ever happen once, doubling the maximum target count, needs the opposite kind of guard, a bool flag checked alongside the threshold so the doubling cannot repeat once it has already happened. All of these checks read from score but change entirely different pieces of state, so they can be written as independent if statements inside one function, run once per frame.
Part 6 - Key-Based Slicing
A std::vector<sf::RectangleShape> works fine as long as every target only ever needs its shape, but slicing by colour means every target also needs to remember which colour it was given, and a plain sf::RectangleShape has nowhere to store that. The fix is a small struct bundling a shape together with an enum class value naming its colour, and changing the vector to hold that struct instead of a bare shape. Every place that used to read targets[i] as a shape directly now reads targets[i].shape, a small, mechanical change repeated everywhere the old type was used.
Turning an enum value into something the player can act on needs two small mapping functions, one from colour to an sf::Color for drawing, and one from a sf::Keyboard::Key back to the colour it represents. Because a key press is a single discrete event with no target under a cursor to check bounds against, slicing by key removes every target sharing that colour in one pass, using the same index-controlled erase loop already used elsewhere in this game to despawn targets that fall off the bottom of the window. Once slicing works this way, the mouse position tracking and the click-detection branch built earlier have no remaining purpose and should be deleted outright rather than left in place unused.
Tasks
Part 1 - Falling Targets and the Game Loop
A single Game class owns its own window and a capped pool of falling rectangle targets. This part declares the class, opens the window, spawns targets at a random x position gated by a timer and a maximum count, moves them down the screen every frame, and removes any target once it passes the bottom edge.
Task 1 - Creating the project and the Game class
Create a new Console App (C++) project the same way as in the Environment Setup lab, naming it AlmostFruitNinja, and set the solution platform to x64.
- The project template generates a source file named after the project. Rename it to
main.cppin Solution Explorer. - In Solution Explorer, use Add > Class... to create a class named
Game, generating a matchingGame.handGame.cpp. - Delete the generated contents of
Game.handGame.cpp. The next tasks fill them back in.
Success condition: Solution Explorer shows an empty Game.h, an empty Game.cpp, and main.cpp still holding its stub main, and the project still builds.
Task 2 - Declaring the Game interface
Open Game.h and declare the class's public and private members. Only update(), render() and running() are ever called from outside the class, so everything else stays private.
- Include
<SFML/Graphics.hpp>and<vector>. - In the private section, declare a
sf::RenderWindow* windowand asf::VideoMode videoMode. - Declare a
std::vector<sf::RectangleShape> targetsmember. - Declare an
unsigned int maxTargetCount, afloat spawnTimerand afloat spawnTimerMax. - Declare private helper methods
initVariables(),initWindow(),updateEvents(),sf::RectangleShape spawnTarget(),updateTargets()andrenderTargets(), all returningvoidexceptspawnTarget(). - In the public section, declare a default constructor, a destructor,
update(),render(), andconst bool running() const.
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
class Game
{
private:
// TODO: sf::RenderWindow* window;
// TODO: sf::VideoMode videoMode;
// TODO: std::vector<sf::RectangleShape> targets;
// TODO: unsigned int maxTargetCount;
// TODO: float spawnTimer;
// TODO: float spawnTimerMax;
// TODO: initVariables(), initWindow(), updateEvents()
// TODO: sf::RectangleShape spawnTarget();
// TODO: updateTargets(), renderTargets()
public:
// TODO: default constructor and destructor
// TODO: update(), render()
// TODO: const bool running() const;
};
Task 3 - Setting starting values in initVariables
Open Game.cpp, include "Game.h", and define Game::initVariables(). This sets every piece of state to a sensible starting point before the window even exists.
- Set
windowtonullptr. - Set
spawnTimerMaxto60.0fandspawnTimerto that same value, so a target is ready to spawn the moment the game starts. - Set
maxTargetCountto5.
#include "Game.h"
void Game::initVariables()
{
// TODO: window = nullptr;
// TODO: spawnTimerMax = 60.0f;
// TODO: spawnTimer = spawnTimerMax;
// TODO: maxTargetCount = 5;
}
Task 4 - Opening the window in initWindow
Below initVariables() in Game.cpp, define Game::initWindow().
- Set
videoModetosf::VideoMode({800u, 600u}). - Allocate
windowwithnew sf::RenderWindow(videoMode, "Almost Fruit Ninja", sf::State::Windowed). - Call
window->setFramerateLimit(60).
void Game::initWindow()
{
// TODO: videoMode = sf::VideoMode({800u, 600u});
// TODO: window = new sf::RenderWindow(videoMode, "Almost Fruit Ninja", sf::State::Windowed);
// TODO: window->setFramerateLimit(60);
}
Task 5 - Polling window events and handling Escape
Below initWindow(), define Game::updateEvents(). SFML 3 returns each pending event as a std::optional<sf::Event>, checked with is<T>() and read with getIf<T>().
- Loop
while (std::optional<sf::Event> event = window->pollEvent()). - If
event->is<sf::Event::Closed>(), callwindow->close(). - Otherwise, use
const auto* keyPressed = event->getIf<sf::Event::KeyPressed>(). IfkeyPressedis not null andkeyPressed->code == sf::Keyboard::Key::Escape, also callwindow->close().
void Game::updateEvents()
{
// TODO: while (std::optional<sf::Event> event = window->pollEvent())
// TODO: if event->is<sf::Event::Closed>(), close the window
// TODO: else if getIf<sf::Event::KeyPressed>() and code is Escape,
// close the window
}
Task 6 - Spawning a target at a random x position
Below updateEvents(), define Game::spawnTarget(). It builds and returns one new target, ready to be dropped into the window at the top edge.
- Create a local
sf::RectangleShape targetand set its size tosf::Vector2f(100.0f, 100.0f). - Compute the widest valid x position as the window's width minus the target's own width, and set the target's position to a random x between
0and that value, with y at0.f. Userand()and cast as needed. - Set the target's fill colour to
sf::Color::Red. - Return the target.
sf::RectangleShape Game::spawnTarget()
{
sf::RectangleShape target;
// TODO: target.setSize(sf::Vector2f(100.0f, 100.0f));
// TODO: compute maxX = window's width - target's width
// TODO: target.setPosition(sf::Vector2f(random x in [0, maxX], 0.f));
// TODO: target.setFillColor(sf::Color::Red);
return target;
}
Task 7 - Gating spawns with a timer and a maximum count
Below spawnTarget(), define Game::updateTargets(). Start with the spawning half of the function, the part that decides whether a new target is allowed to appear this frame.
- If
targets.size()is less thanmaxTargetCount, check whetherspawnTimer >= spawnTimerMax. - If it is, push the result of
spawnTarget()ontotargetsand resetspawnTimerto0.0f. - Otherwise, add
1.0ftospawnTimer.
void Game::updateTargets()
{
// TODO: if (targets.size() < maxTargetCount)
// TODO: if (spawnTimer >= spawnTimerMax)
// TODO: targets.push_back(spawnTarget());
// TODO: spawnTimer = 0.0f;
// TODO: else
// TODO: spawnTimer += 1.0f;
}
Task 8 - Moving targets down and despawning them off-screen
Still inside Game::updateTargets(), add a second loop after the spawning logic that advances every existing target and removes any that have fallen past the bottom of the window.
- Write a loop
for (size_t i = 0; i < targets.size();)so the index only advances when nothing is erased this pass. - Inside the loop, call
targets[i].move(sf::Vector2f(0.f, 2.f)). - If
targets[i].getPosition().yis greater thanvideoMode.size.y, erase that element withtargets.erase(targets.begin() + i)andcontinuesoiis not incremented. - Otherwise, increment
i.
void Game::updateTargets()
{
if (targets.size() < maxTargetCount)
{
if (spawnTimer >= spawnTimerMax)
{
targets.push_back(spawnTarget());
spawnTimer = 0.0f;
}
else
{
spawnTimer += 1.0f;
}
}
// TODO: for (size_t i = 0; i < targets.size();)
// TODO: targets[i].move(sf::Vector2f(0.f, 2.f));
// TODO: if (targets[i].getPosition().y > videoMode.size.y)
// TODO: targets.erase(targets.begin() + i);
// TODO: continue;
// TODO: else
// TODO: ++i;
}
Task 9 - Rendering targets and wiring the constructor and destructor
Below updateTargets(), define Game::renderTargets(), the constructor, the destructor, update(), render() and running().
- In
renderTargets(), loop overtargetswith a range-basedfor (auto& target : targets)and callwindow->draw(target)for each. - In the constructor
Game::Game(), callinitVariables()theninitWindow(). - In the destructor
Game::~Game(), calldelete window. - In
update(), callupdateEvents()thenupdateTargets(). - In
render(), callwindow->clear(), thenrenderTargets(), thenwindow->display(). - Define
const bool Game::running() constto returnwindow->isOpen().
void Game::renderTargets()
{
// TODO: for (auto& target : targets) window->draw(target);
}
Game::Game()
{
// TODO: initVariables();
// TODO: initWindow();
}
Game::~Game()
{
// TODO: delete window;
}
void Game::update()
{
// TODO: updateEvents();
// TODO: updateTargets();
}
void Game::render()
{
// TODO: window->clear();
// TODO: renderTargets();
// TODO: window->display();
}
const bool Game::running() const
{
// TODO: return window->isOpen();
return false;
}
Task 10 - Launching the game from main
Open main.cpp.
- Include
"Game.h"and<cstdlib>and<ctime>. - In
main, construct aGame game;, then callstd::srand(static_cast<unsigned int>(time(NULL)))sospawnTarget()'s random positions differ between runs. - Write
while (game.running()) { game.update(); game.render(); }, then return0. - Build and run the program.
Success condition: a window titled Almost Fruit Ninja opens, and red rectangles keep appearing near the top at random x positions, drifting down, and disappearing once they reach the bottom, never more than five on screen at once.
#include "Game.h"
#include <cstdlib>
#include <ctime>
int main()
{
// TODO: construct a Game, seed std::rand with the current time,
// then loop update()/render() while game.running()
return 0;
}
Part 2 - Mouse Slicing and Score Display
This part adds slicing and a score. Holding the left mouse button over a target removes it using the window's mapped view coordinates, and a running score is displayed on screen through an sf::Text object rebuilt every time a target is sliced.
Task 11 - Declaring the mouse and score members
Open Game.h and add the members this part needs: somewhere to store the mouse's position, a running score, and the font and text objects that will display it.
- Add
<sstream>to the includes. - In the private section, declare a
sf::Vector2i mouseWindowPositionand asf::Vector2f mouseViewPosition. - Declare a private
void updateMousePosition(). - Declare an
unsigned int score, asf::Font font, and asf::Text scoreText. - Declare a private
void refreshScoreText()that will rebuildscoreText's string wheneverscorechanges.
sf::Text in SFML 3 has no default constructor, it must be constructed with a reference to the font it renders with. The moment scoreText exists as a member, Game's constructor has to construct it too, or the project stops compiling. Open Game.cpp and change the constructor's definition to Game::Game() : scoreText(font).
Success condition: the project still builds, with scoreText constructed against font but not yet positioned, sized, or drawn.
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
#include <sstream>
class Game
{
private:
sf::RenderWindow* window;
sf::VideoMode videoMode;
std::vector<sf::RectangleShape> targets;
unsigned int maxTargetCount;
float spawnTimer;
float spawnTimerMax;
// TODO: sf::Vector2i mouseWindowPosition;
// TODO: sf::Vector2f mouseViewPosition;
// TODO: void updateMousePosition();
// TODO: unsigned int score;
// TODO: sf::Font font;
// TODO: sf::Text scoreText;
// TODO: void refreshScoreText();
void initVariables();
void initWindow();
void updateEvents();
sf::RectangleShape spawnTarget();
void updateTargets();
void renderTargets();
public:
Game();
~Game();
void update();
void render();
const bool running() const;
};
Game::Game()
// TODO: : scoreText(font)
{
initVariables();
initWindow();
}
Task 12 - Loading the font and starting the score at zero
Open Game.cpp and extend Game::initVariables().
- Add
<iostream>to the includes, needed forstd::cerr. - Set
scoreto0. - Call
font.openFromFile("C:/Windows/Fonts/arial.ttf"). If it returnsfalse, that means the font failed to load, print an error tostd::cerrin that case so a missing font is easy to notice.
#include <iostream>
void Game::initVariables()
{
window = nullptr;
spawnTimerMax = 60.0f;
spawnTimer = spawnTimerMax;
maxTargetCount = 5;
// TODO: score = 0;
// TODO: if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
// TODO: print an error to std::cerr
}
Task 13 - Tracking the mouse in the window’s view
Below updateEvents() in Game.cpp, define Game::updateMousePosition().
- Set
mouseWindowPositiontosf::Mouse::getPosition(*window). - Set
mouseViewPositiontowindow->mapPixelToCoords(mouseWindowPosition).
void Game::updateMousePosition()
{
// TODO: mouseWindowPosition = sf::Mouse::getPosition(*window);
// TODO: mouseViewPosition = window->mapPixelToCoords(mouseWindowPosition);
}
Task 14 - Calling updateMousePosition every frame
In Game::update(), call updateMousePosition() after updateEvents() and before updateTargets(), so the mouse position used for slicing this frame is always current.
void Game::update()
{
updateEvents();
// TODO: updateMousePosition();
updateTargets();
}
Task 15 - Detecting and removing a sliced target
In Game::updateTargets(), inside the loop that moves and despawns targets, add a check before the off-screen test that removes a target the player has clicked on.
- Inside the loop, before the off-screen check, test
sf::Mouse::isButtonPressed(sf::Mouse::Button::Left). - If it is pressed, test whether
targets[i].getGlobalBounds().contains(mouseViewPosition). - If both are true, erase that target with
targets.erase(targets.begin() + i), incrementscore, andcontinuesoiis not incremented.
Success condition: the project still builds. Clicking and holding over a falling target now removes it, though the score is not shown on screen yet.
void Game::updateTargets()
{
if (targets.size() < maxTargetCount)
{
if (spawnTimer >= spawnTimerMax)
{
targets.push_back(spawnTarget());
spawnTimer = 0.0f;
}
else
{
spawnTimer += 1.0f;
}
}
for (size_t i = 0; i < targets.size();)
{
targets[i].move(sf::Vector2f(0.f, 2.f));
// TODO: if (sf::Mouse::isButtonPressed(sf::Mouse::Button::Left))
// TODO: if (targets[i].getGlobalBounds().contains(mouseViewPosition))
// TODO: targets.erase(targets.begin() + i);
// TODO: score++;
// TODO: continue;
if (targets[i].getPosition().y > static_cast<float>(videoMode.size.y))
{
targets.erase(targets.begin() + i);
continue;
}
++i;
}
}
Task 16 - Refreshing the score text
Below renderTargets() in Game.cpp, define Game::refreshScoreText().
- Build a
std::stringstream, stream"Score: "and thenscoreinto it. - Call
scoreText.setString()with the stream'sstr().
Then call refreshScoreText() from updateTargets() right after score++, so the displayed text updates the instant a target is sliced.
void Game::refreshScoreText()
{
// TODO: std::stringstream scoreStream;
// TODO: scoreStream << "Score: " << score;
// TODO: scoreText.setString(scoreStream.str());
}
Task 17 - Positioning and displaying the score text
scoreText is already constructed against font from the previous task. This task gives it a position, a size, a colour, and starts drawing it.
- Inside the constructor's body, after
initWindow(), setscoreText's position tosf::Vector2f(10.f, 10.f), its character size to24, and its fill colour tosf::Color::White. - Call
refreshScoreText()so it starts showingScore: 0. - In
Game::render(), drawscoreTextafterrenderTargets()and beforewindow->display().
Success condition: a window titled Almost Fruit Ninja opens with Score: 0 shown in the top-left corner. Clicking and holding over a falling target removes it and the score climbs by one for each one sliced.
Game::Game()
: scoreText(font)
{
initVariables();
initWindow();
// TODO: scoreText.setPosition(sf::Vector2f(10.f, 10.f));
// TODO: scoreText.setCharacterSize(24);
// TODO: scoreText.setFillColor(sf::Color::White);
// TODO: refreshScoreText();
}
void Game::render()
{
window->clear();
renderTargets();
// TODO: window->draw(scoreText);
window->display();
}
Part 3 - Super Move
This part adds a limited-use super move. Pressing Enter clears every target currently on screen and awards one point per target cleared, guarded by an early return once a fixed number of uses runs out, with a second line of on-screen text showing how many uses remain.
Task 18 - Declaring the super move state
Open Game.h and add the members the super move needs.
- In the private section, declare an
unsigned int superMovesRemaining. - Declare a
sf::Text superMoveText. - Declare a private
void triggerSuperMove()and a privatevoid refreshSuperMoveText().
Like scoreText, superMoveText needs constructing with a reference to its font the moment it exists as a member. Open Game.cpp and extend the constructor's member initializer list to Game::Game() : scoreText(font), superMoveText(font).
Success condition: the project still builds, with superMoveText constructed against font but not yet positioned, sized, or drawn.
// TODO: unsigned int superMovesRemaining;
// TODO: sf::Text superMoveText;
// TODO: void triggerSuperMove();
// TODO: void refreshSuperMoveText();
Game::Game()
: scoreText(font) // TODO: , superMoveText(font)
{
initVariables();
initWindow();
scoreText.setPosition(sf::Vector2f(10.f, 10.f));
scoreText.setCharacterSize(24);
scoreText.setFillColor(sf::Color::White);
refreshScoreText();
}
Task 19 - Initializing the super move count
Open Game.cpp and extend Game::initVariables() by setting superMovesRemaining to 3.
void Game::initVariables()
{
window = nullptr;
spawnTimerMax = 60.0f;
spawnTimer = spawnTimerMax;
maxTargetCount = 5;
score = 0;
if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
{
std::cerr << "Error loading font\n";
}
// TODO: superMovesRemaining = 3;
}
Task 20 - Handling the Enter key
In Game::updateEvents(), extend the existing keyPressed check with a second branch for the super move.
- Alongside the existing check for
sf::Keyboard::Key::Escape, add anelse ifbranch checkingkeyPressed->code == sf::Keyboard::Key::Enter. - In that branch, call
triggerSuperMove().
void Game::updateEvents()
{
while (std::optional<sf::Event> event = window->pollEvent())
{
if (event->is<sf::Event::Closed>())
{
window->close();
}
else if (const auto* keyPressed = event->getIf<sf::Event::KeyPressed>())
{
if (keyPressed->code == sf::Keyboard::Key::Escape)
{
window->close();
}
// TODO: else if (keyPressed->code == sf::Keyboard::Key::Enter)
// TODO: triggerSuperMove();
}
}
}
Task 21 - Guarding against an empty counter
Below updateMousePosition() in Game.cpp, define Game::triggerSuperMove(), starting with the guard clause that stops it doing anything once every use is spent.
- At the top of the function, if
superMovesRemaining == 0,returnimmediately. - Otherwise, decrement
superMovesRemaining.
void Game::triggerSuperMove()
{
// TODO: if (superMovesRemaining == 0) return;
// TODO: superMovesRemaining--;
}
Task 22 - Clearing every target and awarding points
Still inside Game::triggerSuperMove(), after decrementing superMovesRemaining, clear the board and pay out for it.
- Read
targets.size()into a local variable before clearing anything, sinceclear()would otherwise throw that count away. - Call
targets.clear(). - Add the saved count onto
score, then callrefreshScoreText()so the change is visible immediately.
Success condition: pressing Enter clears every target on screen and adds one point per target cleared to the score display, but only while at least one use remains, and remaining uses are not shown yet.
void Game::triggerSuperMove()
{
if (superMovesRemaining == 0) return;
superMovesRemaining--;
// TODO: unsigned int clearedCount = static_cast<unsigned int>(targets.size());
// TODO: targets.clear();
// TODO: score += clearedCount;
// TODO: refreshScoreText();
}
Task 23 - Refreshing the super move text
Below refreshScoreText() in Game.cpp, define Game::refreshSuperMoveText(), following the same shape as refreshScoreText().
- Build a
std::stringstream, stream"Super Moves: "and thensuperMovesRemaininginto it. - Call
superMoveText.setString()with the stream'sstr().
Then call refreshSuperMoveText() at the end of triggerSuperMove(), after the guard clause has already returned for an empty counter.
void Game::refreshSuperMoveText()
{
// TODO: std::stringstream superMoveStream;
// TODO: superMoveStream << "Super Moves: " << superMovesRemaining;
// TODO: superMoveText.setString(superMoveStream.str());
}
void Game::triggerSuperMove()
{
if (superMovesRemaining == 0) return;
superMovesRemaining--;
unsigned int clearedCount = static_cast<unsigned int>(targets.size());
targets.clear();
score += clearedCount;
refreshScoreText();
// TODO: refreshSuperMoveText();
}
Task 24 - Positioning and displaying the super move text
superMoveText is already constructed against font from the previous task. This task gives it a position, a size, a colour, and starts drawing it.
- Inside the constructor's body, after the existing
scoreTextsetup, setsuperMoveText's position tosf::Vector2f(10.f, 40.f), its character size to24, and its fill colour tosf::Color::White. - Call
refreshSuperMoveText()so it starts showingSuper Moves: 3. - In
Game::render(), drawsuperMoveTextalongsidescoreText.
Success condition: a second line under the score shows Super Moves: 3. Pressing Enter clears every target on screen, adds a point per target cleared, and counts down to Super Moves: 0, after which pressing Enter again has no effect.
Game::Game()
: scoreText(font), superMoveText(font)
{
initVariables();
initWindow();
scoreText.setPosition(sf::Vector2f(10.f, 10.f));
scoreText.setCharacterSize(24);
scoreText.setFillColor(sf::Color::White);
refreshScoreText();
// TODO: superMoveText.setPosition(sf::Vector2f(10.f, 40.f));
// TODO: superMoveText.setCharacterSize(24);
// TODO: superMoveText.setFillColor(sf::Color::White);
// TODO: refreshSuperMoveText();
}
void Game::render()
{
window->clear();
renderTargets();
window->draw(scoreText);
// TODO: window->draw(superMoveText);
window->display();
}
Part 4 - Lives and Game Over
This part adds a fail condition. The player starts with a fixed number of lives, shown in a third line of on-screen text, loses one each time a target reaches the bottom unsliced, and the window closes on its own once lives reach zero.
Task 25 - Declaring lives state
Open Game.h and add the members lives needs.
- In the private section, declare an
unsigned int lives. - Declare a
sf::Text livesText. - Declare a private
void refreshLivesText().
Like the other two text objects, livesText needs constructing with a reference to its font the moment it exists as a member. Open Game.cpp and extend the constructor's member initializer list to Game::Game() : scoreText(font), superMoveText(font), livesText(font).
Success condition: the project still builds, with livesText constructed against font but not yet positioned, sized, or drawn.
// TODO: unsigned int lives;
// TODO: sf::Text livesText;
// TODO: void refreshLivesText();
Game::Game()
: scoreText(font), superMoveText(font) // TODO: , livesText(font)
{
// ...existing constructor body from earlier tasks...
}
Task 26 - Initializing lives
Open Game.cpp and extend Game::initVariables() by setting lives to 3.
void Game::initVariables()
{
// ...existing initialization from earlier tasks...
// TODO: lives = 3;
}
Task 27 - Losing a life when a target reaches the bottom
In Game::updateTargets(), find the second erase site, the one that removes a target once its y position passes videoMode.size.y. This is the branch that means the player missed one, so it is the only branch that should cost a life.
- Before erasing the target in that branch, decrement
lives, but only when it is greater than zero.livesis unsigned, so decrementing it once it is already zero would wrap it around to a huge number instead of going negative. - Do not change the earlier erase site inside the mouse-click branch, slicing a target must not cost a life.
void Game::updateTargets()
{
// ...spawn logic from earlier tasks...
for (size_t i = 0; i < targets.size();)
{
targets[i].move(sf::Vector2f(0.f, 2.f));
if (sf::Mouse::isButtonPressed(sf::Mouse::Button::Left))
{
if (targets[i].getGlobalBounds().contains(mouseViewPosition))
{
targets.erase(targets.begin() + i);
score++;
refreshScoreText();
continue;
}
}
if (targets[i].getPosition().y > static_cast<float>(videoMode.size.y))
{
// TODO: if (lives > 0) lives--;
targets.erase(targets.begin() + i);
continue;
}
++i;
}
}
Task 28 - Displaying remaining lives
Below refreshSuperMoveText() in Game.cpp, define Game::refreshLivesText(), following the same shape as the other two refresh functions.
- Build a
std::stringstream, stream"Lives: "and thenlivesinto it. - Call
livesText.setString()with the stream'sstr().
Then call refreshLivesText() in updateTargets(), right after decrementing lives in the branch from the previous task.
livesText is already constructed against font from the previous task. This task gives it a position, a size, a colour, and starts drawing it.
- Inside the constructor's body, after the
superMoveTextsetup, setlivesText's position tosf::Vector2f(10.f, 70.f), its character size to24, and its fill colour tosf::Color::White, then callrefreshLivesText(). - In
Game::render(), drawlivesTextalongside the other two text objects.
Success condition: a third line under the super move counter shows Lives: 3, and it counts down by one each time a target reaches the bottom of the window unsliced.
void Game::refreshLivesText()
{
// TODO: std::stringstream livesStream;
// TODO: livesStream << "Lives: " << lives;
// TODO: livesText.setString(livesStream.str());
}
Game::Game()
: scoreText(font), superMoveText(font), livesText(font)
{
// ...existing constructor body from earlier tasks...
// TODO: livesText.setPosition(sf::Vector2f(10.f, 70.f));
// TODO: livesText.setCharacterSize(24);
// TODO: livesText.setFillColor(sf::Color::White);
// TODO: refreshLivesText();
}
void Game::render()
{
window->clear();
renderTargets();
window->draw(scoreText);
window->draw(superMoveText);
// TODO: window->draw(livesText);
window->display();
}
Task 29 - Ending the game at zero lives
Still in Game::updateTargets(), add the check that ends the game right where lives is decremented, not after the whole loop. Two targets can reach the bottom in the same frame, and checking only once after the loop would let the second miss run past zero unnoticed.
- Immediately after decrementing
lives, test whether it is now0. - If it is, call
window->close().
Success condition: letting three targets reach the bottom of the window without slicing any of them closes the window and ends the program, the same way clicking the close button does.
void Game::updateTargets()
{
// ...spawn logic from earlier tasks...
for (size_t i = 0; i < targets.size();)
{
targets[i].move(sf::Vector2f(0.f, 2.f));
if (sf::Mouse::isButtonPressed(sf::Mouse::Button::Left))
{
if (targets[i].getGlobalBounds().contains(mouseViewPosition))
{
targets.erase(targets.begin() + i);
score++;
refreshScoreText();
continue;
}
}
if (targets[i].getPosition().y > static_cast<float>(videoMode.size.y))
{
if (lives > 0)
{
lives--;
}
// TODO: if (lives == 0) window->close();
targets.erase(targets.begin() + i);
continue;
}
++i;
}
}
Part 5 - Difficulty Scaling
This part makes the game get harder the longer a run lasts. Crossing score thresholds repeatedly raises the maximum target count and fall speed, and a separate flag-guarded rule doubles the maximum target count exactly once at a higher score.
Task 30 - Replacing the fixed fall speed with a member variable
Open Game.h and declare a float fallSpeed in the private section, alongside maxTargetCount.
Open Game.cpp and make two changes.
- In
initVariables(), setfallSpeedto2.0f, the same value the literal used before. - In
updateTargets(), changetargets[i].move(sf::Vector2f(0.f, 2.f))to usefallSpeedin place of the literal2.f.
// TODO: float fallSpeed;
void Game::initVariables()
{
// ...existing initialization from earlier tasks...
// TODO: fallSpeed = 2.0f;
}
void Game::updateTargets()
{
// ...spawn logic from earlier tasks...
for (size_t i = 0; i < targets.size();)
{
// TODO: targets[i].move(sf::Vector2f(0.f, fallSpeed));
// ...slice, miss and despawn logic from earlier tasks...
}
}
Task 31 - Declaring difficulty thresholds
Open Game.h and declare the thresholds each scaling rule needs.
- Declare an
unsigned int nextCountThresholdfor the repeatable maximum-count increase. - Declare an
unsigned int nextSpeedThresholdfor the repeatable speed increase. - Declare a
bool hasDoubledfor the one-time doubling rule. - Declare a private
void updateDifficulty().
// TODO: unsigned int nextCountThreshold;
// TODO: unsigned int nextSpeedThreshold;
// TODO: bool hasDoubled;
// TODO: void updateDifficulty();
Task 32 - Initializing the thresholds
Extend Game::initVariables().
- Set
nextCountThresholdto5. - Set
nextSpeedThresholdto10. - Set
hasDoubledtofalse.
void Game::initVariables()
{
// ...existing initialization from earlier tasks...
// TODO: nextCountThreshold = 5;
// TODO: nextSpeedThreshold = 10;
// TODO: hasDoubled = false;
}
Task 33 - Increasing the maximum target count as score rises
Below renderTargets() in Game.cpp, define Game::updateDifficulty(), starting with the repeatable count-increase rule.
- If
score >= nextCountThreshold, incrementmaxTargetCountand add5ontonextCountThresholdso the same crossing cannot fire again until score rises another five points.
void Game::updateDifficulty()
{
// TODO: if (score >= nextCountThreshold)
// TODO: maxTargetCount++;
// TODO: nextCountThreshold += 5;
}
Task 34 - Increasing fall speed as score rises
Still in Game::updateDifficulty(), add the repeatable speed-increase rule, following the same shape as the previous task.
- If
score >= nextSpeedThreshold, add0.5fontofallSpeedand add10ontonextSpeedThreshold.
void Game::updateDifficulty()
{
if (score >= nextCountThreshold)
{
maxTargetCount++;
nextCountThreshold += 5;
}
// TODO: if (score >= nextSpeedThreshold)
// TODO: fallSpeed += 0.5f;
// TODO: nextSpeedThreshold += 10;
}
Task 35 - Doubling the target count once
Still in Game::updateDifficulty(), add the one-time doubling rule. Unlike the previous two rules, this one must never fire more than once, so it needs a flag rather than an advancing threshold.
- If
!hasDoubled && score >= 20, multiplymaxTargetCountby2and sethasDoubledtotrue.
void Game::updateDifficulty()
{
if (score >= nextCountThreshold)
{
maxTargetCount++;
nextCountThreshold += 5;
}
if (score >= nextSpeedThreshold)
{
fallSpeed += 0.5f;
nextSpeedThreshold += 10;
}
// TODO: if (!hasDoubled && score >= 20)
// TODO: maxTargetCount *= 2;
// TODO: hasDoubled = true;
}
Task 36 - Calling updateDifficulty every frame
In Game::update(), call updateDifficulty() after updateTargets(), so every scaling rule is checked once per frame against whatever the score became this frame.
void Game::update()
{
updateEvents();
updateMousePosition();
updateTargets();
// TODO: updateDifficulty();
}
Task 37 - Building and observing the scaling
Build and run the program, then play a full round.
- Slice targets until the score passes
5. Observe that up to six targets can now be on screen at once, one more than the starting maximum of five. - Keep going past a score of
10. Observe that targets now fall visibly faster than they did at the start. - Keep going past a score of
20. Observe that the maximum target count jumps sharply, roughly doubling, and does not jump again if the score keeps climbing past further multiples of five.
Success condition: the game visibly speeds up and grows busier the longer a run continues, with the count and speed increases repeating at their own fixed intervals and the doubling happening exactly once.
Part 6 - Key-Based Slicing
This part replaces mouse-click slicing with colour-based keyboard input. Every target spawns as one of four colours through a small Target struct, and pressing the key matching a colour slices every target of that colour at once, with the now-unused mouse-slicing code deleted.
Task 38 - Giving each target a colour
Open Game.h. Targets need to remember a colour now, which a plain sf::RectangleShape cannot store, so replace it with a small struct.
- Above the
Gameclass, declare anenum class TargetColor { Red, Green, Blue, Yellow };. - Also above the
Gameclass, declare astruct Target { sf::RectangleShape shape; TargetColor color; };. - Change the
targetsmember fromstd::vector<sf::RectangleShape>tostd::vector<Target>. - Change
spawnTarget()'s return type fromsf::RectangleShapetoTarget.
Success condition: the project does not build yet, every place that used targets[i] as a shape directly now needs to change to targets[i].shape. The next tasks fix those one at a time.
#pragma once
#include <SFML/Graphics.hpp>
#include <vector>
#include <sstream>
// TODO: enum class TargetColor { Red, Green, Blue, Yellow };
// TODO: struct Target { sf::RectangleShape shape; TargetColor color; };
class Game
{
private:
sf::RenderWindow* window;
sf::VideoMode videoMode;
// TODO: std::vector<Target> targets;
unsigned int maxTargetCount;
float spawnTimer;
float spawnTimerMax;
float fallSpeed;
unsigned int nextCountThreshold;
unsigned int nextSpeedThreshold;
bool hasDoubled;
void updateDifficulty();
unsigned int score;
sf::Font font;
sf::Text scoreText;
void refreshScoreText();
unsigned int superMovesRemaining;
sf::Text superMoveText;
void triggerSuperMove();
void refreshSuperMoveText();
unsigned int lives;
sf::Text livesText;
void refreshLivesText();
void initVariables();
void initWindow();
void updateEvents();
// TODO: Target spawnTarget();
void updateTargets();
void renderTargets();
public:
Game();
~Game();
void update();
void render();
const bool running() const;
};
Task 39 - Mapping a colour to a display colour and a key
Open Game.h and declare two private helper methods that translate between TargetColor and the two things it needs to become, something drawable and something the player can press.
- Declare a private
sf::Color colorToSFColor(TargetColor color). - Declare a private
bool keyMatchesColor(sf::Keyboard::Key key, TargetColor color).
Success condition: the project still does not build, these are only declared so far.
// TODO: sf::Color colorToSFColor(TargetColor color);
// TODO: bool keyMatchesColor(sf::Keyboard::Key key, TargetColor color);
Task 40 - Defining the colour and key mapping functions
Open Game.cpp and define both mapping functions, using a switch on color in each.
- In
colorToSFColor(), returnsf::Color::RedforTargetColor::Red,sf::Color::GreenforTargetColor::Green,sf::Color::BlueforTargetColor::Blue, andsf::Color::YellowforTargetColor::Yellow. - In
keyMatchesColor(), return whetherkeyequalssf::Keyboard::Key::RforRed,GforGreen,BforBlue, andYforYellow.
Success condition: the project still does not build, spawnTarget() and updateTargets() still reference the old shape-only type.
sf::Color Game::colorToSFColor(TargetColor color)
{
// TODO: switch (color)
// TODO: case TargetColor::Red: return sf::Color::Red;
// TODO: case TargetColor::Green: return sf::Color::Green;
// TODO: case TargetColor::Blue: return sf::Color::Blue;
// TODO: case TargetColor::Yellow: return sf::Color::Yellow;
return sf::Color::White;
}
bool Game::keyMatchesColor(sf::Keyboard::Key key, TargetColor color)
{
// TODO: switch (color)
// TODO: case TargetColor::Red: return key == sf::Keyboard::Key::R;
// TODO: case TargetColor::Green: return key == sf::Keyboard::Key::G;
// TODO: case TargetColor::Blue: return key == sf::Keyboard::Key::B;
// TODO: case TargetColor::Yellow: return key == sf::Keyboard::Key::Y;
return false;
}
Task 41 - Assigning a random colour when a target spawns
In Game.cpp, update spawnTarget() to build and return a Target instead of a bare shape.
- Rename the local variable's type from
sf::RectangleShapetoTarget, keeping the nametarget. - Change every line that used to call a method directly on
targetto call it ontarget.shapeinstead. - Pick a random
TargetColorby generatingrand() % 4and casting the result toTargetColor, storing it intarget.color. - Set the shape's fill colour with
colorToSFColor(target.color)instead of the fixedsf::Color::Red.
Success condition: the project still does not build, updateTargets() and renderTargets() still treat each element as a bare shape.
Target Game::spawnTarget()
{
Target target;
target.shape.setSize(sf::Vector2f(100.0f, 100.0f));
float maxX = static_cast<float>(window->getSize().x) - target.shape.getSize().x;
target.shape.setPosition(sf::Vector2f(static_cast<float>(rand() % static_cast<int>(maxX)), 0.f));
// TODO: target.color = static_cast<TargetColor>(rand() % 4);
// TODO: target.shape.setFillColor(colorToSFColor(target.color));
return target;
}
Task 42 - Updating movement, despawn and rendering for the new type
In Game.cpp, fix updateTargets() and renderTargets() to work with Target instead of a bare shape. For this task, leave the mouse-click slicing branch in place, it will be removed in a later task.
- In
updateTargets(), changetargets[i].move(...)totargets[i].shape.move(...). - Change
targets[i].getGlobalBounds()totargets[i].shape.getGlobalBounds(), andtargets[i].getPosition()totargets[i].shape.getPosition(). - In
renderTargets(), change the range-based loop's variable type so thatwindow->draw(target)becomeswindow->draw(target.shape).
Success condition: the project builds again, and the game runs exactly as it did in Part 5 (Difficulty Scaling), mouse-click slicing still works, and every target now spawns in one of four colours instead of always red.
void Game::updateTargets()
{
// ...spawn logic from earlier tasks...
for (size_t i = 0; i < targets.size();)
{
// TODO: targets[i].shape.move(sf::Vector2f(0.f, fallSpeed));
if (sf::Mouse::isButtonPressed(sf::Mouse::Button::Left))
{
// TODO: if (targets[i].shape.getGlobalBounds().contains(mouseViewPosition))
{
targets.erase(targets.begin() + i);
score++;
refreshScoreText();
continue;
}
}
// TODO: if (targets[i].shape.getPosition().y > static_cast<float>(videoMode.size.y))
{
lives--;
refreshLivesText();
targets.erase(targets.begin() + i);
continue;
}
++i;
}
if (lives == 0)
{
window->close();
}
}
void Game::renderTargets()
{
for (auto& target : targets)
{
// TODO: window->draw(target.shape);
}
}
Task 43 - Slicing every target of one colour at once
Below renderTargets() in Game.cpp, define a new private method void sliceTargetsOfColor(TargetColor color), declared in Game.h alongside updateTargets().
- In
Game.h, declarevoid sliceTargetsOfColor(TargetColor color);in the private section. - In
Game.cpp, write a loop overtargetsusing the same index-controlled erase loop asupdateTargets(). - If a target's
colorequals the passed-incolor, erase it and incrementscore. Otherwise, advance the index. - After the loop, call
refreshScoreText()once, so a key press that slices several targets at once only rebuilds the score string a single time.
void Game::sliceTargetsOfColor(TargetColor color)
{
// TODO: for (size_t i = 0; i < targets.size();)
// TODO: if (targets[i].color == color)
// TODO: targets.erase(targets.begin() + i);
// TODO: score++;
// TODO: else
// TODO: ++i;
// TODO: refreshScoreText();
}
Task 44 - Wiring key presses to slicing
In Game::updateEvents(), extend the keyPressed branch with four new checks, one per colour, each calling sliceTargetsOfColor() through keyMatchesColor().
- Alongside the existing Escape and Enter checks, add four
else ifbranches, one for each ofTargetColor::Red,Green,BlueandYellow, each testingkeyMatchesColor(keyPressed->code, TargetColor::___). - In each branch, call
sliceTargetsOfColor()with the matching colour.
Success condition: pressing R, G, B or Y now slices every target of the matching colour on screen, awarding a point per target sliced, alongside the still-working mouse-click slicing.
void Game::updateEvents()
{
while (std::optional<sf::Event> event = window->pollEvent())
{
if (event->is<sf::Event::Closed>())
{
window->close();
}
else if (const auto* keyPressed = event->getIf<sf::Event::KeyPressed>())
{
if (keyPressed->code == sf::Keyboard::Key::Escape)
{
window->close();
}
else if (keyPressed->code == sf::Keyboard::Key::Enter)
{
triggerSuperMove();
}
// TODO: else if (keyMatchesColor(keyPressed->code, TargetColor::Red))
// TODO: sliceTargetsOfColor(TargetColor::Red);
// TODO: else if (keyMatchesColor(keyPressed->code, TargetColor::Green))
// TODO: sliceTargetsOfColor(TargetColor::Green);
// TODO: else if (keyMatchesColor(keyPressed->code, TargetColor::Blue))
// TODO: sliceTargetsOfColor(TargetColor::Blue);
// TODO: else if (keyMatchesColor(keyPressed->code, TargetColor::Yellow))
// TODO: sliceTargetsOfColor(TargetColor::Yellow);
}
}
}
Task 45 - Removing the mouse-based slicing code
Mouse-click slicing has no remaining purpose now that colour-matched keys handle it. Leaving it in place would mean two different pieces of code can both remove the same target for two different reasons, so it comes out rather than staying dead and unused.
- In
Game.h, delete thesf::Vector2i mouseWindowPosition,sf::Vector2f mouseViewPositionandvoid updateMousePosition()declarations. - In
Game.cpp, delete theGame::updateMousePosition()function entirely. - In
Game::update(), delete the call toupdateMousePosition(). - In
Game::updateTargets(), delete theif (sf::Mouse::isButtonPressed(...))branch and its contents, leaving only the off-screen despawn check inside the loop.
Success condition: the project builds with no leftover references to the mouse. Clicking on a target no longer does anything, the only way to slice one is to press the key matching its colour.
void Game::updateTargets()
{
// ...spawn logic from earlier tasks...
for (size_t i = 0; i < targets.size();)
{
targets[i].shape.move(sf::Vector2f(0.f, fallSpeed));
// TODO: delete the mouse-click slicing branch entirely
if (targets[i].shape.getPosition().y > static_cast<float>(videoMode.size.y))
{
lives--;
refreshLivesText();
targets.erase(targets.begin() + i);
continue;
}
++i;
}
if (lives == 0)
{
window->close();
}
}
Task 46 - Building and playing the finished game
Build and run the program.
Success condition: targets fall in one of four colours, red, green, blue and yellow. Pressing R, G, B or Y slices every target of the matching colour currently on screen at once and increases the score. The mouse takes no part in slicing. Enter still triggers the super move, missing a target still costs a life, and the difficulty still scales with score exactly as it did in Part 5 (Difficulty Scaling).
Solution
Try the tasks above before revealing the solution.