Recipes

Direct answers to the things you actually want to do, with the working code and the reason it is shaped that way. Everything here is lifted from a plugin currently running on the server.

Move a live player between teams

do not use TeamNum This is the single most expensive bug this project has hit.

// WRONG. The server-side number changes and the CLIENTS ARE NEVER TOLD.
pawn.TeamNum = 3;
// RIGHT. citadel_change_team is documented as the only known safe way to move a live
// player mid-match, and each application FLIPS the pawn between 2 and 3 rather than
// setting a side. So apply it once, and only when the pawn is on the wrong side.
private void SetTeam(CCitadelPlayerPawn? pawn, int target) {
    if (pawn == null || !pawn.IsValid) return;
    if (target != 2 && target != 3) return;      // the flip has only two sides to flip between
    int cur = pawn.TeamNum;
    if (cur == target) return;
    pawn.AddModifier("citadel_change_team");
    int now = pawn.TeamNum;
    if (now != target)
        Console.WriteLine($"[team] asked for {target}, was {cur}, now {now}");
}
What the wrong version looks like in game

"MY BULLETS DON'T LAND." Every server-side team check behaved, the API reported the right teams, and both players' clients still had everyone on one side, so a client refused to let its own shots register on a target it read as an ally. From the Deadworks Discord: "setting TeamNum doesn't network properly", and raw m_iTeamNum writes also leave the ability upgrade UI locked.

Do not pair the modifier with a raw TeamNum write. The modifier does the networking; the write undoes it.

Outside a live match, controller.ChangeTeam(2|3) is the officially blessed call. Teams 2 (Amber) and 3 (Sapphire) are the only two Deadlock really supports. Teams 4 to 47 are engine-safe but the team relationships break in both directions up there: you become your own enemy and enemies read as allies. Bedwars puts each player on their own team and every workaround in it exists only to paper over that.

Make a map quiet

ConVar.Find("citadel_trooper_spawn_enabled")?.SetInt(0);
ConVar.Find("citadel_npc_spawn_enabled")?.SetInt(0);
ConVar.Find("citadel_trooper_use_ziplines")?.SetInt(0);
ConVar.Find("citadel_start_players_on_zipline")?.SetInt(0);
ConVar.Find("ai_disabled")?.SetInt(1);

Prefer the cvars over removing entities. If you do need the bosses gone, the canonical list is npc_boss_tier3, npc_boss_tier2, npc_boss_tier1, npc_barrack_boss, npc_base_defense_sentry, npc_trooper_boss and npc_trooper.

Remove every entity of a kind

// .ToList() first: you are about to mutate what you are iterating.
int n = 0;
foreach (var e in Entities.ByDesignerName("npc_trooper").ToList()) {
    try { if (e.IsValid) { e.Remove(); n++; } } catch { }
}
Console.WriteLine($"removed {n}");

With a position filter, which is what you want for "clean up around my objective":

foreach (var e in Entities.ByDesignerName(unit).ToList()) {
    try {
        if (Vector3.Distance(e.Position, spot) > range) continue;
        e.Remove();
    } catch { }
}
Be exact about what counts as yours

A sweep that removes "anything of this class not near one of my spots" will delete your own objective the moment your "my spots" list is incomplete. If you add a sixth spawn point and forget to add it to that test, the sweep eats it. Derive both lists from the same function.

Freeze a player

Two independent things, and you usually want both.

Block their input

public override void OnAbilityAttempt(AbilityAttemptEvent args) {
    int slot = args.PlayerSlot;
    if (!ShouldBlock(slot)) return;
    args.BlockedButtons |= InputButton.AllAbilities | InputButton.AllItems | InputButton.Attack;
}

Stop them moving

pawn.AddModifier("modifier_citadel_pre_match_wait");   // Valve's own countdown freeze
pawn.AddModifier("modifier_citadel_stunned");          // movement and actions
pawn.AddModifier("modifier_citadel_root");             // movement only, can still shoot

And make them unhurtable while they are

See Zero out damage below. A frozen player who can still be killed is worse than one who cannot act.

Zero out damage

// Pawn entity indices that cannot be hurt. A SET, because OnTakeDamage fires many
// times a second during a fight and must not walk the player list.
private readonly HashSet<int> _safePawns = new();

public override HookResult OnTakeDamage(TakeDamageEvent args) {
    var v = args.Entity;
    if (v == null) return HookResult.Continue;
    if (_safePawns.Count > 0 && _safePawns.Contains(v.EntityIndex)) return HookResult.Stop;
    return HookResult.Continue;
}

// Rebuild once a second rather than maintaining it on events, so it survives a pawn
// entity index changing on respawn.
private void RefreshSafePawns() {
    _safePawns.Clear();
    if (!safeRightNow) return;
    foreach (var c in Players.GetAll()) {
        var pw = c.GetHeroPawn();
        if (pw != null && pw.IsValid) _safePawns.Add(pw.EntityIndex);
    }
}

Refuse damage to a specific thing rather than a player the same way, by matching the entity index you recorded when you created it:

foreach (var thing in myThings) {
    if (thing.EntIndex != v.EntityIndex) continue;
    return HookResult.Stop;        // a punching bag, never a kill target
}

Deal damage yourself

pawn.Hurt(50f, attacker: attackerPawn);

var info = new CTakeDamageInfo(...);
target.TakeDamage(info);

Damage flags worth knowing: PreventDeath, ForceDeath, SuppressHealthChanges, AllowSuicide.

For damage over time, use a sequence rather than a chain of timers:

Timer.Sequence(step => {
    ent.Hurt(dmg, attacker: a);
    return step.Wait(500.Milliseconds());
});

Set souls and ability points

// SET, not add. The ModifyCurrency(0) afterwards is what makes the client update.
private void SetSouls(CCitadelPlayerPawn pawn, int amount) {
    pawn.SetCurrency(ECurrencyType.EGold, Math.Max(0, amount));
    pawn.ModifyCurrency(ECurrencyType.EGold, 0, ECurrencySource.ECheats, silent: true);
}

private void SetAbilityPoints(CCitadelPlayerPawn pawn, int n) {
    pawn.SetCurrency(ECurrencyType.EAbilityPoints, Math.Max(0, n));
    pawn.ModifyCurrency(ECurrencyType.EAbilityPoints, 0, ECurrencySource.ECheats, silent: true);
}
A match start is a RESET, not a top up

Souls went through SetCurrency and were fine. Ability points went through a grant, which adds: two matches in a row left you holding four unspent points, three left six. Worse, points already spent on ability levels carried over as levels, so a second match started with a part-levelled kit.

pawn.ResetHero();              // back to scratch, items and levels included
SetSouls(pawn, startingSouls);  // then SET both currencies absolutely, so they do not
SetAbilityPoints(pawn, 2);     // depend on whatever ResetHero left behind

Give, take and ban items

pawn.AddItem(name, enhanced);
pawn.RemoveItem(name);
pawn.SellItem(name);
pawn.AddAbility(name) / RemoveAbility(name);
pawn.ExecuteAbilityBySlot(slot);

There is no purchase hook. A ban list is enforced by taking the item back off the pawn: once a second through the shop phase, and once more the instant the fight starts in case something slipped through on the last tick.

// Items in Deadlock ARE abilities, so the loadout reads off the ability component.
// Checking first means the sell only runs on an item they genuinely hold, instead of
// firing blind once a second and spamming chat.
var hold = new List<string>();
foreach (var ab in pawn.AbilityComponent.Abilities) {
    string an = ab.AbilityName ?? "";
    if (an.Length > 0 && banned.Any(b => an.Equals(b, StringComparison.OrdinalIgnoreCase)))
        hold.Add(an);
}
foreach (var an in hold) {
    // Refund in full even though normal sells return half: they bought something the
    // shop should never have offered, so charging them for our gap is the wrong way
    // round. Flip the ratio, sell, put it straight back, all in one call.
    ratio?.SetFloat(1f);
    try { pawn.SellItem(an); } catch { try { pawn.RemoveItem(an); } catch { } }
    ratio?.SetFloat(normalRatio);
}
Get the item name from the game, not the display name

Rebuttal's internal name is upgrade_melee_rebuttal, read from the game's own localization file which carries the comment // Rebuttal - upgrade_melee_rebuttal. The display name in the shop is no guide to the identifier.

Also worth knowing before you design around cross-team effects: globally-targeted ally items like Celestial Blessing "replenish your allies globally" and ignore distance entirely, so in a two-arena mode where both side-A squads share engine team 2 they reach across the map.

Teleport a player, facing the right way

private void TeleportTo(CCitadelPlayerPawn? pawn, SavedPoint pt, int fanIndex, Vector3? lookAt) {
    if (pawn == null || !pawn.IsValid) return;

    // Fan them out so four players do not spawn inside each other. One savepoint per
    // side is enough; spread them in code.
    var off = Vector3.Zero;
    if (fanIndex > 0) {
        double ang = fanIndex * (Math.PI * 2.0 / 6.0);
        off = new Vector3((float)(Math.Cos(ang) * 90), (float)(Math.Sin(ang) * 90), 0);
    }

    // Compute the yaw from a point to look at. Every savepoint we have ever placed
    // carries yaw 0, so without this both sides spawn looking due east.
    float yaw = pt.Yaw;
    if (lookAt.HasValue) {
        var d = lookAt.Value - new Vector3(pt.X, pt.Y, pt.Z);
        if (Math.Abs(d.X) > 1f || Math.Abs(d.Y) > 1f)
            yaw = (float)(Math.Atan2(d.Y, d.X) * 180.0 / Math.PI);
    }

    pawn.Teleport(new Vector3(pt.X + off.X, pt.Y + off.Y, pt.Z), new Vector3(0, yaw, 0), Vector3.Zero);
    pawn.AbsVelocity = Vector3.Zero;     // or they keep their momentum
}
Recording a position: EyeAngles, not ViewAngles

ViewAngles came back as zero for all 26 points placed on one map, so every saved point faced due east. EyeAngles is the one that reads the camera. Read eye angles and fall back to view angles:

var a = p.ViewAngles;
try { var ea = p.EyeAngles; if (Math.Abs(ea.Y) > 0.01f || Math.Abs(ea.X) > 0.01f) a = ea; } catch { }

Full health and clear cooldowns

private void Refresh(CCitadelPlayerPawn? p) {
    if (p == null || !p.IsValid) return;
    try { int max = p.GetMaxHealth(); if (max > 0) p.Health = max; } catch { }
    try { foreach (var ab in p.AbilityComponent.Abilities) ab.CooldownEnd = 0f; } catch { }
}
Clearing cooldowns server-side does not clear the client's wheel

Setting CooldownEnd = 0 works on the server and the client keeps drawing the ability wheel as if it were still spinning. Rather than chase the networking, let the engine do it:

Server.ExecuteCommand("citadel_enable_fast_cooldowns 1");   // during the shop phase
Server.ExecuteCommand("citadel_enable_fast_cooldowns 0");   // when the round starts

Timers that cannot fire into the wrong round

the bug this prevents is nasty A round that ends early leaves its own duration timer pending. That timer later fires, sees the phase is still Fight, and ends a different round. Symptom: "keeps ending early on time", with round 2 dying at the exact 120 second mark of round 1.

// Two counters. _matchGen guards across matches, which is not enough on its own:
// _phaseGen is the one that stops a timer from one round ending a later one.
private int _matchGen, _phaseGen;

// capture both when you arm the timer
int gen = _matchGen, pg = _phaseGen;
Timer.Once(duration.Seconds(), () => {
    if (gen != _matchGen || pg != _phaseGen || _phase != Phase.Fight) return;
    // safe: this really is the round that armed me
});

Increment _phaseGen in every phase transition and _matchGen on every start and reset. The pattern also protects anything that reaches into a box: re-find it by number rather than capturing the object, and bail if it is gone.

Timer.Once(delay, () => {
    if (gen != _matchGen || pg != _phaseGen) return;
    var bx = _boxes.FirstOrDefault(x => x.Box == boxNo);
    if (bx == null || bx.Finished) return;
    ...
});

Build many entities without jamming a connection

// Entities are bandwidth. ~45 created in one tick jammed one player's connection
// outright while the server stayed perfectly healthy: the log repeated "recv queue
// overflow 100 messages already queued" for that one player. Spread the work.
int gen = _matchGen, pg = _phaseGen, stagger = 200;
for (int i = 0; i < things.Count; i++) {
    int idx = i;
    Timer.Once((idx * stagger).Milliseconds(), () => {
        if (gen != _matchGen || pg != _phaseGen) return;
        BuildOne(idx);
    });
}

And keep the count in config, so the fix for a stutter is a chat command rather than a deploy.

Do entity work on the next tick, not inside an event hook

[GameEventHandler("player_death")]
public HookResult OnPlayerDeath(GameEvent e) {
    // read what you need from the event here, it is only valid now
    int boxNo = ...;
    int gen = _matchGen, pg = _phaseGen;

    // then get out. Removing the objective, the pickups and every marker from inside a
    // game event hook is the shape of thing that has hung this map before.
    Timer.NextTick(() => {
        if (gen != _matchGen || pg != _phaseGen) return;
        var bx = _boxes.FirstOrDefault(x => x.Box == boxNo);
        if (bx != null) CheckWinConditions(bx);
    });
    return HookResult.Continue;
}

Detect a punch without classifying a melee

this is the good trick You cannot tell a heavy melee from a light one. The game reports citadel_ability_hold_melee for both, and melee damage scales with level and items so an early heavy overlaps a late light. Measured in a real game: light around 50 damage, heavy around 99, same ability name. A damage threshold works for about one round.

So do not classify anything. Spawn a punchable powerup, which grants a modifier the instant it breaks, and treat the modifier arriving as the punch.

public override HookResult OnAddModifier(AddModifierEvent args) {
    string name = args.ModifierVData.Name ?? "";

    // Only the DOUBLED form means it broke. The other three it grants are proximity
    // auras that fire every ~15ms just from standing there.
    int sl = name.IndexOf('/');
    if (sl <= 0 || name.IndexOf('/', sl + 1) >= 0) return HookResult.Continue;
    if (name.Substring(0, sl) != name.Substring(sl + 1)) return HookResult.Continue;

    // resolve the owner to a slot, apply a per-player lockout so one swing is one
    // punch, then do your thing
    if (Environment.TickCount64 < _lock.GetValueOrDefault(slot)) return HookResult.Stop;
    _lock[slot] = Environment.TickCount64 + 600;
    RegisterPunch(slot);

    return HookResult.Stop;     // and never actually gain the buff
}

Two things make it reliable. A per-player lockout, because a single heavy melee produces several damage events and without it one swing could win a round. And setting the powerup camp timings so it comes back quickly:

Server.ExecuteCommand("citadel_powerup_spawn_enabled 1");
Server.ExecuteCommand("citadel_powerup_initial_spawn_time_override 1");
Server.ExecuteCommand("citadel_powerup_spawn_interval_override 1");

Give your plugin an HTTP API

This is how the web panel, the Discord bot and the lobby daemon all read and drive a gamemode.

_listener = new HttpListener();
_listener.Prefixes.Add($"http://+:{_apiPort}/");
_listener.Start();
Task.Run(async () => {
    while (_listener.IsListening) {
        var ctx = await _listener.GetContextAsync();
        _ = Task.Run(() => HandleHttp(ctx));
    }
});
Three rules, each one learned from a real failure
  1. The port must come from config, not a literal. It was hardcoded to 18080 until a second copy of the same plugin ran on one machine: the second one hit the catch in StartHttpApi, logged one line, and then ran with no API at all. No player count, no remote start, nothing the daemon could see. Read it from the mode file, keep the old number as the default so existing callers are unaffected.
  2. Never touch game state from the listener thread. It is not the game thread. Queue the work and drain the queue on a timer:
    private readonly ConcurrentQueue<Action> _q = new();
    Timer.Every(200.Milliseconds(), () => {
        int n = 0;
        while (n++ < 50 && _q.TryDequeue(out var a)) try { a(); } catch { }
    });
    // in the handler: _q.Enqueue(() => StartMatch());
  3. Serialise the live state on a timer, not per request. Build the JSON once a second into a volatile string and have the handler return that. A request must never walk the entity list.

Config that does not lie to you

[PluginConfig]
public MyConfig Config { get; set; } = new();
The trap that silently reverted three fixes

Once the plugin has run, configs/<Plugin>/<Plugin>.jsonc exists, and a key in that file overrides the C# default at every load, forever. You change a default, deploy, and nothing happens, because the saved file still holds the old value.

During development, delete it as part of the deploy:

if (Test-Path "...\configs\MyPlugin\MyPlugin.jsonc") { Remove-Item ... -Force }

In production, compare GET /api/config against the file on disk before believing either one.

Also: a retired field should be left declared even when nothing reads it any more, or an existing saved file fails to deserialise. Mark it retired in a comment instead of deleting it.

Run several copies of one gamemode at once

What makes an instance an instance is its exe directory, and nothing else. Deadworks loads plugins from <exe dir>/managed/plugins, and a plugin reads its scenarios and configs from AppContext.BaseDirectory, which is that same directory. So two instances can only run different gamemodes, savepoints and configs if they have different exe directories.

Everything else in game/ is read-only content and can be an NTFS junction to one shared install, which is why an extra instance costs 610 MB rather than 37 GB.

Per-instance things that are easy to forget
  • The HTTP port, from the mode file. See above.
  • scenarios/api_token.txt, or that instance's API answers 401 to everything.
  • A Windows Firewall rule for the API port, separate from the game port rule. Missing it looks like a healthy server whose every API read times out.
  • Do not mirror-copy the shared install over an instance. A robocopy /MIR with no excludes will delete that instance's scenarios, meaning every savepoint, and its staged gamemodes. Use /E with /XD scenarios plugins.

Savepoints: let the map tell you what is missing

Positions live in scenarios/points.<map>.json. Two things make this pleasant instead of painful.

The map name is empty when the plugin loads

// Server.MapName is EMPTY at plugin load and stays empty for seconds. Take the name
// from the mode file instead, and keep a hint for when the engine has not caught up.
private static bool UsableMapName(string m) =>
    !string.IsNullOrWhiteSpace(m) && m != "?"
    && m.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '-');

Report what is missing in words, with the command to fix it

// A requirement is satisfied by ANY of its accepted names, so an older naming scheme
// still works and a part-placed map can still be played.
private readonly record struct PointReq(string What, string[] Accept);

foreach (var r in PointRequirements()) {
    if (r.Accept.Any(n => _points.ContainsKey(n))) continue;
    out_.Add($"{r.What}: /savepoint {r.Accept[0]}");
}
// "arena 1 MIDDLE capture crystal (2 punches): /savepoint newcrystalmid_b1"
Decide the shape of the match BEFORE you check the points

The point check reads how many squads and arenas are in play. If that number still holds the previous match's value, a two player start demands four prep boxes and both arenas, and a four player start after a two player one only checks half the map. Set the count first, then check.