Case Study — Technical Game Design · Systems
GPE205 — Dune Tank Game
For a Unity programming class I built finite-state-machine AI on top of the course's tank-game framework: a sandworm faction with a three-state machine, a Harkonnen faction with a seven-state machine, and a GameManager that runs the whole flow from title screen to game over.
The design is the interesting part: one shared decision loop, every faction layered on top of it, documented with the README's state diagrams — with an honest record of what's built, what's wired but stubbed, and what's still on the plan.
The challenge. Turn the course's tank framework into a dangerous, Dune-like arena — AI factions that guard, hunt, flee, and lurk — driven by explicit state machines a reviewer can read top to bottom.
Project
The repo is a public Unity project built as a programming class assignment. The course supplied the tank framework — movement, shooting, spawning, map generation, and pickups. My work is the layer on top: the AI faction controllers and the game-flow state machine, plus the README that documents the AI. The point of the exercise was to be able to point at any enemy and say exactly what it's doing and why.
The AI follows a Dune-flavored fiction: a sandworm (Shai Hulud) that lurks by sound and consumes everything in its path; the Harkonnen, who mine spice at a post and defend it; and two more factions — Sardaukar and Smuggler — designed in the plan but not yet built. The README says so plainly: the project is in progress.
The FSM Architecture
Everything sits on one base AIController. It holds a 14-state enum
(ChooseTarget, Seek, GuardDesert,
WormAttack, WormRest, GuardSpice,
Defend, Scan, Chase, Attack,
Flee, BackToPost, Steal, Mine) and a
MakeDecisions() loop — a switch/case over the current state, where each case runs
the state's action and then checks its transitions. ChangeState() stamps the time
of every transition; Seek() overloads rotate toward and advance the pawn; a
RunAway() builds a flee vector away from the target, scaled by distance so the AI
keeps a minimum gap; and distance helpers like isDistanceLessThan() power the
transitions.
Shai Hulud — three states. GuardDesert (hidden, idle — the starting state), WormAttack (seek and consume, triggered by sound), WormRest (after reaching the sand's rim, a timer counts up, then back to GuardDesert). The README names them Asleep → Attack → Rest.
Harkonnen — seven states. GuardSpice (mining at the post — the starting state), Scan (rotate), Defend, Chase and Attack (pursue and assault Fremen; Attack doubles speed and rate of fire), Flee (pack up the mining rig and fly from the sandworm), and BackToPost (return to the post). The transition graph is written out explicitly: GuardSpice → Scan/Flee, Chase → Attack/Flee, Attack → Chase/Flee, Flee → BackToPost → GuardSpice.
Implemented, Wired & Still Planned
I want the record to be as exact as the code. Here's the honest split:
- Implemented & documented. The FSM structure — enum, decision loop, ChangeState with transition timestamps, Seek overloads, RunAway's flee vector, distance helpers. The full transition graphs for Shai Hulud and Harkonnen. The GameManager's five-state flow, the keyboard-driven state tester, spawning (player + 8 AI units), and player-death → lives → respawn-or-game-over. The README with its state diagrams.
- Wired but stubbed. Several per-state actions are log statements or empty placeholders (e.g. the worm's invisible/eat, the Harkonnen's MineSpice/Shoot/DoubleSpeed), and the perception predicates (
IsFremenInAttackRange,DetectShaiHulud,IsAtPost) are placeholders that return false.canHear()— the sound-based "sense" behind the sandworm's ambush — is designed around a NoiseMaker but the integration is commented out, so it currently returns false. - Still planned. Sardaukar and Smuggler controllers are stubs with no FSM yet; the README lists them as coming in future versions, and
ResetGameStateonly logs for now.
In practice this means the machines are fully authored — the states, the transitions, the timers — while the leaf behaviors and senses are the part left unfinished in the build you can read today.
Game Flow & Testability
A singleton GameManager drives the whole app as a five-state machine — Title,
Main Menu, Settings, Play, and Game Over — each backed by a screen object.
DeactivateAllStates() turns everything off, then an
ActivateXScreen() method turns exactly one on, so two screens can never overlap.
Activating Play generates the map and spawns the player plus eight AI units; player death costs
a life, then respawns the player or calls the game over.
A companion GameStateTester lets me flip between all five screens from the keyboard (keys 1–5). It's a deliberate dev tool: I can exercise every state and its transitions without building UI first, which kept the loop for testing tight while the game flow was still being written.
Key Design Decisions
1 · One shared decision loop
The base controller owns the switch/case; each faction overrides MakeDecisions() to add its own states and transitions on top.
Why. Transition logic reads top-to-bottom like a spec, and extending means overriding one method instead of rewriting control flow.
Result. Both faction machines were wired without touching the base movement code.
2 · States as data, actions as methods
Each state runs an action method, then checks its transitions; ChangeState() records the time of every shift.
Why. Separating "what happens next" from "how the pawn moves" keeps states reviewable and testable in isolation.
Result. The whole graph could be designed before every behavior was built — which is exactly why the stubs are still honest.
3 · Perception is its own system
The sandworm is inert until it can hear. canHear() wraps a NoiseMaker so detection logic lives in one place for every AI.
Why. One shared "sense" keeps the read of danger consistent across factions.
Result. Designed and stubbed — the NoiseMaker integration is commented out and canHear() returns false in the current build.
4 · The framework is the sandbox, not the feat
Movement, shooting, spawning, and map generation came with the course project; the AI and the game flow are mine.
Why. Honest attribution — reviewers can compare my systems directly against the inherited baseline in the same repo.
Result. The README and this page mark exactly what's my layer versus what the course provided.
Evidence
The public source repo is the record — README with state diagrams, the AI controllers, the GameManager, and the course framework beside them.
Note. An in-progress class project. The FSM architecture, transition graphs, game-flow layer, and documentation are complete; several per-state behaviors and senses remain stubbed, and two of the four planned factions are unbuilt — exactly as the README states.
What I Learned
- State machines make AI legible. A switch you can read top to bottom beats a tangle of booleans — for me, and for anyone reviewing the code later.
- Wiring the graph before filling in the leaves works. I could design and review every transition before each behavior existed, so the skeleton was inspectable even mid-build.
- Honesty about scope reads better than polish. The README names the unfinished factions and the stubbed behaviors; the project documents what it is at every step.
- Attribution keeps a portfolio honest. Being able to point at the framework boundary — "here's what I wrote, here's what I was given" — makes both halves clearer.
Note. This is a supporting portfolio piece: a systems-and-code sample from a programming course, documented as it stands — including what remains unfinished.