# Mounting your own

Use `CustomHudLayout.create(spec)` when you publish your own workshop layout. The spec is the contract between the XML and the server: which addon to mount, which panel ids exist, and how text / buttons / meters / row pools are named.

A kill feed or scoreboard of your own is a new `CustomHudSpec` plus `create()`. This primitive has no MOTD (or other widget) type — use [`hudkit.motd`](https://s2script.com/docs/concepts/hud/components) for the shipped scrim+OK overlay, or ship your own XML. The CS2 vote rail and TopMenu dashboard live on the shared hudkit layout (`hudkit.layout` / `hudkit.dashboard`); they are not a second `create()`.

Authoring the XML and CSS is [Creating a layout](https://s2script.com/docs/concepts/hud/custom/create). This page is the server side: bind the spec, spawn, drive.

## Mount

1. Publish a workshop addon. Put the layout at `panorama/layout/custom_game/<name>.xml` (source `.xml`, never `.vxml` / `.vxml_c`).
2. List the decimal addon id in `mm_extra_addons` (and in `spec.addons`).
3. Bind a spec and take the layout at load. The layout entity spawns when the first client becomes active. `create(spec)` requires a spec.

```ts
import { CustomHudLayout } from '@s2script/cs2';
import type { CustomHudSpec } from '@s2script/cs2';

const KILLFEED: CustomHudSpec = {
	addons: ['3790153369'], // your workshop id
	resource: 'panorama/layout/custom_game/killfeed.xml',
	observable: false, // default — spectator keeps their own UI
	slots: {
		rows: [
			{ id: 'feed_0', vars: ['feed_0_a', 'feed_0_w', 'feed_0_v'] },
			{ id: 'feed_1', vars: ['feed_1_a', 'feed_1_w', 'feed_1_v'] }
		]
	}
};

export function OnPluginStart(): void {
	const layout = CustomHudLayout.create(KILLFEED);
	// ...
}
```

`CustomHudSpec` fields:

- `addons` — decimal workshop ids clients must mount. Required.
- `resource` — `.xml` path under `panorama/layout/custom_game/`. Required.
- `observable` — show the spectated player's version of this layout to observers. Default `false` (the viewer keeps their own UI). `true` opts in. Requires CS2's September 9, 2026 update and a matching s2script runtime. Fixed at spawn for the entity lifetime. Reusing a `resource` with a conflicting value in the same plugin throws; opposite policies need distinct resources (and thus distinct entity names). This is **rendering policy**, not network confidentiality or click authorization.
- `hideClass` — class that hides a panel when applied. Default `"s2-hide"`.
- `text` — panel id → dialog variable for `setText`. Default empty. When id equals the variable name, `set` is enough.
- `buttons` — ids delivered by `onClick` / `subscribeClick`. Default empty. Inline literals (`buttons: ["accept", "cancel"]`) constrain `subscribeClick` ids and catch typos without `as const`; a dynamic `readonly string[]` keeps the `string` fallback.
- `meters` — name → fill panel id (`setMeter` applies `s2-w0`…`s2-w10`). Default empty.
- `slots` — named pools of `{ id, vars[] }` for `setPool`.

`CustomHudLayout.probe()` is the shipped **probe** (`s2script_hud.xml`). It paints literal **"S2SCRIPT PROBE OK"** with no dialog-variable bindings — use it only to answer "is my addon mounted?" It is not driveable and is no longer the silent default of `hud()`. `PROBE_LAYOUT` is that spec; `LayoutDescriptor` is a deprecated alias of `CustomHudSpec`.

`CustomHudLayout.components(hudkit.spec)` returns the shared library kit (same instance as `hudkit` once live). `hudkit.spec` / `hudkit.descriptor` are static and always readable at module top-level. Kits are cached by layout `resource`, so that call and equivalent descriptor copies reuse existing click bindings instead of installing duplicates.

## Drive

Prefer `HudLayout.forSlot(slot)` — a cached `HudPlayer` for that player. Drive methods on that view do not re-thread the slot. Clicks receive the same `HudPlayer`. Slot-first methods on `HudLayout` stay for bulk loops and adopt the occupant **now**. `p.isValid()` is true while this retained view still belongs to the current client connection; a saved `HudPlayer` goes stale on reconnect or layout replacement. Per-slot writes are **storage** on this shared layout entity, not delivery to that client only. Default `observable: false` keeps the viewer's own UI while spectating; `true` shows the spectated player's version. Observation is rendering policy, not confidentiality — do not put confidential values here ([HUD hazards](https://s2script.com/docs/concepts/hud#hazards)). Every call returns `HudResult` (`null` on success, or a reason). `tryShow` is the structured twin (`UiResult<void>` — branch on `error.code`). `layout.status()` is server readiness; `clientContent` is always `"unknown"`. `slot < 0` is `"needs a player slot"`. Drive never creates the layout entity as a side-effect — register at load and let spawn wait for an active client (or call `layout.ensure()` explicitly). Unchanged values are not re-sent. Class/text caches update only after a successful write, so a failed drive does not poison a later retry.

| Call                                          | Role                                                                        |
| --------------------------------------------- | --------------------------------------------------------------------------- |
| `show(panelId, { cursor? })`                  | Clear `hideClass`. `{ cursor: true }` grabs input.                          |
| `tryShow(panelId, { cursor? })`               | Structured twin of `show` — `UiResult<void>`.                               |
| `hide(panelId)`                               | Apply `hideClass`; release that panel's cursor lease.                       |
| `set(id, value)` / `set(fields)`              | Dialog variable where id equals the variable name. A field map is one call. |
| `setText(panelId, value)` / `setText(fields)` | Dialog variable via the spec `text` map; falls back to id == variable name. |
| `setClass(panelId, className, on)`            | Toggle a class.                                                             |
| `setMeter(name, percent)`                     | 0..100, quantized to `s2-w0`…`s2-w10`.                                      |
| `setPool(pool, entries)`                      | Fill a slot pool. Overflow refuses (does not truncate).                     |
| `setDisabled(buttonId, on)`                   | Server-side gate. A cosmetic class alone does not.                          |
| `cursor(on)`                                  | Input capture for that player.                                              |
| `forget()`                                    | Drop per-player state.                                                      |

On the layout itself: `capacity(pool)` is how many rows a pool holds; `status()` reports server readiness; `onClick(buttonId, (p) => …)` routes a markup button id and hands the clicking player's `HudPlayer` (conflicting handlers throw). `subscribeClick(buttonId, handler)` adds an independently disposable route (`UiSubscription.dispose()` is idempotent) and may coexist with the legacy single `onClick`. Delivery snapshots the legacy handler and all subscription callbacks before invoking them, so subscribe/dispose during a click changes the next delivery only. Inline literal `buttons` on `CustomHudLayout.create` constrain subscription ids; `onClick` stays string-typed and keeps its duplicate-handler error.

```ts
import { hook, Clients } from '@s2script/sdk';
import { Player, CustomHudLayout } from '@s2script/cs2';

export function OnPluginStart(): void {
	const layout = CustomHudLayout.create(KILLFEED);

	hook.on('player_death', (ev) => {
		const attacker = ev.getPlayerSlot('attacker');
		const victim = ev.getPlayerSlot('userid');
		const a = attacker >= 0 ? (Player.fromSlot(attacker)?.playerName ?? '?') : 'world';
		const v = victim >= 0 ? (Player.fromSlot(victim)?.playerName ?? '?') : '?';
		const w = ev.getString('weapon').replace(/^weapon_/, '');

		for (const client of Clients.all()) {
			if (client.slot < 0) continue;
			layout.forSlot(client.slot).setPool('rows', [[a, w, v]]);
		}
	});
}
```

Prefer `HudLayout.onClick` for typed button routing. The handler receives that player's `HudPlayer`. `subscribeClick` is the disposable sibling — it may coexist with `onClick`, and an inline `buttons` list constrains its ids:

```ts
const layout = CustomHudLayout.create({
	addons: ['YOUR_ADDON_ID'],
	resource: 'panorama/layout/custom_game/your_hud.xml',
	buttons: ['ok_btn', 'cancel_btn']
});
layout.onClick('ok_btn', (p) => {
	p.hide('dialog');
	p.cursor(false);
});
const sub = layout.subscribeClick('cancel_btn', (p) => p.hide('dialog'));
sub.dispose();

const p = layout.forSlot(slot);
p.set({ title: 'Hello', ok_label: 'OK' });
p.show('dialog', { cursor: true });
```

`CustomHudLayout.onClicked` is a raw observer (player + slot + button id); it always observes and cannot suppress the engine handler. Custom-HUD clicks are delivered by the CS2 package adapter (`legacy.hud-click.v1`); public button-id copy is unchanged.

## See also

- [Creating a layout](https://s2script.com/docs/concepts/hud/custom/create) — XML / CSS the spec binds to
- [Default components](https://s2script.com/docs/concepts/hud/components) — skip this page if the pool is enough
- [HUD](https://s2script.com/docs/concepts/hud)
- [HUD module](https://s2script.com/docs/api/modules/ui)
