Entities
Read and write any field of a live entity, create entities and send them inputs.
The calls on this page read and write any schema field of the game's entities, create any entity by its designer name and send any input, in every language. The Luau, Python and Go pages describe their typed classes.
Read and write fields
Your mod reads and writes any schema field of a live entity by its class and field names:
import { readField, writeField } from 'modlock'
const health = readField(pawn.entity, 'CBaseEntity', 'm_iHealth', 'int32')
writeField(pawn.entity, 'CBaseEntity', 'm_iHealth', 'int32', 500)local health = modlock.readField(pawn.entity, "CBaseEntity", "m_iHealth", "int32")
modlock.writeField(pawn.entity, "CBaseEntity", "m_iHealth", "int32", 500)health = modlock.read_field(pawn.entity, "CBaseEntity", "m_iHealth", "int32")
modlock.write_field(pawn.entity, "CBaseEntity", "m_iHealth", "int32", 500)health, err := mod.ReadField(pawn.GetEntity(), "CBaseEntity", "m_iHealth", mod.FieldTypeInt32)
err = mod.WriteField(pawn.GetEntity(), "CBaseEntity", "m_iHealth", mod.FieldTypeInt32, int64(500))The Go sample writes int64(500) because Go's WriteField takes only a bool,
float64, int64, string or *mod.Vector. An integer is passed as an
int64 even when the field is narrower, as the 32-bit m_iHealth is. A plain
500 is an int, and WriteField returns an error for it.
For an animated prop, the host finds CBaseAnimGraphController fields through
the prop's body component. This sample sets a sequence that does not use the
animation graph, stops the prop's own playback, and holds the sequence at one
authored pose without restarting it:
fireInput(prop, 'SetAnimation', { text: 'hand_pose_aligned' })
fireInput(prop, 'SetPlaybackRate', { number: 0 })
writeField(
prop,
'CBaseAnimGraphController',
'm_flSeqFixedCycle',
'float32',
1 / 6,
)modlock.fireInput(prop, "SetAnimation", { text = "hand_pose_aligned" })
modlock.fireInput(prop, "SetPlaybackRate", { number = 0 })
modlock.writeField(prop, "CBaseAnimGraphController", "m_flSeqFixedCycle", "float32", 1 / 6)modlock.fire_input(prop, "SetAnimation", modlock.EntityValue(text="hand_pose_aligned"))
modlock.fire_input(prop, "SetPlaybackRate", modlock.EntityValue(number=0))
modlock.write_field(prop, "CBaseAnimGraphController", "m_flSeqFixedCycle", "float32", 1 / 6)_, _ = mod.FireInput(prop, "SetAnimation", "hand_pose_aligned", nil)
_, _ = mod.FireInput(prop, "SetPlaybackRate", float32(0), nil)
_ = mod.WriteField(prop, "CBaseAnimGraphController", "m_flSeqFixedCycle", mod.FieldTypeFloat32, 1.0/6)The sequence hand_pose_aligned and the cycle 1 / 6 suit the sample's model,
so choose a sequence and cycle from your own prop's model.
Writing m_flSeqFixedCycle changes the pose and leaves the model where it
stands. The host refuses the field on an entity that has no animation
controller.
A write changes the field on the server and sends it to the players. The host
refuses an entity that is not of the class you pass or one derived from it,
such as a trooper passed as a CCitadelPlayerPawn. The error gives both
classes. The entity stays unchanged and your mod keeps running.
Some fields matter only while an entity spawns, such as m_iInitialTeamNum.
spawnNpc writes them before the entity spawns:
spawnNpc({
className: 'npc_trooper_boss',
unit: 'npc_boss_tier1',
team: 2,
position,
fields: [
{
className: 'CBaseEntity',
field: 'm_iInitialTeamNum',
type: 'int32',
value: 2,
},
],
})modlock.spawnNpc({
className = "npc_trooper_boss",
unit = "npc_boss_tier1",
team = 2,
position = position,
fields = {
{ className = "CBaseEntity", field = "m_iInitialTeamNum", type = "int32", value = 2 },
},
})modlock.spawn_npc(
modlock.NpcOptions(
class_name="npc_trooper_boss",
unit="npc_boss_tier1",
team=2,
position=position,
fields=[
modlock.FieldWrite(
class_name="CBaseEntity",
field="m_iInitialTeamNum",
type="int32",
value=2,
)
],
)
)// import "github.com/paralin/modlock/src/wasm/fields"
_, _ = mod.SpawnNpc(&mod.NpcOptions{
ClassName: "npc_trooper_boss",
Unit: "npc_boss_tier1",
Team: 2,
Position: position,
Fields: []*mod.FieldWrite{{
ClassName: "CBaseEntity",
Field: "m_iInitialTeamNum",
Type: mod.FieldTypeInt32,
Value: &fields.FieldValue{Value: &fields.FieldValue_Integer{Integer: 2}},
}},
})The entities module has a type for each server entity class, with a getter and a
setter for each field except string fields. In Go, each field's method drops the
field's m_ prefix. To reach a base class's fields, convert the entity to the
base class:
import { CCitadelPlayerPawn } from 'modlock/entities'
const hero = new CCitadelPlayerPawn(pawn.entity)
hero.m_iHealth = (hero.m_iHealth ?? 0) + 50local entities = require("@modlock/entities")
local hero: entities.CCitadelPlayerPawn = entities.new("CCitadelPlayerPawn", pawn.entity)
hero.m_iHealth = (hero.m_iHealth or 0) + 50from modlock import entities
hero = entities.CCitadelPlayerPawn(pawn.entity)
hero.m_iHealth = (hero.m_iHealth or 0) + 50// import "github.com/paralin/modlock/mod/entity"
hero := entity.CBaseEntity{Handle: pawn.GetEntity()}
health, _ := hero.IHealth().Get()
_ = hero.IHealth().Set(health + 50)Your mod's build bundles only the classes it uses. data/dump lists every
class, its base and its fields in schemas.json.
Create entities and send inputs
createEntity creates an entity the server knows by its designer name. It takes
the spawn key values a map would give, and writes fields before the entity
spawns. fireInput sends an entity an input, as a map's output would.
removeEntity removes an entity the mod created, and the host removes the rest
when the world ends or the mod stops.
import { createEntity, fireInput, removeEntity } from 'modlock'
const guardian = createEntity({
designerName: 'npc_trooper_boss',
subclass: 'npc_boss_tier1',
team: 3,
position,
keyValues: [{ key: 'rendercolor', value: { text: '255 0 0' } }],
})
fireInput(guardian, 'Alpha', { integer: 128 })
fireInput(guardian, 'Color', { color: 0x00ff00ff })
fireInput(guardian, 'DisableShadow')
removeEntity(guardian)local guardian = modlock.createEntity({
designerName = "npc_trooper_boss",
subclass = "npc_boss_tier1",
team = 3,
position = position,
keyValues = { { key = "rendercolor", value = { text = "255 0 0" } } },
})
if guardian then
modlock.fireInput(guardian, "Alpha", { integer = 128 })
modlock.fireInput(guardian, "Color", { color = 0x00ff00ff })
modlock.fireInput(guardian, "DisableShadow")
modlock.removeEntity(guardian)
endguardian = modlock.create_entity(
modlock.EntityOptions(
designer_name="npc_trooper_boss",
subclass="npc_boss_tier1",
team=3,
position=position,
key_values=[modlock.KeyValue(key="rendercolor", value=modlock.EntityValue(text="255 0 0"))],
)
)
if guardian is not None:
modlock.fire_input(guardian, "Alpha", modlock.EntityValue(integer=128))
modlock.fire_input(guardian, "Color", modlock.EntityValue(color=0x00FF00FF))
modlock.fire_input(guardian, "DisableShadow")
modlock.remove_entity(guardian)// import "github.com/paralin/modlock/src/wasm/entities"
guardian, err := mod.CreateEntity(&mod.EntityOptions{
DesignerName: "npc_trooper_boss",
Subclass: "npc_boss_tier1",
Team: 3,
Position: position,
KeyValues: []*mod.KeyValue{{
Key: "rendercolor",
Value: &entities.EntityValue{Value: &entities.EntityValue_Text{Text: "255 0 0"}},
}},
})
if err == nil {
_, _ = mod.FireInput(guardian, "Alpha", int32(128), nil)
_, _ = mod.FireInput(guardian, "Color", uint32(0x00ff00ff), nil)
_, _ = mod.FireInput(guardian, "DisableShadow", nil, nil)
_, _ = mod.RemoveEntity(guardian)
}The sample passes subclass because npc_trooper_boss reads a game data entry
while it spawns, and subclass is the name of that entry. Without a subclass, the host
looks for an entry named after the class. The game has no entry named
npc_trooper_boss, and the class crashes the server without one, so the host
refuses the call and its message gives an entry you can pass.
The values passed to fireInput already have the type each input takes, because
an input does not convert text. Alpha takes an integer, and Color takes a
color written as 0xRRGGBBAA. entities.json lists the type each input takes.
In Go, an int32 is sent as an integer, a float32 is sent as a number and a
uint32 is sent as a color.
The Luau, Python and Go samples check the created entity before they send
inputs. createEntity returns no entity for a designer name the server does not
know, and fireInput returns false for an input the entity does not have. The
host logs why, and in Go both calls return an error. Only that call fails: the
rest of your mod keeps running.
The host resolves the designer name's class before it creates anything.
Because only a hero can hold an ability, item or weapon, the host refuses to
create one on its own. One created alone crashes the server the first time it
thinks or is removed. The error points you to giveItem and replaceAbility instead.
spawnNpc and createPickup build on the same call. spawnNpc takes any unit
class the server has and refuses a lane for a class that walks none.
Entity calls keep to the same console limits as
serverCommand. The host refuses what would run console lines on the server
or on players' clients, or load scripts and entities no check sees:
| Refused | What |
|---|---|
| Classes, created, spawned or sent inputs, even when a map placed them | point_servercommand, point_clientcommand, point_broadcastclientcommand, point_commentary_node, logic_script, point_prefab |
| Key values | vscripts, precommands, postcommands and any ending in _script_func |
Fields, before spawn or with writeField | The fields those key values fill, m_iszPrivateVScripts, m_iszPreCommands, m_iszPostCommands |
| Inputs, on every entity | AddOutput, RunScriptCode, RunScriptFile, the Call*ScriptFunction inputs |
create in the entities module is the same call, typed for each designer name. It takes
only that entity's spawn keys, each as the type the entity reads. It returns the
entity as its class, with a method per input. In Go each designer name is a
value, such as entity.NpcTrooperBoss, whose Create takes the options and a
struct of the keys:
import { create } from 'modlock/entities'
const guardian = create('npc_trooper_boss', {
subclass: 'npc_boss_tier1',
team: 3,
position,
keys: { rendercolor: 0xff0000ff },
})
guardian?.inputAlpha(128)
guardian?.inputDisableShadow()local keys: entities.CNPC_TrooperBossKeys = { rendercolor = 0xff0000ff }
local guardian: entities.CNPC_TrooperBoss? = entities.create("npc_trooper_boss", {
team = 3,
position = position,
keys = keys,
})
if guardian then
guardian:inputAlpha(128)
guardian:inputDisableShadow()
endkeys: entities.CNPC_TrooperBossKeys = {"rendercolor": 0xFF0000FF}
guardian: entities.CNPC_TrooperBoss | None = entities.create(
"npc_trooper_boss", position, team=3, keys=keys
)
if guardian is not None:
guardian.input_Alpha(128)
guardian.input_DisableShadow()red := uint32(0xff0000ff)
keys := entity.CNPC_TrooperBossKeys{}
keys.Rendercolor = &red
guardian, err := entity.NpcTrooperBoss.Create(&mod.EntityOptions{Team: 3, Position: position}, &keys)
if err == nil {
_, _ = entity.CBaseModelEntity(guardian).InputAlpha(128)
_, _ = entity.CBaseEntity(guardian).InputDisableShadow()
}create takes only the designer names that spawned and stayed alive on a
dedicated server, 578 of the 1,191 surveyed. Each name that needs a subclass gets
the subclass it spawned with, so create('npc_trooper_boss', ...) makes a Guardian without asking
for npc_boss_tier1. A name that never stayed alive, such as an abstract base or
one that crashes the server, has no constructor, and the type checker rejects it.
Entity schema describes the whole entity
system: the dump, the survey, the generated classes in each language and what
the host checks.
showEntityOnlyTo sends an entity the mod created only to the listed player
slots. showEntityToAll undoes it. Call it right after you create the entity,
and no other client ever receives it. This sample keeps a per-player
point_clientui_dialog from every other player:
import { showEntityOnlyTo } from 'modlock'
import { create } from 'modlock/entities'
const dialog = create('point_clientui_dialog', {
position,
transmitAlways: true,
keys,
})
if (dialog) showEntityOnlyTo(dialog.handle, [player.slot])local dialog = entities.create("point_clientui_dialog", { position = position, transmitAlways = true })
if dialog then
modlock.showEntityOnlyTo(dialog.handle, { player.slot })
enddialog = entities.create("point_clientui_dialog", position, transmit_always=True)
if dialog is not None:
modlock.show_entity_only_to(dialog.handle, [player.slot])dialog, err := entity.PointClientuiDialog.Create(&mod.EntityOptions{Position: position, TransmitAlways: new(true)}, nil)
if err == nil {
_ = mod.ShowEntityOnlyTo(dialog.Handle, []int32{p.Slot()})
}transmitAlways sends the entity to every client, past the game's rule that
never sends one without a model.
When a player leaves, the host drops their slot from every list, so the next player in that slot does not receive the entity. Removing the entity drops its list, and each entity parented to it needs its own call.