> For the complete documentation index, see [llms.txt](https://docs.allout.game/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.allout.game/ui/world-space-ui.md).

# World Space UI

Use world space UI to draw text above players heads and any other text that should feel like it's part of the game world itself.

World space UI is drawn in the 2D world instead of on the screen. Use it for nameplates, health bars, signs, and any text that should feel anchored to game objects.

{% hint style="info" %}
World space UI uses **meters** for sizing. If you use screen-space point sizes, your UI will be enormous.
{% endhint %}

### Core Rules

* Interactive UI must use `ao_update`/`ao_late_update` (player UI: `Player.ao_late_update` under `is_local_or_server()`), never `ao_draw`.
* `ao_draw` is for non-interactive cosmetic visuals only.
* For UI anchored to a moving entity, call `UI.begin_world_space_ui(entity)` and `defer UI.end_world_space_ui()` so render interpolation follows that entity.
* Use `UI.push_world_draw_context()` only for unanchored world drawing.
* Use meters for rect sizes and offsets.
* Push Z for proper depth sorting (commonly use `pos.y`).
* Use `subrect` only for percentage fills (like health bars).

### Examples

#### Displaying a player's level

```go
draw_player_level :: proc(player: Player) {
    UI.begin_world_space_ui(player.entity);
    defer UI.end_world_space_ui();

    pos := player.entity.world_position;
    UI.push_z(pos.y);
    defer UI.pop_z();

    ts := UI.default_text_settings();
    ts.size = 0.30; // World space text size
    ts.halign = .CENTER;
    ts.valign = .CENTER;

    text_pos := pos + v2{0, 1.7};
    rect := Rect{text_pos, text_pos}.grow(0.05, 0.4, 0.05, 0.4);
    UI.text(rect, ts, "Lvl %", {player.level});
}
```

#### Player base signs

```go
draw_base_sign :: proc(sign: Entity, label: string) {
    UI.begin_world_space_ui(sign);
    defer UI.end_world_space_ui();

    pos := sign.world_position;
    UI.push_z(pos.y);
    defer UI.pop_z();

    ts := UI.default_text_settings();
    ts.size = 0.30;
    ts.halign = .CENTER;
    ts.valign = .CENTER;

    text_pos := pos + v2{0, 2.2};
    rect := Rect{text_pos, text_pos}.grow(0.06, 0.8, 0.06, 0.8);

    // Optional dark backing for readability
    UI.quad(rect, core_globals.white_sprite, {0, 0, 0, 0.6});
    UI.text(rect, ts, label);
}
```

#### Health bars

```go
// Note: health/max_health are user-defined fields on your own class, not built-in Entity fields.
draw_world_ui :: proc(entity: My_Entity) {
    UI.begin_world_space_ui(entity.entity);
    defer UI.end_world_space_ui();

    pos := entity.entity.world_position;
    UI.push_z(pos.y);
    defer UI.pop_z();

    bar_pos := pos + v2{0, 1.5};
    bar_rect := Rect{bar_pos, bar_pos}.grow(0.1, 0.5, 0.1, 0.5);

    UI.quad(bar_rect, core_globals.white_sprite, {0, 0, 0, 0.8});

    health_pct := entity.health / entity.max_health;
    fill_rect := bar_rect.inset(0.02).subrect(0, 0, health_pct, 1);
    fill_color := lerp(v4{1, 0, 0, 1}, {0, 1, 0, 1}, health_pct);
    UI.quad(fill_rect, core_globals.white_sprite, fill_color);
}
```

### Progress bars

`World_Progress_Bar` provides a standard world-space bar:

```go
Health :: class : Component {
    current: int;
    maximum: int = 100;
}

draw_health_bar :: proc(health: Health) {
    UI.begin_world_space_ui(health.entity);
    defer UI.end_world_space_ui();

    maximum := max(1, health.maximum);
    progress := clamp(
        health.current.(float) / maximum.(float),
        0.0,
        1.0
    );

    options := World_Progress_Bar.default_options();
    options.y_bias = 1.5;
    World_Progress_Bar.draw(health.entity.world_position, progress, options);
}
```

The casts keep the division in floating point, and the clamped denominator prevents division by zero.

For a custom bar whose fill changes over time, keep one `Float_Interpolation_Helper` per bar:

```go
Smooth_Bar :: class : Component {
    progress: float;
    fill_history: Float_Interpolation_Helper;

    draw :: method() {
        UI.begin_world_space_ui(entity);
        defer UI.end_world_space_ui();

        center := entity.world_position + v2{0, 1.5};
        rect := Rect{center, center}.grow(0.1, 0.5, 0.1, 0.5);
        UI.quad(rect, core_globals.white_sprite, {0, 0, 0, 1});

        params: Quad_Params;
        params.fill = UI.quad_fill(
            fill_history.update(clamp(progress, 0.0, 1.0)),
            .RIGHT
        );
        UI.quad(rect.inset(0.02), core_globals.white_sprite, {0.1, 1, 0.1, 1}, params);
    }
}
```

Radial fills start at 12 o'clock and use signed amounts: `-1..0` fills counterclockwise and `0..1` fills clockwise. For an animated radial cooldown, pass `fill_history.update(clamp(amount, -1, 1))` to `UI.quad_fill(..., .RADIAL)`. Do not clamp to `0..1` unless clockwise-only behavior is intentional.

### Tutorial arrows

The entity overload includes the target's interpolation offset:

```go
draw_target_arrow :: proc(player: Player, target: Entity) {
    options := Tutorial_Arrow.default_options();
    Tutorial_Arrow.draw(player, target, options);
}
```

For a script-owned position that moves, store a `Position_Interpolation_Helper` and pass its offset:

```go
Player :: class : Player_Base {
    objective_position: v2;
    objective_history: Position_Interpolation_Helper;

    draw_objective_arrow :: method() {
        options := Tutorial_Arrow.default_options();
        offset := objective_history.update(objective_position);
        Tutorial_Arrow.draw(this, objective_position, options, offset);
    }

    ao_late_update :: method(dt: float) {
        if is_local_or_server() {
            draw_objective_arrow();
        }
    }
}
```

After snapping an entity to a new position, call `entity.mark_teleported()` so interpolation does not smear between the old and new positions.

### Coordinate Conversion

```go
// Convert world position to screen position
screen_pos := world_to_screen(entity.world_position);

// Convert screen position to world position
world_pos := screen_to_world(get_mouse_screen_position());
```

### Tips

* Keep world UI minimal and readable at distance. If you have a zoomable camera, make sure to adjust text size when you zoom out.
* Use `fit_aspect(texture.get_aspect())` if you draw icons in world space.
* If text flickers or overlaps, check your Z values and spacing.
