Skip to content
Edit on GitHub

Add a HUD element

verifiedAgainst 9a2d070 · verifiedOn 2026-09-10 · the automated staleness banner is planned, not built.

This page adds one HUD element end to end, then strips the steps into a checklist. Read it alongside The runtime UI — it assumes that page’s model of self-bootstrapping runtimes over a pure presentation layer. The two closest existing elements to diff against are ContextualTargetHudRuntime (a read-only, single-Text, change-gated HUD companion) and the CarriedOreAssayPresentation + CarriedOreAssayTests pair (the pure-layer-plus-test shape).

A Locale readout — a small two-line panel in the top-left corner showing the current biome name and a 24-hour clock (Forest · 14:32). It is read-only, repaints only when the biome or the minute changes, and hides together with the crosshair and hotbar when the player presses F1.

Everything it needs already exists: BiomeClassifier.Classify and DayNightCycleRuntime.TimeOfDayHours are exactly what the F3 debug overlay reads (DebugOverlayRuntime.cs:348, :368). This element promotes that information from the developer overlay to the player HUD.

  • VoxelSandbox.UI only. One new pure static class, one new MonoBehaviour, one new EditMode test. No new assembly reference — UI already sees World (for the biome + clock) and everything else.
  • It attaches under GameplayHudRuntime.Root and mirrors GameplayHudRuntime.IsHudVisible (GameplayHudRuntime.cs:40) — no scene wiring, no prefab.
  • No Kenney art: it is a Text on a translucent panel, styled by the existing KenneyHudStyleRuntime font pass.
flowchart TD
  RIOL["RuntimeInitializeOnLoadMethod\nAfterSceneLoad — Create()"]
  RT["LocaleHudRuntime : MonoBehaviour\nowns one panel + Text"]
  PRES["LocalePresentation\npure static — biome/clock -> string"]
  BIOME["BiomeClassifier.Classify(x,z,settings)\n.Biome (BiomeType)"]
  CLOCK["DayNightCycleRuntime\n.TimeOfDayHours"]
  HUD["GameplayHudRuntime\nRoot + IsHudVisible"]
  TEST["LocaleHudTests\nassert strings"]

  RIOL --> RT
  HUD --> RT
  BIOME --> RT
  CLOCK --> RT
  RT --> PRES
  PRES --> RT
  PRES --> TEST

Read these Docs/ sections:

  • Docs/MASTERPLAN.md §14 — the HUD/UI plan (code-built, no UI Toolkit, minimal).
  • The runtime UI — the six invariants, especially no scene wiring, runtime is thin / logic is pure, and diff before you draw.
  • Docs/ASSET_LEDGER.md — not needed here (no new art), but the reason this element uses a plain Text rather than a new sprite.

Intake decisions:

Decision Choice Why
Read-only or interactive? read-only it reports state; it never changes it
Hides on F1? yes it is gameplay chrome, like the hotbar
Needs vendor art? no text on a translucent panel
Data source BiomeClassifier + DayNightCycleRuntime the same path the F3 overlay uses
Pure layer LocalePresentation the BiomeType → name map and the clock format are testable

Validation: there is no Workshop module for the HUD — you verify by running the game (or the Scene Composer’s Gameplay scene) and by the EditMode test on the pure layer.


1. The pure layer — LocalePresentation.cs

Section titled “1. The pure layer — LocalePresentation.cs”

Every string decision goes here, with no MonoBehaviour and no UnityEngine.UI, mirroring CarriedOreAssayPresentation (CarriedOreAssayPresentation.cs:8). BiomeDefinition exposes only a BiomeType enum (BiomeDefinition.cs:37) — there is no authored display name — so the map lives here:

// illustrative — new file Assets/_Game/Scripts/UI/LocalePresentation.cs
using VoxelSandbox.Data.Generation;
namespace VoxelSandbox.UI
{
/// <summary>The Locale readout's only text path: a biome name and a 24-hour clock.</summary>
public static class LocalePresentation
{
public static string BiomeName(BiomeType biome) => biome switch
{
BiomeType.Plains => "Plains",
BiomeType.Forest => "Forest",
BiomeType.Desert => "Desert",
BiomeType.Taiga => "Taiga",
BiomeType.Savanna => "Savanna",
BiomeType.Badlands => "Badlands",
BiomeType.Mountains => "Mountains",
BiomeType.Jungle => "Jungle",
BiomeType.Tundra => "Tundra",
_ => "Unknown",
};
/// <summary>Wraps hours into [0,24) so 24.0 reads as 00:00, and formats HH:MM.</summary>
public static string Clock(float timeOfDayHours)
{
float wrapped = timeOfDayHours % 24f;
if (wrapped < 0f) wrapped += 24f;
int hours = (int)wrapped;
int minutes = (int)((wrapped - hours) * 60f);
return $"{hours:00}:{minutes:00}";
}
public static string Line(BiomeType biome, float timeOfDayHours)
=> $"{BiomeName(biome)} · {Clock(timeOfDayHours)}";
}
}

BiomeType is [Flags] (BiomeType.cs) — the switch handles a single classified value; Classify never returns a combination.

A sealed MonoBehaviour following the bootstrap-and-diff pattern of ContextualTargetHudRuntime (ContextualTargetHudRuntime.cs:15):

// illustrative — new file Assets/_Game/Scripts/UI/LocaleHudRuntime.cs
using UnityEngine;
using UnityEngine.UI;
using VoxelSandbox.Data.Generation;
using VoxelSandbox.Player;
using VoxelSandbox.World.Environment;
using VoxelSandbox.World.Generation;
using VoxelSandbox.World.Presentation;
namespace VoxelSandbox.UI
{
/// <summary>Top-left biome + clock readout. Read-only; hides with the HUD on F1.</summary>
public sealed class LocaleHudRuntime : MonoBehaviour
{
private static LocaleHudRuntime instance;
private GameplayHudRuntime hud;
private FirstPersonController player;
private ChunkStreamingRuntime world;
private DayNightCycleRuntime dayNight;
private GameObject panel;
private Text label;
private string displayedText;
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Create()
{
if (instance != null || Object.FindAnyObjectByType<LocaleHudRuntime>() != null) return;
var owner = new GameObject("[Voxel Sandbox] Locale HUD", typeof(LocaleHudRuntime));
instance = owner.GetComponent<LocaleHudRuntime>();
DontDestroyOnLoad(owner);
}
private void Awake() => instance = this;
private void OnDestroy() { if (instance == this) instance = null; }
private void Update()
{
if (panel == null && !TryBuild()) return;
hud ??= Object.FindAnyObjectByType<GameplayHudRuntime>();
if (hud != null && !hud.IsHudVisible) { SetVisible(false); return; }
if (player == null) player = Object.FindAnyObjectByType<FirstPersonController>();
if (world == null) world = Object.FindAnyObjectByType<ChunkStreamingRuntime>();
if (dayNight == null) dayNight = Object.FindAnyObjectByType<DayNightCycleRuntime>();
if (player == null || world?.GenerationSettings == null || dayNight == null) { SetVisible(false); return; }
Vector3 p = player.transform.position;
BiomeType biome = BiomeClassifier.Classify(Mathf.RoundToInt(p.x), Mathf.RoundToInt(p.z), world.GenerationSettings).Biome;
string next = LocalePresentation.Line(biome, dayNight.TimeOfDayHours);
if (next != displayedText) { displayedText = next; label.text = next; } // diff before draw
SetVisible(true);
}
// TryBuild(): create a translucent panel + Text under GameplayHudRuntime.Root,
// anchored top-left. SetVisible(): panel.SetActive only on a change.
// Both are copied structurally from ContextualTargetHudRuntime.
}
}

Key points, each an invariant from The runtime UI:

  • Bootstrap, no scene wiring — [RuntimeInitializeOnLoadMethod(AfterSceneLoad)] + the instance/FindAnyObjectByType guard, exactly as ContextualTargetHudRuntime.cs:22.
  • Thin runtime — the only logic here is “read three values, call LocalePresentation.Line”. The formatting is all in the pure class.
  • Diff before draw — if (next != displayedText), matching ContextualTargetHudRuntime.cs:57. The string only changes on a biome change or a minute rollover, so label.text is set a few times a minute, not 60 times a second.
  • Hides with the HUD — mirrors GameplayHudRuntime.IsHudVisible rather than subscribing (there is no HUD-visibility event yet — see Pitfalls).
  • Unity-null, not ?? — resolve player/world/dayNight with an if (x == null) re-lookup every time they are missing; a destroyed Unity object is not CLR-null, so ??= on a MonoBehaviour reference across a scene load is a bug (PlayerAssayRuntime calls this out in a comment, PlayerAssayRuntime.cs:48). The hud ??= … above is safe only because a missing GameplayHudRuntime is a real CLR-null on first frame; if in doubt, use the explicit form.

TryBuild() parents the panel under GameplayHudRuntime.Root (GameplayHudRuntime.cs:37) and anchors it top-left. Build the Image (translucent dark) and child Text in code, the same way ContextualTargetHudRuntime.TryBuild does (:74). KenneyHudStyleRuntime (KenneyHudStyleRuntime.cs:7) applies the shared font to every Text under the HUD root on its own pass — you do not set the font yourself.

No GameplaySceneComposer change: the element bootstraps itself, and it uses no new art asset, so EnsureHudVisualSettings is untouched.

4. Test — Assets/_Game/Tests/EditMode/UI/LocaleHudTests.cs

Section titled “4. Test — Assets/_Game/Tests/EditMode/UI/LocaleHudTests.cs”

Only the pure layer is tested, following CarriedOreAssayTests (CarriedOreAssayTests.cs):

// illustrative
using NUnit.Framework;
using VoxelSandbox.Data.Generation;
using VoxelSandbox.UI;
namespace VoxelSandbox.Tests
{
public sealed class LocaleHudTests
{
[Test]
public void Clock_WrapsMidnightAndPadsToHHMM()
{
Assert.That(LocalePresentation.Clock(0f), Is.EqualTo("00:00"));
Assert.That(LocalePresentation.Clock(24f), Is.EqualTo("00:00"));
Assert.That(LocalePresentation.Clock(14.53f), Is.EqualTo("14:31"));
}
[Test]
public void Line_NamesEveryClassifiableBiome()
{
foreach (BiomeType biome in System.Enum.GetValues(typeof(BiomeType)))
{
if (biome == BiomeType.None) continue;
Assert.That(LocalePresentation.BiomeName(biome), Is.Not.EqualTo("Unknown"), $"{biome} has no name");
}
}
}
}

VoxelSandbox.UI.Tests.asmdef already references VoxelSandbox.UI (and transitively Data), so no asmdef edit is needed for a test that only touches LocalePresentation.


Run the UI tests headlessly (path from CLAUDE.md):

cd /home/soulwax/workspace/engines/unity/minecraft/Minecraft-HD
"/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity" -runTests -batchmode \
-projectPath . -testPlatform EditMode \
-testFilter "VoxelSandbox.Tests.LocaleHudTests" \
-testResults ./Logs/locale.xml -logFile ./Logs/locale.log

Then, in the open Editor:

  1. Scene Composer ▸ Compose Gameplay, enter Play Mode. The Locale readout appears top-left showing e.g. Plains · 06:00.
  2. Walk across a biome border — the biome word changes; the clock ticks a minute at a time.
  3. Press F1 — the readout hides with the crosshair and hotbar, and returns on the next F1.
  4. Open the F3 overlay and confirm the Locale biome matches the overlay’s Biome: line at the same position.

“Done” = a green LocaleHudTests run plus the readout tracking biome and time in Play Mode and obeying F1.

  1. Decide read-only vs interactive, and whether it is gameplay chrome (hides on F1, parents under GameplayHudRuntime.Root) or always-on (like SaveStatusHudRuntime).
  2. Pure layer first — a static class (*Presentation / *Model) with every string, number, and branch, no MonoBehaviour, no UnityEngine.UI. This is the part you test.
  3. The runtime — a sealed MonoBehaviour with a [RuntimeInitializeOnLoadMethod(AfterSceneLoad)] Create(), an instance guard, Awake/OnDestroy bookkeeping, a lazy TryBuild() that allocates the Image/Text objects once, and an Update() that recomputes the model value and repaints only on a change.
  4. Resolve dependencies with if (x == null) re-lookup, never ??=, on Unity object references that can vanish across a scene load.
  5. Attach under GameplayHudRuntime.Root; let KenneyHudStyleRuntime apply the font.
  6. New art? Add it to HudVisualSettings / KenneyUiStyle and the composer that fills them — never reference Assets/ThirdParty/ from a runtime. Update Docs/ASSET_LEDGER.md.
  7. Diagnostics-only? Guard Create() on Debug.isDebugBuild, like DebugOverlayRuntime.
  8. Test the pure layer in Tests/EditMode/UI/.
  9. CHANGELOG.md + VERSION bump for the landed feature.

Decisions that vary per element: corner/anchor; whether it hides on F1; poll every frame vs a slower cadence (DebugOverlayRuntime refreshes every 0.2 s); whether it needs a pure *Model (state machine) or just a *Presentation (formatting).

A UI script dropped into a scene or prefab. Symptom: two copies of the element, or it survives a scene reload as a ghost. Cause: it was added to a GameObject in the Gameplay scene and it self-bootstraps. Fix: never place a UI MonoBehaviour in a scene. The [RuntimeInitializeOnLoadMethod] + instance guard is the only registration.

label.text = … every frame. Symptom: GC spikes, layout thrash, profiler shows Text.OnPopulateMesh every frame. Cause: the string was rebuilt and assigned unconditionally in Update(). Fix: cache displayedText; assign only when next != displayedText. Every existing HUD element does this.

??= on a MonoBehaviour reference. Symptom: after a scene transition the element points at a destroyed object and silently stops updating. Cause: Unity’s “destroyed” state is not CLR-null, so ??= never re-resolves. Fix: if (x == null) x = FindAnyObjectByType<T>(); every time it is needed. See the comment at PlayerAssayRuntime.cs:48.

Player position used as world position in a large world. Symptom: the biome is wrong after travelling a long way from spawn. Cause: player.transform.position is local to the floating origin, which rebases far from spawn. The F3 overlay converts through FloatingOriginRuntime for exactly this reason. Fix: for anything that must be accurate at long range, resolve true world coordinates via FloatingOriginRuntime.OriginBlockOffset as DebugOverlayRuntime does, not the raw transform.

Scientific text formatted in the runtime. Symptom: a reviewer flags the HUD for asserting a fact the simulation did not record. Cause: the element built a sentence about chemistry/evidence itself instead of rendering a narrator line. Fix: player-facing scientific prose comes from a narrator with a committed ScienceEvent source (VesselRunViewRuntime.FormatAccount is the model). A Locale readout is fine — biome and time are observations, not claims — but a “this ore is 30% malachite” readout is not.

Logic left in the runtime, so it can’t be tested. Symptom: the biome-name mapping or clock format has no test because it lives inside Update(). Cause: the pure layer was skipped. Fix: move it to LocalePresentation / a *Model and test that. If a branch is worth writing, it is worth a test, and an EditMode test cannot touch a MonoBehaviour’s Update.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…