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).
What you will build
Section titled “What you will build”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.
Where this sits
Section titled “Where this sits”VoxelSandbox.UIonly. One new pure static class, one newMonoBehaviour, one new EditMode test. No new assembly reference —UIalready seesWorld(for the biome + clock) and everything else.- It attaches under
GameplayHudRuntime.Rootand mirrorsGameplayHudRuntime.IsHudVisible(GameplayHudRuntime.cs:40) — no scene wiring, no prefab. - No Kenney art: it is a
Texton a translucent panel, styled by the existingKenneyHudStyleRuntimefont 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
Before you start
Section titled “Before you start”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 plainTextrather 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.
The build, step by step
Section titled “The build, step by step”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.csusing 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.
2. The runtime — LocaleHudRuntime.cs
Section titled “2. The runtime — LocaleHudRuntime.cs”A sealed MonoBehaviour following the bootstrap-and-diff pattern of ContextualTargetHudRuntime (ContextualTargetHudRuntime.cs:15):
// illustrative — new file Assets/_Game/Scripts/UI/LocaleHudRuntime.csusing 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)]+ theinstance/FindAnyObjectByTypeguard, exactly asContextualTargetHudRuntime.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), matchingContextualTargetHudRuntime.cs:57. The string only changes on a biome change or a minute rollover, solabel.textis set a few times a minute, not 60 times a second. - Hides with the HUD — mirrors
GameplayHudRuntime.IsHudVisiblerather than subscribing (there is no HUD-visibility event yet — see Pitfalls). - Unity-null, not
??— resolveplayer/world/dayNightwith anif (x == null)re-lookup every time they are missing; a destroyed Unity object is not CLR-null, so??=on aMonoBehaviourreference across a scene load is a bug (PlayerAssayRuntimecalls this out in a comment,PlayerAssayRuntime.cs:48). Thehud ??= …above is safe only because a missingGameplayHudRuntimeis a real CLR-null on first frame; if in doubt, use the explicit form.
3. Attach and style
Section titled “3. Attach and style”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):
// illustrativeusing 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.
Verify
Section titled “Verify”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.logThen, in the open Editor:
- Scene Composer ▸ Compose Gameplay, enter Play Mode. The Locale readout appears top-left showing e.g.
Plains · 06:00. - Walk across a biome border — the biome word changes; the clock ticks a minute at a time.
- Press F1 — the readout hides with the crosshair and hotbar, and returns on the next F1.
- 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.
Now do your own
Section titled “Now do your own”- Decide read-only vs interactive, and whether it is gameplay chrome (hides on F1, parents under
GameplayHudRuntime.Root) or always-on (likeSaveStatusHudRuntime). - Pure layer first — a
staticclass (*Presentation/*Model) with every string, number, and branch, noMonoBehaviour, noUnityEngine.UI. This is the part you test. - The runtime — a
sealed MonoBehaviourwith a[RuntimeInitializeOnLoadMethod(AfterSceneLoad)]Create(), aninstanceguard,Awake/OnDestroybookkeeping, a lazyTryBuild()that allocates theImage/Textobjects once, and anUpdate()that recomputes the model value and repaints only on a change. - Resolve dependencies with
if (x == null)re-lookup, never??=, on Unity object references that can vanish across a scene load. - Attach under
GameplayHudRuntime.Root; letKenneyHudStyleRuntimeapply the font. - New art? Add it to
HudVisualSettings/KenneyUiStyleand the composer that fills them — never referenceAssets/ThirdParty/from a runtime. UpdateDocs/ASSET_LEDGER.md. - Diagnostics-only? Guard
Create()onDebug.isDebugBuild, likeDebugOverlayRuntime. - Test the pure layer in
Tests/EditMode/UI/. CHANGELOG.md+VERSIONbump 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).
Pitfalls
Section titled “Pitfalls”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.
See also
Section titled “See also”- The runtime UI — the conventions this page applies.
- Add a scientific route · Add a voxel block type — the other worked examples.
Docs/MASTERPLAN.md§14;Docs/ASSET_LEDGER.md(Kenney provenance).
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.