Skip to content

Achievements

Krish edited this page Oct 20, 2026 · 3 revisions

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
Loading

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
Loading

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: 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.

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 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

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
TODO: enemy kill reporter 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 Attempt object, 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.update is 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 (TODO):

  • ![Unlock toast](TODO: toast screenshot): the toast shown on unlock.
  • ![Achievements screen](TODO: screenshot): 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.

  1. Context: add public String potionUsed; to AchievementContext.
  2. Config: add any new config field to AchievementConfig (here the existing target is enough).
  3. 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;
}
  1. Reporter: forward the event.
entity.getEvents().addListener("potionUsed", (String id) -> {
  AchievementContext ctx = new AchievementContext();
  ctx.potionUsed = id;
  achievements.update(ctx);
});
  1. JSON and tests: add the entry and a case in AchievementsFactoryTest.

C. Scripted or boss challenge

  1. Add a Component with a reportAchievement(String id) method (copy the one in SnakeAchievementComponent).
  2. Add a JSON entry with "type": "snake" or "finalBoss" and a unique bossAchievementId. Both types behave the same, apart from the snake type tolerating a missing ID. The names are historical.
  3. 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, via setProgress or addProgress. 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

  • enemySet tracks which enemies have been seen in a lambda-local set. This is not saved, so set progress resets on reload.
  • gold counts pickup events, not coin amounts.
  • RoomAcheivements does not null-check AchievementService or RunTimer.
  • The finalBoss type throws a NullPointerException if bossAchievementId is missing from the config. The snake type does not.
  • Achievements added after a save was made restore with progress 0.

8. Testing

See Achievements Test Plan.

9. Reflection

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.

Table of Contents

Home

Getting Started

Game Engine

Entities and Components
Input Handling

Features

Components
Achievements
Player
Items and Inventory
Enemies
Bosses
Interactable NPCs
Cutscenes and Dialogue
Music
Weapons
Abilities & Spells
Rooms and Dungeons
Game Screens and Areas
UI

Test Plans

Team 2

Troubleshooting

Clone this wiki locally