Skip to Content
Modlock

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:

  1. It checks that the placement is finite.
  2. It resolves the designer name's entity class through the game's own class lookup. A name the server lacks fails here.
  3. 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 at giveItem and replaceAbility, which give one to a hero.
  4. It creates the entity, installs its subclass, and applies the spawn key values, the position and the facing.
  5. It writes the fields in EntityOptions.fields. These are fields the game reads only while spawning, such as a unit's m_iInitialTeamNum. A write that fails removes the entity unspawned and fails the call.
  6. 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) + 50

The 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.
  • vector reads a Vector, VectorWS or QAngle.
  • string reads a CUtlString or CUtlSymbolLarge, and never writes.
  • A list call reads a CUtlVector or CNetworkUtlVectorBase of 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 = 10000

Setting 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.
  • killEntity credits 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:

MessageCause
the server has no entity NAMEThe designer name is not in this build
NAME is an ability, which only a hero holdsAn ability, item or weapon; give it to a hero instead
NAME crashes the server when created aloneOne of the entities below; the message says what it needs
NAME walks no lanespawnNpc was given a lane for a class without m_iLane
NAME has no native identityThe entity was created without a handle the host can address
NAME has no input INPUTThe input is not one the entity accepts
A class mismatch naming both classesA 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.

NeedsDesigner names
The ability that makes itcitadel_bounce_pad, citadel_deployable_preview, citadel_magician_turret_object, citadel_mobile_resupply_object, citadel_nano_predatory_statue
An ownercitadel_hideout_prop_base, citadel_trigger_capture_zipline, npc_familiar_helper, npc_shielded_sentry
Its map's game datacitadel_herotest_orbspawner, citadel_hideout_shootable_target_spawner, simple_animating_ai, env_laser, point_prefab
A model or path from a mapfunc_precipitation, spark_shower, path_node, path_node_mover
The hideout mapnpc_neutral_hideout_cat, npc_neutral_hideout_rabbit
A game mode's capture datacitadel_capture_point, citadel_multi_capture_point
A game mode's cash-in datacitadel_koth_cashin
A player or botbaseplayerpawn (spawn a hero), npc_player_bot_brain (add a bot)
Joined objectsphysics_npc_solver

On this page