← Back to projects

Unity • C# • AI Systems • Third-Year Programming Systems for Games

Project AstroRot

A lunar zombie outbreak shooter about containing a failed moon-base test before the infection reaches Earth.

Main gameplay loop: start screen, lunar arena combat, zombie waves and HUD feedback.

Overview

Project AstroRot started as a custom grid-based zombie AI prototype and developed into a playable first-person horde shooter. The game is set during a failed lunar testing programme, where a zombie outbreak has broken containment on the Moon. The player's role is to hold the site, destroy the infected, and prevent the outbreak from reaching Earth.

The project focuses on gameplay programming and AI systems rather than using Unity's built-in NavMesh. I built a runtime-sampled grid graph, A* pathfinding, perception-driven enemy behaviour, variant-based zombie setup, wave spawning, scoring, HUD feedback, player health, recoil, ADS weapon handling and a simple menu flow.

Project AstroRot gameplay showing the player aiming at zombies in the lunar arena
The playable prototype reframes the original AI test into a lunar containment scenario.

Core systems

Custom grid navigation

The navigation system uses a GridGraph component that samples the level at runtime. Each node stores a world position and walkability state, with traversal rules based on obstacles, slope limits, step height and jump height. This gave me more control than a default NavMesh and made the AI behaviour easier to explain, debug and extend.

// GridGraph.cs — nodes expose traversal edges for A*
public IEnumerable<Edge> GetEdges(Node n)
{
    foreach (var nb in GetNeighbours(n))
    {
        float heightDelta = nb.worldPos.y - n.worldPos.y;
        bool requiresJump = heightDelta > maxStepHeight;
        float baseCost = Vector3.Distance(n.worldPos, nb.worldPos);

        yield return new Edge
        {
            node = nb,
            cost = baseCost,
            requiresJump = requiresJump
        };
    }
}
Debug view of the custom grid graph used for zombie navigation
Runtime grid graph and walkability debugging, used to inspect how zombies interpret the arena.

A* pathfinding and movement

AStarPathFinder calculates routes over the sampled grid, while PathAgent follows those paths using Rigidbody movement. The agent can repath when goals move, draw debug paths, use a direct chase range close to the player, maintain a minimum player distance, avoid other zombies, and handle jumpable height changes under moon-style gravity.

// AStarPathFinder.cs — jump-aware path cost
foreach (var edge in graph.GetEdges(current.node))
{
    if (!edge.node.walkable || closed.Contains(edge.node))
        continue;

    float jumpCost = edge.requiresJump
        ? graph.jumpPenalty * jumpPenaltyMultiplier
        : 0f;

    float g = current.g + edge.cost + jumpCost;
    float f = g + Heuristic(edge.node, goal);
}

Perception-driven zombie AI

Zombies do not simply chase the player at all times. The PerceptionSensor checks field of view, line of sight, recent sight memory and sound events. Gunfire notifies nearby zombies through their hearing radius, and sound latching prevents constant firing from creating noisy repath spam.

// SimplePistol.cs — gunfire becomes a stimulus
Collider[] hits = Physics.OverlapSphere(soundPos, soundRadius, zombieSenseMask);

for (int i = 0; i < hits.Length; i++)
{
    PerceptionSensor sensor = hits[i].GetComponentInParent<PerceptionSensor>();
    if (sensor != null)
        sensor.HearSound(soundPos);
}
Combat is tied into perception: firing damages enemies, but also gives the horde a stimulus to investigate.

FSM plus GOAP-inspired decision layer

The enemy logic is split between a traditional FSM and a higher-level decision controller. The FSM handles concrete states such as idle, chase, investigate, search and attack. The GOAP-inspired layer selects a current goal based on what the zombie knows, then chooses an allowed action such as chasing, investigating sound, searching the last seen position, melee attacking, ranged attacking or flanking.

// ZombieGoapController.cs — simplified goal selection
if (perception.CanSeePlayer)
    currentGoal = "EngagePlayer";
else if (perception.HeardRecently)
    currentGoal = "InvestigateSound";
else if (perception.HasRecentSight)
    currentGoal = "SearchLastSeen";
else
    currentGoal = "Wander";

Variant-based enemies

Enemy data is stored in ZombieVariantSO ScriptableObjects. Each variant can define health, move speed, attack range, score value, perception values, jump preference, ranged behaviour, flanking settings and a list of actions that the variant is allowed to use. This made it easier to create different enemy types without hardcoding every zombie separately.

Variants change the pressure of a wave, from simple melee enemies to ranged and flanking threats.
  • Shambler: slower pressure enemy using basic chase and melee actions.
  • Sprinter: faster close-range enemy that makes waves more chaotic.
  • Flanker: uses side-offset and serpentine-style movement instead of always taking the direct route.
  • Spitter: keeps distance and uses ranged projectile attacks.

Wave, score and UI loop

WaveManager runs the horde loop. Each round increases the number of zombies, spawns them from defined spawn points, applies weighted variants, scales health and speed, tracks zombies alive, and awards score on death. The UI listens to wave, score and zombie-count events so the HUD updates without every system directly depending on each other.

// WaveManager.cs — round scaling
zombiesToSpawn = startingZombies + (currentWave - 1) * zombiesPerWaveIncrease;
health.maxHealth += (currentWave - 1) * healthIncreasePerWave;
agent.speed += (currentWave - 1) * speedIncreasePerWave;

I also added a start screen, death screen, high score storage using PlayerPrefs, and a segmented health display that swaps full health bars into dots as the player takes damage.

Player combat and feel

The player uses a first-person controller with camera bob, recoil, ADS and a hitscan pistol. Firing applies visual feedback through muzzle effects and recoil, damages zombies through their health component, and broadcasts sound to nearby zombie perception sensors. This tied the weapon system directly into the AI loop: shooting is useful, but it also tells the horde where to go.

What I learned

This project helped me turn an isolated pathfinding prototype into a more complete gameplay system. The biggest lesson was that AI becomes more convincing when navigation, perception, combat and UI are connected carefully. A zombie that sees, hears, remembers, searches, attacks, gets scored, and affects wave pacing feels much more alive than a simple object moving toward the player.

I also learned the value of throttling expensive decisions. Repath intervals, target movement thresholds, sound latching and per-zombie thinking jitter helped keep groups of enemies from all updating in the same frame, which made the system more stable as the waves grew.

Next steps

  • Polish the lunar test-site art direction so the outbreak setting reads more clearly.
  • Add stronger feedback for ranged and flanking variants so players can identify threats faster.
  • Improve wave pacing with intermission upgrades or temporary objectives.
  • Profile larger waves and optimise the pathfinding/open-list implementation if needed.
  • Replace placeholder assets with a consistent sci-fi zombie visual style.