Razor UI component for the in-game scoreboard. Renders player rows (kills, headshots, downs, revives, points), an optional Easter egg progress card, and an optional map panel; it builds its data from networked connection/stat tables and local PlayerStats and computes a hash for UI updates.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
SCOREBOARD — held on TAB.
⛔ HELD, NOT TOGGLED. `Input.Down`, not `Pressed`: this is a glance at the table mid-round,
which is how every shooter behaves, and a toggle leaves the panel over your face while a horde
closes in. The lobby used to own TAB and has moved to L — see LobbyMenu's toggle.
⚠️ IT READS `PlayerStats`, WHICH RECORDS ITSELF. Nothing here counts anything; the columns are
whatever the component on each player says. That keeps the panel free to be wrong on screen
without being wrong in the data, and `nz_score` prints the same table to console.
⚠️ NOT SHOWN IN THE LOBBY. There is nothing to score before a game starts, and the lobby is a
full-screen panel this would land on top of.
*@
<root class="scoreboard @(Showing ? "" : "hidden") @HudTheme.Class">
@* ⛔ TWO COLUMNS (the user's layout, 2026-09-29: *"score on the left, map on the right, zombies left on the top, and current
Easter egg step bellow the score"*): the score, and under it the Easter egg's own panel, on the left; the map on the right.
On a map without a TAB map the right column is not drawn and the left one centres. *@
<div class="side">
<div class="board">
<div class="head">
<div class="title">SCOREBOARD</div>
<div class="round">@RoundLabel</div>
</div>
<div class="row hdr">
<div class="c name">Player</div>
<div class="c num">Kills</div>
<div class="c num">Headshots</div>
<div class="c num">HS%</div>
<div class="c num">Downs</div>
<div class="c num">Revives</div>
<div class="c num wide">Points earned</div>
</div>
@foreach ( var row in Rows )
{
<div class="row @(row.IsLocal ? "me" : "")">
<div class="c name">@row.Name</div>
<div class="c num">@row.Kills</div>
<div class="c num">@row.Headshots</div>
<div class="c num">@row.HeadshotPercent.ToString( "0.#" )%</div>
<div class="c num">@row.Downs</div>
<div class="c num">@row.Revives</div>
<div class="c num wide">@row.PointsEarned.ToString( "N0" )</div>
</div>
}
@if ( Rows.Length == 0 )
{
<div class="empty">No players</div>
}
</div>
@* ⛔ THE EASTER EGG, ITS OWN PANEL UNDER THE SCORE (2026-09-29): *"I want the steps to be divided in main step, and the
little steps to complete it"*. The main step on now (`HexPlatforms.MainNowShown`), its little steps as this machine is
shown them (`HexPlatforms.LittleStepsShown`), and the nine as hexes. Basalt's egg only, the same on every machine. *@
@if ( EggOn )
{
<div class="board egg-card">
<div class="head">
<div class="title">EASTER EGG</div>
<div class="round">@EggProgress.Counter( EggMain ).ToUpperInvariant()</div>
</div>
<div class="egg-name">@(EggMain > EggProgress.Mains ? "The Core is home" : EggProgress.MainName( EggMain ))</div>
@foreach ( var l in EggRows )
{
<div class="little @l.Class">
<div class="mark"><div class="glyph"></div></div>
<div class="text">@l.Step.Text</div>
@if ( l.Step.Need > 0 && !l.Step.Done )
{
<div class="count">@l.Step.Have / @l.Step.Need</div>
}
</div>
}
<div class="pips">
@for ( var m = 1; m <= EggProgress.Mains; m++ )
{
<div class="pip @PipClass( m )"><div class="h"></div><div class="h b"></div><div class="h c"></div></div>
}
</div>
</div>
}
</div>
@* ⛔ THE MAP OF THE WHOLE PLACE, ON THE RIGHT (2026-09-29): *"I also want to see a map of the whole place"*. `TabMap` builds,
sizes and moves everything in it from code, so the table's rebuilds leave it alone. *@
@if ( MapOn )
{
<div class="board map-card">
<div class="head">
<div class="title">MAP</div>
<div class="round">@TabMap.Title.ToUpperInvariant()</div>
</div>
<TabMap></TabMap>
</div>
}
</root>
@code {
/// <summary>One line of the table, flattened so the markup does no work.</summary>
record Row( string Name, int Kills, int Headshots, float HeadshotPercent,
int Downs, int Revives, int PointsEarned, bool IsLocal );
static RoundManager Rounds => Game.ActiveScene?.GetAllComponents<RoundManager>()
.FirstOrDefault();
/// <summary>
/// Is the board on screen.
///
/// ⛔ GATED ON THE MODE, NOT ONLY THE KEY. In the lobby the panel would sit on top of a
/// full-screen menu and show a table of zeroes for a game that has not started.
/// </summary>
static bool Showing => (Input.Down( "Score" ) || Pinned) && NZGame.Mode != GameMode.Lobby;
/// <summary>
/// Hold the board open without the key, for looking at it.
///
/// ⚠️ A DEV AFFORDANCE, NOT A FEATURE. `Showing` is a HELD state, so the layout cannot be
/// judged from a screenshot — and the same problem is why `nz_stats_fake` exists. Off by
/// default; nothing in the game sets it.
/// </summary>
public static bool Pinned { get; set; }
/// <summary>`nz_score_pin [0/1]` — hold the board open, or let TAB own it again.</summary>
[ConCmd( "nz_score_pin" )]
public static void PinCmd( int on = -1 )
{
Pinned = on < 0 ? !Pinned : on != 0;
Log.Info( $"[nz-score] pinned {(Pinned ? "ON" : "off")}"
+ (NZGame.Mode == GameMode.Lobby ? " — but the lobby hides it, leave the lobby" : "") );
}
/// <summary>Is the Easter egg's panel drawn: on basalt, whose egg it is.</summary>
static bool EggOn => HexPlatforms.OnBasalt;
/// <summary>The main step on now, 1-9, or 10 once all nine are done (`HexPlatforms.MainNowShown`).</summary>
static int EggMain => HexPlatforms.MainNowShown;
/// <summary>A little step and how it is drawn: `done`, `now` (the first not done), or `todo`.</summary>
record EggRow( HexPlatforms.EggLittle Step, string Class );
/// <summary>
/// The main step on now's little steps, marked.
/// ⚠️ "NOW" IS THE FIRST NOT DONE, not the one after the last done: the Shield Lock can open before the Torch Carry, so a later
/// little step can be ticked while an earlier one is still on.
/// </summary>
static EggRow[] EggRows
{
get
{
var main = EggMain;
if ( main > EggProgress.Mains ) return System.Array.Empty<EggRow>();
var steps = HexPlatforms.LittleStepsShown( main );
var rows = new EggRow[steps.Length];
var on = false;
for ( var i = 0; i < steps.Length; i++ )
{
rows[i] = new EggRow( steps[i], steps[i].Done ? "done" : on ? "todo" : "now" );
if ( !steps[i].Done ) on = true;
}
return rows;
}
}
/// <summary>A main step's hex: done, the one on now, or neither.</summary>
static string PipClass( int main ) => HexPlatforms.MainDoneShown( main ) ? "done" : main == EggMain ? "now" : "";
/// <summary>Is the map panel drawn: this map has a TAB map (`TabMap.AvailableFor`), and `nz_tabmap 0` has not hidden it.</summary>
static bool MapOn => TabMap.On && TabMap.AvailableFor( NZMap.Current );
static string RoundLabel
{
get
{
var r = Rounds;
return r.IsValid() && r.Round > 0 ? $"ROUND {r.Round}" : "";
}
}
/// <summary>
/// The table, best score first.
///
/// ⚠️ SORTED BY POINTS EARNED, not kills. Points are the game's own measure of contribution —
/// they already weight a headshot over a body shot and pay for revives and repairs, so sorting
/// by kills would rank a player who did nothing but shoot above one who kept the team alive.
/// </summary>
static Row[] Rows
{
get
{
// ⛔ FROM THE NETWORK TABLE, NOT FROM THE `PlayerStats` COMPONENTS IN THE SCENE.
//
// A `PlayerStats` is filled in on the machine where the kill happened, so THIS
// machine's copy of somebody else's body has one that has never recorded anything
// and never will. Every player saw a scoreboard with themselves on it and everybody
// else on zero. User: *"each player can only see their own."*
//
// ⚠️ MY OWN ROW COMES FROM MY OWN COMPONENT, because it is right here and cannot lag
// a frame behind my own kill on my own screen.
//
// ⚠️ DRIVEN BY `Connection.All`, NOT BY THE BODIES. A player is on the scoreboard
// because they are in the game, not because their body happens to exist in my scene
// yet — which is exactly the window a joining player spends on nobody's list.
var rows = new List<Row>();
if ( !Networking.IsActive )
{
var solo = PlayerStats.For( NZPlayer.Local );
if ( solo is not null )
rows.Add( new Row( NameFor( NZPlayer.Local ), solo.Kills, solo.Headshots,
solo.HeadshotPercent, solo.Downs, solo.Revives, solo.PointsEarned, true ) );
return rows.ToArray();
}
var meId = Connection.Local?.Id ?? default;
var mineStats = PlayerStats.For( NZPlayer.Local );
foreach ( var c in Connection.All )
{
if ( c is null ) continue;
var mine = c.Id == meId;
var kills = mine && mineStats is not null ? mineStats.Kills : NZNet.StatsOf( c.Id ).Kills;
var heads = mine && mineStats is not null ? mineStats.Headshots : NZNet.StatsOf( c.Id ).Headshots;
var downs = mine && mineStats is not null ? mineStats.Downs : NZNet.StatsOf( c.Id ).Downs;
var revs = mine && mineStats is not null ? mineStats.Revives : NZNet.StatsOf( c.Id ).Revives;
var pts = mine && mineStats is not null ? mineStats.PointsEarned : NZNet.StatsOf( c.Id ).Points;
rows.Add( new Row( c.DisplayName ?? "?", kills, heads,
kills > 0 ? 100f * heads / kills : 0f, downs, revs, pts, mine ) );
}
return rows
.OrderByDescending( r => r.PointsEarned )
.ThenByDescending( r => r.Kills )
.ToArray();
}
}
/// <summary>
/// What to call a player: their Steam profile name.
///
/// ⛔ NOT THE CHARACTER. Two players can both pick Dempsey, and the character is a costume
/// rather than an identity — a scoreboard has to say WHO, which is the Steam name the lobby
/// already lists (`Connection.All` -> `DisplayName`, LobbyMenu:171).
///
/// ⚠️ THE OWNING CONNECTION FIRST, THEN THE LOCAL ONE. `Network.Owner` is the right answer once
/// players are networked; it is null while the game is effectively single-player, so the local
/// connection covers the only player there is. Falling straight back to the object name would
/// print "Player Controller" for the one case that works today.
/// </summary>
static string NameFor( NZPlayer p )
{
if ( !p.IsValid() ) return "—";
// ⛔ THROUGH `NZPlayers.NameOf` NOW, AND THIS COPY ASKED THE WRONG THING. It read
// `p.Network?.Owner`, which is null for the host's own body — an unowned object is nobody's
// — so the host's row fell through to the object name. `NZPlayer.OwningConnection` is the
// project's settled answer to "whose body is this" and carries a screen of comment about
// the two derived answers that failed before it.
var name = NZPlayers.NameOf( p );
if ( !string.IsNullOrWhiteSpace( name ) ) return name;
// ⚠️ THE OBJECT NAME IS THIS CALLER'S FALLBACK, not the helper's — a cell wants
// something in it, whereas the revive prompt wants "your teammate" in a sentence.
return p.GameObject.Name;
}
// ⚠️ THE WHOLE TABLE IS IN THE HASH, so a kill landing while the board is held updates it.
// Hashing only `Showing` would freeze the numbers at the moment TAB went down.
protected override int BuildHash()
{
var h = new HashCode();
h.Add( Showing );
h.Add( HudTheme.Class );
h.Add( RoundLabel );
h.Add( MapOn );
h.Add( TabMap.Title );
h.Add( EggOn );
// ⚠️ THE EGG'S WHOLE STATE IN THE HASH, as the table's is: a little step ticking while the board is held must show
if ( EggOn )
{
h.Add( EggMain );
foreach ( var r in EggRows )
{
h.Add( r.Class );
h.Add( r.Step.Have );
}
for ( var m = 1; m <= EggProgress.Mains; m++ )
h.Add( HexPlatforms.MainDoneShown( m ) );
}
foreach ( var r in Rows )
{
h.Add( r.Name );
h.Add( r.Kills );
h.Add( r.Headshots );
h.Add( r.Downs );
h.Add( r.Revives );
h.Add( r.PointsEarned );
}
return h.ToHashCode();
}
}