# HUD

Custom on-screen UI for CS2. Two layers:

1. **Screen effects** — `Fade`, `Shake`, `HintText` from `@s2script/cs2`. Usermessages; no workshop addon.
2. **Custom Panorama** — `CustomHudLayout` and `hudkit` from `@s2script/cs2`. Prefer [`hudkit`](https://s2script.com/docs/concepts/hud/components) (pooled modals, badges, toasts, callouts, banners, MOTD, plus the TopMenu dashboard) when you do not ship a layout. Use [`CustomHudLayout.create(spec)`](https://s2script.com/docs/concepts/hud/custom) when you publish your own workshop XML.

There is no `@s2script/sdk/hud`. Import `CustomHudLayout` / `hudkit` from `@s2script/cs2` and drive panels through them. `@s2script/cs2/ui` is types-only — `require("@s2script/cs2/ui")` is `undefined`. `CustomHudLayout.create` is load-window (throws after settle). Use `hudkit` from `OnPluginStart` or a later callback — methods and `layout`/`hud` throw before plugin context exists (`s2script: hudkit.<name> requires plugin context; use it in OnPluginStart or a later callback`); claimed handles stay usable after initialization settles. `hudkit.spec` / `hudkit.descriptor` stay readable at module top-level. Modal/badge `null` means the pool is exhausted, not a missing context. `ui` / `hud()` / `components()` remain as deprecated aliases of the same objects.

## Operator setup

Custom Panorama needs:

- CS2 dedicated build **≥ 24934554** (`AllowCustomGameUI` is on; older clients crash on addon layouts).
- [MultiAddonManager](https://github.com/Source2ZE/MultiAddonManager) **v1.6.1 or later** on the server. v1.6 is stale for CS2 builds 25515854+: it writes the client addon list at the wrong offset, so clients never mount the HUD addon.
- `mm_extra_addons "3790153369"` in `game/csgo/cfg/multiaddonmanager/multiaddonmanager.cfg` (decimal id, quoted, no `workshop/` prefix). That is the shipped s2script HUD addon.
- Observable layouts (`CustomHudSpec.observable`) and [`Pawn.getCustomCamera()`](https://s2script.com/docs/concepts/camera) need CS2's **September 9, 2026** engine update and a matching s2script runtime shim.

Clients must actually **mount** that addon. `+host_workshop_map` delivers a map, not a content addon. The server cannot detect a missing client mount — drive calls succeed and the player simply sees nothing.

When the addon gains new panel trees, **publish the layout before the runtime that addresses them**. The six-sheet pool (`s2_m0`–`s2_m5`) is that kind of change: extra unused trees in the client layout are harmless; a runtime expecting 6 against a 2-tree client fails silently for sheets 2–5. Old two-sheet clients keep the common case (claims lowest-first). CSS-only changes (`Row.tone`, buy-menu / scoreboard yield) can land after the runtime, but they still need a **workshop republish** — updating the server bundle alone does not update clients.

Screen effects (`Fade` / `Shake` / `HintText`) skip this checklist.

## First HUD

Register each layout from `OnPluginStart` (`CustomHudLayout.create` / `hudkit`). Do not call hudkit methods or `layout`/`hud` at module top-level — they throw until this plugin's context exists (a stand-in kit used to paint panels that never delivered clicks). `hudkit.spec` / `hudkit.descriptor` stay readable at module top-level, so `CustomHudLayout.components(hudkit.spec)` does not depend on resolution order. Claim in `OnPluginStart`; open and drive from a later callback (a command, player-join, a game event). The layout entity spawns when the first client becomes `SIGNON_ACTIVE` — never from `OnMapStart` (that segfaults). Paint and drive never create the entity as a side-effect.

```ts
import type { Client } from '@s2script/sdk';
import { hudkit } from '@s2script/cs2';
import type { Badge, Modal } from '@s2script/cs2';

let badge: Badge | null;
let modal: Modal | null;

export function OnPluginStart(): void {
	badge = hudkit.badge({ corner: 'tr', accent: 'accent' });
	modal = hudkit.modal({
		title: 'Pick one',
		rows: [
			{ id: 'heal', a: 'Heal', b: '100 HP' },
			{ id: 'tk', a: '[BAD] TK', tone: 'bad' },
			{ id: 'nope', a: 'Nope', disabled: true }
		],
		onPick: (s, _index, row) => {
			hudkit.forSlot(s).toast({ message: `picked ${row.id ?? row.a}`, holdSeconds: 3 });
		},
		buttons: [{ text: 'Close', variant: 'ghost', onClick: (_s, v) => v.close() }]
	});
}

export function OnClientActive(client: Client): void {
	const slot = client.slot;
	const p = hudkit.forSlot(slot);

	p.toast({ title: 'Hello', message: 'from s2script', variant: 'good' });
	p.callout({ message: 'Press E for the next corner' });
	badge?.forSlot(slot).show({ title: 'STATUS', text: 'online' });
	modal?.forSlot(slot).open();
}
```

`HudLayout.ensure()` / `hudkit.ensure()` force a spawn of a specific layout. They are optional once `create()` or `hudkit` has already run and a client is active.

Toast, badge, modal, callout, banner, motd, and the TopMenu dashboard live on [Default components](https://s2script.com/docs/concepts/hud/components). A kill feed or scoreboard of your own is [Mounting your own](https://s2script.com/docs/concepts/hud/custom).

## Screen effects

No addon. Each call returns `false` if the usermessage / fields do not resolve:

```ts
import { command } from '@s2script/sdk';
import { Fade, Shake, HintText } from '@s2script/cs2';

export function OnPluginStart(): void {
	command('flash', (cmd) => {
		const slot = cmd.callerSlot;
		if (slot < 0) return;
		Fade.blind(slot, 2);
		Shake.to(slot, { amplitude: 10, frequency: 40, duration: 1 });
		HintText.to(slot, 'eyes open');
	});
}
```

`Fade.to` takes packed RGBA `color` plus `duration` / `holdTime` / `flags`. Prefer `Fade.blind` for a full-screen flash.

## Menus and votes

`Menu` from `@s2script/sdk/menu` is the authoring model (add items, `display` to a slot). On CS2, both `MenuStyle.Center` and `MenuStyle.Chat` paint a **hudkit center sheet** from the six-sheet pool (`s2_m0`–`s2_m5`) shared across plugins. The built-in presenter claims a sheet on first open and releases it when the last viewer closes, disconnects, or the map changes — idle plugins reserve zero panels. If the pool is busy at open, that session uses the existing chat renderer (server log `modal pool busy`); later opens retry allocation. Exhaustion does not unregister Center. A HUD sheet that fails to paint or show (`tryOpen`) also falls that session to chat (server log `HUD open failed`); unused panel claims are released and `HudInput` is not armed on the failed path. `Menu.registerRenderer(name, renderer)` returns the previous renderer (or `undefined`) so a replacement can delegate when its own display is unavailable.

`menu.activation` is `"immediate"` (default — opens with the cursor) or `"tab"` (paints first; Tab grabs the mouse). Unknown values coerce to immediate. Set `activation` on the menu; `HudInput` (`arm` / `disarm` / `isArmed` / `isActive` from `@s2script/cs2`) is the advanced Tab-arming primitive. Footer Back/Next/Close follow the Menu session page; disabled rows stay visible and are omitted from click routing.

Votes are **not** a Menu and **not** a hudkit modal. CS2 paints a compact right-side rail on the shared `s2script_lib.xml` (addon `3790153369`) — the same `custom_hud_layout` entity as menus. The server drives `s2_vote*` through `hudkit.layout`; it is not a second `CustomHudLayout.create` and not a separate `s2_vote.xml`. The vote rail binds option clicks once the plugin's kit exists — not at module eval, not at first paint. The Menu HUD registers its renderer the same way, then claims a sheet only when a menu actually opens. Tab arms `HudInput` until that player picks or the vote expires; after a pick the rail stays as a live tally (counts visible, choice highlighted, clicks ignored). Chat is one line: `[Vote] question — Tab, or type 1–N`. Options are not listed in chat; digits still cast. When a tally renderer is registered (CS2 always registers the rail), `showLiveTally` is ignored. Renderers see `VoteTally.choice` — that slot's 0-based cast, or `null`.

`sm_admin` / `sm_menu` are also **not** a Menu and **not** a hudkit modal. CS2 paints a tabbed dashboard (`s2_dash`) through `hudkit.dashboard` — one sheet, plugin-owned tabs, not a category drill-down. See [TopMenu](https://s2script.com/docs/authoring#topmenu).

## Hazards

- **Wait for an active client** — never spawn a `custom_hud_layout` from `OnMapStart` (segfault). Paint/drive never create the entity. Player-join, game events, and commands are all fine.
- **No hudkit at module top-level** — `hudkit.modal()`, `hudkit.layout`, `hudkit.dashboard`, and the rest throw until the plugin context exists (`s2script: hudkit.<name> requires plugin context; use it in OnPluginStart or a later callback`). Use `hudkit` from `OnPluginStart` or a later callback. `hudkit.spec` / `hudkit.descriptor` stay readable at module top-level for `CustomHudLayout.components(hudkit.spec)`. Modal/badge `null` is pool exhaustion, not a missing context.
- **Unload and reload clear HUD surfaces** — the host owner-sweep hides this plugin's panels. A non-focused hudkit modal and a hudkit badge hold an owned, hide-only surface lease while shown, so a hot reload does not leave a kit sheet on screen with nothing able to close it. `badge.release()` also hides the badge for every player still seeing it.
- **Don't hold idle sheets** — six modal panels are shared across every plugin. Built-in menus claim on open and release when idle. An explicit `hudkit.modal()` you never `release()` occupies a sheet until unload/reload (which then hides it).
- **Per-slot paint is storage, not delivery** — `HudLayout.forSlot` / hudkit `forSlot` file writes under that slot on **one** shared layout entity; they do not promise only that client sees them. Observation is a **rendering policy** (`CustomHudSpec.observable`), not network confidentiality or click authorization. Default `false` (CS2 September 9 update): a spectator keeps their **own** UI. `observable: true` opts into showing the spectated player's version. The value is fixed at spawn for the entity lifetime. Shared hudkit is non-observable. Conflicting reuse of the same `resource` in one plugin throws; opposite policies need distinct resources (and thus distinct entity names). Do not put admin controls, private balances, or secrets into HUD state. Transmit filtering (`Transmit.setVisibleTo`) remains a separate mechanism. A panel that must be private still needs one layout entity per recipient plus transmit filtering, which multiplies the intern budget.
- **Retained views follow the connection** — `HudPlayer`, `HudKitPlayer`, `ModalView`, `DashboardView`, `BadgeView`, and `MotdHandle` expose `isValid()`. Slot-first APIs (`modal.open(slot)`, `dashboard.open(slot)`, `badge.show(slot)`, `hudkit.motd(slot)`, `hudkit.forSlot(slot)`) adopt the **current** occupant and return a fresh valid view. A retained view stays reusable across ordinary same-client close/open (or hide/show). It goes stale on same-slot reconnect (even the same SteamID), component `release` / `forget`, layout replacement, dashboard spec replacement, or MOTD close. Stale ops no-op or fail closed (`open` throws `/stale/`; hudkit methods return `"stale client"`). Delayed fades are fenced by client + component lifetime. After that, `lastUpdateResult()` on a stale modal/dashboard view is `null`.
- **Exclusive focus is opt-in** — modal/dashboard `open` / `tryOpenResult` and `MotdSpec.focus` take `{ mode: "exclusive"; priority? }`. Higher priority wins; latest acquisition wins ties. Covered components keep desired state but stay hidden/noninteractive. Not engine z-order and not global click suppression. Calls without `focus` keep legacy behavior.
- **Coalesce many updates with `invalidate()`** — modal/dashboard `invalidate()` schedules one next-frame repaint (repeated calls coalesce). Use `refresh()` / `tryRefresh()` when you need a synchronous repaint now. `lastUpdateResult()` is the last completed open/repaint; scheduling an invalidation does not erase it.
- **Explicit claims do not overwrite** — `tryOwnToast` / `tryOwnBanner` / `tryOwnCallout` / `tryOwnMotd` / `tryOwnDashboard` report `Busy` instead of evicting a live presentation. `hideAll` preserves those claims; dispose the handles yourself.
- **Intern budget** — three × 1024 vectors shared by every plugin on the HUD entity. The shipped lib interns 432 panel ids (six sheets × ~51). Prefer the [hudkit pool](https://s2script.com/docs/concepts/hud/components) over private layouts.
- **No client-mount detection** — missing addon → silent blank screen. `HudLayout.status().clientContent` is always `"unknown"`; `server: "ready"` is not a render acknowledgement.
- **Do not `require("@s2script/cs2/ui")`** — types-only subpath; runtime is `CustomHudLayout` / `hudkit` from `@s2script/cs2`.
- **`CustomHudLayout.onClicked` is observe-only** — prefer `HudLayout.onClick`. Clicks arrive via the CS2 package adapter (`legacy.hud-click.v1`); public button-id copy is unchanged.
- **`disabled` is cosmetic** unless you also call `setDisabled` (primitive) or accept that `onPick` still fires (hudkit).
- **`tone` is decoration** — it tints `.s2-cell-a` green / amber / red and degrades to untinted on an older addon. Never make a tone the only signal; keep a text marker like `[BAD]`.

## See also

- [Default components](https://s2script.com/docs/concepts/hud/components) — `hudkit` toast, badge, modal, callout, banner, motd, dashboard
- [Mounting your own](https://s2script.com/docs/concepts/hud/custom) — `CustomHudSpec` + `CustomHudLayout.create`
- [Creating a layout](https://s2script.com/docs/concepts/hud/custom/create) — XML / CSS for `custom_hud_layout`
- [Custom camera](https://s2script.com/docs/concepts/camera) — `Pawn.getCustomCamera()` (same CS2 update)
- [HUD module](https://s2script.com/docs/api/modules/ui) — generated from `ui.d.ts`
- [CS2 module](https://s2script.com/docs/api/modules/cs2) — `Fade` / `Shake` / `HintText`, `CustomPlayerCamera`
- [Menus](https://s2script.com/docs/api/modules/menu) — CS2 paints a hudkit center sheet from the six-sheet pool (Chat fallback if busy or the HUD open fails)
- [TopMenu](https://s2script.com/docs/authoring#topmenu) — tabbed `sm_admin` / `sm_menu` hub (`hudkit.dashboard`)
- [Votes](https://s2script.com/docs/api/modules/votes) — CS2 right-side rail on `s2script_lib` (not a Menu, not a hudkit modal)
- [Usermessages](https://s2script.com/docs/api/modules/usermessages)
