Software · NP-004
Oasis Wildlife Simulator
A C++ program that simulates 27 Australian animals month by month, season by season. It’s built on a three-level class hierarchy, and I traced 14 bugs back to their causes and fixed them along the way.
Class hierarchy · 10 classes · 27 animals
01 · The brief
Twenty-seven animals, one month at a time
The final assignment was a C++ simulation of an oasis full of Australian animals. The user, greeted by the program as Dr. Elara, enters how many months to simulate and which month to start in. For every month, the program works out the season, applies each zone’s habitat to every animal and its food to the mammals and birds, and removes any that die. Then it prints a table of every animal’s health, hunger, hydration, energy and comfort, followed by that month’s deaths.
The population starts at 27 animals: six species in three families, living across five zones. The family rules were based on the brief’s guidelines (AnimalFamilies.txt), with a few additions of my own. The assignment was turning them into a working program, with classes, a monthly loop, death checks and output someone could read.
| Class | Family | Animals | Zone | Habitat | Zone food / month |
|---|---|---|---|---|---|
| Koala | Mammal | 5 | Whispering Woods | forest | 350 |
| Wombat | Mammal | 7 | Savannah Safari | grassland | 400 |
| EBTLizard | Reptile | 3 | Coral Cove | coastal | 500 |
| FrilledLizard | Reptile | 3 | Scrub Country | desert | 300 |
| Kookaburra | Bird | 5 | Lagoon Landing | wetlands | 200 |
| Emu | Bird | 4 | Coral Cove | coastal | 500 |
Zone food is what GetZoneFood() returns for each zone. Reptiles don’t eat zone food in the model, and Scrub Country isn’t listed in GetZoneFood(), so it gets the 300 default.
10
Classes in the hierarchy, three levels deep
7
Source files, headers kept apart from code
14
Bugs traced to a cause and documented
0
Compiler warnings, with strict g++ flags on
02 · Class design
One base class, three families, six species
Animal holds what every animal shares: eight private stats (health, hunger, hydration, energy, comfort and three more), each one behind a getter and a setter. Mammal, Reptile and Bird add their family’s fields and behaviour. Each species then adds fields of its own, plus its own survival check, CheckLife().
Two jobs are built into the classes, so the code that uses them can’t get them wrong. Every setter goes through KeepInRange() in Animal, so no rule can push a stat outside 0–100. And at every level (Animal, family and species), the constructor adds one to that class’s counter and the destructor takes one away. That keeps the counts in each table header equal to the number of objects that actually exist.
Base class
Animal
- −HP, HungerLvl, HydrationLvl, EnergyLvl, ComfyLvl + 3 more
- +get…() / set…() for each, clamped by KeepInRange()
- +Animal() / ~Animal() keep Animal_count
Family
Mammal
- +dailyFoodRequirement, preferredHabitatType, nocturnalActivity
- +CheckFoodAvailability(), CheckHabitat()
Koala × 5
+ climbingDistance
+ CheckFeeding()
+ CheckLife()
Wombat × 7
+ burrowingDepth
+ CheckBurrowingAbility()
+ CheckLife()
Family
Reptile
- +coldBlooded, shedsSkin, preferredSunningSpot
- +CheckTemperature(), ShedSkin()
EBTLizard × 3
+ prefBaskingTemp
+ CheckThermoregulation()
+ CheckLife()
FrilledLizard × 3
+ frillSize
+ CheckThreatResponse()
+ CheckLife()
Family
Bird
- +flyingAbility, nestingPreference, migratorySeasons[4]
- +CheckNestingSite(), FeedingBehaviour(), Migrate(), ActivityCycle()
Kookaburra × 5
+ huntingEfficiency
+ CheckHuntingSuccess()
+ CheckLife()
Emu × 4
+ foragingSkill
+ CheckForagingSuccess()
+ CheckLife()
The clamp started out as a fix (bug 03 in the log below). Before it existed, a well-fed mammal’s hunger kept falling to −270 within a year, and a lizard reached 235% health. Because every setter goes through the same function, adding it meant changing one file. Nothing that calls setHP() had to change.
Globals follow the one-definition rule: extern int Koala_count; in the header, and exactly one int Koala_count = 0; in a .cpp file. That rule came from link errors over Koala_count and Kookaburra_count (bug 14).
double KeepInRange(double value) { if (value < 0.0) { return 0.0; } else if (value > 100.0) { return 100.0; } else { return value; }}03 · The monthly loop
One month, in order
One_Month() updates every animal in a fixed order, then runs a death pass. Order matters here: CheckHabitat() and CheckTemperature() set values outright instead of adding to them, so running them last would overwrite the changes made before them.
01
Habitat
- CheckHabitat()
- CheckTemperature()
- CheckNestingSite()
CheckHabitat() and CheckTemperature() set comfort (and, for reptiles, energy and hydration) outright, so they run first. Birds’ CheckNestingSite() also runs first, but only adjusts comfort.
02
Food, season & species
- CheckFoodAvailability()
- FeedingBehaviour()
- ShedSkin()
- Migrate()
- CheckFeeding()
- CheckBurrowingAbility()
- …
Adds to and takes from the stats: food against need, the season’s effects and each species’ own rules. Each mammal gets its zone’s food divided by the total number of mammals; birds run their hunting or foraging check first, because it sets how much they catch.
03
Death pass
- CheckLife()
- BasicDeathCheck()
- erase()
Runs after every animal has been updated. The species’ own rule comes first, then the shared one. The dead are erased, and their destructors correct the counts.
The death rules every animal shares live in a single function that takes an Animal&. A Koala is an Animal, so the same function checks all six species through inheritance. It used to be copy-pasted into six separate loops. Removal uses erase(), and the loop index only moves on when nothing was removed. That keeps the survivors in the same order, so each animal can be followed from month to month.
bool BasicDeathCheck(Animal& a) { if (a.getHP() <= 0.0) {// HP at or below 0 means dead return true; } else if (a.getEnergyLvl() <= 0.0) {// Energy at or below 0 also means dead return true; } else if (a.getHungerLvl() >= 100.0) {// if hunger is 100 or higher then dead (starved) return true; } else { return false;// anything in between is alive }}// KOALASfor (int i = 0; i < Koala_count; ) { bool dead = false;// start by assuming this is alive //species-specific first (calls Koala::CheckLife on this object) if (!koalas[i].CheckLife()) { dead = true; } else if (BasicDeathCheck(koalas[i])) {// then the general rules (a Koala IS an Animal) dead = true; } if (dead) {// if this died this month d.koala = d.koala + 1;// record 1 death in the Deaths struct return to main() koalas.erase(koalas.begin() + i);// remove koala number i, the rest slide up one } else {// still alive i = i + 1;// move to the next index only when we didn't delete }}04 · Debug log
Fourteen bugs, each traced to a cause
Every fix is written up in a comment where it was made: what went wrong, why, and what changed. The log below follows those comments, symptom first, because that’s how each bug showed up.
BUG-01Animal_count fell 27 → 0 → −27 (every other counter by its own population on each call), and the tables stopped printing rows after the first one.main.cpp
Cause
PrintProperties()took the six vectors by value. The copies were made by the compiler’s own copy constructor, which never runsAnimal(), so the counters never went up for them. But~Animal(),~Mammal()and~Koala()still ran when the copies were thrown away, so every call took each counter down by the number of animals it counts.FixPass the vectors by reference (
vector<Koala>&). The same fix meantOne_Month()finally changed the real animals instead of copies.BUG-02Species counts went negative after deaths.main.cpp
CauseThe death loop decremented
Koala_countby hand, anderase()destroyed the object, which ran the destructors that decrement it again. Every death was counted twice.FixDeleted the manual decrements. Each count now goes up in a constructor and down in a destructor, and nowhere else.
BUG-03Every koala and wombat “died” in April while eating perfectly.main.cpp · Animal.cpp
CauseNothing limited the stats. A well-fed mammal’s hunger kept falling (−270 after a year), and a “hunger ≤ −100 is dead” rule was killing the best-fed animals. Lizard HP reached 235 “percent”.
Fix
KeepInRange()clamps every setter to 0–100, and that rule is gone. The death checks now work cleanly because HP or energy can land exactly on 0.BUG-04Every kookaburra and emu was starving, every single month.main.cpp
CauseTheir daily food needs (400–480 for kookaburras, 550–700 for emus) were more than their zones held: 200 at Lagoon Landing for the kookaburras, 500 at Coral Cove for the emus.
FixA bird now eats what it catches: zone food × hunting or foraging efficiency. Food needs were rescaled to that scale, in the same order as before.
BUG-05Kookaburras got +40 comfort every month, wherever they lived.main.cpp
Cause
CheckNestingSite()was passed the bird’s own nesting preference, so it compared the preference with itself. And once it was compared with the zone’s habitat,"Wetlands"would never have equalled"wetlands".FixIt now gets the zone’s real habitat from
GetZoneHabitat(), with lower-case habitat words throughout. A kookaburra that wants grassland but lives in the wetlands is now uncomfortable, as it should be.BUG-06Over two or three years, every kookaburra slowly got too weak to hunt and starved.Species.cpp
CauseThe seasonal multipliers (×0.9 in winter, ×1.1 in summer) were saved back into the stat each month, so they compounded, and integer maths rounded down every time.
FixFrilled lizards, kookaburras and emus now keep a “normal” value. Each month the season adjusts a copy of it for that month only.
BUG-07Animals with no energy left didn’t die.Species.cpp
CauseThe check was
getEnergyLvl() == 0.0. Energy moves in steps like −15, so it can jump from 10 to −5 and never be exactly zero.Fix
getEnergyLvl() <= 0.0.BUG-08Weak but well-fed kookaburras died, and starving ones lived.Species.cpp
CauseThe hunger test in
Kookaburra::CheckLife()was backwards (≤ 60), even though a higher HungerLvl means hungrier everywhere else.Fix> 60, consistent with the mammals’ > 70.
BUG-09Every kookaburra ran out of energy by its second month.AnimalFamilies.cpp
CauseBirds start on 50 energy and migrating costs 40 a month in a migratory season, but nothing ever gave any back.
ActivityCycle()was optional in the brief, so it had been left empty.Fix
ActivityCycle()now models roosting: +20 energy a month, and +10 more for a bird that’s comfortable in its nest.BUG-10Latent undefined behaviour in the death loop.AnimalFamilies.cpp
Cause
Mammal()left its bool, int and double fields uninitialised untilmain()seeded them.erase()copies animals, and copying an uninitialised bool is undefined behaviour. Becausemain()happened to seed everything first it never fired, but nothing guaranteed that.Fix
Mammal()now sets defaults, the same wayReptile()andBird()already did.BUG-11A mammal’s comfort ignored whether it was living in its preferred habitat.main.cpp
Cause
CheckHabitat()was written but never called. It also sets comfort outright (= 100) instead of adding to it, so calling it last would wipe out the month’s other comfort changes.FixIt’s called first in each mammal’s monthly update, and the same rule applies to
CheckTemperature()for reptiles. Order matters.BUG-12Table rows reshuffled after a death, so one animal couldn’t be followed from month to month.main.cpp
CauseDead animals were removed by copying the last animal into their slot and calling
pop_back().Fix
erase()keeps everyone in order, and the loop index only moves on when nothing was removed.BUG-13Bad input quietly ran a simulation nobody asked for.main.cpp
CauseInvalid entries were forced to the nearest valid value (0 months became 1, month 15 became December), and typing letters jammed cin for every read after it.
FixThe program re-prompts until the input is valid, using
cin.clear()andcin.ignore()to recover from a failed read.BUG-14The program wouldn’t link: “undefined reference to EBTLizard::CheckLife()”, plus “undefined symbol” errors for counters such as Koala_count and Kookaburra_count.Species.cpp
CauseEach one was declared in a header but never defined anywhere.
FixOne definition per symbol in its .cpp file, with extern in the header. That’s the rule every global counter in the program follows.
Bug 06 is the subtlest. Taking last month’s hunting efficiency and multiplying it by the season compounds: three winter months is ×0.9³, and int maths rounded down every time on top of that. A year of seasons left each bird slightly worse than it started, so the decline only showed up as kookaburras starving two or three years into a run.
The fix is to keep each animal’s normal value and apply the season to a copy for that month only. Winter hurts for the winter months, and things go back to normal afterwards.
// (same "normal value" idea as CheckThreatResponse above)void Kookaburra::CheckHuntingSuccess(string season) { int hEff = normalHuntingEfficiency; if (season == "Winter") { hEff = (hEff * 90) / 100; } else if (season == "Summer") { hEff = (hEff * 110) / 100; } if (hEff > 100) {// it's a percentage, so it can't go past 100% hEff = 100; } huntingEfficiency = hEff;05 · Run it
The real program, every start month
Interactive · the program’s own output
Choose a start month and how long to run it
These are the two inputs the program asks for. Every number below comes from its printed tables. The compiled program was run for all 12 start months, 120 months each, and the counts were read from its output, so the chart only shows what the real program printed.
- Alive at the end
- 21 of 27
- Deaths
- 6
- Last death
- Month 20
alive
Shaded months are winter
$ ./oasis Hello Dr. Elara, please enter how many months you would like to simulate: 24 Next, please enter the month you want to start this simulation: [ menu: 1 - January … 12 - December ] Enter your choice (1-12): 1 [ initial values + 24 monthly tables ] ################################# Simulation Summary ################################### Months simulated: 24 Animals still alive: 21 of 27 Total deaths: 6 0 Koala Deaths in total (5 left) 0 Wombat Deaths in total (7 left) 1 Eastern Blue-Tongued Lizard Deaths in total (2 left) 1 Frilled Lizard Deaths in total (2 left) 3 Kookaburra Deaths in total (2 left) 1 Emu Deaths in total (3 left)
Show the numbers as a table
| Month | Koalas | Wombats | Blue-tongues | Frilled | Kookaburras | Emus | Total |
|---|---|---|---|---|---|---|---|
| Start | 5 | 7 | 3 | 3 | 5 | 4 | 27 |
| 12 · December | 5 | 7 | 2 | 3 | 3 | 3 | 23 |
| 24 · December | 5 | 7 | 2 | 2 | 2 | 3 | 21 |
Counters
1,452 / 1,452
Tables where every header count matched the rows printed under it: 12 start months × 121 tables. The fixes for bugs 01 and 02 hold for ten simulated years.
Compiler
0
Warnings from g++ with -Wall -Wextra -Wpedantic -Wshadow -Wconversion, on the unmodified source.
Memory
0
Errors from AddressSanitizer and UndefinedBehaviorSanitizer across all 1,440 simulated months, including every erase() in the death loops.
06 · Ten years in
Every start month ends the same way
Run any start month for ten years and the oasis settles on the same 14 survivors: five wombats, two of each lizard, two kookaburras and three emus. The last death always falls between months 74 and 83, in a winter of the seventh year. After that the head count never moves: every survivor settles into the same yearly cycle, and nothing in the model adds animals.
The health traces show why the mammals die. Nothing in the koala’s rules ever adds health back. Poor eucalyptus costs 5 HP in each winter month, and in autumn as well for the two koalas that eat the fewest leaves and climb too low to reach the fresh ones. So every koala is on a countdown. Wombats live or die on one number: an effective burrow depth under 100 cm costs 3 HP a month, and 100 cm or deeper gives 3 back. The two shallowest burrowers die, and the deeper ones stay at or near full health.
14 of 27
Survivors after 120 months, from every start month
0 of 5
Koalas left: no rule ever restores their health
100 cm
Effective burrow depth that decides a wombat’s fate
Koalas HP, 10-year run from January
#1–2 died in month 40 · #3–5 died in month 79
Wombats HP, 10-year run from January
#1 died in month 34 · #2 died in month 66 · #3, #4–7 alive at month 120
Show every animal as a table
| Animal | HP at 12 | 24 | 60 | 120 | Outcome |
|---|---|---|---|---|---|
| Koala #1 · climbing distance 20 | 70 | 40 | — | — | Died in month 40 (April) |
| Koala #2 · climbing distance 35 | 70 | 40 | — | — | Died in month 40 (April) |
| Koala #3 · climbing distance 50 | 85 | 70 | 25 | — | Died in month 79 (July) |
| Koala #4 · climbing distance 65 | 85 | 70 | 25 | — | Died in month 79 (July) |
| Koala #5 · climbing distance 80 | 85 | 70 | 25 | — | Died in month 79 (July) |
| Wombat #1 · burrow 80 cm | 64 | 28 | — | — | Died in month 34 (October) |
| Wombat #2 · burrow 93.5 cm | 76 | 58 | 4 | — | Died in month 66 (June) |
| Wombat #3 · burrow 107 cm | 100 | 100 | 100 | 100 | Alive |
| Wombat #4 · burrow 120.5 cm | 100 | 100 | 100 | 100 | Alive |
| Wombat #5 · burrow 134 cm | 100 | 100 | 100 | 100 | Alive |
| Wombat #6 · burrow 147.5 cm | 100 | 100 | 100 | 100 | Alive |
| Wombat #7 · burrow 150 cm | 100 | 100 | 100 | 100 | Alive |
07 · Next steps
Where it could go next
- N1Let well-fed animals recover health.Nothing gives a koala HP back, so the model guarantees every koala dies. A small gain for a good feed would let it settle into a real balance.
- N2Add births.The brief marked BirthProcess() as “omitted”, so it was left empty and the population can only fall. With births, a long run would mean something.
- N3One loop instead of six.Make CheckLife() and a monthly update virtual in Animal and keep every animal in one vector<unique_ptr<Animal>>. Then adding a species no longer means editing main().
- N4Rules as data, with tests.Put zone food, season multipliers and death thresholds in tables, with unit tests on the edge cases behind bugs 03, 07 and 08.