Overview
In this lab you add a game loop to the maze from the previous two labs. The player now has a countdown timer and must collect every gold coin before time runs out. The test class gains a three-state machine: Playing, Won, and Lost. Coins are rendered as small spinning, bobbing cubes and disappear when walked over. A HUD panel shows the remaining time and coin count, and full-screen banners appear when the game ends. The key and exit door mechanics that complete the game are added in the final maze lab (Lab 23).
Learning Objectives
By the end of this lab you will be able to:
- Implement a three-state game state machine (Playing, Won, Lost) driven by a countdown timer and a win condition.
- Detect walk-over collectibles using an XZ-only distance check, ignoring the Y axis because pickups bob up and down.
- Apply time-based animation (sin for bobbing, angle accumulation for spinning) to render coins as dynamic objects.
- Use edge detection on the Enter key so the restart action fires once per press rather than every frame.
- Render HUD overlays using ImGui's foreground draw list to draw a crosshair and centred win/lose banners on top of the 3D viewport.
Concept
A game loop is a state machine: the game is always in
exactly one state, and each state determines what logic runs each frame and
what transitions are possible. Here the states are Playing
(normal gameplay), Won (all coins collected), and
Lost (timer expired). The state is stored as an
enum class member and a switch in
Update() branches to the appropriate logic.
Coin pickup uses a simple proximity test. Each
Collectible has a world-space position (set by
MazeMap::Load when it parses the 'C' tiles).
Every frame we compare the player's XZ position to each uncollected coin's
XZ position. Y is deliberately excluded because coins bob vertically: the
player would miss them if the height had to match exactly. The distance
formula is sqrt(dx*dx + dz*dz), and if it falls below a small
pickup radius the coin is marked collected.
Edge detection prevents a key press from firing many times
while the key is held. The pattern is to store whether the key was pressed
last frame: bool advancePressed = advance && !m_AdvancePressedLastFrame.
This makes advancePressed true only on the single frame where
the key transitions from released to pressed. The same technique is used
for any "act on press" interaction, such as the E-key door unlock in the
next lab. Coin animation is driven by
glfwGetTime(): a sine wave controls the vertical bob and a
linearly growing angle drives the spin, both offset by a per-coin phase so
each coin moves independently.
Tasks
Task 1 - Extend the TestMaze header
-
Open
src/tests/maze/TestMaze.hand add aGameStatescoped enum at the top of theprivatesection:enum class GameState { Playing, Won, Lost }; -
Declare three new private methods alongside
BuildCubeMeshandDrawCube:void LoadLevel(unsigned int seed); void UpdatePlaying(float deltaTime); bool AllCoinsCollected() const; -
Add four new member variables at the bottom of the private section:
GameState m_State = GameState::Playing; float m_TimeRemaining = 0.0f; int m_CoinsCollected = 0; bool m_AdvancePressedLastFrame = false; - The skeleton below shows the complete updated header. Use it to check your placement before moving on.
#pragma once
#include "../Tests.h"
#include "../../Renderer.h"
#include "../../Shader.h"
#include "../../Texture.h"
#include "../../VertexArray.h"
#include "../../VertexBuffer.h"
#include "../../IndexBuffer.h"
#include "../../utils/Camera.h"
#include "MazeMap.h"
#include "Player.h"
#include <memory>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
namespace test {
class TestMaze : public Tests {
public:
TestMaze(GLFWwindow* window);
void Update(float deltaTime) override;
void Render() override;
void RenderGUI() override;
private:
// TODO: Declare an enum class GameState with three values:
// Playing, Won, Lost
void BuildCubeMesh();
// TODO: Declare three new private methods:
// void LoadLevel(unsigned int seed);
// void UpdatePlaying(float deltaTime);
// bool AllCoinsCollected() const;
void DrawCube(const glm::mat4& model, const glm::vec3& colour, const Texture* texture);
GLFWwindow* m_window;
Renderer m_Renderer;
std::unique_ptr<Camera> m_Camera;
std::unique_ptr<Player> m_Player;
MazeMap m_Map;
std::unique_ptr<Shader> m_Shader;
std::unique_ptr<Texture> m_WallTexture;
std::unique_ptr<Texture> m_FloorTexture;
std::unique_ptr<VertexArray> m_CubeVAO;
std::unique_ptr<VertexBuffer> m_CubeVBO;
std::unique_ptr<IndexBuffer> m_CubeIBO;
// TODO: Declare game state members:
// GameState m_State = GameState::Playing;
// float m_TimeRemaining = 0.0f;
// int m_CoinsCollected = 0;
// bool m_AdvancePressedLastFrame = false;
};
}
Task 2 - Add LoadLevel and initialise the game
-
In
src/tests/maze/TestMaze.cpp, add two file-scope constants at the top ofnamespace test, alongsideWallHeight:static const float s_LevelTime = 90.0f; static const float s_PickupRadius = 0.6f; -
Implement
LoadLevel(unsigned int seed). This function is the single reset point for the game: it regenerates the maze, resets the timer and coin count, resets the state to Playing, and respawns the player. Callm_Map.Load(0, seed)first - everything else depends on the grid being populated. -
Implement
AllCoinsCollected(). It should return true whenm_CoinsCollectedequals the total number of coins in the current map (m_Map.Coins().size()). -
In the constructor, replace the two separate
m_Map.Loadandm_Player->Respawncalls with a single call toLoadLevel(1337u).
// Add these near the top of TestMaze.cpp, inside namespace test { ... }
// (before or after BuildCubeMesh - order does not matter)
static const float s_LevelTime = 90.0f; // seconds on the clock
static const float s_PickupRadius = 0.6f; // walk-over distance for coins
// -------------------------------------------------------------------------
// Resets all game state and loads a fresh maze for level 0.
// The constructor and the "retry" path in Update both call this.
// -------------------------------------------------------------------------
void TestMaze::LoadLevel(unsigned int seed)
{
// TODO: Call m_Map.Load(0, seed) to generate and parse the tile grid
// TODO: Set m_TimeRemaining = s_LevelTime
// TODO: Set m_CoinsCollected = 0
// TODO: Set m_State = GameState::Playing
// TODO: Call m_Player->Respawn(m_Map.PlayerStart(), m_Map.StartYaw())
}
// Returns true when every coin in the current level has been walked over.
bool TestMaze::AllCoinsCollected() const
{
// TODO: Return (m_CoinsCollected == (int)m_Map.Coins().size())
return false; // placeholder
}
// -------------------------------------------------------------------------
// Constructor - replace the m_Map.Load + Respawn calls with LoadLevel
// -------------------------------------------------------------------------
TestMaze::TestMaze(GLFWwindow* window)
: m_window(window)
{
m_Camera = std::make_unique<Camera>(
window,
glm::vec3(0.0f, 1.6f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f),
0.0f, 0.0f, 60.0f
);
m_Player = std::make_unique<Player>(m_Camera.get(), &m_Map);
m_Shader = std::make_unique<Shader>("res/Shaders/Maze.shader");
m_WallTexture = std::make_unique<Texture>("res/Textures/maze/wall.png");
m_FloorTexture = std::make_unique<Texture>("res/Textures/maze/floor.png");
BuildCubeMesh();
glEnable(GL_DEPTH_TEST);
// TODO: Replace the two separate m_Map.Load / m_Player->Respawn calls
// with a single call: LoadLevel(1337u)
}
Task 3 - Implement the state machine and coin pickups
-
Replace
Update()inTestMaze.cppwith the version below. The key addition is a state switch after the camera update. -
Add the edge detection pattern for the Enter key before
the switch:
bool advance = glfwGetKey(m_window, GLFW_KEY_ENTER) == GLFW_PRESS; bool advancePressed = advance && !m_AdvancePressedLastFrame; m_AdvancePressedLastFrame = advance;advancePressedis onlytrueon the single frame where Enter transitions from released to pressed. -
In the
switch, callUpdatePlaying(deltaTime)for the Playing state. For Won and Lost, callLoadLevel(1337u)only whenadvancePressedis true. -
Implement
UpdatePlaying():-
Subtract
deltaTimefromm_TimeRemaining. If it reaches 0, clamp it to 0, setm_State = GameState::Lost, and return early. -
Loop over
m_Map.Coins(). For each uncollected coin, compute the XZ distance to the camera:
Iffloat dx = playerPos.x - coin.position.x; float dz = playerPos.z - coin.position.z; float d = std::sqrt(dx*dx + dz*dz);d < s_PickupRadius, mark the coin collected and incrementm_CoinsCollected. -
After the loop, call
AllCoinsCollected(). If it returns true, setm_State = GameState::Won.
-
Subtract
// Replace Update() and add UpdatePlaying() in TestMaze.cpp.
// Both belong inside namespace test { ... }.
void TestMaze::Update(float deltaTime)
{
m_Player->Update(deltaTime);
m_Camera->Update(deltaTime);
// While the cursor is free (ESC paused), freeze game logic entirely.
if (m_Camera->isDetached())
return;
// EDGE-DETECTED ENTER KEY:
// We only want to act on the moment the key is pressed, not while it is
// held. Store the previous frame's state and compare.
// TODO: bool advance = glfwGetKey(m_window, GLFW_KEY_ENTER) == GLFW_PRESS;
// TODO: bool advancePressed = advance && !m_AdvancePressedLastFrame;
// TODO: m_AdvancePressedLastFrame = advance;
switch (m_State)
{
case GameState::Playing:
// TODO: Call UpdatePlaying(deltaTime)
break;
case GameState::Won:
case GameState::Lost:
// TODO: if (advancePressed) LoadLevel(1337u);
break;
}
}
void TestMaze::UpdatePlaying(float deltaTime)
{
// ---- Timer ----
// TODO: Subtract deltaTime from m_TimeRemaining
// TODO: If m_TimeRemaining <= 0, clamp it to 0, set m_State = GameState::Lost, return
glm::vec3 playerPos = m_Camera->getPosition();
// ---- Coin pickups ----
// Coins bob up and down, so we ignore Y and test horizontal distance only.
// The lambda computes sqrt(dx^2 + dz^2) between two world positions.
auto distanceXZ = [](const glm::vec3& a, const glm::vec3& b)
{
float dx = a.x - b.x;
float dz = a.z - b.z;
return std::sqrt(dx * dx + dz * dz);
};
// TODO: Iterate m_Map.Coins() (non-const reference so we can set collected)
// TODO: For each coin, if (!coin.collected && distanceXZ(...) < s_PickupRadius)
// set coin.collected = true and increment m_CoinsCollected
// ---- Win condition ----
// TODO: if (AllCoinsCollected()) m_State = GameState::Won;
}
Task 4 - Render coins and display the HUD
-
In
Render(), declarefloat time = (float)glfwGetTime();before the tile loop (you will use it for both the tile loop - not needed there, but makes the coins section self-contained) and add the coin render loop immediately after the tile loop.-
Each coin translates to its bobbing position
(
position + bob), rotates around Y with a phase offset, and scales to 0.3 units. -
Pass
nullptras the texture toDrawCube- coins use a flat gold colourvec3(1.0f, 0.85f, 0.1f)via theu_UseTexture = 0path in the shader.
-
Each coin translates to its bobbing position
(
-
Replace
RenderGUI(). Add a panel section at the top with twoImGui::Textcalls for the time remaining and coin count. -
Get the foreground draw list:
This draw list renders on top of every ImGui window and the 3D view.ImDrawList* drawList = ImGui::GetForegroundDrawList(); ImGuiIO& io = ImGui::GetIO(); ImVec2 center(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f); -
When in the Playing state and the cursor is captured, draw a crosshair:
two lines of length 10 px crossing at
center, colourIM_COL32(255, 255, 255, 200), thickness 2. -
In the
switch (m_State)block, draw the end-game banners using thedrawCenteredTextlambda provided in the skeleton. The lambda usesfont->CalcTextSizeAto measure text at an arbitrary font size (larger than ImGui's default) so it can centre the string precisely.
// 1. In Render(), add the coin loop AFTER the tile loop.
// 'time' comes from glfwGetTime(); make sure it is declared before the tile loop.
// ---- Coin rendering ----
// Each coin is a small gold-coloured spinning cube that bobs up and down.
// A per-coin phase offset stops them all bobbing in unison.
// TODO: const std::vector<Collectible>& coins = m_Map.Coins();
// TODO: for (size_t i = 0; i < coins.size(); i++)
// TODO: {
// TODO: if (coins[i].collected) continue;
// TODO: float phase = (float)i * 0.7f;
// TODO: glm::vec3 bob(0.0f, 0.1f * std::sin(time * 3.0f + phase), 0.0f);
// TODO: glm::mat4 model = glm::translate(glm::mat4(1.0f), coins[i].position + bob);
// TODO: model = glm::rotate(model, time * 2.0f + phase, glm::vec3(0.0f, 1.0f, 0.0f));
// TODO: model = glm::scale(model, glm::vec3(0.3f));
// TODO: DrawCube(model, glm::vec3(1.0f, 0.85f, 0.1f), nullptr); // gold, no texture
// TODO: }
// ---------------------------------------------------------------------------
// 2. Replace RenderGUI() with the version below.
void TestMaze::RenderGUI()
{
// ---- Panel widgets ----
// TODO: ImGui::Text("Time: %.0f s", m_TimeRemaining);
// TODO: ImGui::Text("Coins: %d / %d", m_CoinsCollected, (int)m_Map.Coins().size());
// TODO: ImGui::Separator();
m_Camera->cameraGUI();
ImGui::TextWrapped("WASD: move | Mouse: look | Enter: continue | ESC: pause + cursor");
// ---- Foreground overlays ----
// ImGui::GetForegroundDrawList() gives a draw list that renders ON TOP of
// everything, including other ImGui windows. We use it for the crosshair
// and the large win/lose text so they appear over the 3D viewport.
ImDrawList* drawList = ImGui::GetForegroundDrawList();
ImGuiIO& io = ImGui::GetIO();
ImVec2 center(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
// Helper: draw text centred at (center.x, center.y + yOffset).
// ImGui::CalcTextSize only works at the default font size, so we call
// font->CalcTextSizeA to measure at the requested fontSize.
auto drawCenteredText = [&](float fontSize, float yOffset, ImU32 colour, const char* text)
{
ImFont* font = ImGui::GetFont();
ImVec2 size = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text);
drawList->AddText(font, fontSize,
ImVec2(center.x - size.x * 0.5f, center.y + yOffset - size.y * 0.5f),
colour, text);
};
// Crosshair: only when Playing and cursor is captured
if (m_State == GameState::Playing && !m_Camera->isDetached())
{
// TODO: Draw two crossing lines (arm length 10 px) using drawList->AddLine.
// Horizontal: (center.x - arm, center.y) to (center.x + arm, center.y)
// Vertical: (center.x, center.y - arm) to (center.x, center.y + arm)
// Colour: IM_COL32(255, 255, 255, 200) Thickness: 2.0f
}
switch (m_State)
{
case GameState::Won:
// TODO: drawCenteredText(48.0f, -30.0f, IM_COL32(120, 255, 120, 255), "ALL COINS COLLECTED!");
// TODO: drawCenteredText(24.0f, 30.0f, IM_COL32(255, 255, 255, 255), "Press ENTER to play again");
break;
case GameState::Lost:
// TODO: drawCenteredText(48.0f, -30.0f, IM_COL32(255, 100, 100, 255), "OUT OF TIME...");
// TODO: drawCenteredText(24.0f, 30.0f, IM_COL32(255, 255, 255, 255), "Press ENTER to retry");
break;
case GameState::Playing:
break;
}
}
Solution
Try the tasks above before revealing the solution.