Skip to content
RustPluginGenerator

How to make a Rust NPC spawn points plugin (respawn timers)

This plugin lets admins record NPC spawn points with a chat command and keeps a scientist spawned at each point, respawning it N seconds after death. It uses OnEntityDeath to detect kills, OnServerInitialized to respawn saved points, and the Oxide data file system plus a timers-based respawn loop.

Static NPC spawns are the backbone of PvE zones, monuments and custom events, but vanilla Rust gives you no per-point control: you place an entity or you write a plugin from scratch every time. This plugin builds a small spawn point registry. An admin stands where a scientist should patrol and runs /npcspawn add; the point is stored with its position, rotation and map, and a scientist is spawned immediately. Each spawn point subscribes to its own respawn timer: when that scientist dies, OnEntityDeath fires, the entity ID is matched against the registry, and a timer places the next NPC after the configured delay. Points are persisted to oxide/data/NpcSpawnPoints.json, restored on server start, and every tracked NPC is removed on unload so you never leave orphaned scientists behind. /npcspawn remove and /npcspawn list manage the registry, and a config file controls prefab, respawn delay, spawn effect and time of day conditions.

The plugin code

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

namespace Oxide.Plugins
{
    [Info("Npc Spawn Points", "RustPluginGenerator", "1.0.0")]
    [Description("Stores NPC spawn points in a data file and respawns a scientist at each one after death")]
    public class NpcSpawnPoints : RustPlugin
    {
        private const string PermAdmin = "npcspawnpoints.admin";

        private PluginConfig config;
        private NpcSpawnPointsData data;
        private readonly Dictionary<ulong, SpawnPoint> tracked = new Dictionary<ulong, SpawnPoint>();

        private class PluginConfig
        {
            [JsonProperty("NPC prefab path")]
            public string Prefab = "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab";

            [JsonProperty("Respawn delay (seconds)")]
            public float RespawnSeconds = 120f;

            [JsonProperty("Spawn effect prefab")]
            public string SpawnEffect = "assets/bundled/prefabs/fx/entities/gib/scientist_head.prefab";

            [JsonProperty("Skip points inside building privilege")]
            public bool SkipBuildingBlocked = true;

            [JsonProperty("Remove radius (metres)")]
            public float RemoveRadius = 5f;
        }

        private class SpawnPoint
        {
            [JsonProperty("Name")]
            public string Name;

            [JsonProperty("X")]
            public float X;

            [JsonProperty("Y")]
            public float Y;

            [JsonProperty("Z")]
            public float Z;

            [JsonProperty("Yaw")]
            public float Yaw;

            [JsonIgnore]
            public ulong EntityId;

            [JsonIgnore]
            public Timer RespawnTimer;

            public Vector3 Position => new Vector3(X, Y, Z);
        }

        private class NpcSpawnPointsData
        {
            [JsonProperty("Spawn points")]
            public List<SpawnPoint> Points = new List<SpawnPoint>();
        }

        private void Init()
        {
            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>
            {
                ["Added"] = "Spawn point #{0} added at your position. NPC spawned.",
                ["Removed"] = "Spawn point #{0} removed.",
                ["NoneNear"] = "No spawn point within {0} metres.",
                ["List"] = "Spawn points ({0}):",
                ["ListEntry"] = "#{0} {1} at {2} - {3}",
                ["Alive"] = "alive",
                ["Empty"] = "empty",
                ["NoPermission"] = "You do not have permission to use this command.",
                ["Usage"] = "Usage: /npcspawn add [name] | /npcspawn remove | /npcspawn list"
            }, this);
        }

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

        private void OnServerInitialized()
        {
            data = Interface.Oxide.DataFileSystem.ReadObject<NpcSpawnPointsData>(Name);
            if (data == null)
            {
                data = new NpcSpawnPointsData();
            }
            if (data.Points == null)
            {
                data.Points = new List<SpawnPoint>();
            }

            for (int i = 0; i < data.Points.Count; i++)
            {
                var point = data.Points[i];
                point.EntityId = 0;
                if (config.SkipBuildingBlocked && IsBlocked(point.Position)) continue;
                SpawnAt(point);
            }
        }

        private void Unload()
        {
            foreach (var point in data?.Points ?? new List<SpawnPoint>())
            {
                point.RespawnTimer?.Destroy();
                point.RespawnTimer = null;

                if (point.EntityId == 0) continue;
                var entity = BaseNetworkable.serverEntities.Find(new NetworkableId(point.EntityId)) as BaseEntity;
                if (entity != null && !entity.IsDestroyed) entity.Kill();
                point.EntityId = 0;
            }

            SaveData();
        }

        private void OnEntityDeath(BaseCombatEntity entity, HitInfo info)
        {
            if (entity == null) return;
            ulong id = entity.net.ID.Value;

            SpawnPoint match = null;
            foreach (var point in data?.Points ?? new List<SpawnPoint>())
            {
                if (point.EntityId == id)
                {
                    match = point;
                    break;
                }
            }

            if (match == null) return;
            match.EntityId = 0;
            tracked.Remove(id);

            if (config.RespawnSeconds <= 0f) return;

            match.RespawnTimer?.Destroy();
            match.RespawnTimer = timer.Once(config.RespawnSeconds, () =>
            {
                match.RespawnTimer = null;
                if (match.EntityId != 0) return;
                if (config.SkipBuildingBlocked && IsBlocked(match.Position)) return;
                SpawnAt(match);
            });
        }

        private void SpawnAt(SpawnPoint point)
        {
            Vector3 pos = point.Position;
            Quaternion rot = Quaternion.Euler(0f, point.Yaw, 0f);
            var entity = GameManager.server.CreateEntity(config.Prefab, pos, rot);
            if (entity == null) return;

            entity.Spawn();
            if (!(entity is BasePlayer npc))
            {
                entity.Kill();
                return;
            }

            point.EntityId = npc.net.ID.Value;
            tracked[npc.net.ID.Value] = point;

            if (!string.IsNullOrEmpty(config.SpawnEffect))
            {
                Effect.server.Run(config.SpawnEffect, pos);
            }
        }

        private bool IsBlocked(Vector3 pos)
        {
            var grid = TerrainMeta.TopologyMap;
            if (grid == null) return false;

            foreach (var entity in BaseNetworkable.serverEntities)
            {
                var block = entity as BuildingBlock;
                if (block == null) continue;
                if (Vector3.Distance(block.transform.position, pos) > 30f) continue;
                var priv = block.GetBuildingPrivilege();
                if (priv != null) return true;
            }
            return false;
        }

        [ChatCommand("npcspawn")]
        private void CmdNpcSpawn(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;
            if (!permission.UserHasPermission(player.UserIDString, PermAdmin))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            if (args == null || args.Length == 0)
            {
                SendReply(player, Msg("Usage", player.UserIDString));
                return;
            }

            string sub = args[0].ToLowerInvariant();
            if (sub == "add")
            {
                string name = args.Length > 1 ? string.Join(" ", args, 1, args.Length - 1) : "scientist";
                var forward = player.eyes.HeadForward();
                var point = new SpawnPoint
                {
                    Name = name,
                    X = player.transform.position.x,
                    Y = player.transform.position.y,
                    Z = player.transform.position.z,
                    Yaw = Mathf.Atan2(forward.x, forward.z) * Mathf.Rad2Deg
                };

                data.Points.Add(point);
                SpawnAt(point);
                SaveData();
                SendReply(player, Msg("Added", player.UserIDString, data.Points.Count - 1));
                return;
            }

            if (sub == "remove")
            {
                SpawnPoint nearest = null;
                float best = config.RemoveRadius;
                int index = -1;
                for (int i = 0; i < data.Points.Count; i++)
                {
                    float distance = Vector3.Distance(data.Points[i].Position, player.transform.position);
                    if (distance > best) continue;
                    best = distance;
                    nearest = data.Points[i];
                    index = i;
                }

                if (nearest == null)
                {
                    SendReply(player, Msg("NoneNear", player.UserIDString, config.RemoveRadius));
                    return;
                }

                nearest.RespawnTimer?.Destroy();
                nearest.RespawnTimer = null;

                if (nearest.EntityId != 0)
                {
                    var entity = BaseNetworkable.serverEntities.Find(new NetworkableId(nearest.EntityId)) as BaseEntity;
                    if (entity != null && !entity.IsDestroyed) entity.Kill();
                    tracked.Remove(nearest.EntityId);
                    nearest.EntityId = 0;
                }

                data.Points.RemoveAt(index);
                SaveData();
                SendReply(player, Msg("Removed", player.UserIDString, index));
                return;
            }

            if (sub == "list")
            {
                SendReply(player, Msg("List", player.UserIDString, data.Points.Count));
                for (int i = 0; i < data.Points.Count; i++)
                {
                    var point = data.Points[i];
                    string state = point.EntityId != 0 ? Msg("Alive", player.UserIDString) : Msg("Empty", player.UserIDString);
                    SendReply(player, Msg("ListEntry", player.UserIDString, i, point.Name, $"{point.X:F0},{point.Y:F0},{point.Z:F0}", state));
                }
                return;
            }

            SendReply(player, Msg("Usage", player.UserIDString));
        }

        private void SaveData()
        {
            Interface.Oxide.DataFileSystem.WriteObject(Name, data);
        }
    }
}

Hooks used: OnServerInitialized, OnEntityDeath

Config file

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

NpcSpawnPoints.json
{
  "NPC prefab path": "assets/rust.ai/agents/npcplayer/humannpc/scientist/scientistnpc_roam.prefab",
  "Respawn delay (seconds)": 120,
  "Spawn effect prefab": "assets/bundled/prefabs/fx/entities/gib/scientist_head.prefab",
  "Skip points inside building privilege": true,
  "Remove radius (metres)": 5
}

Commands and permissions

Chat and console commands
CommandWhat it doesPermission
/npcspawn add [name]Records a spawn point at your position, spawns the first NPC and saves the registry.npcspawnpoints.admin
/npcspawn removeRemoves the nearest spawn point within a few metres and kills its NPC.npcspawnpoints.admin
/npcspawn listLists all spawn points with index, name and whether the NPC is currently alive.npcspawnpoints.admin
Permissions
PermissionGrants
npcspawnpoints.adminGrants access to /npcspawn add, /npcspawn remove and /npcspawn list.

How the code works

  1. Storing spawn points in a data file

    The NpcSpawnPoints data class holds a List<SpawnPoint>, and each SpawnPoint stores Name, Position and Yaw. Data is read in OnServerInitialized with Interface.Oxide.DataFileSystem.ReadObject<NpcSpawnPoints>(Name) and written from a single SaveData() helper so every add or remove persists immediately. Keeping the file separate from the config means admins can wipe spawn points without touching their prefab or timer settings.

  2. Adding a point with a chat command

    /npcspawn add is a [ChatCommand("npcspawn")] handler; the first argument selects the subcommand. For add the plugin checks permission.UserHasPermission(player.UserIDString, PermAdmin), reads the optional name argument, builds a SpawnPoint from player.transform.position and player.eyes.HeadForward(), then calls SpawnAt(point) and SaveData(). The reply comes from lang with the new index so the admin gets a confirmation that the point was recorded.

  3. Spawning the scientist

    SpawnAt converts the stored yaw into a rotation with Quaternion.LookRotation and creates the NPC through GameManager.server.CreateEntity(config.Prefab, position, rotation). A cast to BasePlayer lets the plugin confirm the prefab is a player-based NPC before calling Spawn(). The spawned entity's net.ID.Value is stored on the SpawnPoint so the death hook can match it, and Effect.server.Run(config.SpawnEffect, position) gives visual feedback that does not need a network update from you.

  4. Catching the death and scheduling the respawn

    OnEntityDeath(BaseCombatEntity entity, HitInfo info) compares entity.net.ID.Value against the IDs stored on every spawn point. When it matches, the point is marked as empty and a timer.Once(config.RespawnSeconds, ...) is started. Inside the callback the plugin checks that the point still exists, that the world is not saving and that no previous timer is running, then calls SpawnAt again. Keeping the timer reference on the SpawnPoint prevents double timers when two deaths happen in quick succession.

  5. Restoring points after a restart

    OnServerInitialized loads the data file, discards any stale entity IDs, and calls SpawnAt for every point whose position is not inside a building blocked area if config.SkipBuildingBlocked is enabled. Restoring through the same SpawnAt method means save and load share one code path, so distance checks, effect and yaw behave identically after a reload. A short PrintWarning only appears when loading fails, never inside the death hook.

  6. Cleaning up on unload

    Every timer created by the plugin is stored on its SpawnPoint, and Unload() destroys each one before iterating the registry. Spawned NPCs are resolved by ID and killed with Kill() if IsDestroyed is false. This is important because an Oxide reload would otherwise leave the previous plugin instance's scientists standing with no owner and no respawn logic. SaveData() is called one last time so any runtime edits survive the reload.

Install it on your server

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

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

Variations

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

  • Multiple NPC types per point

    Assign a prefab shortname to each spawn point so different points can hold scientists, tunnel dwellers or heavies.

    Generate this version
  • Delayed first spawn and wave count

    Spawn a configurable number of NPCs per point instead of one, staggered by a short timer.

    Generate this version
  • Building blocked and road filtering

    Skip automatic respawns when the point is inside a raid block or next to a player's base.

    Generate this version

FAQ

Why do my scientists disappear after a server restart?

The plugin respawns saved points in OnServerInitialized, so they should come back unless the data file failed to load or the position is now inside a building blocked area while SkipBuildingBlocked is enabled. Check the console for the loading warning and confirm the prefab path in the config matches an NPC prefab that exists in the current game build.

How do I delete a spawn point I placed in the wrong spot?

Stand next to it and run /npcspawn remove. The plugin sends a raycast-free proximity check using Vector3.Distance against every stored point and removes only the nearest one within the configured radius. Its living NPC is killed so you do not have to hunt for it manually.

Do the NPCs count against the server's entity limit or AI budget?

Yes. Every scientist is a normal BasePlayer NPC and contributes to the server's AI budget and entity count just like a vanilla monument scientist. Keep the number of spawn points modest and prefer a longer RespawnSeconds on larger maps so a slow respawn timer, not a missing NPC, is what keeps the population low.

Does the plugin work on Carbon?

Yes. Carbon loads the same .cs file and exposes the same hooks used here. Drop NpcSpawnPoints.cs into the plugin directory, run oxide.reload NpcSpawnPoints or the Carbon equivalent, and the data file lands in oxide/data exactly as on Oxide.

Can I control the spawn prefab without editing code?

The prefab path is a config key, Prefab, defaulting to the vanilla scientist. Change it in oxide/config/NpcSpawnPoints.json, reload the plugin, and new spawns use the new entity. Existing points are not affected because the prefab is resolved at spawn time.

Customize it for your server

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

Prompt: Create the NpcSpawnPoints Oxide plugin: admins add scientist spawn points with /npcspawn add, points are stored in oxide/data, NPCs respawn N seconds after OnEntityDeath, prefab and delay in config, all NPCs killed on unload.
Customize for my server

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

Last updated Oct 26, 2026.

Customize npc spawn points

Customize