Search docs
Search guides, modules and symbols
HUD

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 (pooled modals, badges, toasts, callouts, banners, MOTD, plus the TopMenu dashboard) when you do not ship a layout. Use CustomHudLayout.create(spec) 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 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() 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.

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. A kill feed or scoreboard of your own is Mounting your own.

Screen effects#

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

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.

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.

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 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 — hudkit toast, badge, modal, callout, banner, motd, dashboard
  • Mounting your own — CustomHudSpec + CustomHudLayout.create
  • Creating a layout — XML / CSS for custom_hud_layout
  • Custom camera — Pawn.getCustomCamera() (same CS2 update)
  • HUD module — generated from ui.d.ts
  • CS2 module — Fade / Shake / HintText, CustomPlayerCamera
  • Menus — CS2 paints a hudkit center sheet from the six-sheet pool (Chat fallback if busy or the HUD open fails)
  • TopMenu — tabbed sm_admin / sm_menu hub (hudkit.dashboard)
  • Votes — CS2 right-side rail on s2script_lib (not a Menu, not a hudkit modal)
  • Usermessages