Repository navigation
Achievements
Related pages: Achievements Service · Achievements Test Plan
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. |
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
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).
Location: configs/achievements.json, read with FileLoader.readClass(AchievementConfig[].class, "configs/achievements.json") · Registered in: TODO: class that calls AchievementsFactory.build + AchievementService.register
{ "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.
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.
| Field | Set by | Read by |
|---|---|---|
enemyKilled |
TODO: enemy death reporter |
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
|
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 |
TODO: enemy kill reporter |
enemyKilled |
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 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.
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 (TODO):
-
: the toast shown on unlock. -
: the achievements list screen grouped by category.
Add an entry to the achievements JSON:
{ "type": "enemyKillCount", "category": "Monsters", "enemyType": "MUMMY",
"target": 20, "name": "Mummy's Boy", "description": "Defeat 20 Mummies." }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.
- 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.
-
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.
-
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.
TODO (team): how the design changed during the project and why. Suggested points: hardcoded vs JSON-driven achievements, how boss achievements were added, issues found in testing.
Entities and Components
Input Handling
- Service Locator
- Loading Resources
- Logging
- Unit Testing
- Debug Terminal
- Animations
- Audio
- AI
- Physics
- Terrain
- Concurrency & Threading
- Settings
Components
Achievements
Player
Items and Inventory
Enemies
Bosses
- Mini Boss 1: Cerberus (Egyptian)
- Mini Boss 2: Snake (Greek)
- Mini Boss 2: Snake Remake
- Mini Boss 3: Chinese Dragon
- Mini Boss 4: Sleipnir (Norse)
- Final Boss: Grandpa (Stage 2 Remake and Stage 3 Improvements)