HUD
Custom on-screen UI for CS2. Two layers:
- Screen effects —
Fade,Shake,HintTextfrom@s2script/cs2. Usermessages; no workshop addon. - Custom Panorama —
CustomHudLayoutandhudkitfrom@s2script/cs2. Preferhudkit(pooled modals, badges, toasts, callouts, banners, MOTD, plus the TopMenu dashboard) when you do not ship a layout. UseCustomHudLayout.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 (
AllowCustomGameUIis 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"ingame/csgo/cfg/multiaddonmanager/multiaddonmanager.cfg(decimal id, quoted, noworkshop/prefix). That is the shipped s2script HUD addon.- Observable layouts (
CustomHudSpec.observable) andPawn.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.
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.
Hazards
- Wait for an active client — never spawn a
custom_hud_layoutfromOnMapStart(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). UsehudkitfromOnPluginStartor a later callback.hudkit.spec/hudkit.descriptorstay readable at module top-level forCustomHudLayout.components(hudkit.spec). Modal/badgenullis 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 neverrelease()occupies a sheet until unload/reload (which then hides it). - Per-slot paint is storage, not delivery —
HudLayout.forSlot/ hudkitforSlotfile 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. Defaultfalse(CS2 September 9 update): a spectator keeps their own UI.observable: trueopts 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 sameresourcein 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, andMotdHandleexposeisValid(). 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), componentrelease/forget, layout replacement, dashboard spec replacement, or MOTD close. Stale ops no-op or fail closed (openthrows/stale/; hudkit methods return"stale client"). Delayed fades are fenced by client + component lifetime. After that,lastUpdateResult()on a stale modal/dashboard view isnull. - Exclusive focus is opt-in — modal/dashboard
open/tryOpenResultandMotdSpec.focustake{ 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 withoutfocuskeep legacy behavior. - Coalesce many updates with
invalidate()— modal/dashboardinvalidate()schedules one next-frame repaint (repeated calls coalesce). Userefresh()/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/tryOwnDashboardreportBusyinstead of evicting a live presentation.hideAllpreserves 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().clientContentis always"unknown";server: "ready"is not a render acknowledgement. - Do not
require("@s2script/cs2/ui")— types-only subpath; runtime isCustomHudLayout/hudkitfrom@s2script/cs2. CustomHudLayout.onClickedis observe-only — preferHudLayout.onClick. Clicks arrive via the CS2 package adapter (legacy.hud-click.v1); public button-id copy is unchanged.disabledis cosmetic unless you also callsetDisabled(primitive) or accept thatonPickstill fires (hudkit).toneis decoration — it tints.s2-cell-agreen / 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 —
hudkittoast, 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_menuhub (hudkit.dashboard) - Votes — CS2 right-side rail on
s2script_lib(not a Menu, not a hudkit modal) - Usermessages