Cyber Station – Passenger Flow & Station Systems

Codebase Breakdown • Unity 3D • FSM AI • AI Navigation

Overview

Cyber Station is a solo Unity 3D station-management game built around a simple but expandable loop: the player grows a neon transport hub, places facilities, unlocks train lines, and keeps passengers moving before service delays drag ratings down.

Passenger hue is tied to train-line data. For example, the inspected Train ScriptableObjects include Cyan and Orange starter lines, plus later Yellow, Pink, Green, Red, Blue and Purple lines. Each line defines its passenger need profile through fields such as hungerNeedChance, thirstNeedChance, energyNeedChance and hygieneNeedChance. That lets the game introduce different service pressures as the station expands.

The rating model measures whether the station is serving passengers efficiently across cleanliness, crowding, queues, need coverage, facility choice and decoration. Those ratings then feed demand: higher station ratings raise target train occupancy, creating a feedback loop between good service and busier stations.

Core Gameplay Systems

Facility Placement

Placement is coordinated by BuildController and GridManager. The preview snaps to a grid, checks build-surface raycasts, validates footprint bounds, checks occupancy with IsAreaFree, then commits by spending money, instantiating the selected prefab, attaching PlacedBuildable, and occupying the grid cells.

Station Expansion

ExpansionManager loads Expansion assets from Resources/Expansions, gates purchases by progression tier, money and platform order, then instantiates the expansion prefab under levelParent. After expansion, it rebuilds the NavMesh through NavMeshManager.BuildNavMesh().

Passenger Flow

PassengerSpawner runs at a fixed tick interval and spawns passengers per active TrainService. Spawn demand is based on train capacity and current station rating. PassengerManager owns the passenger list, assigns train services, rolls needs, routes passengers to facilities, redirects platform passengers when services move, and destroys passengers when they board or leave.

Hue/Type Needs

Passenger needs are represented as booleans on Passenger and exposed through NeedType: Ticket, Hunger, Thirst, Energy and Hygiene. Train-line assets tune which needs appear for each hue/type. The visual hue comes from Train.trainColor and is applied to passenger visor materials.

Service Interactions

Facilities derive from StationFacility, which itself derives from QueuableObject. The base class manages queue capacity, processing state, service-duration scaling, queue reshuffling and completion hooks; concrete controllers such as TicketMachineController, SnackPrinterController, HydratingObeliskController and PrivateLavatoryController deliver specific services.

Trainline Flow

TrainManager tracks unlocked trains, active TrainService objects and registered PlatformControllers. TrainController moves physical trains through approaching, stationary and departing states. TrainDoorController handles disembarking passengers first, then switches doors into boarding mode and notifies PassengerManager when waiting passengers can be called to the train.

Economy & Progression

EconomyManager tracks money, income-per-minute samples, ticket refunds, staff salary billing and train service costs. ProgressionManager awards XP for builds, ticket sales, boarding, fulfilled needs, staff hires, train unlocks and expansions, then unlocks content by tier.

Architecture

The project uses scene-level singleton managers and ScriptableObject data. The inspected code uses these real classes:

  • BuildController + GridManager handle placement, demolition, grid occupancy and build cost.
  • PassengerSpawner creates demand from active train services and station rating.
  • PassengerManager is the passenger session coordinator: spawning, FSM updates, need routing, queues, litter, boarding and cleanup.
  • Passenger stores ticket state, need state, current master/sub-state and current queue target.
  • FacilityManager maps FacilityType to live StationFacility instances and maps passenger needs to valid facility types.
  • StationFacility + QueuableObject define the shared queue/service contract used by buildable service hubs.
  • TrainManager, TrainService, TrainController, TrainDoorController and PlatformController coordinate lines, slots, physical trains and boarding.
  • RatingManager samples passenger/facility/buildable state and smooths the live station rating.
  • EconomyManager, ProgressionManager, ExpansionManager and StaffManager handle money, tiers, map growth and hireable staff.
  • UIController and menu controllers such as BuildMenuController, TrainMenuController, RatingMenuController, ExpansionMenuController and ProgressionMenuController present those systems to the player.

The main communication pattern is manager-to-manager orchestration: build and expansion systems update the grid/NavMesh, facilities register with FacilityManager, train services create passenger demand, PassengerManager chooses destinations and service queues, service completion clears needs and awards XP, and RatingManager reads the resulting station state.

Expanded Cyber Station trainline and platform area
Expanded trainline and passenger-flow view

Data Model

The project is data-driven where content benefits from tuning: buildables, trains, staff and expansions are ScriptableObjects loaded from Resources.

SourceFieldTypeDescription
PassengerassignedTrainServiceTrainServiceCurrent route/platform target for the passenger.
PassengerneedsHunger, needsThirst, needsEnergy, needsHygieneboolActive service needs rolled from passenger manager defaults or train-line profile.
PassengerhasTicket, isTicketEvader, hasFailedNeed, hasGivenUpNeedboolState flags used by ticketing, security, satisfaction and fallback behaviour.
PassengercurrentMasterState, currentSubState, currentSpecialTargetenumFSM state split between station/platform/train, movement/interaction, and special targets.
TraintrainName, trainColorstring, ColorLine identity and passenger hue, including Cyan, Orange, Yellow, Pink, Green, Red, Blue and Purple assets.
TrainhungerNeedChance, thirstNeedChance, energyNeedChance, hygieneNeedChancefloatPer-line need profile; e.g. Orange introduces hunger, Cyan introduces thirst, Blue can generate all core needs.
TraincarriageCount, capacityPerCarriage, secondsStationaryint, int, floatCapacity and dwell time used by train services, doors and spawn demand.
TraincostPerRide, upfrontCost, costPerMinute, requiredTierintEconomy and progression fields for ticket income and active service costs.
ObjectBuildableobjectName, requiredTier, cost, size, decorationStrengthstring, int, Vector2Int, floatBuild menu, placement footprint, unlock tier, price and local decoration effect.
StationFacilityfacilityType, passengerQueueCapacity, potentialLitterPrefabsenum, int, List<GameObject>Facility category, queue limit and litter side-effects after completed service.
QueuableObjectPeopleOnWay, queueLineMode, queueStyle, baseDistance, queueSpacingList<Person>, enum, floatShared queue data for facilities and train doors.
ExpansionplatformNumber, upfrontCost, requiredTier, expansionPrefabint, GameObjectStation-area and platform unlock data, including ordered platform requirements.
GridManagerwidth, height, cellSize, occupancyCountint, floatPlacement grid dimensions and tile occupancy tracking.
RatingManagerstationRating, cleanlinessRating, crowdednessRating, queueTimesRating, passengerNeedsRating, choiceRating, decorationRatingfloatLive station score and sub-scores shown in the rating UI.

Key APIs / Contracts

Placement & Grid

public class BuildController : MonoBehaviour
{
    public void ChangePreviewObject(ObjectBuildable objectBuildable);
    public void ExitBuildModes();
    public void ToggleDecorationOverlay();
    public static int GetBuildCost(ObjectBuildable buildable);
}

public class GridManager : MonoBehaviour
{
    public Vector2Int GetGridPosition(Vector3 worldPosition);
    public bool IsAreaFree(int startX, int startZ, int areaWidth, int areaHeight);
    public void OccupyArea(int startX, int startZ, int areaWidth, int areaHeight);
    public void VacateArea(int startX, int startZ, int areaWidth, int areaHeight);
}

Passenger Flow

public class PassengerManager : MonoBehaviour
{
    public void SpawnPassengerForService(TrainService service);
    public Passenger SpawnExitingPassenger(Vector3 spawnPos, Quaternion spawnRot, TrainService service);
    public void ReceiveTicket(Passenger passenger);
    public void MeetNeedFromTarget(Passenger.NeedType needType, Passenger passenger);
    public void SendPassengerToTrainDoor(Passenger passenger, TrainService service);
    public void BoardTrain(Passenger passenger);
}

public class Passenger : Person
{
    public void RollNeeds(bool hungerUnlocked, bool thirstUnlocked, bool energyUnlocked, bool hygieneUnlocked, bool requireAtLeastOne = false, Train trainProfile = null);
    public NeedType GetNextNeed();
    public void ClearNeed(NeedType need);
}

Facilities & Services

public class FacilityManager : MonoBehaviour
{
    public void RegisterFacility(StationFacility facility);
    public List<FacilityType> GetFacilitiesForNeed(Passenger.NeedType need);
    public List<FacilityType> GetUnlockedFacilitiesForNeed(Passenger.NeedType need);
    public bool HasFacility(FacilityType type);
}

public abstract class StationFacility : QueuableObject
{
    public override bool CanAcceptPerson(Person person);
    public override void ProcessInteraction(Person person);
    public float GetEstimatedQueueWaitTime();
    protected abstract void DeliverService(Passenger passenger);
}

Trainlines, Economy & Expansion

public class TrainManager : MonoBehaviour
{
    public void UnlockTrain(Train train);
    public TrainService AssignTrainServiceToPassenger();
    public bool SpawnTrainService(TrainService service);
    public void AssignTrainToPlatformSlot(Train train, PlatformController platform, int slotIndex);
    public void RemoveTrainFromService(Train train);
}

public class ExpansionManager : MonoBehaviour
{
    public bool TryBuyExpansion(Expansion expansion);
    public bool CanBuyExpansion(Expansion expansion);
}

public class EconomyManager : MonoBehaviour
{
    public void AddMoney(int amount, bool includeInIncomeAverage = true);
    public void SpendMoney(int amount, bool includeInIncomeAverage = false);
}

I did not find a dedicated public object-pool API in the inspected scripts. Passenger and train spawn/despawn paths currently use Instantiate and Destroy, so object pooling is best described as an important optimisation target for the spawn-heavy parts of the project rather than a verified class-level contract in this checkout.

Representative Code Snippets

Passenger decision FSM – resets stale targets, prioritises ticketing, resolves passenger needs, handles blocked needs, and finally routes the passenger to platform boarding when service work is complete.

Show code
@PassengerManager.cs lines 404-507
void DecideNextAction(Passenger passenger)
{
    if (passenger.currentTarget != null)
    {
        passenger.currentTarget.RemovePerson(passenger);
        passenger.currentTarget = null;
    }
    passenger.currentSpecialTarget = Passenger.passengerSpecialTargets.None;

    passenger.currentSubState = Passenger.passengerSubStates.Idle;
    if (passenger.navAgent != null && passenger.navAgent.isOnNavMesh)
    {
        passenger.navAgent.ResetPath();
    }

    if (passenger.assignedTrainService == null || !TrainManager.Instance.activeTrainServices.Contains(passenger.assignedTrainService))
    {
        if (passenger.shouldUseFacilitiesBeforeExit)
        {
            HandleDisembarkingPassengerNeeds(passenger);
            return;
        }

        ClearBlockedNeed(passenger);
        passenger.assignedTrainService = null;
        LeaveStation(passenger);
        return;
    }

    if (passenger.currentMasterState == Passenger.passengerMasterStates.InStation)
    {
        if (!passenger.hasTicket && !passenger.isTicketEvader)
        {
            Passenger.NeedType ticketNeed = Passenger.NeedType.Ticket;

            if (passenger.blockedNeed == ticketNeed &&
                passenger.blockedNeedFailureStage > 0 &&
                Time.time < passenger.nextBlockedNeedCheckTime)
            {
                TryWanderWhileBlocked(passenger);
                return;
            }

            if (TrySendPassengerToNeedFacility(passenger, ticketNeed))
            {
                ClearBlockedNeed(passenger, true);
                return;
            }

            HandleBlockedNeed(passenger, ticketNeed);
            return;
        }

        if (!passenger.hasTicket && passenger.isTicketEvader)
        {
            MoveToPlatformPosition(passenger);
            return;
        }

        if (passenger.hasGivenUpNeed)
        {
            MoveToPlatformPosition(passenger, true);
            return;
        }

        while (true)
        {
            Passenger.NeedType nextNeed = passenger.GetNextNeed();

            if (nextNeed == Passenger.NeedType.None)
            {
                ClearBlockedNeed(passenger);
                break;
            }

            if (passenger.blockedNeed == nextNeed &&
                passenger.blockedNeedFailureStage > 0 &&
                Time.time < passenger.nextBlockedNeedCheckTime)
            {
                TryWanderWhileBlocked(passenger);
                return;
            }

            if (TrySendPassengerToNeedFacility(passenger, nextNeed))
            {
                ClearBlockedNeed(passenger, true);
                return;
            }

            HandleBlockedNeed(passenger, nextNeed);
            return;
        }

        MoveToPlatformPosition(passenger);
    }
    else if (passenger.currentMasterState == Passenger.passengerMasterStates.OnPlatform)
    {
        if (passenger.assignedTrainService.physicalTrainInstance != null &&
            passenger.assignedTrainService.physicalTrainInstance.IsAtStation())
        {
            SendPassengerToTrainDoor(passenger, passenger.assignedTrainService);
        }
    }
}

View on GitHub (lines 404-507)

Placement footprint validation – converts the cursor hit to grid space, validates bounds, samples the build surface under every occupied tile, and returns whether the footprint is free before the build commit spends money and occupies cells.

Show code
@BuildController.cs lines 530-560
private bool TryGetBuildPreviewPlacement(out BuildPreviewPlacement placement)
{
    placement = default;

    if (!TryGetBuildSurfaceHit(out RaycastHit cursorHit))
    {
        return false;
    }

    Vector2Int size = GetRotatedSize();
    Vector2Int gridPos = GridManager.Instance.GetGridPosition(cursorHit.point);

    if (!GridManager.Instance.IsAreaWithinBounds(gridPos.x, gridPos.y, size.x, size.y))
    {
        return false;
    }

    if (!TryGetFootprintSurfaceY(gridPos, size, cursorHit.point.y, out float surfaceY))
    {
        return false;
    }

    placement = new BuildPreviewPlacement
    {
        gridPos = gridPos,
        size = size,
        surfaceY = surfaceY,
        isValid = GridManager.Instance.IsAreaFree(gridPos.x, gridPos.y, size.x, size.y)
    };

    return true;
}

View on GitHub (lines 530-560)

Station rating aggregation – samples independent service dimensions, smooths each target score, then averages them into the station rating used by the UI and demand model.

Show code
@RatingManager.cs lines 60-78
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;
}

View on GitHub (lines 60-78)

Performance Notes

Passenger spawning is rating-driven rather than frame-spammy: PassengerSpawner ticks at a fixed interval and derives spawn probability from active train capacity, a 240-second service cycle, and RatingManager.stationRating. This keeps demand tunable as the station grows.

The codebase already shows performance-conscious patterns in places such as NavMeshManager.BuildNavMesh(), which temporarily disables person and litter colliders/renderers during NavMesh baking, and PrefabIconRenderer, which caches generated runtime icons for buildables, trains and staff.

Object pooling matters most for short-lived, high-frequency objects such as passengers, litter, UI prompts, bottles, snacks and effects because repeated Instantiate/Destroy calls can create garbage collection spikes. The inspected scripts do not contain a dedicated object-pool manager or reusable despawn API, so I have not claimed concrete pooling metrics. Profiling and pool integration for spawn-heavy paths are ongoing optimisation work.

Testing & Validation

Validation is currently manual rather than automated. The local project includes Unity's test framework package, but I did not find checked-in NUnit or Unity Test Runner scripts in the inspected source.

Manual playtesting has focused on:

  • Passenger routes from materializers to ticketing, service facilities, platforms, train doors and exits.
  • Placement rules, including invalid surfaces, occupied grid cells, rotated footprint sizes and build cost checks.
  • Service interactions for ticketing, hunger, thirst, energy and hygiene needs.
  • Rating changes when queues grow, litter appears, decoration coverage changes and passengers fail needs.
  • Expansion progression, especially tier locks, money checks and ordered platform unlocks.
  • Edge cases such as destroyed queue targets, blocked needs, full facilities, train reassignment and passengers waiting for moved services.

Trade-offs & Next Steps

  • FSMs are simple and readable, but behavior trees or utility AI could scale more complex passenger behaviour.
  • The manager-based architecture is quick to navigate for a solo project, but more explicit service boundaries would help if the project grew.
  • Queue-aware routing makes facilities feel practical, while deeper analytics would make balancing service times and capacities easier.
  • Object pooling would improve runtime stability for spawn-heavy objects, but it adds lifecycle-management complexity around reset state and ownership.
  • The current rating logic is readable and tunable, but automated simulation tests would make balancing safer.
  • Future work could include richer passenger archetypes, stronger visual feedback for blocked routes, automated tests around placement and routing, and more profiling on larger stations.