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
Vector3coordinate 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
OnPlayerUpdatePositionworks 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 50The 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
| Map | Approximate 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 maps | Varies | Depends 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 mode | Typical speed (units/s) | Expected update rate |
|---|---|---|
| Standing still | 0 | 0 updates/s |
| Walking | 4.5 | 50 updates/s |
| Running | 7.5 | 50 updates/s |
| Sprinting | 9.0 | 50 updates/s |
| Swimming | 3.5 | 50 updates/s |
| Vehicle (slow) | 10-20 | 50 updates/s |
| Vehicle (fast) | 40-60 | 50 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
| Rule | Reason |
|---|---|
| No database queries | Every query blocks the main thread |
No foreach over all players | O(n) inside an O(n) call is O(n²) |
| No file I/O | Disk latency is unpredictable |
No Debug.Log | Logging is surprisingly expensive at 50 updates/s |
| No string concatenation in hot path | String allocation causes GC pressure |
| Pre-allocate dictionaries | Avoid 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 noiseHow 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
- Player Connect and Disconnect Events — previous article; position is available in connect handlers.
- Player Stats and Experience — next article; movement speed is affected by skill levels.
- Player Vitals — health and stamina affect movement speed.
- RocketMod and OpenMod Plugin Basics — event subscription fundamentals.
- Debugging Server Exceptions — diagnosing handler performance issues.
- Server Commands Reference — RocketMod management commands.
Document history
| Version | Date | Author | Notes |
|---|---|---|---|
| 1.0 | 2025-06-18 | 57 Studios | Initial publication. Position update event, coordinate system, speed calculation, teleportation, zone detection, speed-hack detector, performance optimization, common pitfalls. |
