Skip to content
RustPluginGenerator

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

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

TeleportRequests.json
{
  "Request Timeout Seconds": 60,
  "Warmup Seconds": 5,
  "Cooldown Seconds": 120
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/tpr <player>Send a teleport request to another player by name or Steam ID.Everyone
/tpaAccept the most recent pending teleport request sent to you.Everyone
/tpcCancel your outgoing request and any incoming request you have not accepted.Everyone
tpr.cooldownConsole command that reports your remaining teleport cooldown to the client.Everyone
Permissions
PermissionGrants
teleportrequests.useGrants access to /tpr, /tpa and /tpc.
teleportrequests.adminAllows bypassing the teleport cooldown; also lets /tpr.cooldown run without the use permission.

How the code works

  1. Request bookkeeping

    Two dictionaries drive the flow. requests maps a sender's UserIDString to a PendingRequest holding the receiver's ID and an ExpiresAt stamp, and incoming maps a receiver to a list of sender IDs in arrival order. /tpr <player> resolves the target with BasePlayer.FindAwakeOrSleeping(string.Join(" ", args)), rejects self-targets and NPCs, writes the entry with DateTime.UtcNow.AddSeconds(config.RequestTimeoutSeconds), removes the sender from any previous receiver's list and appends them to the new one.

  2. Accepting a request and starting the warmup

    /tpa iterates incoming[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 passed ExpiresAt; stale rows are removed on the spot. A valid sender is passed to StartWarmup, which cancels any previous warmup for the receiver, records receiver.health in a WarmupState and calls timer.Once(config.WarmupSeconds, callback). The timer is stored in warmups by the receiver's ID so other hooks can find and destroy it.

  3. Guarding the warmup with hooks

    OnEntityTakeDamage checks whether the victim or the attacker is in warmups; if so the timer is destroyed and the receiver gets a chat line. OnPlayerTick is cheaper: it returns immediately unless the player's ID is in warmups, then cancels when the player disconnected, when player.health dropped below the stored StartHealth by more than 0.01, or when IsBlocked now reports a reason. Both hooks keep the map small because entries are removed as soon as a warmup ends.

  4. Blocked conditions

    IsBlocked returns a language key through an out string reason and checks four things in order: IsDead(), IsWounded(), isMounted, and then water. Water is detected with WaterLevel.Test(player.transform.position, true, player). The check runs on /tpr for sender and target, on /tpa for the accepting player and again inside the warmup timer callback for the sender. Building privileges are deliberately ignored.

  5. Finishing the teleport and applying the cooldown

    When the timer fires it confirms the stored WarmupState is still the current one, removes it from warmups and checks that both players are connected. FinishTeleport then removes the request entry, writes DateTime.UtcNow.AddSeconds(config.CooldownSeconds) to cooldowns for the sender and moves them with sender.Teleport(receiver.transform.position + receiver.eyes.HeadForward() * 1.5f), so they arrive just in front of the receiver. /tpr reads HasCooldown first, which skips the check for teleportrequests.admin holders.

  6. The cooldown console command

    tpr.cooldown is a ConsoleCommand, so it is normally reached from the client console or through a bind. It calls arg.Player() for the caller and refuses when that returns null, then checks teleportrequests.admin or teleportrequests.use. If cooldowns holds a future time it replies with the remaining seconds from the CooldownLeft message, otherwise it replies with CooldownReady. Responses go through arg.ReplyWith.

  7. Cleaning up on disconnect and unload

    OnPlayerDisconnected calls RemoveRequest for the leaving player, drops their own incoming list and destroys any warmup they owned. Without it a timer.Once callback could still fire against a destroyed BasePlayer and a sender would linger in someone else's request list. Unload iterates warmups.Values, calls Destroy on each Timer and clears every dictionary so a reload starts with no stale cooldowns or requests.

Install it on your server

  1. Save TeleportRequests.cs

    Keep the file name exactly as the class name. Oxide matches them.
  2. 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.
  3. Edit the config if you want

    On first load the default config is written to oxide/config/TeleportRequests.json (Carbon: carbon/configs/TeleportRequests.json). After editing, reload it:oxide.reload TeleportRequests c.reload TeleportRequests # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip: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 version
  • Team-only requests

    Allow /tpr only between players in the same RelationshipManager team.

    Generate this version
  • Log 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.

Prompt: Create a TeleportRequests Oxide plugin with /tpr, /tpa, /tpc and a tpr.cooldown console command, a 60 second request timeout, 5 second warmup, 120 second cooldown, blocking when wounded, dead, mounted or swimming.
Customize for my server

Edit the prompt on the next screen. 2 free plugins a month, no card.

Last updated Oct 26, 2026.

Customize teleport requests (tpr)

Customize