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;
}
- 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_cubehangs 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;
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 { }
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);
enableddefaults to FALSE. WithoutSetBool("enabled", true)you get a label nobody can see and no error.- Pass real angles. The first attempt passed
Vector3.Zeroand every label lay flat on its back in the air.0 0 90is confirmed correct in game. The keyvalue isreorient_mode, notorientation. - There is no setter for the string.
m_messageTextis a networked inlinechar[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
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.
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:
- Uncompiled paths only.
.vmdland.vpcf, never.vmdl_cor.vpcf_c. - A resource that is not precached does nothing when spawned, silently. This is why the first particle test "succeeded" and showed nothing.
- A missing precache on some resources is a hard hang of the game thread, not a
missing effect. The engine named the file itself in the log:
Resource "particles/environment/powerup_spawner_buff.vpcf" was not precached but was loaded by a just in time blocking load. That file is referenced by no script at all; it is a child effect inside an ambient particle, and precaching a parent effect does not pull its children. When a particle system misbehaves, precache every file in its family. - On a cold start the map loads before the plugin exists, so
OnPrecacheResourcesnever fires and everything you reference silently does nothing. The workaround is to reload the same map once, after the plugin is up. Guard it with a static flag so a failure can never become a changelevel loop.
Entity inputs and outputs
e.AcceptInput("Kill", activator, caller, value);
Entities.HookOutput("class", "OnOutput", ev => HookResult.Continue);
Entities.HookInput("class", "Input", ev => HookResult.Continue);
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.
citadel_trigger_teleportcrashes without its target. Always setexitpointandprop, and spawn those dependencies first. One report put the cost at an hour and a half.citadel_trigger_teleportneedsspawnflags 4097to behave as expected.logic_compareis 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 1on to avoid it. - Bots do not work on large custom maps. Navmesh generation runs out of memory.
ent_createfrom the console spawns where the invoking player is looking, which is not what you want from a server-side call. Setoriginexplicitly.solidvalues:6is the common value for a non-solid prop,0for 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
| Classname | What it is | Status |
|---|---|---|
citadel_item_powerup_spawner | The golden statue. Punchable, hands out a pickup that grants a modifier when broken. | proven |
neutral_trooper_strong | Generic durable body. What the Bedwars beds are. | proven |
neutral_trooper_weak | Weaker variant, used as a fallback body. | proven |
point_worldtext | Floating label. See the three gotchas above. | proven |
citadel_restorative_goo_cube | Viscous 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 entity | Modifier it grants |
|---|---|
movement_powerup_pickup | modifier_citadel_powerup_movement |
survival_powerup_pickup | modifier_citadel_powerup_survival |
gun_powerup_pickup | modifier_citadel_powerup_gun |
casting_powerup_pickup | modifier_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.
For troopers, prefer the cvars over removal: see Make a map quiet.
Props and anchors
Triggers
Breakables and pickups
Map furniture
Managed API types, not things you spawn
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.