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
| ConVar | Default | What it does |
|---|---|---|
citadel_player_override_spawn_time | -1 | Any positive value forces that respawn time and skips the whole ramp. This is the big hammer. |
citadel_bot_override_spawn_time | -1 | Same, 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.
| ConVar | Default | Meaning |
|---|---|---|
citadel_player_spawn_time_min_ramp_1 | 8 | Minimum respawn, first band |
citadel_player_spawn_time_min_ramp_1_time | 300 | Band 1 ends at 5 minutes |
citadel_player_spawn_time_min_ramp_2 | 30 | Minimum respawn, second band |
citadel_player_spawn_time_min_ramp_2_time | 1140 | Band 2 ends at 19 minutes |
citadel_player_spawn_time_min_ramp_3 | 70 | Minimum respawn, third band |
citadel_player_spawn_time_min_ramp_3_time | 1800 | Band 3 ends at 30 minutes |
citadel_player_spawn_time_max_ramp_1 | 30 | Maximum respawn, first band |
citadel_player_spawn_time_max_ramp_1_time | 1140 | 19 minutes |
citadel_player_spawn_time_max_ramp_2 | 70 | Maximum respawn, second band |
citadel_player_spawn_time_max_ramp_2_time | 1800 | 30 minutes |
citadel_player_spawn_time_max_ramp_3 | 85 | Maximum respawn, third band |
citadel_player_spawn_time_max_ramp_3_time | 2400 | 40 minutes |
citadel_player_spawn_time_max_respawn_time | 90 | Hard ceiling on respawn time |
The penalties, which are the easy ones to miss
| ConVar | Default | What it adds |
|---|---|---|
citadel_player_spawn_time_in_base_penalty | 10 | Extra seconds for dying inside your own base |
citadel_player_death_no_heroes_respawn_penalty | 10 | Extra seconds when nobody on your team is alive |
citadel_player_spawn_time_rich_nw_penalty_max_ramp_pct | 30 | Up to this much extra, as a percentage, for being ahead on net worth |
citadel_player_spawn_time_rich_nw_penalty_max_ramp_extratime | 22 | Extra seconds at the top of the rich penalty |
citadel_player_spawn_time_rich_nw_penalty_max_ramp_behind_nw_pct | 20 | Net worth gap at which the max penalty applies |
citadel_player_spawn_time_rich_nw_penalty_min_ramp_behind_nw_pct | 5 | Net worth gap at which it starts |
citadel_player_spawn_time_rich_nw_penalty_max_ramp_gametime | 1500 | Game 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]");
}
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);
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
| Event | Fields | Notes |
|---|---|---|
player_death | userid, attacker | Reliable. Read the position here, before the engine moves the corpse. |
player_spawn | userid | Fires before the pawn is fully populated on a first connect. Do not trust the pawn here. |
player_respawned | userid | What our plugins use to move a player after the engine brings them back. |
player_hero_changed | userid | Reliable post-hero. Use this instead of player_spawn when you need the pawn. |
entity_killed | entindex_killed | For 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
- Punch the marker a set number of times. This needs you to classify light against
heavy melee, and you cannot: the game reports
citadel_ability_hold_meleefor both, and melee damage scales with level and items so an early heavy and a late light overlap. Measured in a real game: light reported around 50 damage, heavy around 99, with the same ability name. - Count the melees anyway. Replaced with "let the marker take real damage and fire the revive when it dies", so nothing has to classify anything.
- A 35 unit ring to stand in. A hero is about 70 units wide, so the ring was narrower than the player and charging it meant standing on one exact pixel. 140 is the working radius.
What works
- 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.
- 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.
- 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.
- Apply a brief debuff so the revive is not a free extra gun the instant it lands. BattleBox uses
modifier_citadel_cursedfor three seconds.
- 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;
}
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
| ConVar | Default | Controls |
|---|---|---|
citadel_crate_respawn_interval | 300 | Breakable crates |
citadel_breakable_prop_initial_spawn_time_override | -1 | First breakable spawn |
citadel_breakable_prop_respawn_spread | 0.5 | Jitter on breakable respawns |
citadel_neutral_initial_spawn_time_override | -1 | Neutral camps |
citadel_midboss_initial_spawn_time_override | -1 | Mid boss |
citadel_powerup_initial_spawn_time_override | -1 | First powerup spawn at every powerup camp |
citadel_powerup_spawn_interval_override | -1 | Powerup respawn interval. Default behaviour is 300s, which is why a freshly placed spawner looks empty. |
citadel_debug_neutral_spawn_times | false | Logs neutral spawn timing |
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.