Search docs
Search guides, modules and symbols
Mounting your own

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 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. 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.
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). 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.

CallRole
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.

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:

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#