Repository navigation
Tortoise System
Hidden tortoises are collectibles. Finding all of them earns the Tortoise Champion honour on the win screen (see Win System), and each one counts towards the "Time Freeze" perk.
Where the tortoises are is decided once, at the start of every new game, and then saved with the game. A game hides between 3 and 12 tortoises. Three always exist (the fixed ones) and the rest are random, so two playthroughs are never the same.
| Kind | How many | Where it is |
|---|---|---|
| Fixed | 3 | the tortoises placed on a map by hand (for example level1_a in Level 1) and the one Zeus drops (zeus_tortoise) |
| Random | 0 to 9 | carried by an easy enemy, chosen when the map is built |
New game
| TortoisePlanner.newGame(random)
| rolls the total, 3 to 12
| shares the random tortoises between the maps by weighted draw
v
TortoisePlan (map name -> how many random tortoises it holds)
| total is given to TortoiseLedger.setTotal
v
A map is built (LevelGameArea)
| TortoiseCarrierAssigner.assign(map, enemies, random)
| picks easy enemies and adds a TortoiseDropComponent to each
| any tortoise with no carrier is handed back
v
Handed-back tortoises are placed on the ground near the player's start
v
A carrier dies -> TortoiseDropComponent spawns the tortoise where it stood
v
The player touches it -> TortoiseComponent marks it found
Here is a built sequence diagram based on the above outlined logic:
TortoisePlanner makes the plan. The numbers below are constants in the class, so they can be tuned in one place.
| Rule | Value |
|---|---|
| Fixed tortoises (always exist, count towards the total) | 3 |
| Largest total a game can roll | 12 |
| Random tortoises one main level can hold | 4 |
| Random tortoises Level 3 can hold (it is the last level, so it gets fewer) | 2 |
| Weight of each room's single slot | 3.5 |
| Weight of each slot in Level 1, Level 2, Level 3 | 1.0, 0.6, 0.3 |
Each of the eight side rooms has one slot, so a room holds at most one. A room slot is weighted 3.5 against 1.0 for Level 1, so rooms are likelier than main levels, and the chance falls as the player climbs. Because the plan is made up front, a total of 12 can always be reached. The same seed always gives the same plan, which is what makes the planner testable.
TortoiseCarrierAssigner turns the plan into carriers each time a map is built.
- Only easy enemies can carry one: the Skeleton, Ranged Skeleton, Harpy and Ranged Harpy. A mini boss, an escort or Zeus is never picked.
- An enemy carries at most one tortoise.
- The enemies must not have been created yet, because a component added after creation is ignored. If one was, its tortoise is handed back like any other without a carrier.
- A tortoise that was already found is skipped and is not handed back.
- If a carrier cannot be found (no easy enemy on the map, or the enemies were killed on an earlier visit) the tortoise is handed back and placed on the ground. That is what stops a total from becoming unreachable.
Random tortoises have stable ids of the form rnd_<map slug>_<n>, for example rnd_hound_s_den_1. A stable id is what lets a found tortoise stay found after a save and a load.
TortoiseDropComponent is added to a carrier. A tortoise is a world pickup, not an inventory item, so it cannot use ItemDropComponent. The component listens for the enemy's own death event, reads where the enemy stood and spawns the tortoise there. It does not touch EnemyDeathComponent, which disposes the enemy on a later frame, so the position can still be read. A tortoise that was already found is never dropped again, and a second death event is ignored.
-
TortoiseFactory.createTortoise(id)builds the pickup: a static body, a solid fixture and an item-layer hitbox, with the idle animation fromimages/tortoise.atlas. -
TortoiseComponentmarks the tortoise found when the player touches it, removes it on the render thread, and collects only once. -
TortoiseRegistryremembers which ids were found (package-private;TortoiseFactory.isFoundandresetAllare the public surface). -
TortoiseLedger(in thewinpackage) holds the total and the found count for the win screen.
| Moment | What happens |
|---|---|
| New game |
TortoiseFactory.resetAll(), TortoisePlan.reset(), then TortoisePlanner.newGame rolls a new plan and TortoiseLedger.setTotal is given the total |
| Save |
GameSaveData stores foundTortoiseIds, tortoisePlan and tortoiseTotal
|
| Load | the ledger is loaded from the found ids, TortoiseFactory.resetAll() clears anything marked found by an earlier save, TortoiseFactory.restoreFound(ids) marks only the saved ones, and the saved plan and total are restored (a missing total makes a new plan) |
Bug #347 (a tortoise found without saving could never be found again) is why load clears the found state before restoring: only what was saved counts as found.
| Class | Kind | Responsibility |
|---|---|---|
TortoisePlanner |
static logic | rolls the total and shares the random tortoises between maps |
TortoisePlan |
static state | map name to how many random tortoises it holds; saved with the game |
TortoiseCarrierAssigner |
static logic | chooses carriers when a map is built; builds the stable ids |
TortoiseDropComponent |
component | spawns the tortoise when its carrier dies |
TortoiseFactory |
factory | builds a tortoise; found-state helpers for save and load |
TortoiseComponent |
component | marks the tortoise found when the player touches it |
TortoiseRegistry |
static state | which ids have been found |
Class diagram of com.csse3200.game.perks.tortoises: the planner and plan, the carrier assigner, the factory, the registry, and the drop and pickup components.

How a tortoise gets from the plan to being found. At the start of a new game TortoisePlanner.newGame rolls the total and stores the plan in TortoisePlan. When a map is built, LevelGameArea asks TortoiseCarrierAssigner.assign for carriers, which reads the count for that map and adds a TortoiseDropComponent to easy enemies. Any tortoise with no carrier is handed back and placed on the ground. When a carrier dies, the drop component spawns the tortoise at its feet, and when the player touches it, TortoiseComponent marks it found.

| To change | Edit |
|---|---|
| the largest total | TortoisePlanner.MAX_TOTAL |
| how many a level can hold |
MAX_PER_LEVEL, LEVEL_3_MAX
|
| how likely rooms and levels are |
ROOM_WEIGHT, LEVEL_1_WEIGHT, LEVEL_2_WEIGHT, LEVEL_3_WEIGHT
|
| who can carry one | the easy enemy labels in TortoiseCarrierAssigner
|
Each class has its own JUnit test class in com.csse3200.game.perks.tortoises. The registry, the plan and the ledger are static, so each test resets them before and after it runs (TortoiseTestSupport). There are 97 test methods across the 8 classes.
Tests were used to ensure the following situations were working:
- The total is always from 3 to 12, and both ends can be rolled
- The same seed rolls the same total and gives the same plan
- A total below 3 or above 12 is rejected
- With only the fixed tortoises, nothing is placed
- The random share is placed exactly, for every total
- A room never holds more than one, a main level never more than 4, and Level 3 never more than 2
- A lone level or Level 3 is filled up to its own limit
- Only known maps are used, and rooms are favoured over the highest level and lower levels over higher ones
- The planner stores the plan it made and replaces an earlier one
- The room and level lists match the map names in the real map files
Tests were used to ensure the following situations were working:
- Ids are built from the map name, trimmed of punctuation, and are different for every map and slot
- An enemy with no type, or one that already carries a tortoise, is not eligible
- Each planned tortoise gets its own carrier, and a map with no plan gets none
- A tortoise with no carrier is handed back, including when no enemy can carry one or there are no enemies
- A found tortoise is skipped and is not handed back
- A tortoise is handed back when its carrier was already created
- The same seed picks the same carriers and different seeds can pick different ones
Tests were used to ensure the following situations were working:
- Nothing spawns before the enemy dies, and exactly one tortoise spawns on death
- It spawns at the enemy's feet, in the middle of its width, with the configured id
- A second death event does nothing
- A tortoise that was already found is not spawned, and it spawns again only if the found state was cleared
- The spawn is posted to the render thread when a game is running and run straight away in tests
TortoiseComponentTest, TortoiseFactoryTest, TortoiseFactoryRestoreTest, TortoiseRegistryTest, TortoisePlanTest
Tests were used to ensure the following situations were working:
- Touching the tortoise marks it found once, reports one step of perk progress and removes it
- Anything that is not the player, another fixture, or a collision with no hitbox is ignored
- A tortoise is built with every part, on the item layer, colliding only with obstacles, with its own id each time
- Restoring from a save marks every saved id as found, replaces what was found before, clears everything for a null list and ignores null or blank ids
- The registry remembers a found tortoise, does not affect others, allows marking twice and forgets all on reset
- The plan starts empty, returns what was set, returns zero for an unknown map, rejects a negative or null count and keeps the old plan if a bad one is rejected
- The plan exports a copy, does not share the caller's map and survives an export and set round trip
- Start a new game twice and check the tortoise total on the win screen can differ.
- Kill a Skeleton that carries a tortoise and check it appears where the enemy stood and can be picked up.
- Find a tortoise, save, reload, and check it stays found. Find one without saving, reload, and check it can be found again.
- Kill Zeus (
killzeusterminal command) and check his tortoise drops.