Skip to content

LanderCapsule

Vanilla game class at Assets.Scripts.Objects.LanderCapsule. The drop pod that delivers players from orbit. Inherits from DynamicThing. Entirely independent of the death/respawn system.

Independence from death/respawn

Source: F0095m.

The LanderCapsule (Assets.Scripts.Objects, inherits DynamicThing) is the drop pod that delivers players from orbit. It is completely independent of the death/respawn system. The respawn flow uses it through XML spawn data config, but the capsule itself has no awareness of whether the player is alive, dead, or respawning.

Key constants

Source: F0087.

KinematicStartHeight = 100m     (teleport distance above ground)
DURATION = 10s                  (total descent time)
ENGINE_START_TIME = 3s          (engines fire at this point)
DESCENT_SHAKE = 0.03            (camera shake during freefall)
ENGINES_SHAKE = 0.2             (camera shake when engines fire)
DOOR_EJECT_FORCE = 9f           (force on door when blown off)

LanderMode enum

Source: F0095n.

LanderMode enum: AtRest = 0 (on ground, idle), Descending = 1 (in-flight descent), Venting = 2 (door ejected, gas particles).

Descent sequence

Source: F0088.

Triggered by setting InteractableType.Mode to 1 (Descending):

  1. BeginDescent(): sets isKinematic = true, saves ground position, teleports capsule 100m straight up, locks the door (InteractLock = 1), starts ControlledDescent() coroutine
  2. ControlledDescent(): lerps position from start (high) to target (ground) over 10 seconds using EaseOutQuad(t) = 1 - (1-t)^2 (fast start, decelerating end). At t > 3s: enables Activate (fires engines, starts EntryEffects). Sets ControlledDescentLerp each frame (0 to 1) which drives thruster intensity. Camera shake increases from 0.03 to 0.2.
  3. TerminateDescent(): disables kinematic (physics resume), disables entry effects, clears camera shake, sets mode to AtRest
  4. WaitThenOpen(): waits 3 seconds, opens capsule (InteractOpen = 1), waits 500ms, unlocks (InteractLock = 0)
  5. Door ejection: door slot occupant gets MoveToWorld with forward force of 9. Mode changes to Venting, gas particles emit for 3 seconds

At 0.2.6428.27798: the descent waits for Running, and the opening saves the world for the local player

Two things the steps above leave out, read in the 0.2.6428.27798 decompile. Every member cited here is textually identical in the dedicated server's compile (BeginDescent 298538, TerminateDescent 298565, WaitThenOpen 298585, Brain.TakeControl 333394, SaveHelper.Save 256800).

The descent waits for the game to run. BeginDescent (312154-312173) first yields a frame at a time until GameManager.GameState is Running, and gives up if it drops to None:

while (GameManager.GameState != GameState.Running)
{
    await UniTask.Yield();
    if (GameManager.GameState == GameState.None)
    {
        return;
    }
}

So a capsule spawned while a world loads starts down when the world starts running, not when it was spawned.

The opening saves the world when the seat holds the local player. TerminateDescent (312181-312199) starts WaitThenOpen only where GameManager.RunSimulation is true, and WaitThenOpen (312201-312214) ends with a save:

private async UniTaskVoid WaitThenOpen()
{
    await UniTask.Delay(3000);
    if (!IsOpen)
    {
        OnServer.Interact(base.InteractOpen, 1);
    }
    await UniTask.Delay(500);
    OnServer.Interact(base.InteractLock, 0);
    if (GameManager.RunSimulation && SeatSlot.Get<Entity>() == InventoryManager.Parent)
    {
        SaveHelper.Save(XmlSaveLoad.Instance.CurrentStationName, default(CancellationToken)).Forget();
    }
}

InventoryManager.Parent is the entity the local player controls. Brain.TakeControl sets it (347044), after returning early in batch mode (347033-347036), so a dedicated server never has one. The save therefore happens in single player, and on a listen host for the host's own capsule. It does not happen for a joiner's capsule on a listen host, whose seat holds the joiner rather than the host's entity, and never on a dedicated server. SaveHelper.Save is SaveGame(SaveMethod.Save, stationName, ...) (270387-270390), here under the world's current name. With the 10 s descent from Running, 3 s to the door and 0.5 s to the lock, the save starts about 13.5 s after the world starts running.

Measured on the test rig, in the Spray Paint Plus playtest runs of 2026-09-24 and 2026-09-27. The save shows in the console as Starting Save for <world> (SaveHelper, 270487) and the Saving - ... lines after it, with no save command before it. In the two runs, 80 single-player and listen-host cells loaded the committed fixture world, where the rig's player arrives in a capsule, and stayed in it for the whole check:

How long the check ran past the rig's Game State: Running Cells Logged Starting Save for SprayPaintPlusWorld
13.8 s or more 23 all 23
12.4 to 13.7 s 11 1
under 12.4 s 46 none

The console lines carry no timestamps and the rig's clock is whole seconds, so this brackets the moment rather than timing it; it agrees with the 13.5 s the source gives. Cells on a newly created world logged Starting Save for Lunar the same way, after the rig's own save of the new world. On the dedicated server (run 17 of 2026-09-27) the joiner took control of its new Human at 21:54:13 on the server's clock and had stepped out of its opened capsule by 21:54:30, and the server's log holds no save line from then until the server quit at 21:55:18. The console line does not name its caller; that it is WaitThenOpen's rests on the timing and the source.

So a test that is sensitive to saves (a reload, anything written at save time), run in single player or on a listen host on a world that holds no character of the player, gets an unannounced save of that world about 13.5 s after it loads.

Player experience timeline

Source: F0089.

Time Event
0s Pulled into capsule seat, camera switches to interior view, teleported 100m up
0-3s Rapid freefall descent, gentle camera shake (0.03)
3-10s Engines fire, thruster effects visible, shake intensifies (0.2), descent decelerates
10s Ground contact, physics settle
13s Door blows off, gas venting particles
13.5s Lock released, player can exit

EntryEffects and SpaceSuitRespawn visuals

Source: F0095o.

EntryEffects component on the capsule controls re-entry fire/thruster visuals. EnableEffects() / DisableEffects() toggle linked GameObjects. SetIntensity(lerpFactor) scales thruster transforms using EaseOutQuart(lerp) * 0.7 with random jitter. SpaceSuitRespawn visual materialization effect: SpawnEffectTime = 5s fade-in, PauseTime = 1s, HideEffectTime = 3s fade-out. Animates a _cutoff shader property.

Slots

Source: $(StationeersPath)\rocketstation_Data\Managed\Assembly-CSharp.dll :: Assets.Scripts.Objects.LanderCapsule.

LanderCapsule.Slots is inherited from Thing:

// Thing
public List<Slot> Slots;

LanderCapsule defines two fixed slot accessors:

private Slot DoorSlot => Slots[0];
private Slot SeatSlot => Slots[1];

Index 0 is the door slot (the breakable door Thing occupies this). Index 1 is the seat for the human occupant. Both accessors are private, so external callers must reach into capsule.Slots[0] / capsule.Slots[1] directly.

InteractMode

Source: $(StationeersPath)\rocketstation_Data\Managed\Assembly-CSharp.dll :: Assets.Scripts.Objects.Thing and LanderCapsule.

InteractMode is inherited from Thing:

// Thing
public Interactable InteractMode => _interactableMode;

It is a property returning an Interactable instance whose Action == InteractableType.Mode. Interactable.State is an int indexed over LanderMode (byte-backed enum): 0 = AtRest, 1 = Descending, 2 = Venting.

Descent is triggered by LanderCapsule.OnInteractableStateChanged:

if (GameManager.RunSimulation
    && interactable.Action == InteractableType.Mode
    && newState != oldState
    && newState == 1)
{
    BeginDescent().Forget();
}

Setting state 1 via OnServer.Interact(capsule.InteractMode, 1) is what spawns the descent sequence documented in the "Descent sequence" section above.

A player who joins arrives seated in one

Measured on the dedicated server at 0.2.6428.27798 (test rig, 2026-09-27; three arrivals of two client instances into the committed Spray Paint Plus fixture world, which holds no character of theirs): a joining player's new Human arrives in a LanderCapsuleSmall, sitting in the capsule's Seat slot (Slots[1]), and the server and the joiner both report it there (/thing location in Lander Capsule's Seat slot, parent ref 509225). What followed, timed from the moment /connect answered on the joiner:

After /connect answered The capsule on the server (LanderMode, IsLocked, IsOpen), and the Human
0 to about 10 s Descending, locked, closed. The Human in the seat, coming down from about 85 m above the ground (first server reads at y 289 to 294, over ground at about 204 to 207)
about 10 s AtRest, still locked and closed
about 13 s Venting, then AtRest; IsLocked false, IsOpen true
from then on the Human still in the seat, until the player leaves it

This matches the timeline above (10 s descent, the door at 13 s, the lock released at 13.5 s). One trap in reading it from outside: the joiner's own /player position read about 206 to 209, the ground, for the first second or so after the join, and only then the capsule's height, so the first position a joiner reports is not where it is.

The player does not get out by itself. Human.ExitAnything() (client 370404-370440, dedicated server 356772) is how a seated player leaves any IExitable, and it refuses while the capsule is locked:

public bool ExitAnything()
{
    Slot parentSlot = base.ParentSlot;
    if (parentSlot == null || !(parentSlot.Parent is IExitable exitable) || LockedToSeat)
    {
        return false;
    }
    if (base.ParentSlot?.Parent is LanderCapsule { IsLocked: not false })
    {
        return false;
    }
    if (base.ParentSlot?.Parent is CrewModuleChair crewModuleChair && !crewModuleChair.CanExitSeat())
    {
        return false;
    }
    if (Assets.Scripts.Networking.NetworkManager.IsClient)
    {
        NetworkClient.SendToServer(new MoveToWorldMessage
        {
            ChildId = base.ReferenceId,
            Force = 0f,
            IsPrecisionPlacement = true,
            Position = exitable.GetExitPosition(this),
            Rotation = base.ParentSlot.Parent.Rotation,
            Velocity = Vector3.zero,
            AngularVelocity = Vector3.zero
        });
        if (base.IsLocalPlayer)
        {
            exitable.Exit(this);
        }
    }
    else
    {
        exitable.Exit(this);
    }
    return true;
}

The seated branch of MovementController calls it on the Ascend or Forward key (216943):

if (ControlMode == Mode.Seated && !flag && (KeyManager.GetButton(KeyMap.Ascend) || KeyManager.GetButton(KeyMap.Forward)))
{
    parentEntity.ExitAnything();
}

and the drop key's handler calls it first (DropKeyDown, 295805: if (_human.ExitAnything() || IsUsingSmartTool || _human.HasRecentParent || !ActiveHand.Slot.Occupant) { return; }). LanderCapsule.Exit puts the Human at the capsule's exit point (312402-312405: human.MoveToWorld(GetExitPosition(human), human.ParentSlot.Parent.Rotation, Vector3.zero, Vector3.zero);, where GetExitPosition returns _exitPoint.position).

Measured: a 5-frame press of Forward on the joiner (the test rig's /input/key, reported consumed) while the capsule was still descending and locked left the Human seated on both sides. The same press after IsLocked had gone false put it "loose in the world" at the capsule's exit point on both sides within a second, three times out of three.

Why it matters: the server holds a seated Human in its seat and ignores the position its owner streams (see RemotePlayerPosition), so until the player has stepped out, anything that moves that player on its own client, or places something relative to it on the server, acts on two different positions.

A player whose character the world holds comes back on its feet

The capsule is for a player the world holds no character of. Measured on the dedicated server, 2026-09-27: the server saved the world while the joiner was connected, was stopped and started again into that save, and the joiner joined again. It came back with the same Human ReferenceId (509106 before and after, in three separate runs; a second joiner, 509305, likewise), standing in the world at the position it was saved at, with no capsule; the server read it loose in the world. The same held for a joiner joining a server just started into a save that already carried its character. A player joining a world that holds no character of theirs (a fresh world, or a save made before they ever joined) gets the capsule, as above. This agrees with PlayerIdentityAcrossRejoin: a normal load keeps every id, and only a player with no brain in the world is given a new Human.

A single player or a listen host arrives seated the same way

Not only joiners. A single player, and a listen host's own player, who load a world that holds no character of theirs (a new world, or a save made without them) arrive seated in a descending capsule exactly as a joiner does.

Read in the decompile, where every member below is textually identical in the dedicated server's compile. After a load, XmlSaveLoad.LoadWorld gives the local player its saved body through World.HandlePlayerControl(), and only when the world holds no brain for the local client falls through to World.CreateCharacterAndTakeControl() (274429-274438; skipped in batch mode). A new world calls it outside batch mode without that check (World.NewAsync, 330877-330880). CreateCharacterAndTakeControl (330940-330951) calls the same Human.CreateCharacter (368800-368850) that a joiner's RespawnMessage reaches on the server (284330-284338), and both pass isRespawn as "the save's ClientInfo already holds this client id". CreateCharacter then runs WorldSetting.OnNewPlayer (61333-61343) or WorldSetting.OnRespawnPlayer (61377-61387), which execute the start condition's NewPlayer or RespawnPlayer spawns. In the standard start conditions (startconditions.xml: DefaultStart at lines 8-21, DefaultStartCommunity at 131-144, and the per-world ones) those are LanderCapsuleSmall and LanderCapsuleSmallRespawn, and both spawn definitions (318-339 and 341-357) contain

<MovePlayer SlotIndex="1"/>
<Interaction Type="Mode" Value="1">
    <Delay Seconds="0"/>
</Interaction>

MovePlayer becomes OnServer.MoveToSlot(player, parent.Slots[1]), the seat (MovePlayerAction.MovePlayer, 116548-116566), and Mode 1 starts the descent (see "InteractMode"). DefaultScenario (22-27) spawns neither, so a world on that start condition gives its new player no capsule.

Measured on the test rig, in Spray Paint Plus playtest cells of 2026-09-24 and 2026-09-27, single player and listen host, on the committed fixture world (which holds no character of the rig's players) and on newly created worlds. /player read the camera at the capsule, and in most reads the cursor on the LanderCapsuleSmall, from 1.4 s after Game State: Running (run 17: camera at y 288.1 in single player and y 280.8 on the listen host, both coming down) to about 10 s after it; at about 15 s the camera was still at the landed capsule. The camera sits on the capsule's camera point only while the local player is seated in it: Entity.HasEnteredExitable moves the player's camera rig to exitable.GetCameraPoint(this) (308964-308984), which for the capsule is its _cameraPoint (312407-312410). A body teleported out of the seat in those cells rode down with the capsule, and the Human's cell read the capsule's (see RemotePlayerPosition, "The authority's own seated player: the body moves, its seat and its cell do not"). No read took the local Human's ParentSlot directly in either mode; the seat is shown by those three readings together with the source.

For the authority's own player there is only one copy, so the split between a joiner's client and the server described above does not arise. What the seat still holds is the Human's slot and, with it, its cell. The way out is the same Human.ExitAnything, which in single player or on a host takes its non-client branch and calls exitable.Exit(this) directly (370404-370440); that was read in the source and has not been measured in either mode. The capsule's opening also saves the world in these two modes (see "At 0.2.6428.27798: the descent waits for Running, and the opening saves the world for the local player").

Verification history

  • 2026-04-20: page created from the Research migration; verbatim content lifted from F0087, F0088, F0089, F0095m, F0095n, F0095o. No conflicts.
  • 2026-04-21: added "Slots" and "InteractMode" sections from direct decompile. Additive only; no existing content changed. Verified against Assets.Scripts.Objects.LanderCapsule in game version 0.2.6228.27061.
  • 2026-09-27 (game version 0.2.6428.27798): added "A single player or a listen host arrives seated the same way", from the decompile of both compiles, startconditions.xml, and single-player and listen-host Spray Paint Plus playtest cells of 2026-09-24 and 2026-09-27. Additive: no section said only joiners arrive seated, and "The capsule is for a player the world holds no character of" already covered these players.
  • 2026-09-27 (game version 0.2.6428.27798): added "At 0.2.6428.27798: the descent waits for Running, and the opening saves the world for the local player" under "Descent sequence", from the decompile of both compiles and the console captures of 80 single-player and listen-host playtest cells plus the server log of one dedicated-server cell. Additive; the numbered steps above it keep their 0.2.6228.27061 stamp and were not re-read in full.
  • 2026-09-27 (game version 0.2.6428.27798): added "A player whose character the world holds comes back on its feet" under "A player who joins arrives seated in one", from three save-and-restart runs and one hand-driven join on the dedicated server. Additive.
  • 2026-09-27: added "A player who joins arrives seated in one" (game version 0.2.6428.27798) from the decompile of both compiles and three measured joiner arrivals on the dedicated server: the Seat slot, the timeline from /connect, Human.ExitAnything and its lock refusal, and the keys that reach it. Additive; the measured timeline agrees with the existing "Player experience timeline", which keeps its own stamp.

Open questions

None.