Skip to Content
Modlock

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) + 50
local entities = require("@modlock/entities")

local hero: entities.CCitadelPlayerPawn = entities.new("CCitadelPlayerPawn", pawn.entity)
hero.m_iHealth = (hero.m_iHealth or 0) + 50
from 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)
end
guardian = 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:

RefusedWhat
Classes, created, spawned or sent inputs, even when a map placed thempoint_servercommand, point_clientcommand, point_broadcastclientcommand, point_commentary_node, logic_script, point_prefab
Key valuesvscripts, precommands, postcommands and any ending in _script_func
Fields, before spawn or with writeFieldThe fields those key values fill, m_iszPrivateVScripts, m_iszPreCommands, m_iszPostCommands
Inputs, on every entityAddOutput, 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()
end
keys: 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 })
end
dialog = 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.

On this page