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:
GameStateis alreadyRunningat the momentUpdateThingsOnGameStart()is invoked. A guardGameManager.GameState != GameState.Runningis therefore not sufficient to distinguish "during load finalize" from "in-game running".Thing.OnFinishedLoadhas been called on every Thing by the timeUpdateThingsOnGameStart()returns. APostfixonGameManager.StartGame(or onUpdateThingsOnGameStartitself) is the earliest reliable hook for "every Thing's transforms, rotations, and side-car-restored state are settled". Hooking individualThing.OnFinishedLoadpostfixes only sees its own state; partner Things may not have had theirOnFinishedLoadrun yet.StructureNetwork.StructureNetworksOnFinishedLoad,Rocket.OnFinishedLoad,SpaceMap.CleanUpOnFinishedLoadrun sequentially after the per-Thing pass. A mod whose post-load pass depends on cable networks being formed should run afterStructureNetworksOnFinishedLoad; for transform-only work, hooking afterUpdateThingsOnGameStartis enough.StartGameisasync UniTask. A HarmonyPostfixreturning before the awaits will run synchronously after the body up to the first await. The firstawaitisNetworkServer.Host()(gated bySettings.CurrentData.StartLocalHost || IsBatchMode); for single-player and host-as-server, the Postfix fires after every synchronous step includingUpdateThingsOnGameStart. For pure-client joining a remote host,StartGamemay not be called the same way; the join path usesNetworkClient.ProcessJoinDatainstead (seeResearch/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
StartGamebody,UpdateThingsOnGameStartstatic, andUpdateThingsOnGameStartActiondelegate. 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 onGameManager.StartGamefor transform-settled state across all Things; per-ThingOnFinishedLoadis 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.
UpdateThingsOnGameStartstill dispatches per-ThingOnFinishedLoadthrough the same delegate (decompile 203847;thing.OnFinishedLoad()at 203855), with one evolution: the delegate body is now wrapped in a try/catch that printsOnFinishedLoad failed for {DisplayName} #{ReferenceId}: {exception}per failing Thing, so one throwingOnFinishedLoadno 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 callsGameManager.UpdateThingsOnGameStart()directly fromNetworkClient.ProcessJoinData(decompile 213188+) after the join reader closes; seeResearch/Protocols/PlayerConnectedThingFindTiming.mdfor the full join ordering.StartGameitself 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 inUniTask.decompiled.cs. It coversLeaveGameand its unawaitedWaitingCloseThreads, whose wait loops never wait, soClearGameAllruns at that frame'sLastPostLateUpdate; whatClearGameAllresets (GameState, every Thing,InventoryManager.Parent, the network throughEndConnection); whyInventoryManager.NormalModecan run once more with the old world, which turns on the scene'sManagersorder and was not read; every reference toLeaveGameandLeaveGameAfterFadein the assembly; the application-quit paths that skipLeaveGame; and the four other callers ofNetworkClient.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.mdsection 7.3 © while fixing Spray Paint Plus's preference-mask report after a server restart, where a marker cleared by aLeaveGamepostfix was rejected for this reason. Changed from that draft while checking it: the role reset isClearGameAll's ownEndConnectioncall (210346), notNetworkClient.Disconnect's after its 500 ms, which the console's secondEnding network connectionshows; theNormalModegates 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 skipsLeaveGame; and the callers ofNetworkClient.Disconnectother thanCloseare four, not two. Additive; the other sections were not re-read and keep their stamps. The top-levelverified_inmoved to 0.2.6428.27798 for this section.
Open questions¶
None at creation.