Server-owned NPCs
Server-owned NPCs: templates, streaming, authority leases, the task queue, health and damage, events, the read-only client API, and the current limitations.
CyberM NPCs are canonical server entities projected into REDengine only for nearby players. A Lua resource creates and owns the canonical NPC; the server controls identity, routing bucket, health, tasks and simulation authority. Clients cannot create or mutate canonical NPCs.
The reference implementation is resources/cyberm_npcs/README.md.
Manifest permissions
Server mutation requires world.npcs. Client inspection requires npcs.read.
permissions { "world.npcs", "npcs.read" }
Architecture
- NPC IDs are opaque, generation-aware 64-bit server IDs.
- The server streams NPCs only to players in the same routing bucket and inside the configured radius. Hysteresis prevents rapid stream-in/stream-out near the boundary.
- One ready client receives a short simulation lease and executes native REDengine commands. The lease carries an epoch; reports from an old owner or epoch are rejected.
- Other clients interpolate authoritative motion and never run a competing task.
- Late joiners receive the complete NPC state and active tasks.
- Movement, look, action and full-body channels execute independently. Starting a look-at does not cancel movement, and starting movement does not stop an active full-body animation.
- A resource may inspect and mutate only NPCs it owns.
- Stopping a resource removes its non-persistent NPCs. Persistent NPCs must be removed explicitly or restored/managed by server code after a resource restart.
Templates
Raw Character.* records are deliberately not accepted. Use a reviewed alias from
CyberM.npcs.templates():
for _, template in ipairs(CyberM.npcs.templates()) do
print(template.name, template.record)
end
| Alias | Intended use |
|---|---|
civilian_female_relaxed_01 |
Relaxed civilian projection and scripted locomotion. |
hostile_female_ranged_lab |
Ranged laboratory template; native combat remains experimental. |
An alias contains its server-approved record, observer record and capability list. An advertised capability describes the underlying rig/template; it does not make an unstable task public.
Complete 2.31 research catalogue
The reviewed runtime allowlist above is intentionally small, but the vanilla research catalogue is now exhaustive for Cyberpunk 2077 2.31 + Phantom Liberty:
docs/generated/npc-records-2.31.csv, including template, gameplay metadata, crowd appearances and structural risk classification;docs/generated/npc-templates-2.31.md, all verified present and readable in the installed base-game/EP1 archives;docs/generated/npc-entity-appearances-2.31.csv;docs/generated/npc-appearance-resources-2.31.csv;docs/generated/npc-entity-appearances-2.31.json.
The category and risk columns are research filters, not permission to spawn a raw record. In
particular, candidate means only that no obvious quest/player/vendor/special-rig blocker was found
in the extracted fields. A record becomes available to Lua only after it has been tested and added
to the server-owned alias registry.
See the docs/research/npc-templates-and-appearances-catalog.md.
Create and inspect
local npcId, reason = CyberM.npcs.create({
template = "civilian_female_relaxed_01",
position = { x = -1378.0, y = 1262.0, z = 123.0 },
yaw = 90.0,
bucket = 0,
appearance = nil,
loadout = {},
aiMode = CyberM.npcs.ai.tasks,
damagePolicy = CyberM.npcs.damage.immortal,
health = 100,
maxHealth = 100,
streamingRadius = 180,
streamingHysteresis = 40,
despawnWhenUnobserved = false,
persistent = false,
})
if not npcId then error(reason) end
local npc = CyberM.npcs.get(npcId)
local owned = CyberM.npcs.all() -- this resource only
local inBucket = CyberM.npcs.all(12) -- optional bucket filter
get returns the template, appearance, loadout JSON, position, bucket, streaming values, health,
flags, AI/damage modes, revisions, active task and current authority lease.
Limits are enforced server-side: 4096 NPCs globally, 512 per resource and 64 tasks per NPC.
Server API reference
All functions in this table require world.npcs. IDs are opaque 64-bit Lua integers. Mutation
functions return true only when the NPC exists and belongs to the calling resource. Invalid
definitions, task parameters, IDs or enum values raise a Lua error; callers should treat these as
resource bugs rather than normal gameplay failures.
| Function | Parameters | Return |
|---|---|---|
CyberM.npcs.create |
definition |
npcId, or nil, reason when the subsystem/permission is unavailable |
CyberM.npcs.get |
npcId |
owned NPC snapshot or nil |
CyberM.npcs.all |
optional bucket |
array of owned NPC snapshots |
CyberM.npcs.templates |
none | array of approved template snapshots |
CyberM.npcs.update |
npcId, fields |
boolean |
CyberM.npcs.setTransform |
npcId, transform |
boolean |
CyberM.npcs.setBucket |
npcId, bucket |
boolean |
CyberM.npcs.setAppearance |
npcId, appearance |
boolean |
CyberM.npcs.setLoadout |
npcId, loadout |
boolean |
CyberM.npcs.setHealth |
npcId, health, optional maxHealth |
boolean |
CyberM.npcs.setDamagePolicy |
npcId, policy |
boolean |
CyberM.npcs.setAiMode |
npcId, mode |
boolean |
CyberM.npcs.setRagdoll |
npcId, enabled |
boolean |
CyberM.npcs.applyDamage |
npcId, amount, optional source, optional cause |
boolean |
CyberM.npcs.kill |
npcId, optional reason |
boolean |
CyberM.npcs.revive |
npcId, optional health |
boolean |
CyberM.npcs.remove |
npcId |
boolean |
An NPC snapshot contains:
| Field | Type | Meaning |
|---|---|---|
id, revision, taskRevision |
integer | Canonical identity and monotonic revisions. |
resource |
string | Owning resource. |
template, record, observerRecord |
string | Approved alias and resolved REDengine records. |
appearance, loadout |
string | Appearance name and canonical loadout JSON. |
x, y, z, yaw |
number | Canonical transform. |
bucket |
integer | Routing bucket. |
streamingRadius, streamingHysteresis |
number | Interest thresholds in metres. |
health, maxHealth |
number | Canonical health. |
flags, aiMode, damagePolicy |
integer | Values from the constant tables below. |
currentTaskId |
integer | Preferred active task for compact replication, or 0. |
authorityPlayerId, authorityEpoch |
integer | Current simulation lease owner and epoch, or 0. |
A template snapshot contains name, record, observerRecord, defaultAppearance and a
capabilities array.
State mutation
CyberM.npcs.setTransform(npcId, { position = { x = 1, y = 2, z = 3 }, yaw = 180 })
CyberM.npcs.setBucket(npcId, 7)
CyberM.npcs.setAppearance(npcId, "appearance_name")
CyberM.npcs.setLoadout(npcId, { weapon = "Items.Preset_Lexington_Default" })
CyberM.npcs.setHealth(npcId, 80, 100)
CyberM.npcs.setDamagePolicy(npcId, CyberM.npcs.damage.mortal)
CyberM.npcs.setAiMode(npcId, CyberM.npcs.ai.tasks)
CyberM.npcs.setRagdoll(npcId, true)
CyberM.npcs.remove(npcId)
CyberM.npcs.update(id, fields) can atomically change appearance, loadout, AI mode, damage policy,
health, maximum health and ragdoll. The named setters above are convenience wrappers.
Moving an NPC between buckets or teleporting it revokes the current simulation lease before the new state is broadcast.
Constants
CyberM.npcs.flags.alive
CyberM.npcs.flags.ragdoll
CyberM.npcs.flags.despawnWhenUnobserved
CyberM.npcs.flags.persistent
CyberM.npcs.ai.tasks
CyberM.npcs.ai.frozen
CyberM.npcs.ai.native
CyberM.npcs.damage.mortal
CyberM.npcs.damage.immortal
CyberM.npcs.damage.invulnerable
tasks is the stable default. frozen immediately revokes the simulation lease and suspends every
task channel; returning to tasks resumes them with fresh timeout/duration accounting. A dead NPC
cannot receive a lease, motion report or new task until it is revived. native is reserved for
templates whose autonomous REDengine behaviour has been explicitly validated.
Tasks
Tasks are server queues partitioned into movement, look, action and full-body channels. Priority is
evaluated within a channel. One task may execute in each channel at the same time. A timeout of 0
means no timeout. Task/channel combinations are validated server-side; for example moveTo is
movement-only, lookAt is look-only and playAnimation is full-body-only. wait may be assigned
to any channel and blocks only that channel.
| Helper | Channel | Behaviour |
|---|---|---|
moveTo |
movement | Native navigation to one position. |
follow |
movement | Re-paths toward a player, NPC or fixed position. |
patrol |
movement | Sequences arbitrary positions, waits and optional loops. |
wander |
movement | Deterministic roaming around a centre. |
face |
look | Rotates the body toward a point. |
lookAt |
look | Continuously aims the look-at target at a player, NPC or point. |
wait |
action by default | Server-timed delay. |
hold |
movement | Holds the current position. |
playAnimation |
full body | Plays a reviewed named workspot animation. |
Move, follow and patrol
local move = CyberM.npcs.tasks.moveTo(npcId, { x = 10, y = 20, z = 30 }, {
speed = "walk", acceptanceRadius = 1.0, timeoutMs = 30000,
})
local follow = CyberM.npcs.tasks.follow(npcId, { type = "player", id = playerId }, {
speed = "run", distance = 2.0, onTargetLost = "wait",
})
local followNpc = CyberM.npcs.tasks.follow(npcId, { type = "npc", id = otherNpcId })
local patrol = CyberM.npcs.tasks.patrol(npcId, {
{ x = 10, y = 20, z = 30, waitMs = 500 },
{ x = 16, y = 20, z = 30, waitMs = 1000 },
}, { speed = "walk", loop = true, backAndForth = false })
local wander = CyberM.npcs.tasks.wander(npcId, {
x = 10, y = 20, z = 30, radius = 15, speed = "walk", seed = 42,
})
walk, run and sprint are supported movement speeds. Patrol accepts 1 to 64 points. The
current onTargetLost policy waits for the target to become available again.
Look, hold and animation
CyberM.npcs.tasks.face(npcId, { x = 1, y = 2, z = 3 }, {
tolerance = 3, speed = 180, timeoutMs = 5000,
})
CyberM.npcs.tasks.lookAt(npcId, { type = "player", id = playerId })
CyberM.npcs.tasks.wait(npcId, 1500)
CyberM.npcs.tasks.hold(npcId, { durationMs = 5000 })
CyberM.npcs.tasks.playAnimation(npcId, "emote_smoke", { loop = false })
Named full-body animations remain active until they are cancelled, preempted, timed out or the NPC
streams out. REDengine does not expose a reliable completion signal for every workspot clip, so use
timeoutMs or explicit cancellation when the animation must end deterministically.
Generic queue and cancellation
local taskId = CyberM.npcs.tasks.enqueue(npcId, "moveTo", {
x = 1, y = 2, z = 3, speed = "walk",
}, { channel = CyberM.npcs.channels.movement, priority = 10, timeoutMs = 30000 })
local task = CyberM.npcs.tasks.get(npcId, taskId)
local tasks = CyberM.npcs.tasks.all(npcId)
CyberM.npcs.tasks.cancel(npcId, taskId, "script_cancel")
CyberM.npcs.tasks.clear(npcId, CyberM.npcs.channels.movement, "new_route")
Only the task names listed above are accepted. Unsupported types and malformed targets, paths, durations or animation names are rejected before replication.
Task API reference
| Function | Parameters | Return |
|---|---|---|
CyberM.npcs.tasks.enqueue |
npcId, type, parameters, optional options |
task ID |
CyberM.npcs.tasks.moveTo |
npcId, position, optional options |
task ID |
CyberM.npcs.tasks.follow |
npcId, target, optional options |
task ID |
CyberM.npcs.tasks.patrol |
npcId, points, optional options |
task ID |
CyberM.npcs.tasks.wander |
npcId, optional options |
task ID |
CyberM.npcs.tasks.face |
npcId, target, optional options |
task ID |
CyberM.npcs.tasks.lookAt |
npcId, target, optional options |
task ID |
CyberM.npcs.tasks.wait |
npcId, durationMs, optional options |
task ID |
CyberM.npcs.tasks.hold |
npcId, optional options |
task ID |
CyberM.npcs.tasks.playAnimation |
npcId, animation, optional options |
task ID |
CyberM.npcs.tasks.get |
npcId, taskId |
task snapshot or nil |
CyberM.npcs.tasks.all |
npcId |
array of task snapshots |
CyberM.npcs.tasks.cancel |
npcId, taskId, optional reason |
boolean |
CyberM.npcs.tasks.clear |
npcId, optional channel, optional reason |
number cancelled |
Common options are priority (signed integer) and timeoutMs (0 disables the timeout). Generic
enqueue also accepts channel. A task snapshot contains npcId, id, resource, type, the
JSON string parameters, channel, priority, timeoutMs, status, revision and reason.
Statuses are queued, suspended, executing, success, failure, cancelled and
interrupted.
Health, damage and death
CyberM.npcs.applyDamage(npcId, 25, "resource:arena", "firearm")
CyberM.npcs.kill(npcId, "admin")
CyberM.npcs.revive(npcId, 100)
mortalallows health to reach zero.immortalapplies damage but clamps health to 1.invulnerablerejects damage.
Health and death are canonical server state. Client hit detection should request a bounded server action; it must not directly mutate a projection.
Events
Server resource events:
AddEventHandler("onNpcCreated", function(npcId, resource, template) end)
AddEventHandler("onNpcUpdated", function(npcId, revision) end)
AddEventHandler("onNpcRemoved", function(npcId, reason, resource) end)
AddEventHandler("onNpcTaskState", function(npcId, taskId, status, reason) end)
AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId, epoch, reason) end)
AddEventHandler("onNpcDamaged", function(npcId, source, amount, health, cause) end)
AddEventHandler("onNpcDied", function(npcId, source, cause) end)
Client resource events:
AddEventHandler("onNpcStreamIn", function(npcId, revision) end)
AddEventHandler("onNpcReady", function(npcId, entity) end)
AddEventHandler("onNpcChanged", function(npcId, revision) end)
AddEventHandler("onNpcTaskChanged", function(npcId, taskId) end)
AddEventHandler("onNpcAuthorityChanged", function(npcId, playerId) end)
AddEventHandler("onNpcStreamOut", function(npcId, reason) end)
Client read-only API
local npc = CyberM.npcs.get(npcId)
local visible = CyberM.npcs.all()
local ready = CyberM.npcs.isStreamedIn(npcId)
local entity = CyberM.npcs.entity(npcId) -- local CyberM entity handle or nil
local taskId = CyberM.npcs.currentTask(npcId) -- canonical current task ID or nil
Snapshots expose streamed and locallyAuthoritative. The local entity handle is ephemeral: do
not cache it across stream-out, reconnect or resource reload.
Client snapshots contain id, revision, entity, template, appearance, flags, bucket,
aiMode, damagePolicy, health, maxHealth, currentTaskId, taskRevision,
authorityPlayerId, authorityEpoch, streamed and locallyAuthoritative. Without npcs.read,
all() returns an empty array, get()/entity()/currentTask() return nil, and
isStreamedIn() returns false.
Current limitations
- Server persistence storage is resource-defined;
persistent=trueonly changes cleanup policy. - Arbitrary records, raw REDengine handles and client-side canonical mutation are intentionally unsupported.
- Native attack/shoot/melee/combat tasks are not in the stable API yet. Use server-authoritative scripted damage until animation, targeting and hit validation are proven safe for each rig.
- Native authored patrol paths (
NodeRef) are not exposed; CyberM patrols sequencemoveTotasks. - A client authority lease is required for native navigation. With no ready client, tasks suspend and resume when authority becomes available.
For agents and LLMs: this page as Markdown · llms.txt · llms-full.txt
