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+GridManagerhandle placement, demolition, grid occupancy and build cost.PassengerSpawnercreates demand from active train services and station rating.PassengerManageris the passenger session coordinator: spawning, FSM updates, need routing, queues, litter, boarding and cleanup.Passengerstores ticket state, need state, current master/sub-state and current queue target.FacilityManagermapsFacilityTypeto liveStationFacilityinstances and maps passenger needs to valid facility types.StationFacility+QueuableObjectdefine the shared queue/service contract used by buildable service hubs.TrainManager,TrainService,TrainController,TrainDoorControllerandPlatformControllercoordinate lines, slots, physical trains and boarding.RatingManagersamples passenger/facility/buildable state and smooths the live station rating.EconomyManager,ProgressionManager,ExpansionManagerandStaffManagerhandle money, tiers, map growth and hireable staff.UIControllerand menu controllers such asBuildMenuController,TrainMenuController,RatingMenuController,ExpansionMenuControllerandProgressionMenuControllerpresent 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.
Data Model
The project is data-driven where content benefits from tuning: buildables, trains, staff and expansions are ScriptableObjects loaded from Resources.
| Source | Field | Type | Description |
|---|---|---|---|
Passenger | assignedTrainService | TrainService | Current route/platform target for the passenger. |
Passenger | needsHunger, needsThirst, needsEnergy, needsHygiene | bool | Active service needs rolled from passenger manager defaults or train-line profile. |
Passenger | hasTicket, isTicketEvader, hasFailedNeed, hasGivenUpNeed | bool | State flags used by ticketing, security, satisfaction and fallback behaviour. |
Passenger | currentMasterState, currentSubState, currentSpecialTarget | enum | FSM state split between station/platform/train, movement/interaction, and special targets. |
Train | trainName, trainColor | string, Color | Line identity and passenger hue, including Cyan, Orange, Yellow, Pink, Green, Red, Blue and Purple assets. |
Train | hungerNeedChance, thirstNeedChance, energyNeedChance, hygieneNeedChance | float | Per-line need profile; e.g. Orange introduces hunger, Cyan introduces thirst, Blue can generate all core needs. |
Train | carriageCount, capacityPerCarriage, secondsStationary | int, int, float | Capacity and dwell time used by train services, doors and spawn demand. |
Train | costPerRide, upfrontCost, costPerMinute, requiredTier | int | Economy and progression fields for ticket income and active service costs. |
ObjectBuildable | objectName, requiredTier, cost, size, decorationStrength | string, int, Vector2Int, float | Build menu, placement footprint, unlock tier, price and local decoration effect. |
StationFacility | facilityType, passengerQueueCapacity, potentialLitterPrefabs | enum, int, List<GameObject> | Facility category, queue limit and litter side-effects after completed service. |
QueuableObject | PeopleOnWay, queueLineMode, queueStyle, baseDistance, queueSpacing | List<Person>, enum, float | Shared queue data for facilities and train doors. |
Expansion | platformNumber, upfrontCost, requiredTier, expansionPrefab | int, GameObject | Station-area and platform unlock data, including ordered platform requirements. |
GridManager | width, height, cellSize, occupancyCount | int, float | Placement grid dimensions and tile occupancy tracking. |
RatingManager | stationRating, cleanlinessRating, crowdednessRating, queueTimesRating, passengerNeedsRating, choiceRating, decorationRating | float | Live 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;
}
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.