Encounter Composition (Multi‐Enemy Battles) - UQcsse3200/2026-studio-3 GitHub Wiki
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
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
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.
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
-
Read the state — room type, floor (the node's height) and the run seed come from
RunState -
Derive the seed —
seed = runSeed * 31 + nodeId, so nodes differ within a run and a node differs between runs - Filter and pick — candidates are filtered by room type and floor, entries naming unknown enemies are dropped, and one is picked by weight
-
Spawn —
BattleGameAreaspawns the ids left to right, with stats scaled for the floor byEnemyScaling
Configuration file: source/core/assets/configs/encounters.json
| 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 |
{
"id": "crawler_and_acolyte",
"roomType": "COMBAT",
"minProgression": 4,
"weight": 10,
"enemies": ["bone_crawler", "dark_acolyte"]
}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.
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.
-
At most two enemies per battle.
BattleGameAreaplaces enemies at20 + 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%
| 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.
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.
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.
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.
- Add an entry to
encounters.json, making sure every id inenemiesexists inenemies.json - 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.
- Verify in game with the debug terminal:
F1 → debug on → goto <floor> <roomtype>
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