Vintage Story teleport cheat: the server trusts your coordinates

Game version

Tested on Vintage Story v1.21.6 (Stable), .NET 8.0. The analysis is limited to this version and the code paths shown below.

Disclaimer

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.

Previous article

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.

What we’re building

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:

TL;DR

The shown 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

Every game tick (~15ms), the client sends a UDP packet containing the player’s current position:

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);
}

The position packet is constructed by 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

The server receives this packet in 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
}

And 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);
    // ...
}
The critical finding

The shown 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

The shown handler checks the position version:

int num = entity.WatchedAttributes.GetInt("positionVersionNumber");
if (packet.PositionVersion < num)
    return;  // reject stale/replayed packets

This PositionVersion check prevents old packets from overwriting newer positions (e.g., after a server-side teleport). It does not prevent:

  • Moving further than physically possible in one tick
  • Moving faster than the player’s speed allows
  • Teleporting across the entire map in a single packet
  • Moving through solid blocks
  • Moving to coordinates without validating speed or distance

The shown path contains no AntiAbuse check or reference to a Config option for position validation. Other handlers or server mods may add checks.

Comparing block-placement checks

The same codebase has 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;
}

And 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 infrastructure for validation exists. It was applied to blocks and mounts. It was not applied to player position.


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);
}

We set both 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.

No Harmony patching. No packet crafting. No reflection. We write to a public field and let the game’s existing network loop deliver it.

The coordinate system trap

Two coordinate systems

Vintage Story uses two different origins for coordinates. Getting this wrong teleports you to the wrong side of the map.

ContextOriginExample
Game UI (F1 overlay, chat)World spawn point(500, 148, -200)
Internal (entity.Pos)Absolute world origin(512500, 148, 511800)

The conversion:

Internal = UI coordinate + spawn position
UI coordinate = Internal - spawn position

For a standard 1024k world, the spawn is around (512000, ?, 512000). So UI coordinate (500, 148, -200) is actually absolute (512500, 148, 511800).

The game UI subtracts the spawn position when displaying coordinates:

asBlockPos.Sub(game.SpawnPosition.AsBlockPos.X, 0, game.SpawnPosition.AsBlockPos.Z);

The fix in our teleport function:

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

The mod registers three client-side chat commands via api.RegisterCommand. Commands with the . prefix are handled locally; resulting position changes are still sent to the server.

How dot-commands are handled

Two code paths handle these messages:

  1. Client-side: HudDialogChat.HandleClientMessage checks message.StartsWith("."), calls HandleClientCommand, and returns without sending to the network.
  2. Server-side (fallback): ServerMain.HandleChatMessage silently drops any message starting with . — even if the client somehow sent it.

.tp <name> — teleport to player

Searches clientApi.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

Iterates all online players, shows their positions relative to spawn (matching the game UI), and calculates distance:

[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

Takes UI-style coordinates (relative to spawn), converts to absolute, and teleports:

.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.
  |                                                 |

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.


Why this works

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:

  • Running full collision detection for every player on the server every tick
  • Sending inputs (WASD, jump, sneak) instead of positions
  • Reconciling client prediction with server state
  • Handling latency compensation

Accepting client-reported positions without movement checks allows a modified client to report coordinates outside normal movement limits.


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

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 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.

There is no server log entry. 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

The teleport hack is 12 lines of position assignment + coordinate conversion. No Harmony patches, no packet manipulation — just writing to entity.Pos and waiting for the next network tick.

Why it works:

  1. The client owns its position. SendPlayerPositionPacket reads from entity.Pos every ~15ms and sends it to the server.
  2. The server trusts it unconditionally. HandlePlayerPosition calls SetFromPacket with no distance, speed, or collision validation.
  3. Commands stay local. The . prefix ensures commands never reach the server — the client intercepts them, and the server drops them as a fallback.
  4. Coordinate conversion is the only tricky part. The game UI shows coordinates relative to spawn; internal positions are absolute. Forgetting to add DefaultSpawnPosition sends you to the wrong side of the map.
  5. The validation infrastructure exists but isn’t used. Block placement has AntiAbuse range checks. Mount positions verify ownership. Player positions get none of that.

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.

Report an error

Found an error? Contact me on LinkedIn with the page link and what needs correcting.