Entities

Everything in the world is an entity: props, triggers, NPCs, pickups, world text, beams and particle systems. This page is what you can create, what will hang the game thread if you create it carelessly, how to remove things, and the classname list.

Creating one

var e = CBaseEntity.CreateByDesignerName("citadel_item_powerup_spawner");  // preferred
var e = CBaseEntity.CreateByName("...");                                    // crash risk, avoid

if (e == null) { /* not spawnable in this build. Handle it, do not assume. */ }

e.Teleport(position, angles, velocity);     // position BEFORE spawning
e.Spawn(new CEntityKeyValues());             // or Spawn() with no keyvalues

The order matters: create, position, then spawn. The working sequence used by both gamemodes, with a log line after every step so that a hang names the step it hung on:

foreach (var unit in new[] { Config.PrimaryUnit, Config.FallbackUnit }) {
    if (string.IsNullOrWhiteSpace(unit)) continue;
    Console.WriteLine($"creating '{unit}'");
    CBaseEntity? e = null;
    try { e = CBaseEntity.CreateByDesignerName(unit); }
    catch (Exception cx) { Console.WriteLine($"create threw: {cx.Message}"); }
    if (e == null) { Console.WriteLine($"'{unit}' not spawnable, trying next"); continue; }
    e.Teleport(at, new Vector3(0, yaw, 0), Vector3.Zero);
    Console.WriteLine("spawning");
    try { e.Spawn(new CEntityKeyValues()); } catch { try { e.Spawn(); } catch { } }
    Console.WriteLine($"DONE idx={e.EntityIndex}");
    break;
}
Two spawns that hung the game thread, both real
  • Spawn on the floor at a known position, never in mid-air at an offset from a player. A creep created in the air above someone's head hung the game thread twice on 2026-10-08: the log printed START and never DONE, and the server then reported socket thread starvation. The same unit, spawned at a savepoint on the ground, has worked every game for weeks.
  • citadel_restorative_goo_cube hangs the game thread on spawn. Do not put it back without proving it in isolation first.

Making a spawned unit into harmless scenery

A spawned NPC will attack players. Two switches deal with it:

// AI off, globally. This is a real engine bool, default false.
ConVar.Find("ai_disabled")?.SetInt(1);

// Collision off, so players can walk through it
e.Collision?.SetSolid(SolidType.None);

// Health high enough that it cannot be killed by accident, and a neutral team
e.SetField<int>("CBaseEntity"u8, "m_iMaxHealth"u8, 100000);
e.Health = 100000;
e.TeamNum = 4;
Do not force your own health, team or collision onto a powerup spawner

citadel_item_powerup_spawner runs its own logic and owns its own health, team and collision. Overwriting them is what made it inert the first time. Spawn it and leave it alone.

Removing things

e.Remove();                                  // from a plugin
ent_remove <targetname>                     // from the console
ent_fire <targetname> Kill                  // same thing, by input

Always guard the handle. An entity you created may already be gone:

try { if (e != null && e.IsValid) e.Remove(); } catch { }
A powerup spawner leaks a second entity

The spawner hands out a separate pickup entity, and removing the spawner leaves that pickup behind. A leftover pickup still grants its proximity aura and can still be broken, so in a round-based mode it will register a punch into a round that is already over. Sweep the area, do not just remove the thing you created:

foreach (var e in Entities.ByDesignerName("citadel_item_powerup_spawner").ToList())
    if (Vector3.Distance(e.Position, spot) <= range) e.Remove();

Finding entities

Entities.All;
Entities.ByClass<T>();
Entities.ByDesignerName("npc_trooper");
CBaseEntity.FromIndex(i);
CBaseEntity.FromHandle<T>(h);

Call .ToList() before you remove anything inside a loop over the result, or you are mutating the thing you are iterating.

Reading and writing raw schema fields

This is the escape hatch when the managed API has no property for what you need. It is how you set a game state the API does not expose.

e.GetField<int>("CBaseEntity"u8, "m_iMaxHealth"u8);
e.SetField<int>("CBaseEntity"u8, "m_iMaxHealth"u8, 100000);

// hot path: cache the accessor instead of resolving the name every call
static SchemaAccessor<int> _hp = new("CBaseEntity"u8, "m_iMaxHealth"u8);
_hp.Set(e.Handle, 100000);

The u8 suffix is required: these take UTF-8 literals, not strings.

World dressing: text, beams, particles

These three are the only primitives that have never misbehaved in this project. When an entity spawn is risky, build the thing out of these instead.

World text

var t = CBaseEntity.CreateByDesignerName("point_worldtext");
var ekv = new CEntityKeyValues();
ekv.SetString("message", "CAPTURED BY");
ekv.SetString("font_name", "Arial Black");
ekv.SetFloat("font_size", 42f);
ekv.SetFloat("world_units_per_pixel", 0.25f);
ekv.SetInt("justify_horizontal", 1);     // centre on the point
ekv.SetInt("justify_vertical", 1);
ekv.SetInt("reorient_mode", 1);           // billboard, always faces the viewer
ekv.SetBool("enabled", true);              // WITHOUT THIS NOTHING RENDERS
ekv.SetBool("fullbright", true);
ekv.SetColor("color", 255, 255, 255, 255);
t.Teleport(pos, new Vector3(0, 0, 90), Vector3.Zero);   // roll 90 stands it up
t.Spawn(ekv);
Three things about world text that each cost an evening
  1. enabled defaults to FALSE. Without SetBool("enabled", true) you get a label nobody can see and no error.
  2. Pass real angles. The first attempt passed Vector3.Zero and every label lay flat on its back in the air. 0 0 90 is confirmed correct in game. The keyvalue is reorient_mode, not orientation.
  3. There is no setter for the string. m_messageText is a networked inline char[512] and the managed API exposes no writer. Changing a label means destroying the entity and creating a new one, so a label that counts down once a second is one entity destroyed and created every second. Fine at a handful of times a round; never do it per tick.

The official docs also note the keyvalue can be message_text rather than message. message is what works in our plugins today, but if a label comes up blank with enabled set, that is the next thing to try.

Beams

CBeam.Create(from, to, width, colour);
CBeam.CreateBox(min, max, width, colour);                   // wireframe box
CBeam.CreatePolyline(points, width, colour, closed: true);   // a ring

beam.SetEndpoints(a, b);        // live, cheap: the only smooth server-side motion there is
beam.RenderColor = colour;      // live recolour, no redraw
Black beams are invisible, and beams are bandwidth

The CBeam docs state plainly that a black beam does not render. Dark grey is the darkest a beam can be and still be seen. This is why the first revive banner was a stack of rows floating in mid-air with nothing holding them up.

A ring is many beams, and every beam is a networked entity. A 10-segment ring is 10 entities. Five rings is 50. Cap the segment count, keep it in config, and stagger creation if you are building more than a handful at once.

Particles

var ps = CParticleSystem.Create("particles/...")
    .AtPosition(pos)
    .WithTint(colour)                       // colours through control point 0
    .WithDataCP(1, new Vector3(s, s, s))     // push data into a control point
    .StartActive(true)
    .Spawn();
ps.AttachTo(entity);                         // so it follows a moving thing

Clean them up yourself on a timer. WithTint is what lets one effect file serve as a gold hit, a red kill and a green revive.

What you cannot do to a particle from the server

A .vpcf's particle count lives inside the effect file and cannot be edited from the server. Pushing a scale down a control point sometimes works and sometimes the effect simply ignores that control point. If it visibly does nothing, the honest answer is that the effect file itself has to change, which means a content addon.

Precaching, and the hard hang

public override void OnPrecacheResources() {
    Precache.AddResource("particles/event/winter/fireworks_rainbow.vpcf");
    Precache.AddResource("models/npc/sewer_beast/sewer.vmdl");
    Precache.AddHero(Heroes.X);
}

Rules, all of them learned the hard way:

Entity inputs and outputs

e.AcceptInput("Kill", activator, caller, value);
Entities.HookOutput("class", "OnOutput", ev => HookResult.Continue);
Entities.HookInput("class", "Input", ev => HookResult.Continue);
Player entities do not fire standard entity I/O

Hooking inputs and outputs works on map-placed entities. For players use OnTakeDamage and the game events instead.

From the console: ent_create and ent_fire

You can build working scenario geometry at runtime with no Hammer compile at all. From a plugin these go through Server.ExecuteCommand.

ent_create <classname> {"key" "value" "key2" "value2"}
ent_fire <targetname> <input> [parameter] [delay] [max_fires]

// output routing, which is the real power: wire one entity's output to another's input
ent_fire my_trigger addoutput "OnTeleport>!Activator>SetScale>0.5>0>-1"
//                             event      target     input  arg delay max_fires

Special targets are !activator (whoever triggered it) and !caller (the entity firing). Useful inputs: Kill, Remove, AddOutput, SetScale, Color, Enable, Disable, TestActivator, Command (on a point_servercommand) and FireUser1 through FireUser4 for your own wiring.

Community footguns, each one someone's lost afternoon
  • citadel_trigger_teleport crashes without its target. Always set exitpoint and prop, and spawn those dependencies first. One report put the cost at an hour and a half.
  • citadel_trigger_teleport needs spawnflags 4097 to behave as expected.
  • logic_compare is broken. It always fires greater-than when it is not firing equal.
  • Deadlock teleports players who fly above the vanilla height limit back down. Keep sv_cheats 1 on to avoid it.
  • Bots do not work on large custom maps. Navmesh generation runs out of memory.
  • ent_create from the console spawns where the invoking player is looking, which is not what you want from a server-side call. Set origin explicitly.
  • solid values: 6 is the common value for a non-solid prop, 0 for no collision at all.

Classname reference

Community-observed and verified in use. The full authoritative list comes from the Deadworks DumperPlugin plus citadel.fgd; this is what we have confirmed.

Proven to spawn and behave in our gamemodes

ClassnameWhat it isStatus
citadel_item_powerup_spawnerThe golden statue. Punchable, hands out a pickup that grants a modifier when broken.proven
neutral_trooper_strongGeneric durable body. What the Bedwars beds are.proven
neutral_trooper_weakWeaker variant, used as a fallback body.proven
point_worldtextFloating label. See the three gotchas above.proven
citadel_restorative_goo_cubeViscous goo cube.hangs the thread

Powerup pickups and their rewards

Read out of the game's own misc.vdata. Every *_powerup_pickup subclass carries m_hModel = "models/null.vmdl", so the pickup is invisible by design and there is nothing to punch. The visible, punchable object in the real game is the spawner, which carries the sewer beast model at scale 1.5 and picks a pickup by weight.

Pickup entityModifier it grants
movement_powerup_pickupmodifier_citadel_powerup_movement
survival_powerup_pickupmodifier_citadel_powerup_survival
gun_powerup_pickupmodifier_citadel_powerup_gun
casting_powerup_pickupmodifier_citadel_powerup_casting

Map NPCs, the canonical removal list

These are what a scenario strips off a real map. Taken from the Deadworks TagPlugin example.

npc_boss_tier3 Patron
npc_boss_tier2 mid boss
npc_boss_tier1 walker
npc_barrack_boss
npc_base_defense_sentry
npc_trooper_boss
npc_trooper
npc_super_neutral

For troopers, prefer the cvars over removal: see Make a map quiet.

Props and anchors

prop_dynamic
prop_dynamic_override
prop_physics_multiplayer
info_target
info_target_server_only
info_player_start
info_player_spawn
info_team_spawn
info_neutral_trooper_spawn
point_nav_walkable
point_servercommand
point_pulse
point_soundevent
logic_timer
logic_compare broken
trooper_approach_horizon

Triggers

trigger_multiple
citadel_trigger_teleport
citadel_trigger_push
citadel_trigger_speed_boost
citadel_trigger_suspend_modifier
citadel_trigger_capture_zipline
citadel_trigger_hotel_exit
citadel_trigger_climb_rope
citadel_trigger_idol_return
citadel_trigger_shop_tunnel
citadel_tunnel_trigger
citadel_dev_trigger

Breakables and pickups

citadel_breakable_prop
citadel_breakable_prop_vase
citadel_breakable_prop_wooden_crate
citadel_breakable_prop_box_multi
citadel_breakable_prop_car
citadel_breakable_item_container
citadel_item_pickup
citadel_item_powerup_spawner
item_pickup
item_crate
item_container
item_powerup_spawner
item_pickup_rejuv_herotest_infospawn
Item_Rejuvenator

Map furniture

citadel_herotest_orbspawner
citadel_hideout_ball
citadel_hideout_shootable_target_spawner
citadel_hideout_trigger
citadel_shop_prop_dynamic
citadel_zipline_path
citadel_zipline_node
citadel_passthrough_fake_wall
citadel_new_years_fireworks

Managed API types, not things you spawn

CCitadelPlayerController
CCitadelPlayerPawn
CCitadelBaseAbility
CBaseEntity
CBeam
CParticleSystem
CPointWorldText
Do not use hero_testing_controller entities

Standing instruction on this project. They are not part of any gamemode here.

Zones, which beat per-tick distance checks

Added in Deadworks v0.5. A real trigger volume with events, which is almost always better than measuring distances on a timer:

// Entered and Left events, Contains, Occupants, and MoveTo for a zone that travels
zone.Entered += ...;
zone.Left += ...;
zone.Contains(pos);
zone.Occupants;
zone.MoveTo(newPos);

If you are writing a 10Hz loop that measures the distance from every player to a point, a zone is the thing you actually wanted.