Skip to content

Player Position Tracking

Player position tracking is the foundation of movement-based plugin features: teleportation systems, zone entry detection, speed-hack detection, distance-based events, proximity chat, and anti-combat-log mechanics. Every plugin that cares about where a player is and where they are going depends on RocketMod's position update event.

This article covers the RocketMod position tracking system in depth. It explains the position update event, the Unturned coordinate system, how to calculate distances and speeds, how to build a zone detection system, and how to implement teleportation commands. The article closes with a speed-hack detection implementation and common pitfalls.

57 Studios maintains a suite of server-side plugins for the Horizon Life RP community. The patterns documented here are drawn from production plugin authoring experience, not from documentation alone.

Prerequisites

  • A working Unturned dedicated server with RocketMod installed. See RocketMod and OpenMod Plugin Basics for installation guidance.
  • Visual Studio 2022 with C# support.
  • .NET Framework 4.7.2 SDK.
  • Familiarity with Unity's Vector3 coordinate system.
  • Basic understanding of Euclidean distance and velocity calculation.
  • Completion of Player Connect and Disconnect Events or equivalent knowledge of RocketMod event subscription patterns.

What you'll learn

  • How OnPlayerUpdatePosition works and when it fires — every game tick (50 times per second) whenever the player's position changes.
  • The Unturned coordinate system: world space, regional coordinates, and how they relate to Unity units.
  • How to calculate distance between players and from fixed points.
  • How to calculate player speed and detect anomalous movement patterns.
  • How to build a teleport command with cooldown and safety checks.
  • How to implement zone entry and exit detection using position data.
  • How to build a speed-hack detector that flags players moving faster than the game's maximum movement speed.
  • Common pitfalls: position sampling frequency, coordinate edge cases, and performance considerations.

OnPlayerUpdatePosition

Event signature

csharp
public static event PlayerUpdatePosition OnPlayerUpdatePosition;
public delegate void PlayerUpdatePosition(UnturnedPlayer player, Vector3 position);

The handler receives the player whose position changed and the player's current world position as a Vector3.

Subscription pattern

csharp
using Rocket.Unturned.Events;
using Rocket.Unturned.Player;
using UnityEngine;

protected override void Load()
{
    UnturnedPlayerEvents.OnPlayerUpdatePosition += HandlePlayerUpdatePosition;
}

protected override void Unload()
{
    UnturnedPlayerEvents.OnPlayerUpdatePosition -= HandlePlayerUpdatePosition;
}

private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    // Position tracking logic here
}

Event firing frequency

The OnPlayerUpdatePosition event fires every game tick — 50 times per second — while the player is moving. Each tick, RocketMod checks whether the player's position has changed since the last tick and fires the event with the updated position. This means the handler receives approximately 50 updates per second during active movement, and zero updates while the player is stationary.

The high firing frequency makes this event unsuitable for expensive operations. Every handler attached to this event must complete in under 1 millisecond to avoid causing server tick lag. Heavy operations like database writes, HTTP requests, or complex raycasts must not run inside the handler.

Movement pattern          Updates per second
────────────────────────────────────────────
Standing still            0
Walking forward           50
Running                   50
Driving                   50
Falling                   50
Swimming                  50

The Unturned coordinate system

Unturned uses Unity's world coordinate system. Positions are Vector3 values where:

  • X: East-west axis. Positive X is east.
  • Y: Vertical axis. Positive Y is up (altitude).
  • Z: North-south axis. Positive Z is north.

All coordinates are in Unity units, where 1 unit equals approximately 1 meter in game scale.

World position vs. regional position

Unturned maps use a coordinate system where the world origin (0, 0, 0) is at the center of the map. Player positions are always absolute world positions — there is no concept of a local or chunk-relative coordinate in the RocketMod position API.

csharp
private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    Logger.Log($"{player.DisplayName} is at " +
        $"X={position.x:F1}, Y={position.y:F1}, Z={position.z:F1}");
}

Common map center points

MapApproximate center (X, Y, Z)Notes
PEI(0, 0, 0)Symmetric around origin
Washington(0, 40, 0)Higher terrain base elevation
Russia(0, 0, 0)Large map, sparse center
Germany(0, 30, 0)Varied terrain height
Custom mapsVariesDepends on terrain definition
Insanities Peak(512, 80, 512)57 Studios RP map center

Distance calculation

Euclidean distance

csharp
private float Distance(Vector3 a, Vector3 b)
{
    return Vector3.Distance(a, b);
}

Distance to a fixed point

csharp
private readonly Vector3 _safeZoneCenter = new Vector3(512f, 80f, 512f);
private readonly float _safeZoneRadius = 100f;

private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    float distance = Vector3.Distance(position, _safeZoneCenter);

    if (distance <= _safeZoneRadius)
    {
        Logger.Log($"{player.DisplayName} is in the safe zone " +
            $"({distance:F1}m from center).");
    }
}

Distance between two players

csharp
private void LogPlayerProximity(UnturnedPlayer a, UnturnedPlayer b)
{
    float distance = Vector3.Distance(a.Position, b.Position);
    Logger.Log($"{a.DisplayName} is {distance:F1}m from {b.DisplayName}");
}

Speed calculation

Player speed is calculated by measuring the distance traveled between consecutive position updates and dividing by the time elapsed.

csharp
private readonly Dictionary<ulong, Vector3> _lastPosition =
    new Dictionary<ulong, Vector3>();
private readonly Dictionary<ulong, DateTime> _lastUpdate =
    new Dictionary<ulong, DateTime>();

private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_lastPosition.TryGetValue(steamId, out Vector3 previous))
    {
        float distance = Vector3.Distance(previous, position);
        double elapsed = (DateTime.UtcNow - _lastUpdate[steamId]).TotalSeconds;

        if (elapsed > 0f)
        {
            float speed = distance / (float)elapsed;
            Logger.Log($"{player.DisplayName} speed: {speed:F1} units/s");
        }
    }

    _lastPosition[steamId] = position;
    _lastUpdate[steamId] = DateTime.UtcNow;
}

Valid movement speeds in Unturned

Movement modeTypical speed (units/s)Expected update rate
Standing still00 updates/s
Walking4.550 updates/s
Running7.550 updates/s
Sprinting9.050 updates/s
Swimming3.550 updates/s
Vehicle (slow)10-2050 updates/s
Vehicle (fast)40-6050 updates/s

The 50-updates-per-second rate is consistent regardless of the player's speed. The event fires 50 times per second whenever the player changes position, so a fast-moving vehicle produces the same number of updates per second as a walking player.

Speed-hack detection

Speed hacks modify the player's movement speed beyond the game's maximum legitimate values. A speed-hack detector compares the calculated player speed against configurable thresholds and flags or kicks players who exceed them.

Threshold values

Base speed thresholds for a speed-hack detector:

csharp
public class SpeedHackConfiguration
{
    public float MaxWalkSpeed = 6f;
    public float MaxRunSpeed = 10f;
    public float MaxSprintSpeed = 12f;
    public float MaxVehicleSpeed = 70f;
    public float MaxSwimSpeed = 5f;
    public int ViolationLimit = 3;
    public bool KickOnViolation = true;
}

Complete speed-hack detector

csharp
using Rocket.Unturned.Events;
using Rocket.Unturned.Player;
using System;
using System.Collections.Generic;
using UnityEngine;

public class SpeedHackDetector
{
    private readonly Dictionary<ulong, Vector3> _lastPos =
        new Dictionary<ulong, Vector3>();
    private readonly Dictionary<ulong, DateTime> _lastTime =
        new Dictionary<ulong, DateTime>();
    private readonly Dictionary<ulong, int> _violations =
        new Dictionary<ulong, int>();

    private readonly SpeedHackConfiguration _config;

    public SpeedHackDetector(SpeedHackConfiguration config)
    {
        _config = config;
    }

    public void HandleUpdatePosition(UnturnedPlayer player, Vector3 position)
    {
        ulong steamId = player.CSteamID.m_SteamID;

        if (!_lastPos.ContainsKey(steamId))
        {
            _lastPos[steamId] = position;
            _lastTime[steamId] = DateTime.UtcNow;
            return;
        }

        float distance = Vector3.Distance(_lastPos[steamId], position);
        double elapsed = (DateTime.UtcNow - _lastTime[steamId]).TotalSeconds;

        if (elapsed <= 0.0)
            return;

        float speed = distance / (float)elapsed;

        float maxSpeed = GetMaxSpeed(player);

        if (speed > maxSpeed * 1.5f && elapsed < 2.0)
        {
            _violations.TryGetValue(steamId, out int count);
            count++;
            _violations[steamId] = count;

            Rocket.Core.Logging.Logger.LogWarning(
                $"[SpeedHack] {player.DisplayName} speed {speed:F1}" +
                $" (limit {maxSpeed:F1}) violation {count}/{_config.ViolationLimit}");

            if (count >= _config.ViolationLimit)
            {
                if (_config.KickOnViolation)
                {
                    Provider.kick(player.CSteamID, "Speed hack detected.");
                    Rocket.Core.Logging.Logger.Log(
                        $"[SpeedHack] Kicked {player.DisplayName}.");
                }
            }
        }
        else if (speed < maxSpeed && _violations.ContainsKey(steamId))
        {
            _violations[steamId] = Math.Max(0, _violations[steamId] - 1);
        }

        _lastPos[steamId] = position;
        _lastTime[steamId] = DateTime.UtcNow;
    }

    private float GetMaxSpeed(UnturnedPlayer player)
    {
        if (player.Player.movement.getVehicle() != null)
            return _config.MaxVehicleSpeed;

        if (player.Player.stance.stance == EPlayerStance.SWIM)
            return _config.MaxSwimSpeed;

        if (player.Player.stance.stance == EPlayerStance.SPRINT)
            return _config.MaxSprintSpeed;

        if (player.Player.stance.stance == EPlayerStance.RUN)
            return _config.MaxRunSpeed;

        return _config.MaxWalkSpeed;
    }
}

Reset on disconnect

csharp
private void HandlePlayerDisconnected(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;
    _lastPos.Remove(steamId);
    _lastTime.Remove(steamId);
    _violations.Remove(steamId);
}

Zone detection system

A zone detection system tracks which regions of the map a player is inside and fires entry/exit events when the player crosses a zone boundary.

Zone definition

csharp
public class Zone
{
    public string Name;
    public Vector3 Center;
    public float Radius;
    public float Height;
}

Zone manager

csharp
public class ZoneManager
{
    private readonly List<Zone> _zones;
    private readonly Dictionary<ulong, HashSet<string>> _playerZones =
        new Dictionary<ulong, HashSet<string>>();

    public ZoneManager(List<Zone> zones)
    {
        _zones = zones;
    }

    public void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
    {
        ulong steamId = player.CSteamID.m_SteamID;

        if (!_playerZones.ContainsKey(steamId))
        {
            _playerZones[steamId] = new HashSet<string>();
        }

        HashSet<string> currentZones = _playerZones[steamId];
        HashSet<string> detectedZones = new HashSet<string>();

        foreach (Zone zone in _zones)
        {
            float horizontalDist = Vector3.Distance(
                new Vector3(position.x, 0, position.z),
                new Vector3(zone.Center.x, 0, zone.Center.z));

            float verticalDiff = Math.Abs(position.y - zone.Center.y);

            bool inside = horizontalDist <= zone.Radius
                          && verticalDiff <= zone.Height;

            if (inside)
            {
                detectedZones.Add(zone.Name);

                if (!currentZones.Contains(zone.Name))
                {
                    OnZoneEnter(player, zone);
                }
            }
        }

        foreach (string zoneName in currentZones)
        {
            if (!detectedZones.Contains(zoneName))
            {
                Zone exitedZone = _zones.Find(z => z.Name == zoneName);
                if (exitedZone != null)
                {
                    OnZoneExit(player, exitedZone);
                }
            }
        }

        _playerZones[steamId] = detectedZones;
    }

    private void OnZoneEnter(UnturnedPlayer player, Zone zone)
    {
        Rocket.Core.Logging.Logger.Log(
            $"{player.DisplayName} entered zone: {zone.Name}");
    }

    private void OnZoneExit(UnturnedPlayer player, Zone zone)
    {
        Rocket.Core.Logging.Logger.Log(
            $"{player.DisplayName} exited zone: {zone.Name}");
    }
}

Rectangular zone support

For buildings or map regions with rectangular boundaries, extend the zone system:

csharp
public class RectZone
{
    public string Name;
    public Bounds Bounds;

    public bool Contains(Vector3 position)
    {
        return Bounds.Contains(position);
    }
}

Teleportation

Basic teleport command

csharp
using Rocket.API;
using Rocket.Unturned.Chat;
using Rocket.Unturned.Player;
using System.Collections.Generic;
using UnityEngine;

public class TeleportCommand : IRocketCommand
{
    public string Name => "tp";
    public string Help => "Teleport to another player or coordinates.";
    public string Syntax => "/tp <player> OR /tp <x> <y> <z>";
    public List<string> Aliases => new List<string>();
    public List<string> Permissions => new List<string> { "myplugin.tp" };
    public AllowedCaller AllowedCaller => AllowedCaller.Player;

    public void Execute(IRocketPlayer caller, string[] command)
    {
        UnturnedPlayer player = (UnturnedPlayer)caller;

        if (command.Length == 1)
        {
            UnturnedPlayer target = UnturnedPlayer.FromName(command[0]);
            if (target == null)
            {
                UnturnedChat.Say(caller, "Target player not found.");
                return;
            }

            player.Teleport(target.Position, target.Rotation);
            UnturnedChat.Say(caller, $"Teleported to {target.DisplayName}.");
        }
        else if (command.Length == 3)
        {
            if (float.TryParse(command[0], out float x)
                && float.TryParse(command[1], out float y)
                && float.TryParse(command[2], out float z))
            {
                Vector3 targetPos = new Vector3(x, y, z);
                player.Teleport(targetPos, player.Rotation);
                UnturnedChat.Say(caller,
                    $"Teleported to ({x:F1}, {y:F1}, {z:F1}).");
            }
            else
            {
                UnturnedChat.Say(caller,
                    "Invalid coordinates. Use numbers: /tp x y z");
            }
        }
        else
        {
            UnturnedChat.Say(caller, "Usage: /tp <player> OR /tp <x> <y> <z>");
        }
    }
}

Teleport cooldown

csharp
public class TeleportCommand : IRocketCommand
{
    private readonly Dictionary<ulong, DateTime> _lastTeleport =
        new Dictionary<ulong, DateTime>();
    private readonly int _cooldownSeconds = 30;

    // ... Name, Help, Syntax, Aliases, Permissions, AllowedCaller ...

    public void Execute(IRocketPlayer caller, string[] command)
    {
        UnturnedPlayer player = (UnturnedPlayer)caller;
        ulong steamId = player.CSteamID.m_SteamID;

        if (_lastTeleport.TryGetValue(steamId, out DateTime lastTime))
        {
            double remaining = _cooldownSeconds -
                (DateTime.UtcNow - lastTime).TotalSeconds;

            if (remaining > 0)
            {
                UnturnedChat.Say(caller,
                    $"Teleport cooldown: {remaining:F0}s remaining.");
                return;
            }
        }

        // Teleport logic...

        _lastTeleport[steamId] = DateTime.UtcNow;
    }
}

Teleport safety: ground detection

csharp
private Vector3 FindGroundPosition(Vector3 position)
{
    if (Physics.Raycast(
        new Ray(position + Vector3.up * 10f, Vector3.down),
        out RaycastHit hit,
        50f,
        RayMasks.GROUND))
    {
        return hit.point + Vector3.up * 0.5f;
    }

    return position;
}

Teleport safety: building and collision checks

csharp
private bool IsSafeTeleportLocation(Vector3 position)
{
    float checkRadius = 2f;

    Collider[] colliders = Physics.OverlapSphere(
        position, checkRadius, RayMasks.BLOCK_COLLISION);

    return colliders.Length == 0;
}

Anti-combat-log teleport blocking

In RP servers, teleportation during combat is typically blocked. Track combat state and reject teleport commands:

csharp
private readonly Dictionary<ulong, DateTime> _lastDamage =
    new Dictionary<ulong, DateTime>();
private readonly int _combatTimeoutSeconds = 30;

private bool IsInCombat(UnturnedPlayer player)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_lastDamage.TryGetValue(steamId, out DateTime lastTime))
    {
        return (DateTime.UtcNow - lastTime).TotalSeconds < _combatTimeoutSeconds;
    }

    return false;
}

private void HandlePlayerDamaged(UnturnedPlayer victim, UnturnedPlayer killer)
{
    _lastDamage[victim.CSteamID.m_SteamID] = DateTime.UtcNow;

    if (killer != null)
    {
        _lastDamage[killer.CSteamID.m_SteamID] = DateTime.UtcNow;
    }
}

Performance optimization

The OnPlayerUpdatePosition handler runs 50 times per second per moving player. With 50 players moving simultaneously, the handler is invoked 2,500 times per second. Every millisecond of execution time in the handler adds 2.5 seconds of CPU work per second.

Handler optimization rules

RuleReason
No database queriesEvery query blocks the main thread
No foreach over all playersO(n) inside an O(n) call is O(n²)
No file I/ODisk latency is unpredictable
No Debug.LogLogging is surprisingly expensive at 50 updates/s
No string concatenation in hot pathString allocation causes GC pressure
Pre-allocate dictionariesAvoid resizing during gameplay

Batch processing pattern

Instead of processing position updates inside the handler, queue the updates and process them on a timer:

csharp
private readonly ConcurrentQueue<PositionUpdate> _updateQueue =
    new ConcurrentQueue<PositionUpdate>();

private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    _updateQueue.Enqueue(new PositionUpdate
    {
        SteamId = player.CSteamID.m_SteamID,
        Position = position,
        Timestamp = DateTime.UtcNow
    });
}

private void ProcessBatch()
{
    while (_updateQueue.TryDequeue(out PositionUpdate update))
    {
        ProcessUpdate(update);
    }
}

Common pitfalls

Event handler throws on first frame

The first OnPlayerUpdatePosition fires immediately after the player spawns. If the handler accesses plugin state that has not been initialized yet, it will throw a NullReferenceException:

csharp
// WRONG — crashes if _lastPosition has no entry for this player
private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    Vector3 previous = _lastPosition[player.CSteamID.m_SteamID]; // KeyNotFoundException
}

// CORRECT
private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (!_lastPosition.ContainsKey(steamId))
    {
        _lastPosition[steamId] = position;
        _lastUpdate[steamId] = DateTime.UtcNow;
        return;
    }

    // Speed calculation here
}

Vector3 comparison with floating-point tolerance

Never use == to compare Vector3 values that come from sequential position updates. Floating-point precision means the same position can have slightly different values:

csharp
// WRONG
if (position == _lastPosition[steamId])
{
    // Rarely true due to floating point drift
}

// CORRECT — use a distance threshold
if (Vector3.Distance(position, _lastPosition[steamId]) < 0.01f)
{
    // Position effectively unchanged
}

Teleporting during position update handler

Do not teleport a player from inside the OnPlayerUpdatePosition handler. Teleportation changes the player's position, which triggers another OnPlayerUpdatePosition call, creating an infinite loop that freezes the server:

csharp
// DANGEROUS — creates infinite teleport loop
private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    if (OutsideBoundary(position))
    {
        player.Teleport(safePosition, player.Rotation);
        // This fires OnPlayerUpdatePosition again → infinite loop
    }
}

To safely teleport from position logic, use a flag:

csharp
private HashSet<ulong> _pendingTeleport = new HashSet<ulong>();

private void HandlePlayerUpdatePosition(UnturnedPlayer player, Vector3 position)
{
    ulong steamId = player.CSteamID.m_SteamID;

    if (_pendingTeleport.Contains(steamId))
    {
        _pendingTeleport.Remove(steamId);
        return; // Skip position logic after teleport
    }

    if (OutsideBoundary(position))
    {
        _pendingTeleport.Add(steamId);
        player.Teleport(safePosition, player.Rotation);
    }
}

Position update stops when player is in vehicle passenger seat

Passengers in a vehicle do not receive position updates at the same rate as the driver. The vehicle's position update is owned by the driver's client. Passenger position updates can lag by up to 500ms behind the actual vehicle position.

Frequently asked questions

How fast does OnPlayerUpdatePosition fire?

The event fires every game tick — 50 times per second — as long as the player is moving. It fires approximately 50 times per second regardless of the player's speed. A walking player and a driving player both produce 50 updates per second.

Can I detect which direction a player is facing?

Yes. The UnturnedPlayer.Rotation property returns a Vector2 where x is the yaw (horizontal facing angle) and y is the pitch (vertical angle). Read it from the player object, not from the position update event.

Does OnPlayerUpdatePosition fire for teleported players?

Yes. When a player is teleported, the server sends a position update to the client, and RocketMod fires OnPlayerUpdatePosition with the new teleport position. The same performance considerations apply — the handler runs 50 times per second through the teleported movement as well.

Can I prevent a player from entering a zone using position events?

No. Position update events are read-only — you cannot cancel a position change. To restrict players from entering areas, use a region-check coroutine that runs on a timer (e.g., every second) and teleports the player out if they are inside a restricted zone.

Why does my distance calculation show players moving when they are standing still?

Floating-point drift causes consecutive position samples to differ by very small amounts (0.001 units or less). Use a noise threshold in your distance calculation to filter out sub-centimeter movements:

csharp
if (distance < 0.01f) return; // Ignore floating-point noise

How do I find a player's initial spawn position?

Read player.Position in the OnPlayerConnected handler. The spawn position is set before the connection event fires.

Cross-references

Document history

VersionDateAuthorNotes
1.02025-06-1857 StudiosInitial publication. Position update event, coordinate system, speed calculation, teleportation, zone detection, speed-hack detector, performance optimization, common pitfalls.