How to make a Rust teleport requests plugin (TPR/TPA)
A complete Oxide plugin that implements /tpr player-to-player teleport requests with /tpa, /tpc, request timeouts, a warmup before the move, a per-player cooldown and blocks for wounded, dead, mounted or swimming players. It uses OnPlayerTick and OnEntityTakeDamage to guard the warmup.
Vanilla Rust has no teleport, and a raw /tp command on a survival server lets anyone escape a fight or bypass a base. The usual fix is a request flow: the sender asks, the receiver consents, and the move only happens after a short warmup so combat and state changes still matter. This plugin adds that flow. /tpr <player> sends a request that expires after Request Timeout Seconds. /tpa walks your incoming list from the newest entry down, skips expired or offline senders and starts a warmup of Warmup Seconds for the player who accepts. /tpc drops your outgoing request and every incoming one. A per-player cooldown of Cooldown Seconds starts when the teleport completes, and both sides must be alive, not wounded, not mounted and not swimming. One pending request per sender is kept, so a second /tpr replaces the first.
The plugin code
using System.Linq;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("Teleport Requests", "RustPluginGenerator", "1.0.0")]
[Description("Player-to-player teleport requests with timeout, warmup, cooldown and safety checks.")]
public class TeleportRequests : RustPlugin
{
private const string PermUse = "teleportrequests.use";
private const string PermAdmin = "teleportrequests.admin";
private class PluginConfig
{
[JsonProperty("Request Timeout Seconds")]
public float RequestTimeoutSeconds { get; set; } = 60f;
[JsonProperty("Warmup Seconds")]
public float WarmupSeconds { get; set; } = 5f;
[JsonProperty("Cooldown Seconds")]
public float CooldownSeconds { get; set; } = 120f;
}
private class PendingRequest
{
public string ReceiverId;
public DateTime ExpiresAt;
}
private class WarmupState
{
public string ReceiverId;
public string SenderId;
public float StartHealth;
public Timer Timer;
}
private PluginConfig config;
private readonly Dictionary<string, PendingRequest> requests = new Dictionary<string, PendingRequest>();
private readonly Dictionary<string, List<string>> incoming = new Dictionary<string, List<string>>();
private readonly Dictionary<string, DateTime> cooldowns = new Dictionary<string, DateTime>();
private readonly Dictionary<string, WarmupState> warmups = new Dictionary<string, WarmupState>();
private void Init()
{
permission.RegisterPermission(PermUse, this);
permission.RegisterPermission(PermAdmin, this);
}
protected override void LoadDefaultConfig() => config = new PluginConfig();
protected override void LoadConfig()
{
base.LoadConfig();
try
{
config = Config.ReadObject<PluginConfig>();
if (config == null) throw new Exception();
}
catch
{
PrintWarning("Configuration file is invalid, using defaults");
LoadDefaultConfig();
}
SaveConfig();
}
protected override void SaveConfig() => Config.WriteObject(config);
protected override void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
["PlayerNotFound"] = "No player found with that name or ID.",
["SelfRequest"] = "You cannot request a teleport to yourself.",
["RequestSent"] = "Teleport request sent to {0}. They have {1} seconds to accept.",
["RequestReceived"] = "{0} wants to teleport to you. Type /tpa to accept or /tpc to cancel.",
["NoPending"] = "You have no pending teleport requests.",
["Accepted"] = "Request accepted. Warmup started, do not move out of safety.",
["Cancelled"] = "Request cancelled.",
["CancelledWarmup"] = "Teleport cancelled: {0}.",
["Cooldown"] = "You must wait {0} more seconds before teleporting again.",
["CooldownLeft"] = "Cooldown remaining: {0} seconds.",
["CooldownReady"] = "You have no cooldown active.",
["BlockedWounded"] = "You cannot teleport while wounded.",
["BlockedDead"] = "You cannot teleport while dead.",
["BlockedWater"] = "You cannot teleport while swimming or in water.",
["BlockedMounted"] = "You cannot teleport while mounted.",
["NoPermission"] = "You do not have permission to use this command.",
["SenderOffline"] = "The other player is no longer online.",
["Teleported"] = "Teleport complete."
}, this);
}
private string Msg(string key, string userId = null, params object[] args) => string.Format(lang.GetMessage(key, this, userId), args);
[ChatCommand("tpr")]
private void CmdRequest(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (args == null || args.Length < 1)
{
SendReply(player, Msg("PlayerNotFound", player.UserIDString));
return;
}
if (IsBlocked(player, out string reason))
{
SendReply(player, Msg(reason, player.UserIDString));
return;
}
if (HasCooldown(player))
{
var remaining = Math.Max(0, (int)(cooldowns[player.UserIDString] - DateTime.UtcNow).TotalSeconds);
SendReply(player, Msg("Cooldown", player.UserIDString, remaining));
return;
}
BasePlayer target = BasePlayer.FindAwakeOrSleeping(string.Join(" ", args));
if (target == null || target.IsNpc || !target.IsConnected)
{
SendReply(player, Msg("PlayerNotFound", player.UserIDString));
return;
}
if (target == player)
{
SendReply(player, Msg("SelfRequest", player.UserIDString));
return;
}
if (IsBlocked(target, out reason))
{
SendReply(player, Msg(reason, player.UserIDString));
return;
}
RemoveRequest(player.UserIDString);
requests[player.UserIDString] = new PendingRequest
{
ReceiverId = target.UserIDString,
ExpiresAt = DateTime.UtcNow.AddSeconds(config.RequestTimeoutSeconds)
};
if (!incoming.TryGetValue(target.UserIDString, out var list))
{
list = new List<string>();
incoming[target.UserIDString] = list;
}
list.Remove(player.UserIDString);
list.Add(player.UserIDString);
SendReply(player, Msg("RequestSent", player.UserIDString, target.displayName, (int)config.RequestTimeoutSeconds));
SendReply(target, Msg("RequestReceived", target.UserIDString, player.displayName));
}
[ChatCommand("tpa")]
private void CmdAccept(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
if (IsBlocked(player, out string reason))
{
SendReply(player, Msg(reason, player.UserIDString));
return;
}
if (!incoming.TryGetValue(player.UserIDString, out var list) || list.Count == 0)
{
SendReply(player, Msg("NoPending", player.UserIDString));
return;
}
for (int i = list.Count - 1; i >= 0; i--)
{
string senderId = list[i];
if (!requests.TryGetValue(senderId, out var pending) || pending.ReceiverId != player.UserIDString || pending.ExpiresAt <= DateTime.UtcNow)
{
list.RemoveAt(i);
requests.Remove(senderId);
continue;
}
BasePlayer sender = BasePlayer.FindByID(ulong.Parse(senderId));
if (sender == null || !sender.IsConnected)
{
SendReply(player, Msg("SenderOffline", player.UserIDString));
list.RemoveAt(i);
requests.Remove(senderId);
continue;
}
StartWarmup(player, sender);
return;
}
SendReply(player, Msg("NoPending", player.UserIDString));
}
[ChatCommand("tpc")]
private void CmdCancel(BasePlayer player, string command, string[] args)
{
if (player == null || !player.IsConnected) return;
if (!permission.UserHasPermission(player.UserIDString, PermUse))
{
SendReply(player, Msg("NoPermission", player.UserIDString));
return;
}
RemoveRequest(player.UserIDString);
if (incoming.TryGetValue(player.UserIDString, out var list))
{
foreach (string senderId in list.ToArray())
{
requests.Remove(senderId);
}
list.Clear();
}
SendReply(player, Msg("Cancelled", player.UserIDString));
}
[ConsoleCommand("tpr.cooldown")]
private void CmdCooldown(ConsoleSystem.Arg arg)
{
var player = arg.Player();
if (player == null)
{
arg.ReplyWith("Run this command in-game.");
return;
}
if (!permission.UserHasPermission(player.UserIDString, PermAdmin) && !permission.UserHasPermission(player.UserIDString, PermUse))
{
arg.ReplyWith(Msg("NoPermission", player.UserIDString));
return;
}
if (cooldowns.TryGetValue(player.UserIDString, out var until) && until > DateTime.UtcNow)
{
arg.ReplyWith(Msg("CooldownLeft", player.UserIDString, (int)(until - DateTime.UtcNow).TotalSeconds));
return;
}
arg.ReplyWith(Msg("CooldownReady", player.UserIDString));
}
private void StartWarmup(BasePlayer receiver, BasePlayer sender)
{
CancelWarmup(receiver.UserIDString);
var state = new WarmupState
{
ReceiverId = receiver.UserIDString,
SenderId = sender.UserIDString,
StartHealth = receiver.health
};
warmups[receiver.UserIDString] = state;
state.Timer = timer.Once(config.WarmupSeconds, () =>
{
if (!warmups.TryGetValue(receiver.UserIDString, out var current) || current != state) return;
warmups.Remove(receiver.UserIDString);
if (receiver == null || !receiver.IsConnected || sender == null || !sender.IsConnected)
{
SendReply(receiver, Msg("SenderOffline", receiver.UserIDString));
return;
}
if (IsBlocked(sender, out string reason))
{
SendReply(receiver, Msg("CancelledWarmup", receiver.UserIDString, lang.GetMessage(reason, this, sender.UserIDString)));
SendReply(sender, Msg(reason, sender.UserIDString));
return;
}
FinishTeleport(receiver, sender);
});
SendReply(receiver, Msg("Accepted", receiver.UserIDString));
}
private void FinishTeleport(BasePlayer receiver, BasePlayer sender)
{
RemoveRequest(sender.UserIDString);
cooldowns[sender.UserIDString] = DateTime.UtcNow.AddSeconds(config.CooldownSeconds);
Vector3 target = receiver.transform.position + receiver.eyes.HeadForward() * 1.5f;
sender.Teleport(target);
SendReply(sender, Msg("Teleported", sender.UserIDString));
SendReply(receiver, Msg("Teleported", receiver.UserIDString));
}
private void CancelWarmup(string receiverId)
{
if (warmups.TryGetValue(receiverId, out var state))
{
state.Timer?.Destroy();
warmups.Remove(receiverId);
}
}
private void RemoveRequest(string senderId)
{
if (requests.TryGetValue(senderId, out var pending))
{
if (incoming.TryGetValue(pending.ReceiverId, out var list)) list.Remove(senderId);
requests.Remove(senderId);
}
}
private bool HasCooldown(BasePlayer player)
{
return !permission.UserHasPermission(player.UserIDString, PermAdmin) &&
cooldowns.TryGetValue(player.UserIDString, out var until) && until > DateTime.UtcNow;
}
private bool IsBlocked(BasePlayer player, out string reason)
{
reason = null;
if (player.IsDead()) { reason = "BlockedDead"; return true; }
if (player.IsWounded()) { reason = "BlockedWounded"; return true; }
if (player.isMounted) { reason = "BlockedMounted"; return true; }
if (WaterLevel.Test(player.transform.position, true, player)) { reason = "BlockedWater"; return true; }
return false;
}
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
if (info == null) return null;
var receiver = info.InitiatorPlayer ?? entity as BasePlayer;
if (receiver == null) return null;
if (warmups.TryGetValue(receiver.UserIDString, out var state))
{
CancelWarmup(receiver.UserIDString);
SendReply(receiver, Msg("CancelledWarmup", receiver.UserIDString, "damage"));
}
return null;
}
private void OnPlayerTick(BasePlayer player)
{
if (player == null) return;
if (!warmups.TryGetValue(player.UserIDString, out var state)) return;
string reason = "state changed";
if (!player.IsConnected || player.health < state.StartHealth - 0.01f || IsBlocked(player, out reason))
{
CancelWarmup(player.UserIDString);
SendReply(player, Msg("CancelledWarmup", player.UserIDString, string.IsNullOrEmpty(reason) ? "state changed" : reason));
}
}
private void OnPlayerDisconnected(BasePlayer player)
{
if (player == null) return;
RemoveRequest(player.UserIDString);
incoming.Remove(player.UserIDString);
CancelWarmup(player.UserIDString);
}
private void Unload()
{
foreach (var state in warmups.Values) state.Timer?.Destroy();
warmups.Clear();
requests.Clear();
incoming.Clear();
cooldowns.Clear();
}
}
}Hooks used: OnEntityTakeDamage, OnPlayerTick, OnPlayerDisconnected
Config file
Created automatically on first load at oxide/config/TeleportRequests.json.
{
"Request Timeout Seconds": 60,
"Warmup Seconds": 5,
"Cooldown Seconds": 120
}Commands and permissions
| Command | What it does | Permission |
|---|---|---|
/tpr <player> | Send a teleport request to another player by name or Steam ID. | Everyone |
/tpa | Accept the most recent pending teleport request sent to you. | Everyone |
/tpc | Cancel your outgoing request and any incoming request you have not accepted. | Everyone |
tpr.cooldown | Console command that reports your remaining teleport cooldown to the client. | Everyone |
| Permission | Grants |
|---|---|
teleportrequests.use | Grants access to /tpr, /tpa and /tpc. |
teleportrequests.admin | Allows bypassing the teleport cooldown; also lets /tpr.cooldown run without the use permission. |
How the code works
Request bookkeeping
Two dictionaries drive the flow.
requestsmaps a sender'sUserIDStringto aPendingRequestholding the receiver's ID and anExpiresAtstamp, andincomingmaps a receiver to a list of sender IDs in arrival order./tpr <player>resolves the target withBasePlayer.FindAwakeOrSleeping(string.Join(" ", args)), rejects self-targets and NPCs, writes the entry withDateTime.UtcNow.AddSeconds(config.RequestTimeoutSeconds), removes the sender from any previous receiver's list and appends them to the new one.Accepting a request and starting the warmup
/tpaiteratesincoming[player.UserIDString]backwards so the newest request wins. For each candidate it checks that the entry still exists, still points at this receiver and has not passedExpiresAt; stale rows are removed on the spot. A valid sender is passed toStartWarmup, which cancels any previous warmup for the receiver, recordsreceiver.healthin aWarmupStateand callstimer.Once(config.WarmupSeconds, callback). The timer is stored inwarmupsby the receiver's ID so other hooks can find and destroy it.Guarding the warmup with hooks
OnEntityTakeDamagechecks whether the victim or the attacker is inwarmups; if so the timer is destroyed and the receiver gets a chat line.OnPlayerTickis cheaper: it returns immediately unless the player's ID is inwarmups, then cancels when the player disconnected, whenplayer.healthdropped below the storedStartHealthby more than 0.01, or whenIsBlockednow reports a reason. Both hooks keep the map small because entries are removed as soon as a warmup ends.Blocked conditions
IsBlockedreturns a language key through anout string reasonand checks four things in order:IsDead(),IsWounded(),isMounted, and then water. Water is detected withWaterLevel.Test(player.transform.position, true, player). The check runs on/tprfor sender and target, on/tpafor the accepting player and again inside the warmup timer callback for the sender. Building privileges are deliberately ignored.Finishing the teleport and applying the cooldown
When the timer fires it confirms the stored
WarmupStateis still the current one, removes it fromwarmupsand checks that both players are connected.FinishTeleportthen removes the request entry, writesDateTime.UtcNow.AddSeconds(config.CooldownSeconds)tocooldownsfor the sender and moves them withsender.Teleport(receiver.transform.position + receiver.eyes.HeadForward() * 1.5f), so they arrive just in front of the receiver./tprreadsHasCooldownfirst, which skips the check forteleportrequests.adminholders.The cooldown console command
tpr.cooldownis aConsoleCommand, so it is normally reached from the client console or through a bind. It callsarg.Player()for the caller and refuses when that returns null, then checksteleportrequests.adminorteleportrequests.use. Ifcooldownsholds a future time it replies with the remaining seconds from theCooldownLeftmessage, otherwise it replies withCooldownReady. Responses go througharg.ReplyWith.Cleaning up on disconnect and unload
OnPlayerDisconnectedcallsRemoveRequestfor the leaving player, drops their ownincominglist and destroys any warmup they owned. Without it atimer.Oncecallback could still fire against a destroyedBasePlayerand a sender would linger in someone else's request list.Unloaditerateswarmups.Values, callsDestroyon eachTimerand clears every dictionary so a reload starts with no stale cooldowns or requests.
Install it on your server
Save TeleportRequests.cs
Keep the file name exactly as the class name. Oxide matches them.Upload it to the plugins folder
Oxide:oxide/plugins/. Carbon:carbon/plugins/. The server compiles and loads it within a few seconds, no restart needed.Edit the config if you want
On first load the default config is written tooxide/config/TeleportRequests.json(Carbon:carbon/configs/TeleportRequests.json). After editing, reload it:oxide.reload TeleportRequests c.reload TeleportRequests # CarbonGrant the permission
Give it to every player, or swapdefaultfor a group such asvip:oxide.grant group default teleportrequests.use c.grant group default teleportrequests.use # Carbon
Variations
Each one opens the generator with a ready prompt you can tweak.
Add a cost per teleport
Charge scrap from the sender's inventory before the warmup starts.
Generate this versionTeam-only requests
Allow /tpr only between players in the same RelationshipManager team.
Generate this versionLog every teleport
Write each accepted teleport to a data file for admin review.
Generate this version
FAQ
How do I install this on an Oxide or Carbon server?
Save the class as TeleportRequests.cs under oxide/plugins, then reload with oxide.reload TeleportRequests or restart. Carbon loads the same file from the same folder. The first run writes oxide/config/TeleportRequests.json; edit that file and reload to apply new values.
Why does the warmup cancel when someone shoots me?
OnEntityTakeDamage returns early unless the victim or the attacker is inside warmups. When they are, the timer is destroyed and a chat line is sent. Only the sender and receiver in the warmup map are checked, so unrelated fights are untouched.
What happens if the receiver goes offline before I accept?
OnPlayerDisconnected removes the player from incoming and destroys their warmup, and /tpa skips any request whose ExpiresAt has passed. Expired entries are removed from the list during that scan, so the next /tpa starts from a clean state.
Can two requests be pending at once from one player?
No. The requests dictionary is keyed by sender, so a new /tpr first calls RemoveRequest, which pulls the sender out of the previous receiver's incoming list. Only the most recent request survives, and the receiving side decides which arrival order to honor when /tpa scans backwards.
Customize it for your server
Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.
Edit the prompt on the next screen. 2 free plugins a month, no card.
More teleport plugins
- Teleport back to deathRecords each player's death position in OnPlayerDeath and lets them teleport back with /back while a configurable time window is still open, subjectโฆOnPlayerDeath
- Home teleportAdds /sethome, /home, /delhome and /homes with per-permission home limits, a warmup timer that is cancelled when the player takes damage, and aโฆOnServerInitializedOnEntityTakeDamageOnPlayerDisconnected
- Outpost and Bandit teleportAdds /outpost and /bandit chat commands that warm up, then teleport the player to the Outpost or Bandit Camp monument found by name inโฆOnServerInitializedOnPlayerDisconnected
Last updated Oct 26, 2026.
Customize teleport requests (tpr)
Customize