Skip to content

GameManager

Vanilla top-level game-manager singleton. Holds global state referenced by many subsystems.

CustomColors list

Source: F0027.

GameManager.Instance.CustomColors is a List<CustomColor> where each entry has a .Normal material. The index into this list is the canonical color identifier used throughout the mod.

Load finalize chain

GameManager.StartGame() is the post-deserialize entry point that flips the game from "loading" to playable. Body (verbatim, in order):

public static async UniTask StartGame()
{
    GameState = GameState.Running;
    GameTime = Time.time;
    HelperHintsTextController.InitializePanel();
    UpdateThingsOnGameStart();
    StructureNetwork.StructureNetworksOnFinishedLoad();
    Rocket.OnFinishedLoad();
    SpaceMap.CleanUpOnFinishedLoad();
    // ... InventoryManager parent-brain hookup, time scale, FoV, UI cleanup,
    //     NetworkServer.Host()/PopulateHostClient(), StationAutoSave reset,
    //     OrbitalSimulation.OnGameStarted(), Steam rich presence ...
}

UpdateThingsOnGameStart() iterates the global registry and dispatches per-Thing finalization:

public static void UpdateThingsOnGameStart()
{
    OcclusionManager.AllThings.ForEach(UpdateThingsOnGameStartAction);
}

private static readonly Action<Thing> UpdateThingsOnGameStartAction = delegate(Thing thing)
{
    if ((object)thing == null) return;
    thing.OnFinishedLoad();
    foreach (Interactable interactable in thing.Interactables)
    {
        if (interactable.JoinInProgressSync && (bool)interactable.Animator)
        {
            interactable.SetState();
            thing.OnFinishedInteractionSync(interactable);
        }
    }
};

Implications for a mod that needs to run a one-shot pass after the world has fully deserialized:

  • GameState is already Running at the moment UpdateThingsOnGameStart() is invoked. A guard GameManager.GameState != GameState.Running is therefore not sufficient to distinguish "during load finalize" from "in-game running".
  • Thing.OnFinishedLoad has been called on every Thing by the time UpdateThingsOnGameStart() returns. A Postfix on GameManager.StartGame (or on UpdateThingsOnGameStart itself) is the earliest reliable hook for "every Thing's transforms, rotations, and side-car-restored state are settled". Hooking individual Thing.OnFinishedLoad postfixes only sees its own state; partner Things may not have had their OnFinishedLoad run yet.
  • StructureNetwork.StructureNetworksOnFinishedLoad, Rocket.OnFinishedLoad, SpaceMap.CleanUpOnFinishedLoad run sequentially after the per-Thing pass. A mod whose post-load pass depends on cable networks being formed should run after StructureNetworksOnFinishedLoad; for transform-only work, hooking after UpdateThingsOnGameStart is enough.
  • StartGame is async UniTask. A Harmony Postfix returning before the awaits will run synchronously after the body up to the first await. The first await is NetworkServer.Host() (gated by Settings.CurrentData.StartLocalHost || IsBatchMode); for single-player and host-as-server, the Postfix fires after every synchronous step including UpdateThingsOnGameStart. For pure-client joining a remote host, StartGame may not be called the same way; the join path uses NetworkClient.ProcessJoinData instead (see Research/Protocols/PlayerConnectedThingFindTiming.md).

Leaving a session: LeaveGame returns before the world is gone

Line numbers in this section refer to the 0.2.6428.27798 decompile of Assembly-CSharp.dll. Every member cited is textually identical in the dedicated server's Assembly-CSharp (each was extracted from both files and compared); the server's lines are in the frontmatter.

GameManager.LeaveGame (210144-210153) is the call nearly every way out of a session reaches, and it returns with the world still loaded:

public static void LeaveGame()
{
    Assets.Scripts.Networking.NetworkManager.Close();
    CameraController.Instance.SetThirdPersonCamera(show: false, setCullingMask: false);
    KeyManager.ResetKeyStateToDefault();
    Singleton<GameManager>.Instance.WaitingCloseThreads().Forget();
    ImGuiLoadingScreen.SetActive(active: false);
    Assets.Scripts.Networking.NetworkManager.CurrentTransport.UnRegisterGameSession(Assets.Scripts.Networking.NetworkManager.CurrentGameSession);
    World.CurrentId = null;
}

The teardown is WaitingCloseThreads (210155-210196), started and not awaited:

private async UniTaskVoid WaitingCloseThreads()
{
    ResetKeepAliveCoroutine(restart: false);
    WorldManager.Instance.ResumePlay();
    await UniTask.WaitForEndOfFrame();
    float waitingStartTime = Time.time;
    while (RoomManager.Instance.IsRunning && waitingStartTime + 5f < Time.time)
    {
        await UniTask.WaitForEndOfFrame();
    }
    waitingStartTime = Time.time;
    while (AtmosphericsManager.Instance.IsRunning && waitingStartTime + 5f < Time.time)
    {
        await UniTask.WaitForEndOfFrame();
    }
    waitingStartTime = Time.time;
    while (OcclusionManager.Instance.IsRunning && waitingStartTime + 5f < Time.time)
    {
        await UniTask.WaitForEndOfFrame();
    }
    waitingStartTime = Time.time;
    while (ElectricityManager.Instance.IsRunning && waitingStartTime + 5f < Time.time)
    {
        await UniTask.WaitForEndOfFrame();
    }
    waitingStartTime = Time.time;
    while (LightManager.Instance.IsRunning && waitingStartTime + 5f < Time.time)
    {
        await UniTask.WaitForEndOfFrame();
    }
    if ((bool)InventoryManager.Instance)
    {
        InventoryManager.Instance.CancelPlacement();
    }
    ClearGameAll();
    TotalClients = 0;
    if (!IsBatchMode)
    {
        PromptPanel.Instance.DisablePromptPanel();
    }
    Time.timeScale = 1f;
}

Each loop sets waitingStartTime = Time.time and then tests waitingStartTime + 5f < Time.time, which is false within the same frame, so none of them waits. The one wait is the first await UniTask.WaitForEndOfFrame(), and UniTask's parameterless WaitForEndOfFrame() is Yield(PlayerLoopTiming.LastPostLateUpdate) (UniTask.decompiled.cs 19045-19048), whose awaiter puts the continuation on that timing's queue (PlayerLoopHelper.AddContinuation, 20320-20327 and 6785-6793), which the player loop runs at that point of every frame. So ClearGameAll() (210233-210356) runs at the LastPostLateUpdate point of the frame LeaveGame was called in, when that call came earlier in the frame, as a drop does (below). ClearGameAll is where GameState = GameState.None is set (210235, which prints Game State: None through OnGameStateChanged, 210020-210022, outside batch mode), where every Thing's GameObject is destroyed (Thing.ClearAll(), 210288, which destroys each Thing in OcclusionManager.AllThings), where InventoryManager.ClearAll() (210339; 292140-292144) sets InventoryManager.Parent = null, and where NetworkManager.EndConnection() is called (210346).

Until then GameState still reads Running, InventoryManager.Parent still holds the player's Human, and on a client the network role still reads Client. NetworkManager.Close (279648-279663) calls NetworkClient.Disconnect(force: true).Forget() on a client (NetworkServer.StopServer() on a server). NetworkClient.Disconnect (218636-218655) prints Client Disconnecting from Host, sends the disconnecting handshake and then awaits 500 ms, so what resets the role is ClearGameAll's own EndConnection call: EndConnection (278378-278395) prints Ending network connection and, while the network is not yet offline, shuts RakNet down, prints Resetting network state, and sets NetworkState.Offline and NetworkRole.None. When Disconnect's 500 ms are up it calls EndConnection again, which finds the network offline and prints only Ending network connection.

So a mod's LeaveGame postfix is not the last code of the session to run. InventoryManager.ManagerUpdate (293015-293088) calls NormalMode() when Parent is set and the game is not paused, the cursor is hidden, the console is closed and Parent is not unresponsive, Parent.State is EntityState.Alive, IsAllowedToLook() and IsParentSafe() hold, Stationpedia is not open and locked, and CurrentMode is Mode.Normal. Its RunGameState() (292836-292875) returns early when GameState != GameState.Running, but only from itself, and nothing else on the way to NormalMode reads GameState (ManagerBase.ManagerUpdate, 239891-239893, is empty). GameManager.Update (210634-210699) calls ManagerUpdate on every entry of Managers in list order (210693-210696), the only place in the assembly that calls it, and Managers is a serialized public List<ManagerBase> (209356). Whether InventoryManager updates after NetworkManager, whose ManagerUpdate (278867-278879) drains RakNet with while (ReceiveEvents()) and so calls LeaveGame on a drop, is therefore set in the scene, not in the code, and was not read. A patch on InventoryManager.NormalMode should be assumed to run once more with the old world after LeaveGame returns, and state a LeaveGame postfix clears can be written again in that window.

Every way a client leaves a session while its game process keeps running reaches LeaveGame:

Path Where How
Quit to the menu from the pause menu, with or without saving first ExitGamePopup.Disconnect (104827-104830), from OnDisconnect and SaveAndDisconnect GameManager.LeaveGameAfterFade() (210129-210142), which calls LeaveGame after a 500 ms fade and then waits for GameState.None
The exit console command ExitCommand.Execute (98335-98343) GameManager.LeaveGame()
RakNet DisconnectionNotification or ConnectionLost, which is how a client learns its server stopped NetworkManager.ReceiveEvents (279319-279326) PlayerDisconnected, which on NetworkRole.Client calls GameManager.LeaveGame() (279371-279382)
Steam P2P: nothing received from the host for more than 15 s NetworkManager.CheckConnection (278881-278900) prints No message received from host for 15 seconds - disconnecting, then PlayerDisconnected(NetworkClient.ConnectionId)
The server's disconnecting handshake with connection id 0 NetworkClient.Handshake (218676-218683) prints Server disconnected, then GameManager.LeaveGame()
A join cancelled, or the connection timer's modal NetworkClient.Cancel (218618-218625) GameManager.LeaveGame()

The other references to LeaveGame are outside a joined session: the Exit button of the modal a failed load or a failed new world shows (LoadHelper.LoadGameTask, 270171-270175; World.StartNewWorld, 330855-330859), the cancel button of the terrain-checksum prompt in XmlSaveLoad.LoadWorld, which is passed GameManager.LeaveGame as a method group (274210), the tutorial's exits (Human.QuitToMainMenu, 368574-368578; CompletedTutorialPopupAction.ExitThenEnableTutorialMenu, 117287-117289) and NetworkServer.Cancel (219725-219730). That is every reference in the assembly: ten calls of LeaveGame, the one method-group use, and one call of LeaveGameAfterFade.

Quitting the application does not go through LeaveGame. GameManager.QuitGame (210009-210018), behind the exit popup's quit button (ExitGamePopup.OnExit) and QuitPrompt, calls NetworkManager.Close(), sets GameState = GameState.None and, outside the editor, kills the process; Human.OnApplicationQuit (368187-368193) calls NetworkClient.Disconnect() when the Human has authority. Nothing of the session survives either, because the process ends.

Four calls reach NetworkClient.Disconnect other than NetworkManager.Close, and none of them is a way out of a running session that skips LeaveGame: ConsoleWindow's private Disconnect() (228389-228398) has no caller in the assembly; NetworkMessages.DisconnectClient.Deserialize (285273-285281) handles a message that neither Assembly-CSharp.dll nor the dedicated server's assembly ever constructs, although both register it (index 117); NetworkManager.CloseP2PConnectionClient (278402-278415) is called by EndConnection on a Steam P2P client, so already inside a leave; and Human.OnApplicationQuit is the process quitting. A game update that wires up either of the first two would add an exit path that skips every LeaveGame postfix.

Measured on the test rig on 2026-09-27: a joiner of a dedicated server that the rig stopped printed, in order, A client lost the connection., Client Disconnecting from Host, Game State: None, Ending network connection and Resetting network state, then, after three unrelated audio errors, a second Ending network connection, and then Game State: Joining as it joined the restarted server in the same game process. That is ReceiveEvents, Disconnect, ClearGameAll and its EndConnection, and Disconnect's own EndConnection 500 ms later, in the order the code gives.

Verification history

  • 2026-04-20: page created from the Research migration; verbatim content lifted from F0027. No conflicts.
  • 2026-05-02: added "Load finalize chain" section with the verbatim StartGame body, UpdateThingsOnGameStart static, and UpdateThingsOnGameStartAction delegate. Sourced from the 0.2.6228.27061 Assembly-CSharp decompile (lines 188908, 189593, 189647). Documents the order in which post-deserialize callbacks fire so a mod-side one-shot post-load pass can pick the right hook (Postfix on GameManager.StartGame for transform-settled state across all Things; per-Thing OnFinishedLoad is too early when partners are involved).
  • 2026-07-18: partial re-check against the 0.2.6403.27689 decompile while validating PowerGridPlus join-time burn-reason ordering. UpdateThingsOnGameStart still dispatches per-Thing OnFinishedLoad through the same delegate (decompile 203847; thing.OnFinishedLoad() at 203855), with one evolution: the delegate body is now wrapped in a try/catch that prints OnFinishedLoad failed for {DisplayName} #{ReferenceId}: {exception} per failing Thing, so one throwing OnFinishedLoad no longer aborts the rest of the pass (the section excerpt above shows the 0.2.6228 shape without the try/catch). Also confirmed the pure-client join path calls GameManager.UpdateThingsOnGameStart() directly from NetworkClient.ProcessJoinData (decompile 213188+) after the join reader closes; see Research/Protocols/PlayerConnectedThingFindTiming.md for the full join ordering. StartGame itself was not re-read this pass, so section stamps are unchanged.
  • 2026-09-27 (game version 0.2.6428.27798): added "Leaving a session: LeaveGame returns before the world is gone", read in the client decompile, with every cited member extracted from the dedicated server decompile as well and compared (textually identical), and with UniTask's WaitForEndOfFrame() read in UniTask.decompiled.cs. It covers LeaveGame and its unawaited WaitingCloseThreads, whose wait loops never wait, so ClearGameAll runs at that frame's LastPostLateUpdate; what ClearGameAll resets (GameState, every Thing, InventoryManager.Parent, the network through EndConnection); why InventoryManager.NormalMode can run once more with the old world, which turns on the scene's Managers order and was not read; every reference to LeaveGame and LeaveGameAfterFade in the assembly; the application-quit paths that skip LeaveGame; and the four other callers of NetworkClient.Disconnect. Confirmed by a joiner's console on the test rig the same day (lock owner d718eb1f). Drafted in .work/2026-09-27-phase3-rig/M5-RESULT.md section 7.3 © while fixing Spray Paint Plus's preference-mask report after a server restart, where a marker cleared by a LeaveGame postfix was rejected for this reason. Changed from that draft while checking it: the role reset is ClearGameAll's own EndConnection call (210346), not NetworkClient.Disconnect's after its 500 ms, which the console's second Ending network connection shows; the NormalMode gates are listed in full; the exit table gained the save-and-disconnect route, and the census the load, terrain-checksum, tutorial and server-side references; quitting the application was added as the way out that skips LeaveGame; and the callers of NetworkClient.Disconnect other than Close are four, not two. Additive; the other sections were not re-read and keep their stamps. The top-level verified_in moved to 0.2.6428.27798 for this section.

Open questions

None at creation.