A Razor UI component for the downed and game-over HUD. It renders three states (game over, bled out, down) showing titles, timers, revive progress, spectate info and final stats, and exposes console commands for testing (down, revive, game over, restart).
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
DOWNED + GAME OVER.
Two states of one story, so one component: you go down, a bleedout bar runs,
and if it empties the score screen replaces it. Splitting them into two
PanelComponents would mean two attach points and two chances for one to be
on screen while the other still is.
Ported behaviour:
bleedout nz_downtime = 45s (config/sh_constructor.lua)
revive nz_revivetime = 4s (unused solo — nobody to revive you)
game over nzRound:GameOver (round/sv_round.lua:483), reached from the
round think when nobody is left up.
⚠️ THE HUD IS NOT HIDDEN UNDERNEATH. The blood overlay sits at z-index -1 so
the health bar stays readable; this sits ON TOP because once you are down the
bleedout IS the only number that matters.
*@
<root class="downed @HudTheme.Class">
@if ( Over )
{
<div class="over">
<div class="title">@OverTitle</div>
@if ( !string.IsNullOrEmpty( Reason ) )
{
<div class="reason">@Reason</div>
}
<div class="stats">
<div class="stat">
<div class="k">Round reached</div>
<div class="v round">@RoundReached</div>
</div>
<div class="stat">
<div class="k">Points</div>
<div class="v">@($"{FinalPoints:N0}")</div>
</div>
</div>
<div class="hint">Returning to lobby in @($"{LobbyIn:0}")s</div>
</div>
}
else if ( BledOut )
{
@*
⛔ ITS OWN STATE, NOT A BLEEDOUT BAR AT ZERO. The body has been taken out of the
world by `ApplyBledOutBody` and nobody can reach it — leaving the timer on screen
showed a number that had stopped meaning anything over a player who looked like they
could still be saved.
*@
<div class="bleed out">
<div class="title">@BledTitle</div>
<div class="secs">Back next round</div>
@if ( Watching is not null )
{
@* ⚠️ WHO YOU ARE WATCHING, AND THE GMOD KEYS TO SWITCH (2026-10-05, SpectateOthers). *@
<div class="spec">WATCHING @Watching</div>
<div class="keys">Left click next · Right click previous · R @(FirstPerson ? "from behind" : "through their eyes")</div>
}
</div>
}
else if ( Down )
{
<div class="bleed">
<div class="title">@DownTitle</div>
<div class="track"><div class="fill" style="width: @(Left * 100)%"></div></div>
<div class="secs">@(SecondsShown)s</div>
@*
⛔ THE ONE FACT A DOWNED PLAYER CANNOT OTHERWISE HAVE. `ReviveProgress` lives on
the RESCUER — deliberately, so two rescuers race rather than share — and in co-op
that is another computer. So you watched a bleedout drain with no way to know
whether anyone was coming, which is exactly what decides whether to spend Quick
Revive.
⚠️ The clock runs locally off two messages, start and stop, and expires by
itself — see `NZPlayer.BeingRevivedSeconds`.
*@
@if ( Helped )
{
<div class="help">
<div class="label">BEING REVIVED</div>
<div class="track help"><div class="fill" style="width: @(HelpFrac * 100)%"></div></div>
</div>
}
</div>
}
</root>
@code
{
static RoundManager Rounds => RoundManager.Instance;
static NZPlayer Player => NZPlayer.Local;
static bool Over => Rounds.IsValid() && Rounds.State == RoundState.GameOver;
static bool Down => Player.IsValid() && Player.IsDown;
static string Reason => Rounds.IsValid() ? Rounds.GameOverReason : "";
static int FinalRound => Rounds.IsValid() ? Rounds.FinalRound : 0;
// ⛔ BASALT SPEAKS FOR ITSELF (`HudTheme`, 2026-09-27) — the fall as inscriptions, and the round as its counter reads it.
static string OverTitle => HudTheme.Basalt ? "BASALT CLAIMS ANOTHER" : "GAME OVER";
static string BledTitle => HudTheme.Basalt ? "THE EMBERS GO OUT" : "YOU BLED OUT";
static string DownTitle => HudTheme.Basalt ? "THE STONE HOLDS YOU" : "YOU ARE DOWN";
/// <summary>The round reached, as the HUD's counter reads it: Roman I to IX on basalt (`HudTheme.RomanFor`), digits else.</summary>
static string RoundReached => HudTheme.RomanFor( FinalRound ) ? HudTheme.ToRoman( FinalRound ) : FinalRound.ToString();
static int FinalPoints => Rounds.IsValid() ? Rounds.FinalPoints : 0;
/// <summary>Bleedout remaining, 1 -> 0.</summary>
static float Left => Player.IsValid() ? (1f - Player.BleedoutFraction).Clamp( 0f, 1f ) : 0f;
static float Seconds => Player.IsValid() ? MathF.Max( 0f, Player.BleedsOutIn ) : 0f;
/// <summary>
/// The bleedout, as the whole number actually shown.
///
/// ⛔ THE COUNTDOWN USED TO STICK ON 1 AND NEVER REACH 0, AND IT WAS THE TWO ROUNDINGS
/// DISAGREEING. `BuildHash` quantised with `(int)Seconds`, which TRUNCATES, while the markup
/// formatted with `{Seconds:0}`, which ROUNDS. Below one second the hash is 0 and never
/// changes again — so the last rebuild is whatever was on screen at the time, and anything
/// from 0.5 upward had rendered as "1". The number was correct; the panel had stopped asking.
///
/// ⚠️ ONE VALUE FOR BOTH, AND IT CEILINGS. A countdown that reads "1" for the final second
/// and "0" only at zero is what a countdown is; truncating instead would show "0" for a whole
/// second while the player could still be saved.
/// </summary>
static int SecondsShown => (int)MathF.Ceiling( Seconds );
/// <summary>
/// Out of time, waiting for the next round. Not the same as merely down.
///
/// ⚠️ `IsOutOfRound`, NOT `HasBledOut`. Game over sets the latter on everybody through
/// `ForceDown`, and this panel already has its own state for that — `Over` wins the branch.
/// </summary>
static bool BledOut => Player.IsValid() && Player.IsOutOfRound;
/// <summary>Who this machine watches after bleeding out (`SpectateOthers`), or null; and whether through their eyes.</summary>
static string Watching => SpectateOthers.WatchingName;
static bool FirstPerson => SpectateOthers.FirstPerson;
/// <summary>Is somebody picking me up right now?</summary>
static bool Helped => Player.IsValid() && Player.BeingRevived;
/// <summary>0 -> 1 of the revive being performed on me.</summary>
static float HelpFrac => Player.IsValid() ? Player.BeingRevivedFraction : 0f;
/// <summary>Seconds until the map clears and everyone goes to the lobby.</summary>
static float LobbyIn => Rounds.IsValid()
? MathF.Max( 0f, Rounds.GameOverHold - Rounds.SinceGameOver )
: 0f;
// ⚠️ Both countdowns enter the hash as WHOLE SECONDS, not as the raw floats —
// hashing a value that changes every frame would rebuild the tree every
// frame. A second is as often as either displayed number can change.
/// <summary>
/// ⚠️ THE HELP BAR IS QUANTISED INTO THE HASH, NOT LEFT TO `Seconds`. Its width is an
/// inline style in the markup, so it only moves when the tree rebuilds — and the bleedout
/// second is the coarsest clock here. A four-second revive driven off it would advance in
/// four visible jumps. Twenty steps is a step every fifth of a second: smooth enough to read
/// as filling, and nowhere near a rebuild per frame.
/// </summary>
/// ⚠️ THE HELP PAIR IS NESTED because `HashCode.Combine` takes at most EIGHT arguments and
/// this panel now tracks nine things. Nesting is the documented way past that, and it groups
/// the two that belong together anyway.
protected override int BuildHash() => System.HashCode.Combine(
Over, Down, BledOut, SecondsShown, (int)LobbyIn, FinalRound, FinalPoints,
System.HashCode.Combine( Helped, (int)(HelpFrac * 20f), HudTheme.Class, Watching, FirstPerson ) );
// ── console ─────────────────────────────────────────────────────────────
/// <summary>
/// `nz_down [seconds]` — go down on the spot, for testing the state.
///
/// ⚠️ THE OPTIONAL BLEEDOUT IS WHAT MAKES THE END OF IT TESTABLE. Watching the real 45
/// seconds run out is the only way to see the countdown reach zero and the body despawn, and
/// nobody does that twice. `nz_down 6` is six.
///
/// ⚠️ IT STAYS SET until changed or the player respawns — it is the player's own
/// `BleedoutTime` property, not a one-shot, so a second `nz_down` uses it again.
/// </summary>
[ConCmd( "nz_down" )]
public static void CmdDown( float seconds = -1f )
{
var p = Player;
if ( !p.IsValid() ) { Log.Info( "[nz] no player" ); return; }
if ( p.IsDown ) { Log.Info( "[nz] already down" ); return; }
if ( seconds > 0f )
{
p.BleedoutTime = seconds;
Log.Info( $"[nz] bleedout set to {seconds:0.#}s" );
}
// ⚠️ Through the DAMAGE path, not by setting IsDown — GoDown is private
// and everything that hangs off going down (health reset, bleedout,
// retargeting) lives there. A test that sets the flag directly would
// exercise a state the game can never actually reach.
p.Hp?.Apply( p.Hp.Current );
Log.Info( "[nz] downed via damage path" );
}
/// <summary>`nz_revive` — back up, full health. The co-op revive, minus the hold.</summary>
[ConCmd( "nz_revive" )]
public static void CmdRevive()
{
var p = Player;
if ( !p.IsValid() ) { Log.Info( "[nz] no player" ); return; }
p.Revive();
Log.Info( "[nz] revived" );
}
/// <summary>`nz_gameover` — end the run now, without bleeding out first.</summary>
[ConCmd( "nz_gameover" )]
public static void CmdGameOver()
{
if ( !Rounds.IsValid() ) { Log.Info( "[nz] no round manager" ); return; }
Rounds.EndGame( "Ended from console" );
}
/// <summary>`nz_restart` — new run from round 1.</summary>
[ConCmd( "nz_restart" )]
public static void CmdRestart()
{
if ( !Rounds.IsValid() ) { Log.Info( "[nz] no round manager" ); return; }
// ⚠️ Revive FIRST. StartGame respawns and resets the map, but nothing in
// it clears IsDown — restarting while down would begin round 1 crawling,
// invisible to zombies, with a bleedout still counting toward a second
// game over.
Player?.Revive();
Rounds.StartGame();
Log.Info( "[nz] restarted" );
}
}