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.

the finished demo is Code/Demos/Compass/. this guide rebuilds its core from a blank file so you can see every piece; the demo then splits the work into a reusable CompassView you will recognize at the end.

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 Code/Demos/Compass/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 Code/Demos/Compass/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> runs OnUpdate every frame, which is where you poll it. read the look-yaw, fold it into a heading, and rebuild only when it actually moved.

float _heading = float.NaN;

protected override void OnUpdate()
{
    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 ) ||
             MathF.Abs( CompassMath.SignedDelta( _heading, yaw ) ) > 0.05f )
        {
            _heading = yaw;
            Rebuild();
        }
    }
    base.OnUpdate();
}

the gate is the point worth keeping. an overlay that calls Rebuild() every frame rebuilds its whole subtree every frame. gate it on a real change (here, a heading that moved more than a hundredth of a degree) and the compass costs nothing while the player holds still.

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
    {
        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 ) )
        return root;

    float half = BandWidth * 0.5f;
    foreach ( var mark in CompassMath.VisibleMarks( _heading, WindowDeg, StepDeg ) )
    {
        float x       = half + mark.XNorm * half;   // XNorm -1..1 maps across the band
        float opacity = 1f - MathF.Abs( mark.XNorm ); // fade toward both edges

        band.Children.Add( new Container
        {
            Key            = $"m{(int)mark.AngleDeg}",  // keyed so a mark keeps identity as it scrolls
            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;
}

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

what just happened

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

the shipped demo pulls the heading state and the band builder out into a CompassView class with its own Tick and Build, so the host panel shrinks to a few lines and the view can be dropped into a larger HUD. that split is the subject of composition, and the same view is reused in the composable HUD demo.

see also