building a compass

read the camera each frame and draw a heading strip that scrolls as the player turns

by the end of this guide you will have a compass band pinned to the top of the screen. it reads where the camera is looking, scrolls its cardinal letters past a fixed center caret, and fades the marks out toward both edges. it is the first HUD in these guides that reads live scene data instead of a click.

there is no shipped demo for this article right now; the code below is not verified against a running s&box project. build it in a test panel and check the heading tracking, mark placement, and fade before you rely on it (see the note at the end of this guide).

the shape of the problem

a compass has three jobs: find the heading, decide which marks are visible and where, and draw them. the middle job is pure angle math with no goo in it at all, so it goes in its own file you could unit-test on its own.

the angle math

create a new file, CompassMath.cs. it folds raw degrees into a known range, measures the shortest distance between two angles (so the 360-to-0 seam never tears), and lists the marks inside the visible arc.

using System;
using System.Collections.Generic;

namespace Sandbox.Compass;

public static class CompassMath
{
    // folds any degree value into [0, 360).
    public static float Normalize360( float deg )
    {
        deg %= 360f;
        if ( deg < 0f ) deg += 360f;
        return deg;
    }

    // shortest signed angle from heading to mark, in (-180, 180]; makes the 360-to-0 wrap seamless.
    public static float SignedDelta( float headingDeg, float markDeg )
    {
        float d = Normalize360( markDeg - headingDeg );
        if ( d > 180f ) d -= 360f;
        return d;
    }

    // 8-point label rounded to the nearest 45 deg; 0 = N, increasing clockwise.
    public static string CardinalLabel( float deg )
    {
        int idx = ((int)MathF.Round( Normalize360( deg ) / 45f )) % 8;
        return idx switch
        {
            0 => "N", 1 => "NE", 2 => "E", 3 => "SE",
            4 => "S", 5 => "SW", 6 => "W", 7 => "NW",
            _ => "",
        };
    }

    public readonly record struct Mark( float AngleDeg, float XNorm, bool IsCardinal );

    // marks inside the visible arc; XNorm is 0 at the center caret and +/-1 at the window edges.
    public static List<Mark> VisibleMarks( float headingDeg, float windowDeg, float stepDeg )
    {
        var marks = new List<Mark>();
        float half = windowDeg * 0.5f;
        int count = (int)MathF.Round( 360f / stepDeg );
        for ( int i = 0; i < count; i++ )
        {
            float a = Normalize360( i * stepDeg );
            float d = SignedDelta( headingDeg, a );
            if ( MathF.Abs( d ) <= half )
                marks.Add( new Mark( a, d / half, (int)MathF.Round( a ) % 45 == 0 ) );
        }
        return marks;
    }
}

XNorm is the payload that matters. it is each mark's position along the strip as a number from -1 (left edge) through 0 (under the caret) to +1 (right edge). the panel later turns that into a pixel offset and an opacity, and because the math owns it, the panel never touches a raw angle.

the overlay root

create a new file, CompassUI.cs. a HUD hangs from a root that fills the screen and lets clicks fall through to the game. you can build that root by hand with three properties:

using System;
using Goo;
using Sandbox.Compass;
using Sandbox.UI;

namespace Sandbox;

public sealed class CompassUI : GooPanel<Container>
{
    protected override void OnEnabled()
    {
        base.OnEnabled();
        Panel.Style.Width  = Length.Percent( 100 );
        Panel.Style.Height = Length.Percent( 100 );
    }

    protected override Container Build() => new Container
    {
        Position       = PositionMode.Absolute,
        Width          = Length.Percent( 100 ),
        Height         = Length.Percent( 100 ),
        AlignItems     = Align.Center,          // pin the band to the horizontal center
        PointerEvents  = PointerEvents.None,    // let the mouse reach the game behind the HUD
    };
}

OnEnabled sizes the host panel itself so it covers the viewport. the root is Absolute at full width and height, centers its child horizontally, and sets PointerEvents.None so the invisible full-screen panel does not eat every click. that last property is the one rule that separates a HUD from a modal. overlay layout covers factory helpers that bake these in.

read the heading

the camera lives on the Scene. GooPanel<T> exposes a Tick hook for exactly this: per-frame polled state. _heading changes on a poll, not inside a handler, so it belongs in a State<T> instead of a plain field: create it with Track in OnEnabled, right after the panel sizing.

State<float> _heading;

protected override void OnEnabled()
{
    base.OnEnabled();
    Panel.Style.Width  = Length.Percent( 100 );
    Panel.Style.Height = Length.Percent( 100 );
    _heading = Track( float.NaN );
}

override Tick, read the look-yaw, fold it into a heading, and write _heading.Value only when it moved enough to matter:

protected override bool Tick( float dt )
{
    var cam = Scene?.Camera;
    if ( cam is not null )
    {
        // s&box yaw is counter-clockwise; the minus sign makes turning right scroll the strip left.
        float yaw = CompassMath.Normalize360( -cam.WorldRotation.Angles().yaw );
        if ( float.IsNaN( _heading.Value ) ||
             MathF.Abs( CompassMath.SignedDelta( _heading.Value, yaw ) ) > 0.05f )
            _heading.Value = yaw;
    }
    return false;
}

the epsilon check is still the point worth keeping, it just moved jobs. writing State<T>.Value already marks the panel dirty for you (an equal write is a no-op), so Tick no longer hand-signals a rebuild through its return value. what the check still buys you is avoiding the write itself: camera yaw is a float that is almost never bit-exact equal from one frame to the next, so writing it unconditionally would dirty the panel every frame even while the player holds still. gating the write on a hundredth of a degree of real movement is what keeps the compass free while the player is not turning. Tick returns false here because there is no in-flight animation that needs one more settling rebuild after motion stops; animations covers the case where it does.

draw the band

now widen Build() from an empty root into the band of marks. the band is a fixed-size strip; each mark is positioned absolutely inside it by its XNorm, and its opacity falls off toward the edges so marks dissolve rather than pop.

const float WindowDeg  = 120f;   // how wide an arc the strip shows
const float StepDeg    = 15f;    // a tick every 15 degrees
const float BandWidth  = 520f;
const float BandHeight = 40f;

protected override Container Build()
{
    var root = new Container
    {
        Position      = PositionMode.Absolute,
        Width         = Length.Percent( 100 ),
        Height        = Length.Percent( 100 ),
        AlignItems    = Align.Center,
        PointerEvents = PointerEvents.None,
    };

    var band = new Container
    {
        Position        = PositionMode.Relative,   // Top is inert until Position leaves the default (Static)
        Top             = 24f,
        Width           = BandWidth,
        Height          = BandHeight,
        BackgroundColor = Color.Black.WithAlpha( 0.35f ),
        BorderRadius    = 6f,
        Overflow        = OverflowMode.Visible,
    };
    root.Children.Add( band );

    if ( float.IsNaN( _heading.Value ) )
        return root;

    float half  = BandWidth * 0.5f;
    var   marks = CompassMath.VisibleMarks( _heading.Value, WindowDeg, StepDeg );

    band.Children.AddRange( marks, mark => (int)mark.AngleDeg, ( i, mark ) =>
    {
        float x       = half + mark.XNorm * half;   // XNorm -1..1 maps across the band
        float opacity = 1f - MathF.Abs( mark.XNorm ); // fade toward both edges

        return new Container
        {
            Position       = PositionMode.Absolute,
            Left           = x - 12f,
            Width          = 24f,
            Height         = BandHeight,
            JustifyContent = Justify.Center,
            AlignItems     = Align.Center,
            Opacity        = opacity,
            FontColor      = Color.White,
            Children       = { new Text( mark.IsCardinal ? CompassMath.CardinalLabel( mark.AngleDeg ) : "|" ) },
        };
    } );

    // fixed center caret marking the current heading.
    band.Children.Add( new Container
    {
        Key       = "caret",
        Position  = PositionMode.Absolute,
        Top       = -6f,
        Left      = half - 6f,
        FontColor = Color.White,
        Children  = { new Text( "v" ) },
    } );

    return root;
}

band.Children.AddRange keys each mark by its own angle, cast to int so the key reads 45 rather than 45.0. that angle is the mark's stable identity: the same physical tick keeps its slot in the tree as it scrolls past the caret, instead of a fresh unkeyed child restarting every frame. dynamic children covers AddRange and its keyOf overload in full.

band sets Position = PositionMode.Relative before Top: per container, Top (and Left, Right, Bottom) is applied only when Position is Relative or Absolute. leave Position unset and the Top = 24f above does nothing; the band would sit wherever the flex layout put it, not 24px down from the root's edge.

press play, drop CompassUI on a screen panel, and look around. the cardinal letters should slide past the caret as you turn, dimming as they near the edges, with N sitting dead center when you face north.

measure the rebuild gate

the claim above, that an idle compass costs nothing, is checkable, not just a promise. goo ships an opt-in profiler for exactly this: flip Perf.Enabled, reset its counters, drive the scenario, then read the report.

Perf.Enabled = true;
Perf.Reset();
// hold the camera still for a few seconds, then turn it for a few more
Log.Info( Perf.Report() );

Perf.Report() prints total rebuilds plus a per-phase and per-op breakdown. leave Perf.Enabled off in shipped code: the check costs a branch per rebuild and per op even when disabled.

what just happened

the compass is three layers that never reach into each other:

the band-drawing code inside Build() needs no state of its own beyond the heading it is handed: that is exactly the shape composition asks for in a presenter. pull it into a static method that takes the heading and the geometry constants and returns a Container, and CompassUI.Build() shrinks to the root plus one call. the heading state and the Tick that samples it stay on CompassUI: only one root polls the Scene per surface, and a presenter takes data in rather than going looking for it. cells covers the one case that would call for something heavier, a subtree that needs its own runtime-only state, which this band does not.

see also