All projects

Case study · Final year project

Cyber Station

Apr 2026 · Unity 3D · C# · Released on itch.io

Best Games Programming & Development Artefact · ExpoTees 2026

A neon station management game about building a transport hub that can take the pressure: placement, passenger flow, ratings and progression all pulling against each other. I built it solo as the artefact for my final-year project at Teesside University, where it scored 90.

  • RoleSolo developer
  • EngineUnity 3D · C#
  • Grade90 / 100
  • AwardExpoTees 2026 winner
  • Scale86 scripts · ~17k lines

Overview

One station, four systems in tension

In Cyber Station you design and operate a growing neon transport hub: lay out the concourse, keep crowds of passengers moving, and keep your star rating high enough to unlock the next tier. The game is built around four systems that constantly push against each other: placement, passenger flow, station ratings and progression. Every layout decision changes how passengers path through the space, which changes the ratings, which gates what you can build next.

Around that core sit the supporting systems that make it a complete game: train scheduling, staff coordination, an economy to balance, and saving and loading so a station survives between sessions.

Building

Placement & construction

Everything in the station is placed by the player. The build menu offers a catalogue of infrastructure and decoration, from ticket machines, barriers, platforms and trains to staff and cosmetic items, each with a cost and a stat effect on the station. Items can be rotated with a live preview before committing, so tight layouts stay readable.

Decorations aren't just cosmetic: each carries a decoration strength value that feeds directly into the station's Decoration rating, turning beautification into an economic decision like everything else.

Cyber Station build menu: a Trainline Globe decoration selected, showing its price, decoration strength and a rotate preview hint, above a scrollable item catalogue
The build catalogue: every item shows its cost and stat effect, with a rotating preview before placing.

Passengers

Passenger flow & AI navigation

Passengers are autonomous agents. They enter the station, navigate to ticket machines, queue, pass barriers and reach their platform in time for the next train, hundreds of them at once. Their routing reacts to the layout the player builds: a badly placed barrier or an undersized bank of ticket machines shows up immediately as bunching crowds and growing queues.

Rather than every passenger running its own per-frame update, the whole crowd is driven from a single manager on a fixed 10 Hz logic tick. Each passenger runs a two-level state machine: a master state for where it is in its journey (InStation, OnPlatform, OnTrain) and a sub-state for what it is doing there (Idle, MovingToTarget, InteractingWithSomething). Splitting it that way means "walking somewhere" is written once and reused, instead of once per place a passenger can be walking.

When a passenger needs a facility it does not simply walk to the nearest one. It picks the option with the lowest estimated total delay, and the useful part is that both halves of that estimate are in the same unit. The queue wait is already seconds; the walk is turned into seconds by measuring the real NavMesh path, corner to corner, and dividing by that passenger's own speed. Adding them is then meaningful, where adding a distance to a queue length would not be, and it is why a nearer machine with six people at it correctly loses to a further one standing empty.

Assets/Scripts/Managers/PassengerManager.cs choosing a facility, and the walk estimate underneath it
private StationFacility GetMostAccessibleFacility(List<StationFacility> facilities, Passenger passenger, float maxWaitTime, out float bestWait)
{
    bestWait = Mathf.Infinity;
    // ... null and empty guards ...
    StationFacility bestFacility = null;

    for (int i = 0; i < facilities.Count; i++)
    {
        StationFacility facility = facilities[i];
        if (facility == null || !facility.CanAcceptPerson(passenger))
        {
            continue;
        }

        float estimatedWait = facility.GetEstimatedQueueWaitTime();
        float estimatedWalkTime = EstimateWalkTimeToService(passenger, facility);
        float totalEstimatedDelay = estimatedWait + estimatedWalkTime;

        // Cross-platform facilities should stay viable; delay only affects ordering.
        if (totalEstimatedDelay < bestWait)
        {
            bestWait = totalEstimatedDelay;
            bestFacility = facility;
        }
    }

    return bestFacility;
}

private float EstimateWalkTimeToService(Passenger passenger, QueuableObject target)
{
    // ... resolve a reachable point on the mesh near the target ...
    float routeDistance = EstimateRouteDistance(passenger.transform.position, routeDestination);
    float walkSpeed = GetEstimatedWalkSpeed(passenger);
    return routeDistance / walkSpeed;
}

private float EstimateRouteDistance(Vector3 startPosition, Vector3 destination)
{
    if (facilityRoutePath != null &&
        NavMesh.CalculatePath(startPosition, destination, NavMesh.AllAreas, facilityRoutePath) &&
        facilityRoutePath.status == NavMeshPathStatus.PathComplete)
    {
        return GetPathLength(facilityRoutePath);
    }

    // No complete path: fall back to flat distance so the facility is ranked
    // last rather than dropped.
    Vector3 flatOffset = destination - startPosition;
    flatOffset.y = 0f;
    return flatOffset.magnitude;
}

Two details in there are deliberate. facilityRoutePath is a single NavMeshPath reused for every query rather than a new one per candidate, because this runs for every passenger that wants something and allocating per candidate would hand the garbage collector a steady drip of work. And when no complete path exists the estimate falls back to flat distance rather than infinity, so an unreachable facility sorts last instead of vanishing, which is what stops a passenger freezing when the player walls something off mid-journey.

Cyber Station: a packed tier 8 concourse with crowds of passengers pathing between shops, ticket machines and platforms
A maxed out tier 8 station: agents pathing between entrances, machines and platforms.
Cyber Station up close: passengers queueing at neon yellow, cyan and pink platforms and ticket machines
Up close, every passenger is queueing, boarding or heading somewhere specific.

Navigation

Paying for the bake only when the floor changes

A management game where the player rearranges the world is a problem for navigation, and the obvious answer, rebaking the mesh whenever anything is placed, is the wrong one: BuildNavMesh() is a synchronous full rebuild on the main thread, so doing it per placement would hitch the game every time you put down a bin.

So there are two mechanisms, split by what actually changed. Placing a ticket machine or a barrier does not change the walkable floor, it just puts something on it, so those 22 buildable prefabs each carry a NavMeshObstacle and carve the existing mesh at runtime for free. The mesh is only genuinely rebuilt when the floor itself grows, which is when the player buys a station expansion, so the cost lands on a deliberate, occasional, already-celebratory action instead of on every click.

The awkward part is what is standing on the floor at that moment. Passengers and dropped litter are colliders too, and a bake would treat a waiting crowd as permanent architecture, carving people-shaped holes into the mesh they are about to walk on. So they are switched off for the duration and switched back after. The try/finally is the bit I would defend in review: if the bake ever throws, the station still gets its passengers back rather than being left with a scene full of invisible, collider-less people.

Assets/Scripts/NavMeshManager.cs hide, bake, and restore whatever happens
public void BuildNavMesh()
{
    if (_navMeshManager != null)
    {
        List<ColliderState> litterColliders = DisableLitterCollidersForBake();
        List<ColliderState> characterColliders = DisablePersonCollidersForBake();
        List<RendererState> characterRenderers = DisablePersonRenderersForBake();

        try
        {
            _navMeshManager.BuildNavMesh();
        }
        finally
        {
            RestoreRendererStates(characterRenderers);
            RestoreColliderStates(characterColliders);
            RestoreColliderStates(litterColliders);
        }
    }
    else
    {
        Debug.LogError("Cannot build NavMesh because NavMeshSurface component is missing.");
    }
}

Each helper records the previous enabled state rather than blanket-enabling everything afterwards, so anything that was already disabled for its own reasons, a passenger mid-despawn, litter queued for cleanup, stays disabled. Restoring to true would have been one line shorter and would have quietly resurrected objects the rest of the game had finished with.

Ratings

The station-rating system

The whole station is graded live. An overall star rating is derived from six separate ratings (Cleanliness, Crowdedness, Queue Lengths, Service, Decoration and Choice) alongside a passenger throughput percentage. Each one watches a different behaviour: Crowdedness, for example, measures how tightly packed passengers get, so a station can be profitable and still bleed stars because people are bunching at a pinch point.

Two of those were renamed for the UI after the code was written, so the excerpt below reads slightly differently: queueTimes is Queue Lengths and passengerNeeds is Service.

This is the system that ties the game together, and it closes a loop: the six ratings are smoothed and rolled into an overall score, and that score feeds back into how many passengers spawn. A better station pulls in bigger crowds, which puts more pressure on the very layout that earned the rating in the first place.

The rating loop succeeding is what makes the next minute harder
How the station rating feeds back into the game Your layout shapes passenger flow, which feeds the six ratings. The six ratings are smoothed and averaged into an overall star rating. That rating sets the spawn rate, which sends more passengers back into the flow, closing the loop. The same rating also gates tier unlocks, which give you a bigger catalogue to build with, changing the layout you started from. Your layout what you place, and where Passenger flow queues, pinch points, walk times Six ratings cleanliness · crowding · queues service · decoration · choice Tier unlocks platforms, trains, staff Spawn rate how many passengers arrive Overall stars smoothed, then averaged shapes feeds rolled up, 1 Hz sets more passengers gates bigger catalogue

The whole thing runs on a one second tick rather than per frame, and every rating is eased toward its target with a Mathf.Lerp rather than snapped. That smoothing is deliberate: raw targets jump around as passengers move, and a star rating that flickers reads as a bug rather than as feedback. Because the tick is fixed at 1 Hz, Mathf.Lerp(current, target, 0.2f) is exponential smoothing on a fixed step, not the frame-rate dependent per-frame Lerp it resembles: the same station settles at the same speed on a 30 FPS laptop and a 240 Hz monitor.

Assets/Scripts/Managers/RatingManager.cs the tick, the six targets, and the roll-up
void Update()
{
    tickTimer += Time.deltaTime;

    if (tickTimer >= 1f)
    {
        CalculateTargetsAndApply();
        tickTimer -= 1f;
    }
}

void CalculateTargetsAndApply()
{
    List<Passenger> floorEligiblePassengers = GetFloorEligiblePassengers();
    float targetCleanliness = GetCleanlinessTarget();
    float targetCrowdedness = GetCrowdednessTarget(floorEligiblePassengers);
    float targetQueueTimes = GetQueueTimesTarget();
    float targetChoice = GetChoiceTarget();
    float targetPassengerNeeds = GetPassengerNeedsTarget();
    float targetDecoration = GetDecorationTarget(floorEligiblePassengers);

    cleanlinessRating = Mathf.Lerp(cleanlinessRating, targetCleanliness, 0.2f);
    crowdednessRating = Mathf.Lerp(crowdednessRating, targetCrowdedness, 0.2f);
    queueTimesRating = Mathf.Lerp(queueTimesRating, targetQueueTimes, 0.2f);
    choiceRating = Mathf.Lerp(choiceRating, targetChoice, 0.2f);
    passengerNeedsRating = Mathf.Lerp(passengerNeedsRating, targetPassengerNeeds, 0.2f);
    decorationRating = Mathf.Lerp(decorationRating, targetDecoration, 0.2f);

    stationRating = (cleanlinessRating + crowdednessRating + queueTimesRating + passengerNeedsRating + choiceRating + decorationRating) / 6f;
}
Cyber Station ratings screen: 99% passenger throughput efficiency, a five star overall station rating, and six ratings for cleanliness, crowdedness, queue lengths, service, decoration and choice
Six ratings and a throughput score roll up into the overall stars.

Progression

Progression & expansion

Income and ratings feed a tier system that paces the game. Higher tiers unlock station expansions, extra platforms, new train lines and staff, each one raising passenger volume and putting fresh pressure back on the layout you've built.

Staff AI

Staff that dispatch themselves

Janitors and security drones do not each hunt for work independently, which would leave two janitors racing to the same piece of litter. Instead, each type has a coordinator. The workers ask it for a job, and the coordinator hands out the nearest unclaimed task and locks it, so nothing is ever double-assigned.

It keeps the behaviour clean and extensible: the coordinator owns the "who does what" decision while each worker just runs its own small state machine. Security even prioritises chasing down fare evaders over routine patrols, all through the same dispatch model.

Architecture

Keeping 17,000 lines workable on my own

The hardest part of this project was not any single system. It was that by the end there were 86 scripts and about 17,000 lines of C#, all written by one person over one academic year, and it still had to be something I could open in week thirty and change without breaking. That only worked because the code is sorted into layers with different jobs, rather than a folder of scripts.

  • 11 Managers Own global state and the simulation tick: passengers, economy, ratings, progression, trains, saves, the grid.
  • 3 Coordinators Hand out work. The janitor and security coordinators decide who does what, so no worker has to know about any other.
  • 39 Controllers One placeable thing each: ticket machines, barriers, platforms, vending machines, menus. The bulk of the file count, and the part that grows every time content is added.
  • 5 Characters Passenger, Staff, Janitor, Security Drone, all on a shared Person base.
  • 11 Components Small reusable behaviours bolted onto anything that needs them: billboarding, hover reveals, progress bars, need icons.
  • 8 ScriptableObjects Data, not code: buildables, trains, staff, expansions, dialogue and visuals, so content is authored in the editor instead of compiled in.
Dependency direction control runs downward, data upward, nothing sideways
Cyber Station architecture, as layers Managers sit at the top and own global state and the simulation tick. They drive two layers below them: three Coordinators, which hand out work, and thirty-nine Controllers, one per placeable thing. Coordinators hand jobs down to the five Character types, each of which runs its own state machine. Eight ScriptableObjects feed the Controllers as authored data. Eleven small Components bolt onto anything above. Every dependency points downward: no Character ever queries another Character. Managers × 11 Own global state and the simulation tick: passengers, economy, ratings, progression, trains, saves, the grid. tick + state owns Coordinators × 3 Hand out work. Decide who takes which job, and lock it so nothing is doubled up. Controllers × 39 One placeable thing each: ticket machines, barriers, platforms, vending machines, menus. next job, already locked read as data Characters × 5 Passenger, Staff, Janitor, Security Drone, on a shared Person base. One FSM each. ScriptableObjects × 8 Data, not code: buildables, trains, staff, expansions, dialogue, visuals. Components × 11 Billboarding, hover reveals, progress bars, need icons. Bolt onto anything above.

The split that mattered most was Coordinators sitting between Managers and Characters. Before that, staff behaviour was asking the world about other staff, and every new worker type made that worse. Moving the "who takes this job" decision up into a coordinator meant a janitor only ever runs its own state machine, and adding the security drone later was a new character plus a new coordinator rather than an edit to everything that already existed.

The ScriptableObject layer is what kept the 39 controllers from becoming 200. Trains, staff and buildables are authored as data, so most new content is an asset rather than a class.

In hindsight

What I'd change

The repository is public, so rather than describe the flaws in general terms, here are three you can go and confirm.

Eleven managers is too many, and the graph proves it. ProgressionManager is referenced by six of the other ten, and it reaches back into EconomyManager itself, which closes a cycle: progression grants a level reward through the economy, the economy pays wages by reading the staff manager's hired list, and hiring staff reports back to progression. Nothing about that is fatal, and it works, but a cycle means there is no order in which those three can be reasoned about independently, and it happened because a tier unlock is simultaneously a money question and a progression question and I never picked one owner for it. The coordinators layer is the proof I know how to fix it: I did exactly this for the staff, moving the shared decision up into something that owns it. I did not do it for progression.

The save system knows about everything. SaveManager is 712 lines and touches seven of the other managers across twenty-seven call sites, so adding any new manager means editing the save system too. It also has no version field anywhere in those 712 lines, which means a save written in week twelve cannot be loaded by the build from week thirty. On a solo project where I was the only person with saves, that was survivable. On anything shipped it is not, and a version number plus a migration path is a day of work I should have spent early rather than not at all.

There are no tests. Not a single test assembly in the project. With 86 scripts and one developer that is an easy corner to cut, and it is the one I would uncut first: the fixed-tick systems are deterministic by construction, which makes them the cheapest things in the codebase to test and the place balance bugs hid longest. Ratings, queueing and the economy, in that order.