Vintage Story teleport cheat: the server trusts your coordinates
Table of Contents
A look at position synchronization in Vintage Story 1.21.6: client-reported coordinates, the inspected server handler, and the limits of the example. Tested on Vintage Story v1.21.6 (Stable), .NET 8.0. The analysis is limited to this version and the code paths shown below. This is a technical write-up for educational purposes. Test only in singleplayer, on your own server, or with the server owner’s permission. Follow the server’s rules. This is a companion piece to Vintage Story creative mode cheat via reverse engineering. The creative mode mod uses Harmony patches to intercept packets. The teleport exploit is simpler — no patches at all, just a field assignment. We examine a client-side teleport mod in the tested setup. Teleporting to a player requires their entity to be loaded on the client. No admin privileges, server-side mod, or Harmony patches are used in this example. Here’s what it looks like on a real server: The shown Every game tick (~15ms), the client sends a UDP packet containing the player’s current position: The position packet is constructed by The server receives this packet in And The shown The shown handler checks the position version: This The shown path contains no The same codebase has And The infrastructure for validation exists. It was applied to blocks and mounts. It was not applied to player position. We set both No Harmony patching. No packet crafting. No reflection. We write to a public field and let the game’s existing network loop deliver it. Vintage Story uses two different origins for coordinates. Getting this wrong teleports you to the wrong side of the map. The conversion: For a standard 1024k world, the spawn is around The game UI subtracts the spawn position when displaying coordinates: The fix in our teleport function: The mod registers three client-side chat commands via Two code paths handle these messages: Searches Iterates all online players, shows their positions relative to spawn (matching the game UI), and calculates distance: Takes UI-style coordinates (relative to spawn), converts to absolute, and teleports: In the shown path, the server processes the update as a movement packet. This does not make the movement invisible: the server receives the changed coordinates. These excerpts do not establish whether other systems log or detect the change. In the inspected synchronization path, the client computes movement and reports coordinates. The shown server handler accepts them after checking the position version, without validating speed or distance. This conclusion is limited to the inspected path and tested setup. Server-authoritative physics generally involves: Accepting client-reported positions without movement checks allows a modified client to report coordinates outside normal movement limits. From other players’ perspective, you vanish from one spot and appear at another on the next server tick (~15ms). There is no movement animation, no trail, no transition — you simply pop into existence at the target location. The server broadcasts your new position via There is no server log entry. The teleport hack is 12 lines of position assignment + coordinate conversion. No Harmony patches, no packet manipulation — just writing to Why it works: For the Harmony-based approach, read the Creative mode analysis: it separates local client state from server-side privileges. Browse both articles under Vintage Story topics. Found an error? Contact me on LinkedIn with the page link and what needs correcting.What we’re building
HandlePlayerPosition path checks PositionVersion, then copies client coordinates into entity.ServerPos without distance, speed, or collision checks. In the tested setup, changes to entity.Pos reach the server through this path. This does not establish what checks or monitoring may exist elsewhere.Reversing position synchronization
The normal flow
public void SendPlayerPositionPacket()
{
Packet_EntityPosition entityPositionPacket =
ServerPackets.getEntityPositionPacket(game.EntityPlayer.Pos, game.EntityPlayer, 0);
Packet_UdpPacket udpPacket = new Packet_UdpPacket
{
Id = 2,
EntityPosition = entityPositionPacket
};
if (game.FallBackToTcp)
game.SendPacketClient(new Packet_Client { Id = 35, UdpPacket = udpPacket });
else
game.UdpNetClient.Send(udpPacket);
}getEntityPositionPacket, which serializes X/Y/Z, yaw, pitch, motion, and a PositionVersion counter:public static Packet_EntityPosition getEntityPositionPacket(EntityPos pos, Entity entity, int tick)
{
return new Packet_EntityPosition
{
EntityId = entity.EntityId,
X = CollectibleNet.SerializeDoublePrecise(pos.X),
Y = CollectibleNet.SerializeDoublePrecise(pos.Y),
Z = CollectibleNet.SerializeDoublePrecise(pos.Z),
Yaw = CollectibleNet.SerializeFloatPrecise(pos.Yaw),
Pitch = CollectibleNet.SerializeFloatPrecise(pos.Pitch),
Roll = CollectibleNet.SerializeFloatPrecise(pos.Roll),
MotionX = CollectibleNet.SerializeDoublePrecise(pos.Motion.X),
MotionY = CollectibleNet.SerializeDoublePrecise(pos.Motion.Y),
MotionZ = CollectibleNet.SerializeDoublePrecise(pos.Motion.Z),
PositionVersion = entity.WatchedAttributes.GetInt("positionVersionNumber"),
Tick = tick,
// ... tags, body yaw, controls
};
}Server-side handling
ServerUdpNetwork.HandlePlayerPosition:public void HandlePlayerPosition(Packet_EntityPosition packet, ServerPlayer player)
{
if (packet == null)
return;
EntityPlayer entity = player.Entity;
int num = entity.WatchedAttributes.GetInt("positionVersionNumber");
if (packet.PositionVersion < num)
return;
player.LastReceivedClientPosition = server.ElapsedMilliseconds;
entity.ServerPos.SetFromPacket(packet, entity);
entity.Pos.SetFromPacket(packet, entity);
// ... notify physics behaviors, broadcast to other players
}SetFromPacket is just direct assignment:public static void SetFromPacket(this EntityPos pos, Packet_EntityPosition packet, Entity entity)
{
pos.X = CollectibleNet.DeserializeDoublePrecise(packet.X);
pos.Y = CollectibleNet.DeserializeDoublePrecise(packet.Y);
pos.Z = CollectibleNet.DeserializeDoublePrecise(packet.Z);
pos.Yaw = CollectibleNet.DeserializeFloatPrecise(packet.Yaw);
pos.Pitch = CollectibleNet.DeserializeFloatPrecise(packet.Pitch);
pos.Roll = CollectibleNet.DeserializeFloatPrecise(packet.Roll);
pos.HeadYaw = CollectibleNet.DeserializeFloatPrecise(packet.HeadYaw);
pos.HeadPitch = CollectibleNet.DeserializeFloatPrecise(packet.HeadPitch);
pos.Motion.X = CollectibleNet.DeserializeDoublePrecise(packet.MotionX);
pos.Motion.Y = CollectibleNet.DeserializeDoublePrecise(packet.MotionY);
pos.Motion.Z = CollectibleNet.DeserializeDoublePrecise(packet.MotionZ);
// ...
}HandlePlayerPosition path checks PositionVersion, then copies client coordinates into entity.ServerPos without distance, speed, or collision checks. In the tested setup, changes to entity.Pos reach the server through this path. This does not establish what checks or monitoring may exist elsewhere.Checks absent from the shown handler
int num = entity.WatchedAttributes.GetInt("positionVersionNumber");
if (packet.PositionVersion < num)
return; // reject stale/replayed packetsPositionVersion check prevents old packets from overwriting newer positions (e.g., after a server-side teleport). It does not prevent:AntiAbuse check or reference to a Config option for position validation. Other handlers or server mods may add checks.Comparing block-placement checks
AntiAbuse checks for block placement:if (server.Config.AntiAbuse != EnumProtectionLevel.Off && !flag
&& num > (double)((player.WorldData.PickingRange + 0.7f)
* (player.WorldData.PickingRange + 0.7f)))
{
ServerMain.Logger.Audit("Client {0} tried to break/place a block out of range");
return false;
}HandleMountPosition — which handles rideable entities — validates ownership before accepting coordinates:Entity entityById = server.api.World.GetEntityById(packet.EntityId);
IMountable mountable = entityById?.GetInterface<IMountable>();
if (mountable == null || !mountable.IsMountedBy(player.Entity))
{
return; // reject — you're not riding this entity
}
// only then: entityById.ServerPos.SetFromPacket(packet, entityById);The exploit
The core: 12 lines
private void TeleportTo(double x, double y, double z)
{
var player = clientApi.World.Player;
if (player?.Entity == null) return;
EntityPlayer entity = player.Entity;
entity.Pos.X = x;
entity.Pos.Y = y;
entity.Pos.Z = z;
entity.Pos.Motion.Set(0, 0, 0);
entity.ServerPos.X = x;
entity.ServerPos.Y = y;
entity.ServerPos.Z = z;
entity.ServerPos.Motion.Set(0, 0, 0);
}Pos (client-side rendering position) and ServerPos (what the client thinks the server knows). On the next tick, SendPlayerPositionPacket reads entity.Pos, serializes it, and sends it via UDP. The server calls SetFromPacket, and we’re there.The coordinate system trap
Context Origin Example Game UI (F1 overlay, chat) World spawn point (500, 148, -200)Internal ( entity.Pos)Absolute world origin (512500, 148, 511800)Internal = UI coordinate + spawn position
UI coordinate = Internal - spawn position(512000, ?, 512000). So UI coordinate (500, 148, -200) is actually absolute (512500, 148, 511800).asBlockPos.Sub(game.SpawnPosition.AsBlockPos.X, 0, game.SpawnPosition.AsBlockPos.Z);var spawn = clientApi.World.DefaultSpawnPosition;
double absX = uiX + spawn.X;
double absY = uiY; // Y is not offset
double absZ = uiZ + spawn.Z;
TeleportTo(absX, absY, absZ);The commands
api.RegisterCommand. Commands with the . prefix are handled locally; resulting position changes are still sent to the server.HudDialogChat.HandleClientMessage checks message.StartsWith("."), calls HandleClientCommand, and returns without sending to the network.ServerMain.HandleChatMessage silently drops any message starting with . — even if the client somehow sent it..tp <name> — teleport to playerclientApi.World.AllOnlinePlayers for a matching name (case-insensitive, supports partial match), reads targetPlayer.Entity.Pos, and calls TeleportTo:private void OnTpCommand(int groupId, CmdArgs args)
{
string targetName = args.PopWord();
IPlayer targetPlayer = null;
foreach (var p in clientApi.World.AllOnlinePlayers)
{
if (p.PlayerName.Equals(targetName, StringComparison.OrdinalIgnoreCase))
{
targetPlayer = p;
break;
}
}
// ... fallback to partial match with StartsWith ...
EntityPos targetPos = targetPlayer.Entity.Pos;
TeleportTo(targetPos.X, targetPos.Y, targetPos.Z);
}.tplist — list all players with coordinates[BHacks] === Online Players ===
Me: (512.3, 148.0, -203.7) [0m away]
SomePlayer: (-1024.5, 155.0, 800.2) [1847m away]
AnotherOne: (200.0, 72.0, 100.5) [380m away].tppos <x> <y> <z> — teleport to coordinates.tppos 500 148 -200
[BHacks] Teleported to (500.0, 148.0, -200.0)The complete packet flow
Client Server
| |
| .tp SomePlayer |
| (handled locally, never sent to server) |
| |
| entity.Pos.X = target.X |
| entity.Pos.Y = target.Y |
| entity.Pos.Z = target.Z |
| |
| ~15ms later: regular position tick |
| |
| UDP Packet { |
| Id: 2 |
| X: target.X (serialized) |
| Y: target.Y (serialized) |
| Z: target.Z (serialized) |
| PositionVersion: current |
| } |
| ----------------------------------------------> |
| |
| | HandlePlayerPosition:
| | version check: OK
| | SetFromPacket()
| | // no distance check
| | // no speed check
| | // position accepted
| |
| | Server now considers player
| | at target coordinates.
| | Chunks load around new pos.
| | Other players see you there.
| |Why this works
Limitations
You can only teleport to loaded players
.tp <name> reads coordinates from targetPlayer.Entity.Pos. But Entity is only populated for players whose entities are loaded on your client — typically those within your view distance. Players far away appear in AllOnlinePlayers with Entity == null:if (targetPlayer.Entity == null)
{
clientApi.ShowChatMessage("[BHacks] Player has no entity (not fully loaded?)");
return;
}.tppos has no such limitation — you can teleport to any coordinates, loaded or not. The server will load chunks around your new position.What other players see
BulkEntityPosition UDP packets to all players tracking your entity. If you teleport out of someone’s view distance, you despawn from their client. If you teleport into someone’s view distance, you spawn in.HandlePlayerPosition doesn’t log position changes, regardless of distance. The only way an admin could notice is by watching you visually or checking your coordinates manually.Summary
entity.Pos and waiting for the next network tick.SendPlayerPositionPacket reads from entity.Pos every ~15ms and sends it to the server.HandlePlayerPosition calls SetFromPacket with no distance, speed, or collision validation.. prefix ensures commands never reach the server — the client intercepts them, and the server drops them as a fallback.DefaultSpawnPosition sends you to the wrong side of the map.AntiAbuse range checks. Mount positions verify ownership. Player positions get none of that.Related reading
Report an error