HUD & UI Guide w/ Sample Code

How to build HUD and UI in s&box using the immediate-mode `HudPainter` API.

## Overview

s&box provides two ways to draw UI:

- **`HudPainter`** (`Scene.Camera.Hud`) — 2D screen-space overlay. Rectangles and text only.
- **`Gizmo.Draw`** — 3D world-space shapes (spheres, lines). Good for debug visuals and AoE indicators.

There's no retained UI tree, no layout engine, no CSS. You draw everything yourself every frame in `OnUpdate()`.

---

## Getting Started

Attach a `Component` to a GameObject and draw in `OnUpdate()`:

```csharp
public sealed class MyHud : Component
{
protected override void OnUpdate()
{
if (Scene.Camera is null) return;
var hud = Scene.Camera.Hud;

hud.DrawRect(new Rect(10, 10, 200, 30), new Color(0f, 0f, 0f, 0.5f));
hud.DrawText(
new TextRendering.Scope("Hello World", Color.White, 12, "Poppins"),
new Vector2(15, 15)
);
}
}
```

Key facts:
- `Scene.Camera.Hud` is the `HudPainter` instance — the only screen-space drawing API
- Always null-check `Scene.Camera` first (it can be null during scene loading)
- `TextRendering.Scope` accepts a font family name (e.g. `"Poppins"`, `"Material Icons"`)
- Coordinate origin is **top-left**. X goes right, Y goes down. Units are **screen pixels**

---

## Drawing Primitives

`HudPainter` has three drawing methods. Everything else is built from these.

### DrawRect

```csharp
hud.DrawRect(new Rect(x, y, width, height), color);
```

### DrawText

```csharp
hud.DrawText(
new TextRendering.Scope(
text: "Some text",
color: new Color(1f, 1f, 1f, 0.8f),
size: 12, // font size in pixels
fontName: "Poppins" // font family name
),
position: new Vector2(x, y) // top-left corner of the text
);
```

### DrawLine

```csharp
hud.DrawLine(start, end, lineWidth, color);
```

---

## Common Helpers

Since the API only gives you rects and text, you'll build helpers for everything else.

### Rounded Rectangle

Approximated with multiple rects (no actual curves):

```csharp
private void DrawRoundedRect(HudPainter hud, float x, float y, float w, float h, float r, Color color)
{
hud.DrawRect(new Rect(x + r, y, w - r * 2f, h), color);
hud.DrawRect(new Rect(x, y + r, r, h - r * 2f), color);
hud.DrawRect(new Rect(x + w - r, y + r, r, h - r * 2f), color);
float cr = r * 0.7f;
hud.DrawRect(new Rect(x + r - cr, y + r - cr, cr, cr), color);
hud.DrawRect(new Rect(x + w - r, y + r - cr, cr, cr), color);
hud.DrawRect(new Rect(x + r - cr, y + h - r, cr, cr), color);
hud.DrawRect(new Rect(x + w - r, y + h - r, cr, cr), color);
}
```

### Line Segment (Manual Alternative)

`HudPainter.DrawLine` exists but if you need more control (e.g. dotted lines, glow layers), you can step small squares along the path:

```csharp
private void DrawLineSegment(HudPainter hud, Vector2 a, Vector2 b, float thickness, Color color)
{
float dist = Vector2.DistanceBetween(a, b);
if (dist < 0.5f) return;

int steps = Math.Max((int)(dist / 1.5f), 1);
float half = thickness / 2f;

for (int s = 0; s <= steps; s++)
{
float t = (float)s / steps;
float px = a.x + (b.x - a.x) * t;
float py = a.y + (b.y - a.y) * t;
hud.DrawRect(new Rect(px - half, py - half, thickness, thickness), color);
}
}
```

### Health Bar

Color gradient from red (low HP) through yellow to green (full HP):

```csharp
private void DrawHealthBar(HudPainter hud, float cx, float y, float health, float maxHealth, float barW, float barH)
{
float x = cx - barW / 2f;
float fraction = maxHealth > 0f ? health / maxHealth : 0f;

// Background
hud.DrawRect(new Rect(x - 1, y - 1, barW + 2, barH + 2), new Color(0f, 0f, 0f, 0.7f));

// Fill: red → yellow → green
Color barColor;
if (fraction > 0.5f)
barColor = Color.Lerp(new Color(1f, 0.9f, 0.2f), new Color(0.2f, 0.9f, 0.3f), (fraction - 0.5f) * 2f);
else
barColor = Color.Lerp(new Color(0.9f, 0.15f, 0.1f), new Color(1f, 0.9f, 0.2f), fraction * 2f);

float fillW = barW * fraction;
if (fillW > 0.5f)
hud.DrawRect(new Rect(x, y, fillW, barH), barColor);
}
```

### Corner Bracket Decoration

L-shaped brackets to frame a rectangular zone:

```csharp
private void DrawCornerAccents(HudPainter hud, float x, float y, float size)
{
float len = 20f, thick = 2f;
var color = new Color(0.3f, 0.6f, 1f, 0.7f);

// Top-left
hud.DrawRect(new Rect(x, y, len, thick), color);
hud.DrawRect(new Rect(x, y, thick, len), color);
// Top-right
hud.DrawRect(new Rect(x + size - len, y, len, thick), color);
hud.DrawRect(new Rect(x + size - thick, y, thick, len), color);
// Bottom-left
hud.DrawRect(new Rect(x, y + size - thick, len, thick), color);
hud.DrawRect(new Rect(x, y + size - len, thick, len), color);
// Bottom-right
hud.DrawRect(new Rect(x + size - len, y + size - thick, len, thick), color);
hud.DrawRect(new Rect(x + size - thick, y + size - len, thick, len), color);
}
```

---

## World-to-Screen Projection

To draw screen-space UI above a 3D entity (nameplates, health bars, floating text):

```csharp
// 1. Pick a world position above the entity
var worldPos = WorldPosition + Vector3.Up * heightAboveEntity;

// 2. Cull if behind camera
var camPos = Scene.Camera.WorldPosition;
var camFwd = Scene.Camera.WorldRotation.Forward;
if (Vector3.Dot(camFwd, (worldPos - camPos).Normal) < 0.1f) return;

// 3. Cull by distance (optional, good for performance)
float dist = Vector3.DistanceBetween(camPos, worldPos);
if (dist > 600f) return;

// 4. Project to normalized screen coords (0..1)
var screenPos = Scene.Camera.PointToScreenNormal(worldPos);

// 5. Cull if off-screen
if (screenPos.x < 0f || screenPos.x > 1f || screenPos.y < 0f || screenPos.y > 1f) return;

// 6. Convert to pixel coords
float sx = screenPos.x * Screen.Width;
float sy = screenPos.y * Screen.Height;

// Now draw at (sx, sy)
hud.DrawText(new TextRendering.Scope("Enemy", Color.Red, 12, "Poppins"), new Vector2(sx, sy));
```

`PointToScreenNormal` returns 0..1 space. You must multiply by `Screen.Width`/`Screen.Height` to get pixels.

### Auto-Detecting Entity Height

To position a nameplate above an entity's head, read model bounds:

```csharp
float height = 0f;
float scale = MathF.Max(GameObject.WorldScale.z, 0.1f);

var skinned = Components.Get<SkinnedModelRenderer>();
if (skinned?.Model is not null)
height = MathF.Max(height, skinned.Model.Bounds.Maxs.z * scale);

var model = Components.Get<ModelRenderer>();
if (model?.Model is not null)
height = MathF.Max(height, model.Model.Bounds.Maxs.z * scale);

if (height < 20f) height = 60f; // fallback minimum
```

Cache this and recalculate every few seconds — don't do it every frame.

---

## Animation Patterns

### Pulsing Alpha

```csharp
float pulse = 0.7f + MathF.Sin(Time.Now * 3f) * 0.3f; // oscillates 0.4..1.0
hud.DrawRect(rect, color.WithAlpha(pulse));
```

### Screen Shake

Compute a shake offset and apply it to the camera or UI positions:

```csharp
private float _shakeTimer;
private float _shakeIntensity;

public void TriggerShake(float intensity, float duration = 0.3f)
{
_shakeTimer = duration;
_shakeIntensity = intensity;
}

public Vector2 GetShakeOffset()
{
if (_shakeTimer <= 0f) return Vector2.Zero;
_shakeTimer -= Time.Delta;
float fade = _shakeTimer / 0.3f;
return new Vector2(
MathF.Sin(Time.Now * 60f) * _shakeIntensity * fade,
MathF.Cos(Time.Now * 45f) * _shakeIntensity * fade
);
}
```

### Timed Message

Show something for N seconds, count down each frame:

```csharp
private float _messageTimer;
private string _message;

// Trigger:
_message = "Not enough mana!";
_messageTimer = 2f;

// In OnUpdate:
if (_messageTimer > 0f)
{
_messageTimer -= Time.Delta;
float alpha = Math.Min(_messageTimer, 1f);
hud.DrawText(
new TextRendering.Scope(_message, Color.Red.WithAlpha(alpha), 14, "Poppins"),
new Vector2(Screen.Width / 2f - 60f, Screen.Height / 2f)
);
}
```

### Floating Numbers (Damage Popups)

Spawn a temporary GameObject with a component that drifts upward and fades:

```csharp
public sealed class FloatingText : Component
{
public float Value { get; set; }
public bool IsPositive { get; set; }

private float _spawnTime;
private const float Duration = 1.2f;
private Vector3 _jitter;

protected override void OnStart()
{
_spawnTime = Time.Now;
// Random jitter so multiple popups don't overlap
_jitter = new Vector3(Game.Random.Float(-10f, 10f), Game.Random.Float(-10f, 10f), 0f);
}

protected override void OnUpdate()
{
if (Scene.Camera is null) return;

float elapsed = Time.Now - _spawnTime;
if (elapsed > Duration) { GameObject.Destroy(); return; }

float t = elapsed / Duration;
float alpha = 1f - t;

// Drift upward over time
var worldPos = WorldPosition + Vector3.Up * (90f + elapsed * 60f) + _jitter;

// Project to screen (with behind-camera + off-screen culling)
var camFwd = Scene.Camera.WorldRotation.Forward;
if (Vector3.Dot(camFwd, (worldPos - Scene.Camera.WorldPosition).Normal) < 0.1f) return;

var sp = Scene.Camera.PointToScreenNormal(worldPos);
if (sp.x < 0f || sp.x > 1f || sp.y < 0f || sp.y > 1f) return;

float sx = sp.x * Screen.Width;
float sy = sp.y * Screen.Height;

// Pop in over first 20% of lifetime, then hold
float sizeScale = t < 0.2f ? t / 0.2f : 1f;
int fontSize = (int)(42f * sizeScale);

Color color = IsPositive
? new Color(0.2f, 1f, 0.4f, alpha)
: new Color(1f, 0.8f, 0.2f, alpha);

string text = IsPositive ? $"+{Value:F0}" : $"{Value:F0}";

Scene.Camera.Hud.DrawText(
new TextRendering.Scope(text, color, fontSize, "Poppins"),
new Vector2(sx - 20f, sy)
);
}

public static void Spawn(Scene scene, Vector3 position, float value, bool positive = false)
{
var go = new GameObject();
go.Name = "FloatingText";
go.WorldPosition = position;
var ft = go.Components.Create<FloatingText>();
ft.Value = value;
ft.IsPositive = positive;
// No NetworkSpawn() — purely local visual effect
}
}
```

---

## Mouse Interaction

### Reading Mouse Position

```csharp
var mp = Mouse.Position; // screen pixels
```

### Hit Testing

No built-in hit testing. Compare mouse position against your rect bounds:

```csharp
bool hovering = mp.x >= x && mp.x <= x + w && mp.y >= y && mp.y <= y + h;
```

### Click Detection

```csharp
bool clicked = Input.Pressed("attack1"); // true for one frame on press
bool holding = Input.Down("attack1"); // true every frame while held
```

### Click-Outside-to-Close Pattern

There's no focus system. Check manually if the click was outside your panel:

```csharp
if (Input.Pressed("attack1"))
{
var mp = Mouse.Position;
if (mp.x < panelX || mp.x > panelX + panelW || mp.y < panelY || mp.y > panelY + panelH)
{
_panelOpen = false;
}
}
```

### Showing/Hiding the Cursor

```csharp
Mouse.Visible = true; // show cursor for menus
Mouse.Visible = false; // hide cursor for gameplay
```

Show cursor when menus/panels are open. Hide it when the player is in gameplay.

---

## Text Width Estimation

There's no runtime `MeasureText` API (`Paint.MeasureText` exists but is editor-only). Estimate width by character count:

```csharp
float width = text.Length * pixelsPerChar;
```

Rough guide:

| Font Size | ~Pixels/Char |
|-----------|-------------|
| 9-10 | 3.2-3.5 |
| 11 | 4.0 |
| 12-13 | 4.0-4.5 |
| 14+ | 5.0+ |

This is imprecise (not monospaced), but good enough for centering:

```csharp
float textW = text.Length * 4f;
float x = centerX - textW / 2f;
```

---

## Text Input

s&box has no text input widget. Read the keyboard buffer via `Input.Text` and process characters yourself:

```csharp
private string _input = "";
private float _cursorBlink;

// In OnUpdate:
var typed = Input.Text;
if (!string.IsNullOrEmpty(typed))
{
foreach (var c in typed)
{
if (c == '\b') // backspace
{
if (_input.Length > 0)
_input = _input.Substring(0, _input.Length - 1);
}
else if (c >= 32 && c < 127 && _input.Length < 150) // printable ASCII
{
_input += c;
}
}
}

// Blinking cursor
_cursorBlink += Time.Delta;
bool showCursor = ((int)(_cursorBlink * 2f) % 2) == 0;
string displayText = showCursor ? _input + "|" : _input;

hud.DrawText(new TextRendering.Scope(displayText, Color.White, 12, "Poppins"), inputPos);
```

---

## Blocking Game Input During UI

When a text field or menu is open, prevent movement and other game actions:

```csharp
// Expose a flag from your UI component
public bool IsMenuOpen => _menuOpen;
public bool IsChatOpen => _chatOpen;

// In your player controller / input handler, check it:
if (chatManager.IsChatOpen) return;
if (hud.IsMenuOpen) return;
// ...proceed with normal gameplay input
```

Every system that reads input needs to respect these flags, or players will move/attack while typing.

---

## World-Space Drawing (Gizmo.Draw)

For 3D visuals like AoE indicators or debug shapes, use `Gizmo.Draw`:

```csharp
protected override void OnUpdate()
{
Gizmo.Draw.Color = new Color(1f, 0f, 0f, 0.15f);
Gizmo.Draw.SolidSphere(WorldPosition, radius);

// Wireframe ring at ground level
Gizmo.Draw.Color = new Color(1f, 0f, 0f, 0.3f);
int segments = 32;
for (int i = 0; i < segments; i++)
{
float a1 = (i / (float)segments) * MathF.PI * 2f;
float a2 = ((i + 1) / (float)segments) * MathF.PI * 2f;
var p1 = WorldPosition + new Vector3(MathF.Cos(a1) * radius, MathF.Sin(a1) * radius, 0);
var p2 = WorldPosition + new Vector3(MathF.Cos(a2) * radius, MathF.Sin(a2) * radius, 0);
Gizmo.Draw.Line(p1, p2);
}
}
```

`Gizmo.Draw` works in **world coordinates** (3D `Vector3`), not screen pixels. Good for:
- AoE radius previews
- Debug collision shapes
- Selection circles under entities

---

## Multiplayer HUD Considerations

### Owner-Only HUD

A player's personal HUD (health bar, inventory, menus) should only render for the owning client:

```csharp
protected override void OnUpdate()
{
if (IsProxy) return; // only the local player draws their own HUD
if (Scene.Camera is null) return;
var hud = Scene.Camera.Hud;
// draw health, mana, menus, etc.
}
```

### Proxy-Only HUD (Nameplates for Other Players)

Nameplates above other players are the **opposite** — skip yourself, draw for everyone else:

```csharp
// In a Nameplate component attached to every player:
var playerCtrl = Components.Get<PlayerController>();
if (playerCtrl is not null)
{
if (!IsProxy) return; // don't draw a nameplate above your own head
// draw name + health bar for this other player
}
```

### Local-Only Visual Effects

Floating text, screen flashes, and particle-like effects don't need networking. Each client spawns them locally when the relevant event happens:

```csharp
// No NetworkSpawn() needed — each client creates their own
FloatingText.Spawn(Scene, hitPosition, damageAmount);
```

---

## Caveats & Gotchas

1. **`TextRendering.Scope` accepts a font family name** (e.g. `"Poppins"`, `"Material Icons"`). Available fonts depend on what's bundled with s&box.

2. **No runtime `MeasureText`.** `Paint.MeasureText` exists but is editor-only. At runtime, estimate text width by character count.

3. **`Mouse.Visible`** controls cursor visibility. Set to `true` for menus, `false` for gameplay.

4. **Coordinates are screen pixels, origin top-left.** Not normalized 0..1. But `PointToScreenNormal` returns 0..1, so you must convert.

5. **Camera can be null.** Always guard with `if (Scene.Camera is null) return;` at the top of any drawing code.

6. **No focus or panel system.** You manage open/closed state, click-outside-to-close, and input blocking yourself.

7. **All state lives on the Component.** Open/closed booleans, timers, scroll positions, drag state — it's all fields on your class. There's no widget tree to query.

8. **`[Sync]` values aren't available immediately for proxies.** If your UI reads synced data (e.g., player name from `Network.Owner`), it may be null on the first few frames. Add a null check or defer setup.

9. **Drawing order is call order.** Whatever you draw last renders on top. There's no z-index. Structure your draw calls accordingly (backgrounds first, text last).