RemotePlayerPosition¶
Who moves a remote player's body in multiplayer, and what that means for anything that wants to put a joined player somewhere. The short answer, measured at 0.2.6428.27798 on a dedicated server: the player's own client does, the server takes the position it is told without checking it, and nothing on the server can move that body back or anywhere else, except a seat. A player who has just joined sits in one, and so does a single player or a listen host's own player on a world that holds no character of theirs; for them too, a teleport moves only the body.
The owning client streams its Human's position, and the server takes it¶
Once a client has taken control of its character, NetworkClient.OwnerToServerStream sends one OwnerMessage about every 50 ms for as long as the network is active, whenever the game is running (client 219043-219073):
_isOwnerLoopAlive = true;
while (Assets.Scripts.Networking.NetworkManager.IsActive && !(await UniTask.Delay(50).SuppressCancellationThrow()))
{
if (GameManager.GameState == GameState.Running)
{
SendToServer(message);
}
}
The message carries the client's OwnerHuman and, when that Human sits in an IPlayerVehicle, the vehicle as well (client 219830-219863; the dedicated server's compile is textually the same at 207734):
public override void Deserialize(RocketBinaryReader reader)
{
if (reader.ReadBoolean())
{
Network.ReadPackedId(reader, out var referenceId);
Thing.Find<Thing>(referenceId)?.ProcessOwnerUpdate(reader);
if (reader.ReadBoolean())
{
Network.ReadPackedId(reader, out var referenceId2);
Thing.Find<Thing>(referenceId2)?.ProcessOwnerUpdate(reader);
}
}
}
Nothing there compares the id with the connection the message came from: the server applies the owner update to whatever Thing the id names. On DynamicThing the owner update is a DynamicThingPosition (307140-307154):
public override void BuildOwnerUpdate(RocketBinaryWriter writer)
{
base.BuildOwnerUpdate(writer);
_currentOwnerUpdate = default(DynamicThingPosition);
PrepareUpdateMessage(ref _currentOwnerUpdate);
_currentOwnerUpdate.SetUpdateFlags(in _lastOwnerUpdate, isPlayer: true);
_currentOwnerUpdate.WriteLite(writer, ref _lastOwnerUpdate);
}
public override void ProcessOwnerUpdate(RocketBinaryReader reader)
{
base.ProcessOwnerUpdate(reader);
_currentOwnerUpdate.ReadLite(reader, this);
ProcessPhysicsUpdate(_currentOwnerUpdate);
}
Human fills the position from its transform (368580-368588: currentPosition.WorldPosition = base.ThingTransformPosition;), and SetUpdateFlags always sets the position bit for a player (283464-283500: if (!RocketMath.Approximately(WorldPosition, lastSentValues.WorldPosition, FineTolerance * num) | isPlayer) { UpdateFlags |= 1; }), so every message carries a position. On the server the update is stored for any Thing the server does not have authority over (305966-305972, and 292359 in the server's compile):
public virtual void ProcessPhysicsUpdate(DynamicThingPosition updateData)
{
if (!HasAuthority)
{
LastPhysicsUpdate = updateData;
}
}
A remote player's Human is such a Thing on the server. Entity.HasAuthority defers to the brain (307949-307958: if (!OrganBrain) return base.HasAuthority; return OrganBrain.HasAuthority;), and Brain.HasAuthority => LocalControl; (346968), which the server sets only for a brain it controls itself.
The stored update is what moves the server's copy. DynamicThing.PhysicsUpdate (306194-306232) ends with
for a body that is not kinematic, and UpdateNetworkPosition (305974-306010) lerps the rigidbody toward it, skipping only an NPC, a brain that is offline or a ragdoll on the simulation owner:
bool flag = this is Npc;
if (GameManager.RunSimulation && IsEntity && base.AsEntity.OrganBrain != null && (flag || !base.AsEntity.OrganBrain.IsOnline || base.AsEntity.IsRagdoll))
{
return;
}
Vector3 worldPosition = LastPhysicsUpdate.WorldPosition;
if (!IsOccluded && !LastPhysicsUpdate.IsSleeping)
{
float num = (LastPhysicsUpdate.Snap ? 20f : LerpRate);
...
RigidBody.MovePosition(Vector3.Lerp(RigidBody.position, worldPosition, Time.deltaTime * num + _stmoothKinematicRate));
Human overrides LerpRate => 10f (366990). The server's /thing read RigidBody.isKinematic false for an unseated remote Human and true for one in a seat.
Measured on the dedicated server, 2026-09-27: after /player/teleport on an unseated joiner, the server's /thing read the Human within 7 cm of the joiner's own position at the first read, 0.7 s and 0.75 s after the teleport started in the two runs, and it stayed there (11 s of reads at about 1 Hz in the first run, 3 reads in the second). The server's LastPhysicsUpdate.WorldPosition read the joiner's reported position as soon as it was sampled, seated or not.
The owning client never takes a position back for its own Human¶
The two paths by which a server position reaches a client both skip the Thing the client has authority over. The physics path is the ProcessPhysicsUpdate above. The transform path, DynamicThing.ProcessUpdateTransform (307245-307252):
Vector3 worldPosition = reader.ReadVector3();
Quaternion quaternion2 = reader.ReadQuaternion();
if (!HasAuthority && ParentSlot == null)
{
ThingTransform.SetPositionAndRotation(worldPosition, quaternion2);
}
On a client, Thing.HasAuthority is true for the client's own brain's Thing (323691-323702: if (!GameManager.RunSimulation) { return ReferenceId == InventoryManager.ParentBrain?.ReferenceId; } return true;), and the Human passes it through its brain. So a joiner's body is where the joiner's own process puts it. There is no server correction to wait out.
Human.ForceSetPosition (368926-368943) is gated on GameManager.RunSimulation, so on the server it moves the server's copy only, and the owner's next OwnerMessage (50 ms later) says where the body really is. MoveToWorldPositionMessage (282877-282921) would move a Thing on the clients, but no code in either compile constructs one, and its DynamicThing.MoveToWorld only acts on a Thing that is in a slot.
Except while the Human sits in a slot¶
A seated Human is held by its seat on the server, whatever position its owner streams. Measured on the dedicated server, 2026-09-27: a joiner still seated in the lander capsule it arrived in was teleported with /player/teleport. The joiner's own copy moved to the target and stayed there, still reporting itself in the capsule's Seat slot; the server's LastPhysicsUpdate.WorldPosition read the target within 0.6 s; and the server's Human stayed at the seat, about 20 m away, for the 11 s it was watched. Neither side corrected the other.
That is the state a player who has just joined is in: see LanderCapsule, "A player who joins arrives seated in one". Leaving the seat is the player's own action, Human.ExitAnything, which on a client sends a MoveToWorldMessage for the exit point to the server and moves its own copy there. Once out, the stream above applies, and the same teleport was held by the server within a second.
Anything the server places relative to such a player lands at the seat. OnServer.SpawnDynamicThingMaxStack, which a client's item spawn request becomes, places the item at the server's copy of the requesting entity (client 40193-40220, server 40189):
Vector3 position = entity.RigidBody.worldCenterOfMass + entity.EntityForward * 1f;
Quaternion rotation = Quaternion.AngleAxis(180f, entity.ThingTransform.up);
DynamicThing dynamicThing2 = Create<DynamicThing>(dynamicThing, position, rotation);
So a joiner teleported while seated asks for an item and gets it at the seat. A test rig run hit exactly that: a teleport one second after the join, while the capsule was still coming down, then a spawn that landed 82 m above the target.
The authority's own seated player: the body moves, its seat and its cell do not¶
A single player, and a listen host's own player, also start seated in a descending lander capsule on a world that holds no character of theirs (LanderCapsule, "A single player or a listen host arrives seated the same way"). Their own process is the authority, so there is one copy and no server to hold it back. The seat still keeps everything except where the body's transform is.
The Human stays in the seat. DynamicThing.MoveToSlot (306632-306697) makes a slotted Thing's transform a child of the slot's Location (or of the parent's transform), zeroes its local position and rotation, and turns its physics off:
Transform parent2 = (destinationSlot.Location ? destinationSlot.Location : destinationSlot.Parent.ThingTransform);
SetPhysics(on: false);
ThingTransform.SetParent(parent2);
base.ThingTransformLocalPosition = Vector3.zero;
base.ThingTransformLocalRotation = Quaternion.identity;
and Human.OnEnterInventory makes the Human's rigidbody kinematic (369646-369661). A teleport writes the transform's world position (Human.ForceSetPosition does, and so does the test rig's /player/teleport, which writes the transform and the rigidbody and never touches ParentSlot). That changes the body's offset from its parent and nothing else: ParentSlot still names the seat, and the body is still the seat's child.
So the body rides with the capsule. Measured in Spray Paint Plus playtest cells, where the rig teleported the authority's own player while its capsule was still descending. The camera stays at the capsule throughout, because a seated local player's camera rig is the capsule's camera point (Entity.HasEnteredExitable, 308964-308984; LanderCapsule.GetCameraPoint, 312407-312410):
| Cell | Seated Human's y at the teleport | Sent to | Read, after Running |
Camera y at the read | Body at the read |
|---|---|---|---|---|---|
| single player, 2026-09-24 | 259.40 | (-1289, 207, -574) | 8.9 s | 209.01 | (-1289, 155.83, -574) |
| listen host, 2026-09-24 | 261.06 | (-1289, 207, -574) | 8.7 s | 209.26 | (-1289, 154.42, -574) |
| single player, 2026-09-27 | 250.41 | (-1293, 205, -687) | 9.7 s | 208.93 | (-1293, 162.76, -687) |
| single player, 2026-09-27 | 268.13 | (-1293, 205, -687) | 8.5 s | 211.38 | (-1293, 147.45, -687) |
In every row the body sits below the camera by as much as the target was below the seated Human, plus 0.76 to 0.80 m (the camera point's height above the seated Human's position), and its x and z are still exactly the target's. The body did not fall: it was carried down by as far as the capsule had come since the teleport, and the same target ended at two different heights in the two 2026-09-27 cells because the capsule had different distances still to fall. The body keeps the seat's rotation as well (pitched about 18 degrees in all four cells). Reads taken after the capsule had landed moved the body by up to 4 m more, as the capsule settled and its rotation changed by up to 4 degrees.
Its cell stays the capsule's. OcclusionManager's dynamic-thing pass, which runs at most once per rendered frame while the game is Running (221230-221261), rewrites a slotted Thing's position and cell from its root parent (220807-220817):
if ((object)parentSlot?.Parent != null)
{
Thing rootParent = parentSlot.Parent.RootParent;
if (rootParent is DynamicThing { WorldCenterOfMass: var worldCenterOfMass })
{
dynamicThing.Position = worldCenterOfMass;
dynamicThing.WorldCenterOfMass = worldCenterOfMass;
}
dynamicThing.WorldGrid = new WorldGrid(rootParent.CenterPosition);
dynamicThing.SetWorldAtmosphere();
CheckForRoom(dynamicThing);
}
For a seated Human the root parent is the capsule, so its Position and WorldGrid are the capsule's wherever the body is. Measured in run 17 of 2026-09-27, 1.4 s (single player) and 1.9 s (listen host) after Running: /player, which reads ThingTransformPosition, gave the target (-1293, 205, -687), while /atmosphere centred on the Human's WorldGrid read the cell at (-1309, 285, -691) and (-1293, 279, -693): the capsule's, 74 to 80 m above the target, in vacuum (0 kPa). Anything the game does at the player's cell happens at the capsule; a spray can in its hand emits there (SprayCan.OnUseItem passes WorldGrid, 361044-361049; see SprayCan, "Where the pollution lands, and how to read it"). The delegate is textually identical in the dedicated server's compile (208669-208763), so the server's copy of a seated joiner has the capsule's cell too; that half was read, not measured.
So for the authority's own player the rule is the joiner's: leave the seat first (Human.ExitAnything, once the capsule has unlocked), then teleport.
There is no server-side teleport of a remote player¶
Every console command that moves a player moves the LOCAL player, from that player's own process. The dedicated server lists 122 commands; the ones that could be expected to move a player:
teleport(TeleportCommand, 103167-103216; server 102553):CommandScope.InGame | CommandScope.CreativeOnly, refuses outright in batch mode (if (GameManager.IsBatchMode) { ConsoleWindow.PrintError("teleport is not available in batch mode", suppressStacktrace: true); return null; }), and otherwise writesInventoryManager.Parent.Transform.position = safePoint;.unstuck(UnstuckCommand, 104073-104096; server 103421):InventoryManager.Parent.Transform.position += Vector3.up * 0.25f;.player(PlayerCommand, 101217) sets mood, hygiene, nutrition and hydration by player name; no position.thing(ThingCommand, 103393) hasfind,delete,spawn,spawnvel,infoand debug subcommands; no move.cleanupplayers(CleanupPlayersCommand, 97124) decays dead or disconnected players' bodies; it relocates nobody.
What the server can do authoritatively is put a Human in a slot (a seat moves its occupant on every client, which is how a lander capsule carries a joiner down) or give a player a new Human (a respawn). Neither is a teleport.
A use on a far target is not distance-checked¶
OnServer.AttackWith (39634-39658) predicts locally and sends an AttackWithMessage; the server's Process (283200-283211) finds the parent and the target by id and calls thing2.AttackWith(attack). Neither checks the distance between them, a line of sight, or that the sender owns the parent (the last is on GameMessageFactory, "AttackWithMessage hostId unreliability"). ISprayer.DoSpray has no distance test either. Measured on the dedicated server, 2026-09-27: a joiner seated in its landed capsule, about 19 m from a StructurePipeCorner, used an ItemSprayCanRed on it by reference id, and the server read the pipe's customColorIndex go from 8 to 4.
A joiner sent into the air falls¶
A teleport puts the body where it is sent and nothing holds it there, so a point with no floor under it is fallen from, and both copies agree while it happens: the server follows the owner's stream a metre or two behind. Measured on the dedicated server, 2026-09-27, on the committed Spray Paint Plus fixture world, with the joiner's own GET /player and the server's GET /thing read twice a second:
| Sent to | What followed |
|---|---|
| (-1289, 207, -574), 2 m from the bottom rung of a ladder tower | 206 m after 0.4 s, 170 m after 6 s, 8 m after 15 s, -65 m after 19 s, still falling. The server's copy trailed by 0.1 to 1.5 m for the first 15 s, then read 53 m while the joiner read -65 m |
| the same x and z, at about 157 to 161 m (5 m above the heights the single-player and listen-host bodies read; see below) | fell: 145 m after 4 s, 80 m after 10 s |
| on the solar panels roofing that tower, and on a floating stair flight | at rest on both copies within a second (212.1 m and 261.7 m), still 25 s later |
Nothing measured gives the ground's height at that x and z. Single-player and listen-host cells of 2026-09-24 sent to the same point read the player at about 152 to 156 m, which looked like a landing on terrain there. It was not: those players were still seated in their descending arrival capsules, and the heights were their teleported bodies carried down with the seat (see "The authority's own seated player: the body moves, its seat and its cell do not" above). Nobody stood at that point in any mode. So a stand has to be a structure's collider, or ground a player has been shown to stand on. Whether the joiner's two falls here went through terrain is open; see the open question.
Verification history¶
- 2026-09-27: conflict on "what the single-player and listen-host readings at the ladder-tower point show". Previous claim: in single player and on a listen host the same ladder-tower point put the player on the terrain about 50 m down (about 152 to 156 m), so the terrain there holds a host and did not hold a joiner. New finding: in those 2026-09-24 cells the local player was still seated in its descending arrival capsule when teleported, and the body, still a child of the seat, was carried down with the capsule; the readings say nothing about terrain. Fresh validator verdict: B is correct, quoting
0152-hostie-get-player.jsonof the 2026-09-24 single-player cell (body at y 155.83, camera at y 209.01, cursor onLanderCapsuleSmall), with one correction: 51 to 53 m is how far the capsule had come by the first read, not the whole of its remaining descent (about 54 to 57 m to rest). Result: the paragraph under the table, two of the table's rows and the open question that rested on the terrain reading are rewritten; the heading "A joiner sent into the air falls, and can fall through terrain" loses "and can fall through terrain", which nothing measured supports now. The joiner measurements in the table were not re-derived in this pass and are unchanged. - 2026-09-27 (game version 0.2.6428.27798): added "The authority's own seated player: the body moves, its seat and its cell do not" under "Except while the Human sits in a slot", from the decompile of both compiles and single-player and listen-host Spray Paint Plus playtest cells of 2026-09-24 and 2026-09-27, and one sentence to the page's opening paragraph. Additive.
- 2026-09-27 (game version 0.2.6428.27798): added "A joiner sent into the air falls, and can fall through terrain", from a hand-driven session on the dedicated server and the positions recorded in two dedicated-server playtest cells the same day. Additive; the cause is an open question.
- 2026-09-27: page created (game version 0.2.6428.27798) from the decompile of both compiles and a hand-driven test rig session on the dedicated server, while finding out why a joiner's teleport did not stick in the 'glow survives a save and a reload' dedicated-server cell of the Spray Paint Plus playtests. Additive; no existing page changed in substance.
Open questions¶
- Whether the joiner that fell twice at (-1289, -574), about 115 m from where it arrived, went through terrain. Its first fall passed -65 m, far below the 204 to 207 m at which arrival capsules came to rest nearby, so either the ground there is that much lower or the joiner's client had no terrain collider there (its own client simulates its body, so its own terrain colliders are what it lands on). The ground's height at that point was not measured. This replaces a question that asked why terrain holding a single player and a listen host there let a joiner through: those players never stood there (see Verification history).