Death, respawn and revive

Deadlock's respawn time is not one number. It is a ramp that grows with match time, plus a penalty for dying in your own base, plus a penalty for being rich, plus a penalty if your team has nobody else alive. For a custom gamemode you almost always want to flatten all of it, and that takes nine cvars rather than one.

The model, with real defaults

Every default on this page was read from a cvar dump taken off a live server, not from memory.

The override, which beats everything

ConVarDefaultWhat it does
citadel_player_override_spawn_time-1Any positive value forces that respawn time and skips the whole ramp. This is the big hammer.
citadel_bot_override_spawn_time-1Same, for bots.

The ramp

Respawn time is interpolated between a minimum and a maximum, and both ends step up at fixed points in match time. The _time cvars are the game-time thresholds in seconds; the others are the respawn lengths.

ConVarDefaultMeaning
citadel_player_spawn_time_min_ramp_18Minimum respawn, first band
citadel_player_spawn_time_min_ramp_1_time300Band 1 ends at 5 minutes
citadel_player_spawn_time_min_ramp_230Minimum respawn, second band
citadel_player_spawn_time_min_ramp_2_time1140Band 2 ends at 19 minutes
citadel_player_spawn_time_min_ramp_370Minimum respawn, third band
citadel_player_spawn_time_min_ramp_3_time1800Band 3 ends at 30 minutes
citadel_player_spawn_time_max_ramp_130Maximum respawn, first band
citadel_player_spawn_time_max_ramp_1_time114019 minutes
citadel_player_spawn_time_max_ramp_270Maximum respawn, second band
citadel_player_spawn_time_max_ramp_2_time180030 minutes
citadel_player_spawn_time_max_ramp_385Maximum respawn, third band
citadel_player_spawn_time_max_ramp_3_time240040 minutes
citadel_player_spawn_time_max_respawn_time90Hard ceiling on respawn time

The penalties, which are the easy ones to miss

ConVarDefaultWhat it adds
citadel_player_spawn_time_in_base_penalty10Extra seconds for dying inside your own base
citadel_player_death_no_heroes_respawn_penalty10Extra seconds when nobody on your team is alive
citadel_player_spawn_time_rich_nw_penalty_max_ramp_pct30Up to this much extra, as a percentage, for being ahead on net worth
citadel_player_spawn_time_rich_nw_penalty_max_ramp_extratime22Extra seconds at the top of the rich penalty
citadel_player_spawn_time_rich_nw_penalty_max_ramp_behind_nw_pct20Net worth gap at which the max penalty applies
citadel_player_spawn_time_rich_nw_penalty_min_ramp_behind_nw_pct5Net worth gap at which it starts
citadel_player_spawn_time_rich_nw_penalty_max_ramp_gametime1500Game time at which the rich penalty is at full strength

Pinning respawn flat

This is what both gamemodes do. Every ramp end is pinned to the same value so neither the engine's interpolation nor the base penalty can stretch it.

private void ApplyFastRespawnCvars() {
    float t = Math.Max(0.5f, Config.RespawnSeconds);
    ConVar.Find("citadel_player_override_spawn_time")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_min_ramp_1")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_min_ramp_2")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_min_ramp_3")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_max_ramp_1")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_max_ramp_2")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_max_ramp_3")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_max_respawn_time")?.SetFloat(t);
    ConVar.Find("citadel_player_spawn_time_in_base_penalty")?.SetFloat(0f);
    Console.WriteLine($"[respawn pinned to {t}s]");
}
What that code does not cover

It leaves the rich net worth penalty family and citadel_player_death_no_heroes_respawn_penalty at their defaults. The override should win regardless, but if you ever see a respawn that is longer than the number you pinned, those are the first two places to look. Zero them as well if you want absolute certainty:

ConVar.Find("citadel_player_death_no_heroes_respawn_penalty")?.SetFloat(0f);
ConVar.Find("citadel_player_spawn_time_rich_nw_penalty_max_ramp_pct")?.SetFloat(0f);
ConVar.Find("citadel_player_spawn_time_rich_nw_penalty_max_ramp_extratime")?.SetFloat(0f);
Why you want it pinned to about one second, not disabled

In a round-based mode a dead player is not "waiting to respawn", they are out of the round and your plugin decides what happens to them. The engine will respawn them whatever you do, so pin it flat and short: then the pawn is alive again almost immediately and your own code only has to move it somewhere. Leaving it at the 90 second default means a revive fired during the dead window has nothing to move.

The events, and the order they actually fire in

EventFieldsNotes
player_deathuserid, attackerReliable. Read the position here, before the engine moves the corpse.
player_spawnuseridFires before the pawn is fully populated on a first connect. Do not trust the pawn here.
player_respawneduseridWhat our plugins use to move a player after the engine brings them back.
player_hero_changeduseridReliable post-hero. Use this instead of player_spawn when you need the pawn.
entity_killedentindex_killedFor anything that is not a player.

Capture the death position in the handler, not later

[GameEventHandler("player_death")]
public HookResult OnPlayerDeath(GameEvent e) {
    var ctrl = e.GetPlayerController("userid");
    int slot = ctrl.EntityIndex - 1;

    // BEFORE the engine moves the corpse, or your marker lands somewhere else
    Vector3 fell = default; bool haveFell = false;
    var vp = e.GetPlayerPawn("userid");
    if (vp != null && vp.IsValid) { fell = vp.Position; haveFell = true; }

    // Anything that removes entities goes on the NEXT TICK, never here. Doing it
    // from inside a game event hook is the shape of thing that has hung this map.
    Timer.NextTick(() => { /* cleanup, win checks */ });
    return HookResult.Continue;
}

Re-read the pawn after a respawn

[GameEventHandler("player_respawned")]
public HookResult OnPlayerRespawned(GameEvent e) {
    int slot = e.GetPlayerController("userid").EntityIndex - 1;
    Timer.Once(700.Milliseconds(), () => {
        // Re-read from the controller rather than trusting the pawn captured at event
        // time. Writing to a captured handle left a Bedwars player stuck out of the
        // world with no log line to show for it.
        var p = Players.FromSlot(slot)?.GetHeroPawn()?.As<CCitadelPlayerPawn>();
        if (p == null || !p.IsValid) return;
        // now it is safe to teleport, set team, set health
    });
    return HookResult.Continue;
}

Life state

Alive = 0   Dying = 1   Dead = 2   Respawnable = 3   Respawning = 4

In practice pawn.Health > 0 is what our gamemodes test, because a plugin usually cares about "can this player act" rather than the exact engine state.

Building a revive mechanic

This is the BattleBox design, which went through three versions before it worked. Worth reading for what was thrown away as much as what was kept.

What did not work

What works

  1. On death, drop a marker where they fell: a ring of beams, a world text with their name, and a beam straight up so it is visible over cover.
  2. On a 100ms tick, test whether a living squad mate is inside the ring radius. Accumulate time while one is, reset to zero the moment they step out.
  3. At the required stand time, revive: remove the marker, teleport them back to the spot they went down on, set health to a fraction of maximum.
  4. Apply a brief debuff so the revive is not a free extra gun the instant it lands. BattleBox uses modifier_citadel_cursed for three seconds.
Two rules that make a revive feel fair
  • Put them back where they died, not at a spawn point. A team mate stood on that ring to earn it; sending the body across the arena made the whole mechanic pointless.
  • Only drop a marker if someone could actually use it. A revive needs a living squad mate, so in a squad of one there is never anybody. The first version dropped a ring nobody could use and then told the player a team mate was on the way, which is a message contradicting itself.

Tint the marker to show state without redrawing it

// CBaseEntity.RenderColor is live, so red turns sapphire the instant charging starts
foreach (var o in ring.Concat(beam))
    if (o is CBaseEntity e && e.IsValid) e.RenderColor = colour;

Making players unkillable

There is no "godmode" cvar to reach for. Refuse the damage:

public override HookResult OnTakeDamage(TakeDamageEvent args) {
    int idx = args.Entity.EntityIndex;
    if (_safePawns.Contains(idx)) return HookResult.Stop;
    return HookResult.Continue;
}
Keep it off the hot path

OnTakeDamage fires many times a second during a fight. Do not walk the player list inside it to work out whether the victim is a player. Keep a HashSet<int> of pawn entity indices and rebuild it once a second, so the test is one lookup. Rebuilding on a timer rather than maintaining it on events also means it survives a pawn entity index changing on respawn.

Blocking abilities is separate and cheaper, and the two are usually wanted together:

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

Respawn timings for things that are not players

ConVarDefaultControls
citadel_crate_respawn_interval300Breakable crates
citadel_breakable_prop_initial_spawn_time_override-1First breakable spawn
citadel_breakable_prop_respawn_spread0.5Jitter on breakable respawns
citadel_neutral_initial_spawn_time_override-1Neutral camps
citadel_midboss_initial_spawn_time_override-1Mid boss
citadel_powerup_initial_spawn_time_override-1First powerup spawn at every powerup camp
citadel_powerup_spawn_interval_override-1Powerup respawn interval. Default behaviour is 300s, which is why a freshly placed spawner looks empty.
citadel_debug_neutral_spawn_timesfalseLogs neutral spawn timing
The powerup interval cvars are global

Setting citadel_powerup_spawn_interval_override 1 so your own crystals cycle quickly also makes the map's own powerup camps cycle every second. If the map ships any, they will start popping. Sweep the ones that are not yours, and be careful that your "is this one of mine" test covers every spot you placed, or you will delete your own objective.