Files
docs/runicnpc/API.md
wtclaude 6d319c843b docs(runicnpc): stage 6 built and walked; API 5; protocol §19.14
API.md version 5: the profile's boss and use blocks, the passive role
and stand, the boss hooks and OnRunicNpcUsed, role and boss in
RunicNpc_List, RunicNpc_Despawn taking a boss's adds, the example kits
written once, and a Kits reload taking RunicNPC with it (D283).

PROTOCOL.md §19.14: npc.boss.spawned, npc.boss.phase and npc.boss.died,
their classes, and runicnpc_api 5.

PLAN.md stage 6: what was built, the builds it was tested on, the
harness and the site walk on both rigs, and what building it found.
The example kits' permission is kits.runicnpc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-05 13:12:16 -05:00

26 KiB
Raw Blame History

RunicNPC — the API

API version 5 (RunicNPC stage 6, 2026-10-05). This is the reference for other plugins. Why it has this shape is in PLAN.md §4 and the decisions D221–D238, D249, D253–D272 and D273–D289. API 5 added the profile's boss and use blocks and the passive role with its stand movement, the hooks OnRunicNpcBossSpawned, OnRunicNpcBossPhase, OnRunicNpcBossDied and OnRunicNpcUsed, and role and boss in RunicNpc_List (PLAN.md stage 6). Nothing of API 4 changed shape. API 4 added the faction table (RunicNpc_Factions, RunicNpc_SetFactions), orders for one NPC (RunicNpc_Escort, RunicNpc_Ally, RunicNpc_Tether), the hook OnRunicNpcEscortEnded, the profile's stage 5 fields, a placement's tether, and more in RunicNpc_List (PLAN.md stage 5). Nothing of API 3 changed shape. API 3 added RunicNpc_AddPlacement, RunicNpc_RenamePlacement, RunicNpc_RespawnPlacement and the hook OnRunicNpcPlacementChanged (stage 4).

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).

[PluginReference] private Plugin RunicNPC;

int api = RunicNPC?.Call<int>("RunicNpc_ApiVersion") ?? 0;
if (api < 5) { /* 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:<id> An event run, through the bridge Never saved. Removed by the caller, or by RunicNpc_DespawnOwner.
plugin:<name> Any other plugin Never saved. Removed when that plugin unloads. Use your plugin's own name.
placement:<id> 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: 5.

RunicNpc_Spawn(Vector3 at, string profile, string owner, JObject overrides) → BasePlayer

Spawns one NPC now, or returns null.

  • owner must be run:<id> or plugin:<name>.
  • 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. For a boss (API 5), it also removes the adds that boss summoned, even once the boss is dead, and counts them in what it returns: an event's teardown removes what its step placed by net id, and a boss's adds are in no step's ledger (D287).

RunicNpc_DespawnOwner(string owner) → int

Removes every NPC of owner, and returns how many it removed.

RunicNpc_List(string owner) → List<Dictionary<string, object>>

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:<name>.
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).
faction string API 4. Its faction, or profile:<name> when its profile has none (D256).
target string API 4. The prefab of the NPC, animal or turret it is fighting itself, or null (players are Rust's design's).
engaged bool API 4. Whether it is fighting that target now (Rust's design fights players first).
escort string API 4. Whom it escorts: a player's Steam id, or an entity's prefab; null for none.
ally string API 4. clan:<id>, team:<id> or player:<steam id>, or null.
tether string API 4. The ZoneManager zone it is held in, or null.
shotsMarked int API 4. Shots it made at an NPC target (each marks the target Horror for that shot only).
kitUsed object API 4. heals, throws, rockets and meleeHits: the kit's extras it has used (D260).
role string API 5. Its profile's role: roamer, sentry, guard or passive.
boss object API 5. For a boss, phase (0 before the first, then 1, 2 …) and damagers (how many players have hurt it); null for any other NPC.

RunicNpc_Profiles() → JObject

{"managed": bool, "profiles": {name: profile, …}, "factions": [pair, …], "refused": {name: reason, …}}. A refused profile is kept in the file but never spawns (a kit the server lacks, for example). factions is the faction table (API 4).

RunicNpc_SetProfiles(JObject all) → Dictionary<string, string>

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<Dictionary<string, object>>

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_AddPlacement(JObject placement) → Dictionary<string, object>

API 3. Creates a placement and names it as rnpc place does: the profile's name and the first free number, warden-3 (D241, D246). It is checked and refused exactly as rnpc place is, with the same sentences, so a placement made from a website and one made in game can never disagree.

  • placement is a placement (below) without an id. A position without y is a point on a map (D245): it is put on the ground there by a ray down on terrain and rock, never a building or a tree (a roof is placed in game), then checked against the navmesh like any other. A point off the map, or under water, is refused.
  • The answer is {id, position, built, cost}: the name given, where it landed, whether that spot is on something players built (D239's warning), and the cost warning (D227). Or {error}, the sentence rnpc place would have said.

RunicNpc_RenamePlacement(string from, string to) → string

API 3. Renames a placement, as rnpc rename: its live NPCs keep living under the new name. Null on success, or why not (no such placement, the name taken, not a name).

RunicNpc_RespawnPlacement(string id) → int

API 3. Removes a placement's NPCs and spawns them again at once, as rnpc respawn. How many, or -1 if there is no such placement.

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_Factions() → JArray

API 4. The faction table (D254): [{"a": "bandits", "b": "guards", "relation": "hostile"}, …], one row per pair, both ways (D268). A pair not listed is neutral, and a faction is always allied to itself.

RunicNpc_SetFactions(JArray pairs) → string

API 4. Replaces the faction table, as a site does with every profile push, and marks the server managed (D221). Null on success; otherwise the first problem, and the old table is kept. Refused: a pair of one faction with itself, a pair of scientists and animals (RunicNPC does not change how Rust's NPCs treat each other), a relation other than hostile, neutral or allied, and a pair given twice in either order.

RunicNpc_Escort(ulong netId, BaseEntity target) → string

API 4 (D258, D269). One of ours escorts target: a player, or any other entity (a crate it guards, a vehicle). It keeps within a few metres, never targets it, and fights whoever damages it. When the target dies, is destroyed, or (a player) leaves the server, it walks back to its spot, carries on as its profile does there, and OnRunicNpcEscortEnded is raised. A null target stops it. Null on success; refused for a sentry (it never moves) and for a player who is not on the server.

RunicNpc_Ally(ulong netId, string kind, string id) → string

API 4 (D257, D270). One of ours is allied to clan (the game's own clan id), team (a Rust team id), or player (a Steam id: that player, and their team if they have one), or to nobody with none. It never targets the ally's people, and defends them and what they own (building blocks, doors, deployables): whoever damages one of them within its leash is fought. Rust's own clans and teams only. Null on success; refused for a clan or team the server does not have.

RunicNpc_Tether(ulong netId, string zone) → string

API 4 (D272). Holds one of ours inside a ZoneManager zone: it never chases or wanders out, and if it ends up outside (pushed, or the zone moved) it forgets the fight and walks home. Null frees it. Refused for a sentry, on a server without ZoneManager, for a zone the server does not have, and for a zone that does not hold the NPC.

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<ulong, float> 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.
OnRunicNpcPlacementChanged(string id, string change, string previous) API 3. Whenever a placement is set, removed or renamed, through the API or in game. change is set, removed or renamed; previous is the old name for a rename and null otherwise.
OnRunicNpcEscortEnded(BasePlayer npc, string profile, string owner) API 4. Whom it escorted died, was destroyed or left the server, and it is walking back to its spot (D269).
OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) API 5. After OnRunicNpcSpawned, for a profile with a boss block. Its spawn line has been said.
OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) API 5. It entered a phase: phase counts from 1, at is the phase's health fraction. Raised after the phase's changes are applied and its line is said. A hit that crosses two thresholds raises both, highest first.
OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary<ulong, float> contributors) API 5. After OnRunicNpcDied, for a boss, with the same contributors. Its death line has been said. RunicNPC gives no reward itself (D276, D285): an event's own reward steps do.
OnRunicNpcUsed(BasePlayer npc, string profile, BasePlayer player) API 5. A player pressed E on a passive NPC and was answered (D278). At most once a second per player.

Data shapes

A profile (D238)

{
  "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],
  "faction": "bandits",
  "relations": { "scientists": "hostile" },
  "alertRadius": 40,
  "turrets": "default",
  "hurtByPlayers": true,
  "hurtsPlayers": true,
  "kitUse": { "heal": false, "grenades": false, "melee": false, "rockets": false, "flamethrower": false },
  "boss": {
    "barDistance": 100,
    "announce": "chat",
    "spawnLine": "{name} has risen!",
    "deathLine": "{name} has fallen to {killer}.",
    "phases": [
      { "at": 0.5, "adds": { "profile": "raider", "count": 3 }, "line": "{name} calls for help!" },
      { "at": 0.25, "damageDealt": 1.5, "speed": 1.3, "kit": "warden_heavy", "line": "{name} is enraged!" }
    ]
  },
  "use": null
}

A passive profile has no boss and a use block:

{
  "names": ["Quartermaster"], "kits": ["outpost_clothes"], "role": "passive",
  "movement": { "mode": "stand", "radius": 0 },
  "hurtByPlayers": false,
  "use": { "mode": "chat", "lines": ["Back again, {player}?", "Supplies are short."], "title": "", "text": "" }
}
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, sentry, guard or passive A sentry never moves, and may stand off the navmesh. A guard (API 4) holds its spot facing the way it was placed, chases within its chase range, and walks back. A passive NPC (API 5) never fights: it takes no target, does not shoot back and raises no alarm, and pressing E on it does what its use says (D278, D279).
movement.mode wander, monument, route:<name>, or stand for a passive profile only D233. A placement may override it. A passive NPC that stands (D288) holds its spot as a sentry does, may stand off the navmesh, and turns to whoever presses E on 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.
faction API 4. null, or 1–40 of a-z, 0-9, _ and -, not scientists or animals D254. Profiles of one faction are allies. With none, its allies are its own profile's NPCs (D256).
relations API 4. keys: a faction, scientists, animals or profile:<name>; values hostile, neutral or allied Its own exceptions, which win over the faction table (D254). A profile with no faction settings fights players only (D255).
alertRadius API 4. 0 or more; default 40 D256. Allies this close learn whoever attacked one of them, seen or not. 0 is off.
turrets API 4. default, ignore or always D259, D264, D267. default: as Rust's scientists (player turrets shoot it, safe-zone sentries do not). ignore: no turret targets it. always: Outpost's and Bandit Camp's sentries too, through a Harmony patch that answers for these NPCs only. It shoots back at a turret that shoots it.
hurtByPlayers, hurtsPlayers API 4. true or false; default true, except hurtByPlayers for a passive profile, which is false unless it is given (API 5, D279) D261, D271. Off makes it unkillable by players, or harmless to them. RunicNPC enforces both itself, and answers TruePVE's and NextGenPVE's CanEntityTakeDamage the same way. Turrets, fire and other NPCs are not affected.
kitUse API 4. heal, grenades, melee, rockets, flamethrower, each true or false D260. The kit's extras it uses, each only if true and each used up from its inventory. The main weapon (the belt's first slot) is always used and never runs out (D265).

| boss | API 5. null, or the block below; not on a passive profile | D273. Makes any roamer, sentry or guard a boss, which keeps moving and fighting as its role does. | | boss.barDistance | above 0, at most 1000; default 100 | D274. Players this close see its health bar at the top of the screen, and so does every player who has hurt it, wherever they are, until it dies or despawns. A player near two bosses sees the nearer. | | boss.announce | chat or popup; default chat | D277. Where its lines are said. popup uses PopupNotifications where it is loaded and chat where it is not. | | boss.spawnLine, boss.deathLine | at most 256 characters; blank says nothing | D286. Said to everyone on the server. {name} is its name; in the death line {killer} is the killing blow's player (empty when no player). | | boss.phases | a list; each at a fraction between 0 and 1, no two alike | D275. When its health falls to at, the phase applies from then on. Every other part is optional. | | phases[].damageDealt, aimCone | 0 or more | Replace the profile's. | | phases[].speed | above 0, at most 5 | A multiple of Rust's own speed for it: 1.3 is a third faster. | | phases[].ranges | as the profile's ranges | Replace the profile's. | | phases[].kit | a Kits kit the server has | A kit swap: its inventory is stripped, the kit given and its weapon equipped. | | phases[].adds | profile: a profile that is not a boss; count: 1 to 20 | NPCs of that profile placed on a 6 m ring around it, one per server frame. They belong to its owner (an event's teardown removes them) and fight on after it dies (D287). | | phases[].line | at most 256 characters | D286. Said only to the players near it (within barDistance) and to those who have hurt it. | | use | API 5. required on a passive profile, refused on any other | D278. What pressing E on it does, from up to 3 m: mode chat says one of lines (picked at random, each at most 256 characters) in that player's chat; mode window opens a window with title (blank: its name), text (at most 2,000 characters) and a Close button. {player} in either is the player's name. |

A phase changes that boss's own copy of its profile; another NPC of the same profile is not touched. A boss gives no reward itself (D276, D285).

Whom it fights besides players is its own sensing (stage 5): only the factions it is hostile to, within its sense range of itself and its chase range of its spot (D263), so it never walks into a monument to look for scientists. Rust's scientists never fight back (Rust's own rule); they take cover. Whoever shoots one of ours, it fights back, whatever the table says (D268).

A placement

{
  "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.
  • tether (API 4) is optional: a ZoneManager zone its NPCs never leave (D272). Setting a placement with a tether the server does not have, or one that does not hold the spot, is refused; a placement whose zone later goes waits, as one whose profile has gone does (D237).

A route

{ "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, profiles and, from API 4, factions), 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.
  • data/RunicNPC/state.json (API 5): examplesWritten, set on the first load. That load writes the four example kits (rnpc_raider, rnpc_campguard, rnpc_sniper, rnpc_juggernaut) into Kits' own data file, each where its name is free and each needing the permission kits.runicnpc (Kits' prefix, since Kits registers it), which nobody is granted, so no player can claim one. On a server no site manages it also adds the four example profiles (raider, campguard, sniper, juggernaut). Then it reloads Kits, which reloads RunicNPC with it. Nothing is written again, even if you delete them (D280, D284, D289).
  • A Kits reload reloads RunicNPC on both frameworks, because RunicNPC requires Kits, and the NPCs it had spawned are lost: placements come back, an event's NPCs do not. Do not reload Kits while an event runs (D283).
  • 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; from API 4 also the faction pairs, the scientists and animals it tracks, how many of ours are fighting an NPC, the alarms and defences raised, what its own fight costs a think, and whether the sentries are patched (turrets: always).
  • rnpc.faction list | set <a> <b> hostile|neutral|allied | clear <a> <b> (console) edits the faction table on a standalone server; refused while a site manages it. /rnpc follow <placement> <player|me|off> makes a placement's live NPCs escort a player, for trying it out (in memory only: a respawn or reload ends it). /rnpc place … tether=<zone> holds a placement inside a zone.
  • rnpc.profile set <name> boss on|none and rnpc.profile set <name> use on|none (API 5) add a profile's boss box or press-E block with its defaults, or remove it. Their fields are then set one at a time (boss.spawnLine The Juggernaut has risen!), and a list of objects as JSON (boss.phases [{"at":0.5,"line":"…"}]). role passive also turns hurtByPlayers off (D279).