# RunicNPC — the API **API version 2** (RunicNPC stage 2, 2026-09-30). This is the reference for other plugins. Why it has this shape is in [PLAN.md](PLAN.md) §4 and the decisions D221–D238. RunicNPC is called by name through `Call`, like any Oxide or Carbon plugin API. Every call is prefixed `RunicNpc_`, so no hook of another plugin is ever matched by accident. Every call is a non-public method, which is what Oxide's `Call` reaches by name (PLAN.md §1.2). ```csharp [PluginReference] private Plugin RunicNPC; int api = RunicNPC?.Call("RunicNpc_ApiVersion") ?? 0; if (api < 2) { /* too old for this caller: say so */ } BasePlayer npc = RunicNPC.Call("RunicNpc_Spawn", position, "warden", "plugin:MyPlugin", null) as BasePlayer; ``` **The version moves when a call or a raised hook changes shape**, not on every release. `plugin.toml` in the repository declares the same number, and the release's `manifest.json` carries it, so an installer or the bridge can refuse a RunicNPC that is too old before it is loaded. A call that cannot do what it was asked **returns null (or 0, or false) and says why in the server log**. Calls that change data return the reason as a string instead, and null means success. --- ## Owners Every NPC has an owner, and the owner decides its lifetime (PLAN.md §2). | Owner | Who uses it | Lifetime | |---|---|---| | `run:` | An event run, through the bridge | Never saved. Removed by the caller, or by `RunicNpc_DespawnOwner`. | | `plugin:` | Any other plugin | Never saved. **Removed when that plugin unloads.** Use your plugin's own name. | | `placement:` | RunicNPC itself | The NPC is never saved; the placement is, and spawns a fresh one at boot. Callers cannot spawn with this owner. | --- ## Calls ### `RunicNpc_ApiVersion()` → `int` The API version: `2`. ### `RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides)` → `BasePlayer` Spawns one NPC now, or returns null. - `owner` must be `run:` or `plugin:`. - `overrides` may change any profile value for this NPC only, in the profile's own shape (below). It may be null. For example: `{"health": 500, "names": ["Captain"], "movement": {"mode": "route:gate", "radius": 0}}`. Arrays are replaced, not merged. The merged profile is validated like a saved one. - A roamer must stand on Rust's navmesh: within 3 m across and 2 m up or down of `at`. A sentry may stand anywhere. - It is refused until the map's navmesh is built, which takes 8–10 minutes on a map's first boot. Refusals: an unknown or refused profile, a bad owner, a failed override, a spot off the navmesh, a cap an admin has set, or a navmesh that is not built yet. ### `RunicNpc_Despawn(ulong netId)` → `int` Removes one NPC by its net id. Returns 1, or 0 if it is not one of RunicNPC's. A placement's NPC comes back after its respawn delay. ### `RunicNpc_DespawnOwner(string owner)` → `int` Removes every NPC of `owner`, and returns how many it removed. ### `RunicNpc_List(string owner)` → `List>` The live NPCs of `owner`; null lists them all. Each has: | Key | Type | | |---|---|---| | `netId` | `ulong` | | | `profile`, `name`, `owner`, `kit` | `string` | The kit is the one picked for this NPC. | | `placement` | `string` | Its placement's id, or null. | | `position`, `home` | `{x, y, z}` | `home` is where it spawned: where it wanders around, and walks back to before it sleeps. | | `health`, `maxHealth` | `float` | | | `movement` | `string` | `wander`, `monument` or `route:`. | | `rest` | `string` | `Awake`, `GoingHome` or `Asleep` (D235). | | `state` | `string` | Rust's AI state: `Idle`, `Roam`, `Chase`, `Combat` and so on. | | `leashes` | `int` | How many times it has given up a chase at its chase range. | | `regrounded` | `int` | How many times its ground went from under it and it was put on the nearest navmesh (D239). | ### `RunicNpc_Profiles()` → `JObject` `{"managed": bool, "profiles": {name: profile, …}, "refused": {name: reason, …}}`. A refused profile is kept in the file but never spawns (a kit the server lacks, for example). ### `RunicNpc_SetProfiles(JObject all)` → `Dictionary` Replaces the whole profile set with `all` (`{name: profile, …}`), as a site does on every push, and **marks the server managed** (D221). It returns the profiles refused and why; the rest are in use at once. A placement whose profile has gone waits, and spawns again when the profile returns (D237). ### `RunicNpc_Placements()` → `List>` Each placement as `{id, placement, alive, waiting, lastError, note}`. `placement` is the placement's JSON (below), `alive` is how many of its NPCs are alive, and `waiting` says why it cannot spawn right now, or is null. `note` is set while its NPCs stand somewhere other than its spot because the ground there went (D239, below), or is null. ### `RunicNpc_SetPlacement(string id, JObject placement)` → `string` Adds or replaces a persistent placement (D222). Null on success, or the reason. Replacing one despawns its NPCs and spawns them again from the new values. Every set logs the cost warning (D227). A roamer's spot must be on Rust's navmesh, within 2 m up or down, when it is set; otherwise the answer says how far off it is, as `rnpc place` does (D219). The check is skipped while the map's navmesh is still being built, and for a profile that does not exist yet. **If the ground under a placement goes later** (a player-built floor is destroyed, D239), a live NPC is put on the nearest navmesh within 2 s, and the placement respawns its NPCs on the nearest navmesh until its spot is walkable again; `note` says so meanwhile. Rust itself leaves such an NPC standing in the air. ### `RunicNpc_RemovePlacement(string id)` → `bool` Removes a placement and its NPCs. ### `RunicNpc_Routes()` → `JObject` `{name: route, …}`. ### `RunicNpc_SetRoute(string name, JObject route)` → `string` Adds or replaces a route (D234). Every point must be on Rust's navmesh. Null on success, or the reason. Unlike `rnpc path` (D240), it does not check that an NPC can walk from each point to the next: a leg Rust cannot path leaves the NPC standing at the last point it reached, so a caller that builds routes should check its own legs. ### `RunicNpc_RemoveRoute(string name)` → `bool` Removes a route. Placements that walk it wait until it exists again. ### `RunicNpc_IsRunicNpc(BaseEntity entity)` → `bool` Whether `entity` is one of RunicNPC's NPCs. ### `RunicNpc_ProfileOf(BaseEntity entity)` → `string` Its profile's name, or null. ### `RunicNpc_CostWarning(int adding)` → `string` The cost warning (D227) for the NPCs the server plans, plus `adding`. It is built from stage 1's measurements (PLAN.md §9), and says what the NPCs add to a server frame, idle and fighting, and how slowly each then reacts. A caller that adds NPCs shows it to whoever added them. --- ## Hooks it raises All are called on every plugin, and **none of them answers**: a return value is ignored. | Hook | When | |---|---| | `OnRunicNpcSpawned(BasePlayer npc, string profile, string owner)` | After an NPC spawns and is equipped. | | `OnRunicNpcHealth(BasePlayer npc, string profile, float threshold)` | The first time its health falls to one of the profile's `healthThresholds`. | | `OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors)` | When it dies. `contributors` maps each player's Steam id to the health that player took from it, the killing blow included, after Rust's protection. It never adds up to more than its health. | | `OnRunicNpcDespawned(BasePlayer npc, string owner)` | When it is removed without dying. Not raised as RunicNPC itself unloads. | --- ## Data shapes ### A profile (D238) ```json { "names": ["Warden", "Old Warden"], "kits": ["warden_rifle", "warden_smg"], "prefab": "scientistnpc_roam", "role": "roamer", "movement": { "mode": "wander", "radius": 20 }, "health": 250, "damageDealt": 1.0, "damageTaken": { "head": 1.0, "body": 1.0, "legs": 1.0 }, "aimCone": 2.0, "ranges": { "sense": 30, "loseTarget": 40, "chase": 40, "attack": 30 }, "visionCone": -0.8, "sleepDistance": 160, "healthThresholds": [0.5] } ``` | Field | Rule | Meaning | |---|---|---| | `names` | at least one | One is picked for each NPC. | | `kits` | at least one; each must exist in Kits | One is picked for each NPC, and it is equipped with it (D217). | | `prefab` | a Rust `scientistnpc_*` prefab that is a plain scientist | What it is built from. | | `role` | `roamer` or `sentry` | A sentry never moves, and may stand off the navmesh. | | `movement.mode` | `wander`, `monument` or `route:` | D233. A placement may override it. | | `movement.radius` | above 0 for `wander` | How far from home it wanders. | | `health` | above 0 | Its health, and its maximum. | | `damageDealt` | 0 or more | Multiplies its weapon's damage; 1 is the weapon's full damage. | | `damageTaken` | each 0 or more | Multiplies the damage it takes to the head, the legs (legs and feet), and the body (everything else, including hits with no body part, like fire and explosions). | | `aimCone` | 0 or more | How much its aim spreads; Rust's scientists use 2. | | `ranges.sense` | above 0 | How far it notices players. | | `ranges.loseTarget` | at least `sense` | How far a target must get before it is forgotten. | | `ranges.chase` | 0 or more | How far from home it will chase (0: no limit). At that distance it waits 5 s for the target to come within range, then gives up and walks home. | | `ranges.attack` | above 0 | How far it shoots from. This replaces the weapon's own range. | | `visionCone` | −1 to 1 | Rust's vision cone. | | `sleepDistance` | 0 or more | D235: with no player this close it walks home, then sleeps. 0: it never sleeps. | | `healthThresholds` | fractions between 0 and 1 | Where `OnRunicNpcHealth` is raised. | ### A placement ```json { "profile": "warden", "position": { "x": 100.0, "y": 12.5, "z": -340.0 }, "yaw": 90, "count": 3, "respawn": 300, "respawnMode": "each", "movement": { "mode": "route:gate", "radius": 0 } } ``` - `count` NPCs spawn at the spot, and all but the first are scattered within 3 m of it. - `respawn` is in seconds, at least 1. `respawnMode` is `each` (every NPC returns `respawn` after its own death) or `group` (none returns until all are dead, then all return together) (D236). - `movement` is optional and overrides the profile's. ### A route ```json { "points": [ { "x": 1, "y": 2, "z": 3 }, { "x": 4, "y": 2, "z": 6 } ], "loop": true } ``` At least two points, each on Rust's navmesh. With `loop`, the last point leads back to the first; without it, the route is walked back and forth. An NPC joins the route at its nearest point. --- ## Files and config - `data/RunicNPC/profiles.json` (`managed` and `profiles`), `placements.json` and `routes.json`, under `oxide/` or `carbon/`. On a standalone server profiles are edited in the file, then `rnpc.reload` reads them again. On a managed one the site pushes them. - `config/RunicNPC.json`: - `caps`: `total`, `perOwner`, `perProfile` and `spawnsPerSecond`. **Each is 0, meaning no cap, until an admin sets it** (D227). - `spawnBudgetMs`: how many milliseconds of each server frame placements may spend spawning. The default is 8. - `rnpc.status` at the console reports the swap's field check, the NPCs by owner and rest, profiles refused, placements waiting, the caps, and the cost warning.