Entity calls
What the host does and checks when a mod creates an entity, touches a field, sends an input or deals damage.
Each entity call takes an entity by one of its four names. The typed libraries generated from the game reduce to the calls on this page, which a mod may also make by hand.
Creating an entity
import { create } from 'modlock/entities'
const guardian = create('npc_trooper_boss', {
team: 3,
position,
keys: { rendercolor: 0xff0000ff },
})
guardian?.inputAlpha(128)
guardian?.inputColor(0x00ff00ff)The same call in Go:
boss, err := entity.NpcTrooperBoss.Create(
&mod.EntityOptions{Team: 3, Position: position},
&entity.CNPC_TrooperBossKeys{},
)
_, err = entity.CBaseModelEntity(boss).InputAlpha(128)And in Luau:
local boss: entities.CNPC_TrooperBoss? = entities.create("npc_trooper_boss", { team = 3, position = position })Underneath, each is one createEntity call, which a mod may also make by
hand with any designer name and untyped key values. The host runs it in this
order:
- It checks that the placement is finite.
- It resolves the designer name's entity class through the game's own class lookup. A name the server lacks fails here.
- It refuses an ability, item or weapon: any class deriving from
CCitadelBaseAbility. One created alone, with no hero to hold it, takes the server down once it thinks or is removed, so the refusal happens before the entity exists. The message points atgiveItemandreplaceAbility, which give one to a hero. - It creates the entity, installs its subclass, and applies the spawn key values, the position and the facing.
- It writes the fields in
EntityOptions.fields. These are fields the game reads only while spawning, such as a unit'sm_iInitialTeamNum. A write that fails removes the entity unspawned and fails the call. - It spawns the entity and returns its handle.
The host records every entity a mod creates. The world removes them when it ends, and stopping or reloading the mod removes them too. A mod never leaves entities behind.
Units and pickups
spawnNpc and createPickup are built on the same call. spawnNpc takes any
unit class the server has, with its subclass as unit, and adds what a unit
needs: health and maximum health, and a lane. The host writes the lane to the
class's m_iLane field before the unit spawns. For a class without that field,
such as npc_yakuza_gangster, the call fails with a message that gives the
class. Npc reads,
moves, heals and removes the unit.
createPickup makes the soul urn or the movement buff with the designer name
and subclass each needs. Pickup reports whether it still lies in the world
and removes it.
Scale
Every entity takes the SetScale input, inputSetScale on CBaseEntity. It
sets the scene node's absolute scale, and the server rebuilds the entity's
physics shape at that scale, so collision and the model grow together. Send it
once the entity has spawned:
const ball = create('citadel_hideout_ball', { position })
ball?.inputSetScale(9)The sample sends the input instead of passing the spawn keys scale or
ModelScale. Those keys set the same scale before spawn, but an entity whose
game data sets its own model scale replaces them while spawning. The hideout
ball shows both: its model spans ±7 units, and its game data sets a model scale
of 3, which gives it a radius of 21. With either key its collision stayed at 21
units, but after inputSetScale(9) a trace meets it at 63, and at 7 times the
scale for every value from 0.5 to 9. Use the input for a scale that must hold.
Fields
A mod reads and writes a field of any live entity by class and name:
import { readField, writeField } from 'modlock'
const health = readField(pawn.entity, 'CBaseEntity', 'm_iHealth', 'int32')
writeField(pawn.entity, 'CBaseEntity', 'm_iHealth', 'int32', 500)or through a typed class, which supplies the class, name and type:
import { CCitadelPlayerPawn } from 'modlock/entities'
const hero = new CCitadelPlayerPawn(pawn.entity)
hero.m_iHealth = (hero.m_iHealth ?? 0) + 50The typed hero.m_iHealth calls readField and writeField with the class
that declares the field, CBaseEntity, so both samples make the same calls.
The first time any mod on the server uses a field, the host looks up its offset
in the schema and caches it until the server exits. Before it touches memory, it
checks that the entity is of the class the call gives or derives from it. A
writeField call that passes a trooper's handle with the class
CCitadelPlayerPawn fails with a message giving both classes, and nothing is
written. A write marks the field changed so the server sends it to the players.
A dotted field name reaches a field inside an embedded structure, such as the collision group of a model entity's collision property:
writeField(prop, 'CBaseModelEntity', 'm_Collision.m_CollisionGroup', 'uint8', 2)
writeField(
prop,
'CBaseModelEntity',
'm_Collision.m_collisionAttribute.m_nInteractsExclude',
'uint64',
0n,
)In the first call, m_Collision holds the collision property, and
m_CollisionGroup is a field inside it. Each segment of a path is a field of
the structure that the segment before it holds, and the host refuses a path
through a field that holds a number, a pointer or a handle.
Field types cover booleans, signed and unsigned integers of 8 to 64 bits, 32
and 64-bit floats, vectors, entity handles and strings. Angles travel as
vectors. A typed field whose type is another entity class reads as that
class, so hero.m_hController is a controller a mod can read further.
The host checks the requested type against the type the schema declares for the field, and refuses a mismatch with a message giving both types. Each requested type fits these fields:
- Any number type, a boolean or a handle reads a field that holds a number,
boolean, enum, handle or boxed number such as
GameTime_t. The value is cut to the requested type's size. vectorreads aVector,VectorWSorQAngle.stringreads aCUtlStringorCUtlSymbolLarge, and never writes.- A list call reads a
CUtlVectororCNetworkUtlVectorBaseof one of those, and a single-value call never reads a list.
A pointer, an embedded structure and any other container hold nothing a mod can read or write by value; reach a structure's fields with a dotted name.
The match's state
The match's state is in the game rules, which are not an entity: each team's
gold, the game state, the winning team, the Mid Boss count and the pause fields.
Their proxy entity, citadel_gamerules, points to them and sends them to the
players. A mod reads them through the classes CGameRules and
CCitadelGameRules with the proxy's handle:
import { findEntities } from 'modlock'
import { CCitadelGameRules } from 'modlock/entities'
const [proxy] = findEntities('citadel_gamerules')
const rules = new CCitadelGameRules(proxy)
rules.m_nAmberGold = 10000Setting m_nAmberGold sends the whole rules to the players, so players already
in the match see the change at once. A field the game's clients derive from
other state may be set again by the game on its next update.
A field that holds a list of numbers, handles, booleans, vectors or text, such as the banned heroes or the neutral camp spawn times, reads as an array and writes by replacing the array. The host grows the game's list when the new array is longer than the space the game allocated. A list of structures, such as the Mid Boss team states, has no accessor.
rules.m_vecBannedHeroes = [1, 2, 3]
const times = rules.m_vecNeutralCampNextSpawnTimes // number[]Inputs
fireInput(handle, input, value) sends an entity an input, as a map's output
would: Kill, Color, Alpha, Skin, SetScale, StartShake, Explode and
the thousand others in entities.json. An input reads its value as the type it
takes and does not convert text. Alpha takes an integer, and Color takes a
color written as 0xRRGGBBAA. A typed class sends each input as a method whose
parameter has that type:
const shake = create('env_shake', {
position,
keys: { amplitude: 12, frequency: 40, duration: 2, radius: 2000 },
})
shake?.inputStartShake()
const boom = create('env_explosion', {
position,
keys: { iMagnitude: 50, iRadiusOverride: 200 },
})
boom?.inputExplode()Each input method calls fireInput with its input's name, so
shake?.inputStartShake() fires StartShake. A fireInput call with an input
the entity does not have returns false, and the host logs the entity's
designer name and the input's. Only that call fails: the rest of the handler
runs, and the mod keeps running.
Damage and deaths
damage deals ordinary damage through the engine, and killEntity forces a
hero or unit to die. Both take a victim, an attacker, an inflictor, which
defaults to the attacker, and an optional ability; damage also takes an
amount.
- A handle includes its entity's generation, so a stale handle fails instead of selecting a replacement entity. An attacker that has since died or left credits nobody.
killEntitycredits the attacker through the game's own damage and reward logic, bypasses resistance and damage modification, and suppresses item procs. A living pawn at zero health still receives its death transition, and a victim already dead succeeds without a second reward. The caller releases any immortality its mode gave the victim first.- A call with no attacker causes an environmental death with no credit or
rewards, as
player.kill()does. - Both calls refuse the world, props and buildings, because clients cannot safely process their damage events.
The engine credits no one for some deaths, such as a hero knocked out of the
play area or into a hazard. To credit one, the mod keeps the handle of the last
enemy that hit or pushed the victim. When the victim dies, the mod passes that
handle as attacker to killEntity. The engine then awards the kill, assists
and rewards, so a mod should leave the scoreboard fields alone.
What fails, and how
Every entity call fails on its own: it logs, returns undefined or false
in TypeScript and Luau and an error in Go, and the mod keeps running. The
failures a mod can meet:
| Message | Cause |
|---|---|
the server has no entity NAME | The designer name is not in this build |
NAME is an ability, which only a hero holds | An ability, item or weapon; give it to a hero instead |
NAME crashes the server when created alone | One of the entities below; the message says what it needs |
NAME walks no lane | spawnNpc was given a lane for a class without m_iLane |
NAME has no native identity | The entity was created without a handle the host can address |
NAME has no input INPUT | The input is not one the entity accepts |
| A class mismatch naming both classes | A field was named on a class the entity does not derive from |
createEntity refuses the 25 designer names below before they exist, and none
has a constructor. Each reads state that a map, a game mode, an owning hero or
the ability that makes it provides, and crashes the server when made alone. A bounce pad, for one, is created safely but finds its team through the
ability that placed it. The first hero to cross a pad made alone crashes the
server.
| Needs | Designer names |
|---|---|
| The ability that makes it | citadel_bounce_pad, citadel_deployable_preview, citadel_magician_turret_object, citadel_mobile_resupply_object, citadel_nano_predatory_statue |
| An owner | citadel_hideout_prop_base, citadel_trigger_capture_zipline, npc_familiar_helper, npc_shielded_sentry |
| Its map's game data | citadel_herotest_orbspawner, citadel_hideout_shootable_target_spawner, simple_animating_ai, env_laser, point_prefab |
| A model or path from a map | func_precipitation, spark_shower, path_node, path_node_mover |
| The hideout map | npc_neutral_hideout_cat, npc_neutral_hideout_rabbit |
| A game mode's capture data | citadel_capture_point, citadel_multi_capture_point |
| A game mode's cash-in data | citadel_koth_cashin |
| A player or bot | baseplayerpawn (spawn a hero), npc_player_bot_brain (add a bot) |
| Joined objects | physics_npc_solver |