Encounter Composition (Multi‐Enemy Battles) - UQcsse3200/2026-studio-3 GitHub Wiki

Overview

Until now every battle spawned the same enemies: ForestGameArea always created a Bone Crawler and BattleGameArea always appended a Lesser Shade. From the first floor to the boss, the player faced the same pair.

This system adds an encounter table and an encounter composer. Each battle's line-up is chosen from the room type and floor of the map node the player entered, driven by a per-run random seed so the result is reproducible.

Design goals:

  • Fights differ by position and floor, with difficulty progressing as the run goes deeper
  • Within a run a node always holds the same fight, including after reloading a save or returning to the main menu and re-entering it, so a player cannot reroll a fight they did not like
  • A new run places different fights on the same map positions
  • Bad configuration or data degrades gracefully instead of crashing

Class diagram

classDiagram
    class EncounterConfig {
      +id String
      +roomType RoomType
      +minProgression int
      +maxProgression int
      +weight int
      +enemies String[]
      +matches(RoomType, int) boolean
    }
    class EncounterConfigs {
      +encounters EncounterConfig[]
      -index Map~String, EncounterConfig~
      +get(String) EncounterConfig
      +all() List~EncounterConfig~
      +matching(RoomType, int) List~EncounterConfig~
      -isValid(EncounterConfig) boolean
    }
    class EncounterComposer {
      +DEFAULT_ENEMIES List~String~
      -table EncounterConfigs
      -isKnownEnemy Predicate~String~
      +compose(RoomType, int, long) List~String~
      -usableCandidates(RoomType, int) List~EncounterConfig~
      -pickWeighted(List~EncounterConfig~, Random) EncounterConfig
      -mix(long) long
    }
    class BattleEncounterSelector {
      <<utility>>
      +enemiesFor(RunState) List~String~
      ~currentRoomType(RunState) RoomType
      ~seedFor(RunState) long
    }
    class RunState {
      -encounterSeed Long
      +startRun(MapGraph, Integer) boolean
      +restoreRun(MapGraph, Integer) boolean
      +endRun() void
      +getEncounterSeed() Long
      +restoreEncounterSeed(Long) void
      +getActiveNodeId() Integer
      +getMapProgression() Integer
    }
    class ForestGameArea {
      #spawnsDefaultEnemy() boolean
      +getEnemies() List~Entity~
    }
    class BattleGameArea {
      -enemyIds List~String~
      -enemyTargets Map~String, Entity~
      +create() void
      +getEnemies() List~Entity~
      +getEnemyTargets() Map~String, Entity~
      #spawnsDefaultEnemy() boolean
    }
    class BattleScreen {
      -createBattleArea() void
    }
    class ProgressSaveData {
      +encounterSeed Long
    }

    ForestGameArea <|-- BattleGameArea : overrides hook
    BattleScreen ..> BattleEncounterSelector : enemiesFor(runState)
    BattleScreen ..> BattleGameArea : creates with line-up
    BattleEncounterSelector ..> RunState : room type, floor, seed
    BattleEncounterSelector ..> EncounterComposer : compose(...)
    BattleEncounterSelector ..> EncounterConfigs : loads encounters.json
    BattleEncounterSelector ..> EnemyConfigs : loads enemies.json
    EncounterComposer --> EncounterConfigs : matching(...)
    EncounterConfigs o-- EncounterConfig
    EncounterComposer ..> EnemyConfigs : known enemy check
    BattleGameArea ..> EnemyConfigs : resolves ids
    BattleGameArea ..> EnemyScaling : scales by floor
    BattleGameArea ..> EnemyFactory : create(EnemyConfig)
    RunState ..> ProgressSaveData : seed saved and restored
Loading

Responsibilities:

Class Responsibility
EncounterConfig One line-up; knows which room type and floors it applies to
EncounterConfigs The whole table: loading, validation, filtering candidates
EncounterComposer Deterministic weighted pick from the candidates, plus every fallback
BattleEncounterSelector Translates RunState into the three inputs the composer needs
BattleGameArea Spawns entities from a list of enemy ids

The composer is written as a pure function: the same inputs always produce the same output and it touches no runtime game state. That makes it easy to test and is the basis of the determinism described below.


How a battle picks its enemies

sequenceDiagram
    participant BS as BattleScreen
    participant Sel as BattleEncounterSelector
    participant RS as RunState
    participant Tbl as EncounterConfigs
    participant Cmp as EncounterComposer
    participant Area as BattleGameArea

    BS->>Sel: enemiesFor(runState)
    Sel->>RS: getActiveNodeId(), getMapProgression()
    Sel->>RS: getEncounterSeed()
    Note over Sel: seed = runSeed * 31 + nodeId
    Sel->>Tbl: load encounters.json
    Sel->>Cmp: compose(roomType, floor, seed)
    Cmp->>Tbl: matching(roomType, floor)
    Tbl-->>Cmp: candidates
    Note over Cmp: drop entries with unknown enemies<br/>weighted pick from mix(seed)
    Cmp-->>Sel: ["tomb_guardian", "lesser_shade"]
    Sel-->>BS: enemy ids
    BS->>Area: new BattleGameArea(..., enemyIds)
    Area->>Area: spawn each enemy left to right
Loading
  1. Read the state — room type, floor (the node's height) and the run seed come from RunState
  2. Derive the seed — seed = runSeed * 31 + nodeId, so nodes differ within a run and a node differs between runs
  3. Filter and pick — candidates are filtered by room type and floor, entries naming unknown enemies are dropped, and one is picked by weight
  4. Spawn — BattleGameArea spawns the ids left to right, with stats scaled for the floor by EnemyScaling

The encounter table

Configuration file: source/core/assets/configs/encounters.json

Fields

Field Type Meaning
id String Unique identifier, used in logs and debugging
roomType RoomType COMBAT, ELITE or FINAL
minProgression int Lowest floor it can appear on (inclusive); defaults to 0
maxProgression int Highest floor it can appear on (inclusive); omit for no upper bound
weight int Relative likelihood; must be positive
enemies String[] Enemy ids in spawn order; duplicates allowed

Example

{
  "id": "crawler_and_acolyte",
  "roomType": "COMBAT",
  "minProgression": 4,
  "weight": 10,
  "enemies": ["bone_crawler", "dark_acolyte"]
}

Why hand-authored line-ups

The system picks a whole pre-written line-up rather than assembling enemies at random. Random assembly easily produces fights that are unbalanced (two elites on screen) or mechanically redundant (two enemies that both only stack armour). Authored combinations are reviewable, tunable level design content.

Current difficulty curve

Ordinary combat, across the 10 floors (progression 0–9):

Floors Candidates
0–2 A lone Bone Crawler, two Lesser Shades, Bone Crawler with Lesser Shade
3–5 Dark Acolyte is introduced
6–9 Lesser Shade drops out; Dark Acolyte pairings dominate

The intent: early floors use only the two weakest enemies, the caster joins mid-run, and the weakest enemy retires late.

Elite rooms have two lines (Tomb Guardian and Void Knight), each gaining a tougher companion variant from floor 5.

⚠️ Tomb Guardian never appears alone. Its taunt mechanic is meaningless when it is the only enemy on screen, so every Tomb Guardian entry includes a companion.

Current limits

  • At most two enemies per battle. BattleGameArea places enemies at 20 + index * 7, and a three-enemy layout has not been verified yet
  • Weights are relative, not percentages. On floor 4 the three candidates weigh 10 / 8 / 10, giving roughly 36% / 29% / 36%

Determinism and the run seed

Seed lifecycle

When Method Behaviour
New run RunState.startRun() Creates a random 64-bit seed
Save GameStateSnapshotProvider.captureProgress() Writes it to ProgressSaveData.encounterSeed
Load RunState.restoreRun() Creates a fallback seed first
Load RunState.restoreEncounterSeed() Overwrites it with the saved seed, or keeps the fallback if there is none
Run ends RunState.endRun() Clears it

restoreEncounterSeed must be called after restoreRun. In the other order the saved seed would be overwritten by the fallback and the anti-reroll guarantee would silently break.

The field is a Long, not a long, so null can mean "this save was made before the seed existed" — such saves get a fresh seed instead of incorrectly using 0.

Why this matters

Leaving a battle (Esc, then Return to Main Menu) calls abandonEncounter(), which makes the node available again. Without a stable seed a player could look at an elite line-up, leave, and re-enter for a different draw. The seed makes a node's fight fixed for the whole run.

mix(): a detail that has to be there

java.util.Random produces highly correlated first values for consecutive seeds. Map node ids are consecutive (nodeId = floor * 7 + column), and the total weight on some floors is a power of two. Measured before the fix: with two equally weighted candidates and seeds 0–999, one of them was never picked.

EncounterComposer therefore scrambles the seed with the SplitMix64 finaliser before using it:

private static long mix(long seed) {
  long z = seed + 0x9E3779B97F4A7C15L;
  z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L;
  z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL;
  return z ^ (z >>> 31);
}

It is an invertible bijection, so determinism is preserved — it only pulls neighbouring seeds apart. The constants are part of the standard algorithm and should not be changed.

The regression test shouldSpreadConsecutiveSeedsAcrossEqualWeights locks this in: removing mix() fails it immediately.


Fallbacks

None of these crash the game. Each falls back to the default line-up ["bone_crawler", "lesser_shade"], which is the fight that existed before this system:

Situation Handling
encounters.json fails to parse The table is null, fall back, log a warning
An entry is invalid (weight ≤ 0, inverted range, no enemies, …) That entry is skipped at load time with a warning
An entry names an enemy id that does not exist That entry is dropped at pick time; if nothing is left, fall back
A room type has no candidates at all (for example START) Fall back, log a warning

compose() guarantees it never returns an empty list, so callers do not need null or emptiness checks.


Adding a new encounter

  1. Add an entry to encounters.json, making sure every id in enemies exists in enemies.json
  2. Run the content test:
.\gradlew.bat test --tests "*EncounterTableContentTest"

It checks that no entry was silently skipped, that every referenced enemy exists, that every room type has candidates on every floor, and that the boss stays out of ordinary rooms.

  1. Verify in game with the debug terminal:
F1 → debug on → goto <floor> <roomtype>

Testing

58 unit tests:

Test class Count Covers
EncounterConfigsTest 18 Validation rules, matching, JSON loading and defaults
EncounterComposerTest 12 Fallbacks, unknown enemies, determinism, weight distribution
EncounterTableContentTest 6 Correctness of the real encounter table
BattleEncounterSelectorTest 11 Room type, seed combination, same encounter after reload
RunStateEncounterSeedTest 8 The full seed lifecycle
EncounterSeedSaveTest 3 Seed survives a real save file and a simulated relaunch

Two of them form the complete anti-reroll proof:

EncounterSeedSaveTest      : seed before quitting == seed after loading
BattleEncounterSelectorTest: same seed == same enemies

⚠️ **GitHub.com Fallback** ⚠️