Achievements - UQcsse3200/2026-studio-4 GitHub Wiki
Achievements
Related pages: Achievements Service ยท Achievements Test Plan
1. Overview
The achievement system tracks player milestones (dungeon clears, kill counts, gold, boss challenges, speed runs) and shows a toast when one unlocks.
Design goals:
- Data-driven. Achievements are defined in JSON, not code. Most new achievements need no Java.
- Decoupled. Gameplay code never references achievements. It reports what happened through an
AchievementContext, and the achievements decide whether that matters. - Persistent. Unlock state and progress are saved and restored by achievement name.
| Class | Role |
|---|---|
AchievementConfig |
Plain object deserialised from JSON, one per achievement. |
AchievementsFactory |
Turns a config into an Achievement by switching on config.type. |
Achievement |
Name, description, condition (Predicate<AchievementContext>), progress, target, category, unlocked flag. |
AchievementContext |
A "something just happened" message. Reporters fill in only the fields relevant to the event. |
AchievementService |
Registry and dispatcher, accessed via ServiceLocator. See Achievements Service. |
| Reporter components | Listen to game events, build a context, call AchievementService.update. |
AchievementDisplay |
UI toast that listens for achievementUnlocked. |
RunTimer |
Supplies dungeon completion time for speed-run achievements. |
2. Architecture
Class diagram
classDiagram
class AchievementConfig {
+String type
+String name
+String description
+String category
+float target
+EnemyType enemyType
+List~EnemyType~ enemyTypes
+String dungeonId
+float maxSeconds
+String bossAchievementId
}
class AchievementsFactory {
+build(AchievementConfig) Achievement
}
class Achievement {
-boolean unlocked
-float progress
-float target
-Predicate~AchievementContext~ condition
+update(AchievementContext) boolean
+restoreState(unlocked, progress)
}
class AchievementContext {
+EnemyType enemyKilled
+boolean playerDamaged
+Integer goldCollected
+String dungeonEnteredId
+String dungeonCompletedId
+String dungeonId
+float dungeonSeconds
+String bossAchievementId
}
class AchievementService {
-List~Achievement~ achievements
-EventHandler eventHandler
+register(Achievement)
+update(AchievementContext)
+restoreState(names, progress)
}
class AchievementDisplay {
+showAchievement(String name)
}
class Reporters
AchievementsFactory ..> AchievementConfig : reads
AchievementsFactory ..> Achievement : creates
AchievementService o-- Achievement
Achievement ..> AchievementContext : tests
Reporters ..> AchievementContext : fills in
Reporters ..> AchievementService : update(ctx)
AchievementService ..> AchievementDisplay : achievementUnlocked event
Event flow
sequenceDiagram
participant G as Gameplay event
participant R as Reporter component
participant S as AchievementService
participant A as Each Achievement
participant D as AchievementDisplay
G->>R: entity event (e.g. damageTaken)
R->>R: build AchievementContext
R->>S: update(context)
loop every registered achievement
S->>A: update(context)
A-->>S: true only on first unlock
end
S->>D: trigger "achievementUnlocked" (name)
D->>D: queue toast and play sound
Key idea: every context is offered to every achievement. Each achievement's condition must ignore contexts that aren't about it (usually a null or default-value check on its own field).
3. Data model
3.1 Achievement definitions (JSON)
Location: configs/achievements.json, read with FileLoader.readClass(AchievementConfig[].class, "configs/achievements.json") ยท Registered in: source/core/src/main/com/csse3200/game/screens/MainGameScreen.java
{ "type": "enemyKillCount", "category": "Monsters", "enemyType": "WOLF",
"target": 8, "name": "Who Let the Wolves Out?", "description": "Defeat 8 Wolves." }
category groups achievements in the UI. The current categories are Dungeons, Bosses, Final Boss, Monsters, Sets and Combat.
3.2 Achievement types
type |
Config fields | Unlocks when | Progress |
|---|---|---|---|
dungeonClear |
dungeonId |
ctx.dungeonCompletedId equals dungeonId |
none |
dungeonReached |
dungeonId |
ctx.dungeonEnteredId equals dungeonId |
none |
speedRun |
dungeonId, maxSeconds |
Dungeon completed and best recorded time is at most maxSeconds |
Last recorded dungeon time (starts at Float.MAX_VALUE) |
enemyKillCount |
target, optional enemyType |
Kill count reaches target. A null enemyType counts any enemy. |
Kills so far |
killStreak |
target |
target kills in a row. Any playerDamaged context resets it to 0. |
Current streak |
gold |
target |
target gold pickups. A pickup is counted once, regardless of amount. |
Pickups so far |
enemySet |
enemyTypes |
At least one kill of each listed type | none (the set is held in memory only) |
snake / finalBoss |
bossAchievementId |
ctx.bossAchievementId equals the configured ID |
none |
An unknown type throws IllegalArgumentException at build time.
3.3 AchievementContext fields
| Field | Set by | Read by |
|---|---|---|
enemyKilled |
EnemyManagerComponent |
enemyKillCount, killStreak, enemySet |
playerDamaged |
AchievementProgressComponent |
killStreak |
goldCollected |
AchievementProgressComponent |
gold |
dungeonEnteredId |
RoomAcheivements |
dungeonReached |
dungeonCompletedId |
RoomAcheivements |
dungeonClear, speedRun |
dungeonId, dungeonSeconds |
RoomAcheivements |
speedRun |
bossAchievementId |
SnakeAchievementComponent, FinalBossAchievementComponent |
snake, finalBoss |
4. Reporting events (usage)
A reporter is a normal Component that listens for entity events and forwards them:
AchievementContext context = new AchievementContext();
context.goldCollected = amount; // only the relevant field
ServiceLocator.getAchievementService().update(context);
| Reporter | Attached to | Listens for | Reports |
|---|---|---|---|
AchievementProgressComponent |
Player | damageTaken, goldPickedUp |
playerDamaged, goldCollected |
RoomAcheivements |
Room entity | RoomClearService.DUNGEON_CLEARED, RoomSelector.ROOM_SWITCHED_EVENT |
Dungeon completed, with time from RunTimer, and dungeon entered. It also stops and starts the RunTimer dungeon clock on a dungeon change. |
SnakeAchievementComponent |
Snake miniboss | Boss and player events | Snake challenge IDs |
FinalBossAchievementComponent |
Final boss | Stage events | Final boss challenge IDs |
EnemyManagerComponent |
enemyKilled |
Speed runs
RoomAcheivements.onRoomSwitched restarts RunTimer whenever the dungeon ID changes. On DUNGEON_CLEARED the current RunTimer.getDungeonTime() is put into dungeonSeconds. The speedRun condition records that time, then unlocks if the dungeon was just completed and the time is at most maxSeconds.
Boss challenge IDs
Boss and miniboss challenges use string IDs, because the logic is too specific for a generic type. The reporter component holds the encounter-specific state and reports the ID when the challenge is met.
| ID | Reported by | Condition |
|---|---|---|
snakeDefeatedWithoutShieldPickup |
Snake | Snake dies, player alive, no shield gem collected |
snakeStageTwoShieldPickup |
Snake | First shield gem collected in Stage 2 |
snakeExposedHit |
Snake | Damaging player attack while Snake is exposed |
finalBossBreakRespected |
Final boss | Full Stage 1 break with no hit on the boss |
finalBossStageTwoIceHit |
Final boss | Player ice projectile hits the boss in Stage 2 |
finalBossStageTwoFireproof |
Final boss | Stage 2 completed without a boss fireball hit |
finalBossStageThreeIceHit |
Final boss | First ice hit on the player in Stage 3 Wave 1 |
finalBossStageThreeIceHitStreak |
Final boss | Three consecutive ice impacts in Stage 3 Wave 1 |
finalBossStageThreeMeleeHit |
Final boss | First sword or dagger hit on the boss in Stage 3 Wave 1 |
finalBossStageThreeStatueFlash |
Final boss | First statue teleport in Stage 3 Wave 2 |
Shared pattern in both boss reporters:
- Encounter-local state lives in a small static
Attemptobject, so listeners kept on the long-lived player entity don't retain a disposed boss. - Each result is one-shot. The flag is set before
AchievementService.updateis called, because listeners may re-enter or dispose the encounter. - Attempts are cancelled on player death, boss disposal or phase change.
5. UI
AchievementDisplay (a UIComponent) listens to AchievementService directly, so notifications don't depend on room or game-area code.
- Names are queued, and each toast shows for 2 seconds with
sounds/achievementSound.mp3. - It sits at the top centre of the HUD, with z-index 2.5.
- It null-checks the service, so it is safe in screens that don't register one.
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Achievement Unlocked: โ
โ First Steps โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Screenshots:
- The toast shown on unlock:
- The achievements list screen grouped by category:
6. Extending the system
A. New achievement of an existing type (JSON only)
Add an entry to the achievements JSON:
{ "type": "enemyKillCount", "category": "Monsters", "enemyType": "MUMMY",
"target": 20, "name": "Mummy's Boy", "description": "Defeat 20 Mummies." }
B. New generic type
Example: count potions used.
- Context: add
public String potionUsed;toAchievementContext. - Config: add any new config field to
AchievementConfig(here the existingtargetis enough). - Factory: add a case and a builder in
AchievementsFactory:
case "potionUsed" -> potionUsed(c);
private static Achievement potionUsed(AchievementConfig c) {
Achievement a = new Achievement(c.name, c.description);
a.setTarget(c.target);
a.setCondition(ctx -> {
if (ctx.potionUsed == null) return false; // ignore unrelated contexts
a.addProgress(1);
return a.getProgress() >= c.target;
});
return a;
}
- Reporter: forward the event.
entity.getEvents().addListener("potionUsed", (String id) -> {
AchievementContext ctx = new AchievementContext();
ctx.potionUsed = id;
achievements.update(ctx);
});
- JSON and tests: add the entry and a case in
AchievementsFactoryTest.
C. Scripted or boss challenge
- Add a
Componentwith areportAchievement(String id)method (copy the one inSnakeAchievementComponent). - Add a JSON entry with
"type": "snake"or"finalBoss"and a uniquebossAchievementId. Both types behave the same, apart from thesnaketype tolerating a missing ID. The names are historical. - Keep state in an encounter-local
Attempt, and make results one-shot and consumed before reporting.
Rules of thumb
- Names must be unique and stable. Save data and the
getAchievement*maps are keyed by name. Renaming an achievement orphans its saved progress. - Conditions must be safe for every context, since every achievement sees every update. Check your own field first.
- Always null-check
ServiceLocator.getAchievementService()in reporters, as screens and tests may not register one. - State that must survive save and load belongs in
progress, viasetProgressoraddProgress. Variables captured in the condition lambda are not saved. - Report facts, not decisions. The reporter says what happened, and the achievement decides if it counts.
7. Known limitations
enemySettracks which enemies have been seen in a lambda-local set. This is not saved, so set progress resets on reload.goldcounts pickup events, not coin amounts.RoomAcheivementsdoes not null-checkAchievementServiceorRunTimer.- The
finalBosstype throws aNullPointerExceptionifbossAchievementIdis missing from the config. Thesnaketype does not. - Achievements added after a save was made restore with progress 0.
8. Testing
9. Reflection
Original plan. We first built achievements as a component with a manager registered on each room. Rooms would trigger achievements and display the unlock notification.
Why it changed. Achievements are triggered from all over the game: enemy kills, gold pickups, boss encounters and dungeon progress. With the room-based design, every one of these sources would have needed a dependency on the room's manager, and rooms would have needed extra wiring for each new achievement type. It also tied the notification to room code, so it would have broken whenever rooms were refactored.
What we did instead.
- Moved achievements into an
AchievementServiceaccessed throughServiceLocator, so any component can report an event without knowing about rooms. - Introduced
AchievementContext, so gameplay code reports what happened and each achievement decides whether it counts. - Made achievements data-driven: they are defined in
configs/achievements.jsonand built byAchievementsFactory, so most new achievements need no Java. - Made
AchievementDisplaylisten to the service directly, independent of rooms. - Handled boss challenges with string IDs reported by small encounter components, because their logic was too specific for a generic type.
Trade-offs. The service locator gives global access, which we used sparingly and null-check in every reporter. Every context is offered to every achievement, which is cheap at about 50 achievements but means each condition must ignore contexts that aren't about it.