A UI component (Razor) that draws teammate name tags on the HUD. It creates, positions and updates a tag per other NZPlayer, shows name and distance, handles off-screen edge placement and a console test command.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Collections.Generic;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
THE PLAYER TAGS — every teammate's name over their head, through walls, and pinned to the edge of the screen when they
are not on it: *"I need a way to always know where all the other players are ... a player tag on top of the character
that can be seen trough walls"* (2026-09-29).
⚠️ `ReviveHud`'S MACHINERY, KEPT WHOLE: a constant BuildHash, the tags built and retired from code, and every one
projected to the screen each frame (`PointToScreenPixels`). Drawn on the HUD, so no wall can cover it — which is the
whole of "through walls", with no depth trick to go wrong.
⛔ AND ALWAYS SOMEWHERE ON SCREEN. `ReviveHud` hides a marker behind you; a tag that vanished when you turned away
would say nothing about where your teammate went. So a teammate off screen is held at its edge, on their side, with
a pointer toward them — and one BEHIND you drops to the bottom edge, left or right as they are.
⚠️ THE SIDE COMES FROM THE CAMERA'S OWN AXES, NOT THE PROJECTION: a point behind the lens projects mirrored, so its
screen position points the wrong way.
⚠️ ONE PER OTHER BODY: never my own, never anyone out of the round (`IsOutOfRound`, the flag that crosses machines).
A downed teammate keeps a tag, red, lifted clear of `ReviveHud`'s badge.
*@
<root class="player-tags"></root>
@code
{
protected override int BuildHash() => 0;
// ⛔ NULLABLE-BACKED (INSTRUCTIONS §1): a static's initialiser does not run again on a hotload.
/// <summary>`nz_tags 0` hides them, `nz_tags 1` brings them back (this session).</summary>
public static bool On { get => _on ?? true; set => _on = value; }
static bool? _on;
/// <summary>How far above a standing player's feet the tag floats, in units: a player stands about 72.</summary>
public static float Lift { get => _lift ?? 84f; set => _lift = value; }
static float? _lift;
/// <summary>
/// A downed teammate's tag, in screen pixels over `ReviveHud`'s badge (which sits at the body + `ReviveHud.Lift`).
/// ⚠️ IN PIXELS, NOT UNITS, so the two never overlap at range: 40 units apart is 180 px at 200 units and 18 px at 2,000.
/// </summary>
const float DownOverBadge = 76f;
/// <summary>How far in from the screen's edge an off-screen tag is held, in the HUD's pixels.</summary>
const float EdgeMargin = 56f;
static readonly string[] Edges = { "left", "right", "top", "bottom" };
readonly Dictionary<NZPlayer, Tag> _live = new();
readonly List<NZPlayer> _gone = new();
/// <summary>One teammate's tag: the name, the distance, and the pointer shown at an edge.</summary>
class Tag
{
public Panel Root;
public Label Name;
public Label Dist;
public Panel Pointer;
public int Metres = -1;
}
// ── the preview (`nz_tags test`): one tag ahead of you and one behind, so the edge can be looked at solo ─────────────
static TimeUntil _previewUntil;
static Vector3 _aheadAt, _behindAt;
Tag _ahead, _behind;
protected override void OnUpdate()
{
if ( Panel is null ) return;
// ⚠️ THE THEME'S CLASS FROM HERE, NOT FROM THE RAZOR: this tree is built once (`HudTheme.Wear`)
HudTheme.Wear( Panel );
Panel.Style.Opacity = On ? 1f : 0f;
Sync();
var cam = Scene?.Camera;
if ( !cam.IsValid() || !On ) return;
foreach ( var (p, t) in _live )
{
if ( !p.IsValid() ) continue;
var down = p.IsDown;
var at = p.WorldPosition + Vector3.Up * (down ? ReviveHud.Lift : Lift);
Place( t, cam, at, down ? DownOverBadge : 0f );
SetName( t, NZPlayers.NameOf( p ) );
SetDistance( t, cam, p.WorldPosition );
t.Root.SetClass( "down", down );
}
Preview( cam );
}
/// <summary>A tag for every other body in the round; retire the rest.</summary>
void Sync()
{
var me = NZPlayer.Local;
foreach ( var go in PlayerSpawner.AllBodies() )
{
var p = go.Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !p.IsValid() || p == me ) continue;
// ⚠️ NOR THE ONE I AM LOOKING OUT OF AFTER BLEEDING OUT (2026-10-05, `SpectateOthers`): their tag sits over the camera.
var want = On && !p.IsOutOfRound && !SpectateOthers.HidesTagOf( p );
if ( want && !_live.ContainsKey( p ) ) _live[p] = Build();
else if ( !want && _live.TryGetValue( p, out var off ) )
{
off.Root.Delete();
_live.Remove( p );
}
}
// ⚠️ A BODY CAN LEAVE THE SCENE — a disconnect — and the loop above only visits bodies that still exist
_gone.Clear();
foreach ( var (p, _) in _live )
if ( !p.IsValid() ) _gone.Add( p );
foreach ( var p in _gone )
{
_live[p].Root.Delete();
_live.Remove( p );
}
}
Tag Build()
{
var root = new Panel();
root.AddClass( "tag" );
Panel.AddChild( root );
var name = new Label();
name.AddClass( "name" );
root.AddChild( name );
var dist = new Label();
dist.AddClass( "dist" );
root.AddChild( dist );
// ⚠️ A PANEL, NOT A GLYPH (`ReviveHud`'s reason): an arrow character depends on the font carrying it
var pointer = new Panel();
pointer.AddClass( "pointer" );
root.AddChild( pointer );
return new Tag { Root = root, Name = name, Dist = dist, Pointer = pointer };
}
static void SetName( Tag t, string name )
{
name = string.IsNullOrWhiteSpace( name ) ? "Player" : name;
if ( t.Name.Text != name ) t.Name.Text = name;
}
/// <summary>Metres from the camera — a unit is an inch — written only when the whole number changes.</summary>
static void SetDistance( Tag t, CameraComponent cam, Vector3 feet )
{
var m = (int)MathF.Round( feet.Distance( cam.WorldPosition ) * 0.0254f );
if ( m == t.Metres ) return;
t.Metres = m;
t.Dist.Text = $"{m} m";
}
/// <summary>
/// On screen, the tag sits on the point, `lift` pixels higher. Off it — outside the margin, or behind the lens — it is
/// held at the edge on the teammate's side, centred on its spot, with the pointer on the side facing them.
/// </summary>
void Place( Tag t, CameraComponent cam, Vector3 world, float lift )
{
var scale = Panel.ScaleFromScreen;
var w = Screen.Width;
var h = Screen.Height;
var margin = EdgeMargin / MathF.Max( 0.01f, scale );
// camera space: x ahead, y to the left, z up
var local = cam.WorldRotation.Inverse * (world - cam.WorldPosition);
var screen = cam.PointToScreenPixels( world, out var behind );
var onScreen = !behind && local.x > 1f
&& screen.x >= margin && screen.x <= w - margin && screen.y >= margin && screen.y <= h - margin;
string edge = null;
if ( !onScreen )
{
// ⚠️ ON SCREEN, RIGHT IS -y AND DOWN IS -z. And a teammate behind you goes DOWN, so they are found at the bottom
// edge on their own side, not flipped to the top.
var dx = -local.y;
var dy = -local.z;
if ( local.x < 0f ) dy = MathF.Abs( dy ) + MathF.Abs( local.x );
if ( MathF.Abs( dx ) < 0.001f && MathF.Abs( dy ) < 0.001f ) dy = 1f;
var cx = w * 0.5f;
var cy = h * 0.5f;
var hx = MathF.Max( 1f, cx - margin );
var hy = MathF.Max( 1f, cy - margin );
var kx = MathF.Abs( dx ) > 0.0001f ? hx / MathF.Abs( dx ) : float.MaxValue;
var ky = MathF.Abs( dy ) > 0.0001f ? hy / MathF.Abs( dy ) : float.MaxValue;
var k = MathF.Min( kx, ky );
screen = new Vector2( cx + dx * k, cy + dy * k );
edge = kx <= ky ? (dx > 0f ? "right" : "left") : (dy > 0f ? "bottom" : "top");
lift = 0f;
}
t.Root.Style.Left = Length.Pixels( screen.x * scale );
t.Root.Style.Top = Length.Pixels( screen.y * scale - lift );
t.Root.SetClass( "edge", edge is not null );
foreach ( var e in Edges )
t.Root.SetClass( "edge-" + e, e == edge );
}
/// <summary>Draw, move or retire the two fake tags. Nothing unless `nz_tags test` asked.</summary>
void Preview( CameraComponent cam )
{
if ( _previewUntil <= 0f )
{
_ahead?.Root.Delete();
_behind?.Root.Delete();
_ahead = _behind = null;
return;
}
_ahead ??= Build();
_behind ??= Build();
Place( _ahead, cam, _aheadAt, 0f );
SetName( _ahead, "Teammate" );
SetDistance( _ahead, cam, _aheadAt - Vector3.Up * Lift );
Place( _behind, cam, _behindAt, 0f );
SetName( _behind, "Behind you" );
SetDistance( _behind, cam, _behindAt - Vector3.Up * Lift );
}
// ── console ──────────────────────────────────────────────────────────────────────────────────────────────────────────
/// <summary>
/// `nz_tags [0|1|test] [seconds]` — the teammates' tags: `0` hides them and `1` shows them (this session); `test` pins a
/// fake one five metres ahead of you and another behind you and to the left, for `seconds` (20; 0 clears), so the tag and
/// the edge pointer can be looked at alone. Bare: how many are up, and for whom.
/// </summary>
[ConCmd( "nz_tags" )]
public static void Cmd( string what = "", float seconds = 20f )
{
switch ( what.ToLowerInvariant() )
{
case "0" or "off": On = false; break;
case "1" or "on": On = true; break;
case "test":
{
var cam = Game.ActiveScene?.Camera;
if ( !cam.IsValid() ) { Log.Warning( "[nz-tags] no camera" ); return; }
// ⚠️ PLACED IN THE WORLD, NOT ON THE SCREEN, so turning round moves them exactly as real tags move
var flat = cam.WorldRotation.Forward.WithZ( 0f ).Normal;
var left = cam.WorldRotation.Left.WithZ( 0f ).Normal;
var feet = cam.WorldPosition - Vector3.Up * 64f;
_aheadAt = feet + flat * 200f + Vector3.Up * Lift;
_behindAt = feet - flat * 300f + left * 150f + Vector3.Up * Lift;
_previewUntil = MathF.Max( 0f, seconds );
break;
}
}
var scene = Game.ActiveScene;
var hud = scene.IsValid() ? scene.GetAllComponents<PlayerTagsHud>().FirstOrDefault() : null;
var others = scene.IsValid()
? PlayerSpawner.AllBodies( scene ).Select( g => g.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) )
.Where( p => p.IsValid() && p != NZPlayer.Local ).ToList()
: new List<NZPlayer>();
if ( hud is null )
{
Log.Warning( "[nz-tags] no PlayerTagsHud in the scene — SurvivalHud attaches it, so the survival HUD is not running" );
return;
}
Log.Info( $"[nz-tags] {(On ? "ON" : "OFF")} · {hud._live.Count} tag(s) for {others.Count} other player(s)"
+ (what == "test" ? $" · test tags for {seconds:0} s, one ahead and one behind you" : "") );
foreach ( var p in others )
Log.Info( $"[nz-tags] {NZPlayers.NameOf( p ) ?? p.GameObject.Name}: {(p.IsOutOfRound ? "out of the round, no tag" : p.IsDown ? "down" : "up")}"
+ $" · tag {(hud._live.ContainsKey( p ) ? "yes" : "no")}" );
}
}