Skip to content
RustPluginGenerator

How to make a Rust sleeping bag teleport plugin (bag list, warmup, cooldown)

Adds /bag, which lists the player's sleeping bags, beds and beach towels with grid references, and /bag <number> to teleport to one after a warmup timer with a per-player cooldown. It relies on the OnEntitySpawned and OnEntityKill hooks to keep an index of placed spawn points.

Sleeping bags are the main respawn network players build across the map, but there is no built-in way to travel back to an existing bag without dying. This plugin gives players a chat command that prints every bag, bed and beach towel they own, sorted by prefab and distance, and labelled with map grid coordinates, plus a second form that teleports them to the selected bag after a warmup.

The plugin keeps its own registry of spawn points instead of scanning every entity on each command. OnEntitySpawned adds new SleepingBag entities that have an owner, OnEntityKill removes destroyed ones, and OnServerInitialized fills the list once at startup so a reload does not lose existing bags. Damage taken during the warmup cancels the teleport, cooldowns are stored per player in oxide data, and a permission controls the command while a second one bypasses the warmup and cooldown entirely.

The plugin code

BagTeleport.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("BagTeleport", "RustPluginGenerator", "1.0.0")]
    [Description("Lists your sleeping bags with grid positions and teleports to them after a warmup.")]
    public class BagTeleport : RustPlugin
    {
        private const string PermUse = "bagteleport.use";
        private const string PermBypass = "bagteleport.bypass";

        private PluginConfig config;
        private readonly Dictionary<ulong, SleepingBag> bags = new Dictionary<ulong, SleepingBag>();
        private readonly Dictionary<ulong, float> warmup = new Dictionary<ulong, float>();
        private readonly Dictionary<string, DateTime> cooldowns = new Dictionary<string, DateTime>();

        private class PluginConfig
        {
            [JsonProperty("Warmup seconds")]
            public float WarmupSeconds = 10f;

            [JsonProperty("Cooldown seconds")]
            public float CooldownSeconds = 60f;

            [JsonProperty("Maximum bags listed")]
            public int MaxListedBags = 10;
        }

        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>
            {
                ["ListHeader"] = "Your sleeping bags:",
                ["ListItem"] = "{0}. {1} - {2}",
                ["ListEmpty"] = "You have no sleeping bags placed.",
                ["Usage"] = "Usage: /bag or /bag <number>",
                ["InvalidNumber"] = "That is not a bag number from the list.",
                ["Warmup"] = "Teleporting to {0} in {1} seconds. Taking damage cancels it.",
                ["Cancelled"] = "Teleport cancelled.",
                ["Cooldown"] = "You must wait {0} seconds before teleporting again.",
                ["Success"] = "You have been teleported to {0}."
            }, this);
        }

        private string Msg(string key, string userId = null, params object[] args) =>
            string.Format(lang.GetMessage(key, this, userId), args);

        private void Init()
        {
            permission.RegisterPermission(PermUse, this);
            permission.RegisterPermission(PermBypass, this);
        }

        private void OnServerInitialized()
        {
            LoadData();
            foreach (var networkable in BaseNetworkable.serverEntities)
            {
                var bag = networkable as SleepingBag;
                if (bag != null && bag.OwnerID != 0)
                    bags[bag.net.ID.Value] = bag;
            }
        }

        private void OnEntitySpawned(BaseNetworkable entity)
        {
            var bag = entity as SleepingBag;
            if (bag != null && bag.OwnerID != 0)
                bags[bag.net.ID.Value] = bag;
        }

        private void OnEntityKill(BaseNetworkable entity)
        {
            if (entity != null)
                bags.Remove(entity.net.ID.Value);
        }

        [ChatCommand("bag")]
        private void CmdBag(BasePlayer player, string command, string[] args)
        {
            if (player == null || !permission.UserHasPermission(player.UserIDString, PermUse))
                return;

            if (args == null || args.Length == 0)
            {
                SendList(player);
                return;
            }

            int index;
            if (!int.TryParse(args[0], out index))
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            var owned = GetOwnedBags(player);
            if (index < 1 || index > owned.Count)
            {
                SendReply(player, Msg("InvalidNumber", player.UserIDString));
                return;
            }

            var target = owned[index - 1];
            if (target == null || target.IsDestroyed)
            {
                SendReply(player, Msg("InvalidNumber", player.UserIDString));
                return;
            }

            bool bypass = permission.UserHasPermission(player.UserIDString, PermBypass);
            if (!bypass && !CooldownReady(player))
            {
                DateTime last;
                if (cooldowns.TryGetValue(player.UserIDString, out last))
                {
                    double remaining = config.CooldownSeconds - (DateTime.UtcNow - last).TotalSeconds;
                    SendReply(player, Msg("Cooldown", player.UserIDString, Math.Max(1, (int)remaining)));
                }
                return;
            }

            if (bypass)
            {
                TeleportTo(player, target);
                SendReply(player, Msg("Success", player.UserIDString, target.ShortPrefabName));
                return;
            }

            var userId = player.userID.Get();
            warmup[userId] = UnityEngine.Time.realtimeSinceStartup;
            SendReply(player, Msg("Warmup", player.UserIDString, target.ShortPrefabName, (int)config.WarmupSeconds));

            timer.Once(config.WarmupSeconds, () =>
            {
                if (!warmup.ContainsKey(userId))
                    return;
                warmup.Remove(userId);
                if (player == null || !player.IsConnected || player.IsDead() || target == null || target.IsDestroyed)
                    return;
                TeleportTo(player, target);
                SendReply(player, Msg("Success", player.UserIDString, target.ShortPrefabName));
            });
        }

        private void SendList(BasePlayer player)
        {
            var owned = GetOwnedBags(player);
            if (owned.Count == 0)
            {
                SendReply(player, Msg("ListEmpty", player.UserIDString));
                return;
            }

            SendReply(player, Msg("ListHeader", player.UserIDString));
            for (int i = 0; i < owned.Count && i < config.MaxListedBags; i++)
            {
                var bag = owned[i];
                SendReply(player, Msg("ListItem", player.UserIDString, i + 1,
                    bag.ShortPrefabName, PositionToGrid(bag.transform.position)));
            }
        }

        private List<SleepingBag> GetOwnedBags(BasePlayer player)
        {
            var owner = player.userID.Get();
            var list = new List<SleepingBag>();
            foreach (var bag in bags.Values)
            {
                if (bag == null || bag.IsDestroyed) continue;
                if (bag.OwnerID != owner) continue;
                list.Add(bag);
            }
            return list
                .OrderBy(b => b.ShortPrefabName)
                .ThenBy(b => Vector3.Distance(player.transform.position, b.transform.position))
                .ToList();
        }

        private bool CooldownReady(BasePlayer player)
        {
            DateTime last;
            if (!cooldowns.TryGetValue(player.UserIDString, out last))
                return true;
            return (DateTime.UtcNow - last).TotalSeconds >= config.CooldownSeconds;
        }

        private void TeleportTo(BasePlayer player, SleepingBag target)
        {
            cooldowns[player.UserIDString] = DateTime.UtcNow;
            player.Teleport(target.transform.position + Vector3.up * 0.2f);
            SaveData();
        }

        private string PositionToGrid(Vector3 position)
        {
            float worldSize = ConVar.Server.worldsize;
            float half = worldSize / 2f;
            int x = (int)((position.x + half) / 150f) + 1;
            int z = (int)((position.z + half) / 150f) + 1;
            return string.Format("{0}{1}", GridLetter(x - 1), z);
        }

        private string GridLetter(int index)
        {
            index = Mathf.Clamp(index, 0, 25);
            return ((char)('A' + index)).ToString();
        }

        private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
        {
            var player = entity as BasePlayer;
            if (player != null && warmup.ContainsKey(player.userID.Get()))
            {
                warmup.Remove(player.userID.Get());
                SendReply(player, Msg("Cancelled", player.UserIDString));
            }
            return null;
        }

        private void LoadData()
        {
            var stored = Interface.Oxide.DataFileSystem.ReadObject<Dictionary<string, string>>(Name);
            if (stored == null) return;
            cooldowns.Clear();
            foreach (var entry in stored)
            {
                DateTime when;
                if (DateTime.TryParse(entry.Value, out when))
                    cooldowns[entry.Key] = when;
            }
        }

        private void SaveData()
        {
            var stored = new Dictionary<string, string>();
            foreach (var entry in cooldowns)
                stored[entry.Key] = entry.Value.ToString("o");
            Interface.Oxide.DataFileSystem.WriteObject(Name, stored);
        }

        private void Unload()
        {
            SaveData();
            warmup.Clear();
        }
    }
}

Hooks used: OnServerInitialized, OnEntitySpawned, OnEntityKill, OnEntityTakeDamage

Config file

Created automatically on first load at oxide/config/BagTeleport.json.

BagTeleport.json
{
  "Warmup seconds": 10,
  "Cooldown seconds": 60,
  "Maximum bags listed": 10
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/bagLists your sleeping bags, beds and beach towels with map grid positions.bagteleport.use
/bag <number>Starts the warmup and teleports you to the bag at that list position.bagteleport.use
Permissions
PermissionGrants
bagteleport.useGrants access to /bag listing and teleporting.
bagteleport.bypassSkips the warmup and cooldown checks.

How the code works

  1. Build a spawn point index

    On startup OnServerInitialized iterates BaseNetworkable.serverEntities, casts each entry to SleepingBag and stores the ones with a non-zero OwnerID in the bags dictionary keyed by net.ID.Value. Afterwards OnEntitySpawned adds newly placed bags, and OnEntityKill removes destroyed ones by the same net ID. Because /bag reads this dictionary, it never walks the whole entity table on a live server.

  2. List bags with grid references

    SendList calls GetOwnedBags, which filters the dictionary down to entities whose OwnerID equals the caller's userID, then orders them by ShortPrefabName and by distance from the player's transform.position. It replies with the header and one line per bag, up to MaxListedBags from config. PositionToGrid divides world coordinates by the 150 metre grid cell using ConVar.Server.worldsize and returns a code such as F12.

  3. Resolve the number argument

    When args is empty the handler calls SendList. Otherwise it parses args[0] with int.TryParse and looks the number up in the exact list returned by GetOwnedBags, so the indices match what the player just saw. A non-numeric argument sends the Usage message; a number outside the list, or a bag that has since been destroyed, sends InvalidNumber. Both come from the language file.

  4. Warmup with a cancel on damage

    Teleporting starts a timer.Once(config.WarmupSeconds, ...) and records the player's userID.Get() in the warmup dictionary. OnEntityTakeDamage checks the victim against that dictionary when it is a BasePlayer, removes the entry and replies with the Cancelled message. When the timer fires it confirms the player is still connected and not dead and the bag still exists, then calls TeleportTo and sends Success.

  5. Cooldown with persistence

    The cooldowns dictionary maps UserIDString to a DateTime.UtcNow timestamp. CooldownReady compares the elapsed time to CooldownSeconds, and a refusal sends the remaining whole seconds. SaveData writes the timestamps to oxide/data/BagTeleport.json after each successful teleport and on Unload, and LoadData restores them in OnServerInitialized. Players with bagteleport.bypass skip both checks and teleport immediately.

  6. Config and messages

    The PluginConfig class holds WarmupSeconds, CooldownSeconds and MaxListedBags behind readable JSON property names, loaded and saved through the standard config overrides. LoadDefaultMessages registers every chat reply, including the list header, item line, usage line, cooldown countdown and cancel notice. All of them are fetched through Msg, which wraps lang.GetMessage with the player's UserIDString.

Install it on your server

  1. Save BagTeleport.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/BagTeleport.json (Carbon: carbon/configs/BagTeleport.json). After editing, reload it:oxide.reload BagTeleport c.reload BagTeleport # Carbon
  4. Grant the permission

    Give it to every player, or swap default for a group such as vip:oxide.grant group default bagteleport.use c.grant group default bagteleport.use # Carbon

Variations

Each one opens the generator with a ready prompt you can tweak.

  • Teleport to team members

    Add a /tpb <name> command that only works when the target is on the same team and uses the same warmup.

    Generate this version
  • Charge a scrap fee

    Take scrap from the player before the warmup and refund it if the teleport is cancelled by damage.

    Generate this version
  • Limit daily uses

    Store a per-player daily counter so each bag teleport is capped to a configurable number per day.

    Generate this version

FAQ

Does this break the vanilla respawn flow?

No. The plugin never touches SleepingBag respawn logic, and it does not remove or modify any bag. It only reads the entity's OwnerID and transform.position and teleports the player with BasePlayer.Teleport. Vanilla sleeping and respawning behave exactly as before.

How do I install it on Oxide or Carbon?

Place BagTeleport.cs in oxide/plugins, then reload the server or run oxide.reload BagTeleport from RCON. The config file appears at oxide/config/BagTeleport.json on first load, and stored cooldowns are written to oxide/data/BagTeleport.json. Carbon loads the same file from its plugins folder.

Why does a bag not show up in my list?

The list only shows entities whose class is SleepingBag, which covers sleeping bags, beds and beach towels. A bag that was placed before the plugin loaded is picked up during OnServerInitialized, so if one is still missing, check that you own it and that it has not been destroyed since it was indexed.

What happens if the player moves during the warmup?

The teleport still lands at the bag position, not at the player's starting spot, because TeleportTo reads target.transform.position when it runs. Only damage cancels the warmup. If you want movement to cancel it as well, store the start position and compare distance in OnPlayerTick.

Are cooldowns kept across a restart?

Yes. The cooldown timestamps are saved through Interface.Oxide.DataFileSystem and reloaded in OnServerInitialized. CooldownReady simply compares each timestamp with the current UTC time, so expired entries no longer block anyone and the dictionary is written again after every successful teleport.

Customize it for your server

Change the reward, add a cooldown, restrict it to VIPs. Start from this prompt and edit anything.

Prompt: Write a Rust Oxide plugin named BagTeleport that lists a player's sleeping bags and beds with map grids on /bag and teleports on /bag <number> after a 10 second warmup and 60 second cooldown, with permissions.
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 to sleeping bag

Customize