HudResultResult of a drive call: null on success, or a human-readable reason it did not happen. Class/text caches update only after a successful write, so a failed drive does not poison a later retry.
@s2script/cs2
Custom Panorama HUD: CustomHudLayout.create(spec) plus hudkit (modals, badges, toasts, callouts, banners, MOTD, dashboard). CustomHudSpec.observable (default false) is spectator rendering policy, not confidentiality. Use from OnPluginStart or later; pre-ctx methods throw. Modal/badge null is pool exhaustion (tryModal / tryBadge report PoolExhausted). spec/descriptor stay readable at module top-level. Retained views expose isValid(); modal/dashboard clicks dispatch from the last complete paint. invalidate() coalesces next-frame repaints; refresh() stays synchronous. tryOwn* / tryOwnDashboard claim shared surfaces (Busy instead of overwrite); hideAll preserves explicit claims. Unload/reload clears surfaces; badge.release() hides for every remaining viewer. HudLayout.subscribeClick is a disposable sibling of onClick. ui / hud() / components() are deprecated aliases.
import { HudResult } from "@s2script/cs2";Author-time types ship in the npm package; the engine injects the runtime at plugin load.
Add @s2script/sdk to your plugin's dependencies.
Generated from the shipped type definitions.
HudResultResult of a drive call: null on success, or a human-readable reason it did not happen. Class/text caches update only after a successful write, so a failed drive does not poison a later retry.
UiErrorCodeStable failure category for structured UI results: NotReady | StaleClient | Released |
Unavailable | PoolExhausted | Busy | InvalidArgument | PaintFailed. Branch on
error.code; never parse error.message.
UiResult{ ok: true, value } or { ok: false, error: { code, message } }. Failed ops roll back
claims/capture. There is no automatic retry queue.
UiSubscriptionA route registration that can be removed without affecting other handlers.
dispose(): voidUiSurfaceHandleAn explicit claim on one shared hudkit presentation. Disposal is idempotent; retained views
report Released after their owner is disposed.
isValid(): booleandispose(): voidUiStatusServer-side drive readiness. clientContent is always "unknown" — this API cannot prove that
the client's workshop content rendered.
readonly server: "ready" | "not-ready" | "unavailable"readonly clientContent: "unknown"readonly reason: string | nullLayoutSlotOne pre-declared row/slot in a pooled collection.
readonly id: stringreadonly vars: readonly string[]CustomHudSpecWhat a custom_hud_layout entity will load and how the server may drive it.
resource and addons are required. Everything else has a default: hideClass is "s2-hide",
text/buttons/meters are empty. HudLayout.set always uses id == dialog variable
name; HudLayout.setText uses text when a panel's variable differs.
readonly addons: readonly string[]readonly resource: string.xml path under panorama/layout/custom_game/.readonly observable?: booleanreadonly hideClass?: string"s2-hide".readonly text?: Readonly<Record<string, string>>readonly meters?: Readonly<Record<string, string>>readonly slots?: Readonly<Record<string, readonly LayoutSlot[]>>LayoutDescriptorPROBE_LAYOUTSpec for the shipped s2script_hud.xml probe in workshop addon 3790153369.
**This targets a PROBE, not a driveable HUD.** The XML carries literal text and has no dialog
variable bindings, so a panel driven through it permanently reads "S2SCRIPT PROBE OK". Use
CustomHudLayout.probe to answer "is my addon mounted and rendering at all?" — and
nothing else. For real UI, hudkit or CustomHudLayout.create with your own layout.
DEFAULT_HUD_DESCRIPTORCustomHudClickedViewBlock-scoped view of one engine CustomHudClicked delivery. Valid only during the handler.
readonly player: EntityRef | nullreadonly slot: numberOnCustomHudClickedViewHudPlayerOne player's state on a CustomHudLayout. The engine's ForPlayer calls — classes,
dialog variables, input capture — are per-slot storage on one entity, not per-recipient
delivery. Observation is a rendering policy (CustomHudSpec.observable), not confidentiality.
Default false keeps the viewer's own UI while spectating; true shows the spectated player's
version. Do not put confidential values here.
Unchanged values are not re-sent (the networked intern tables are not free).
readonly slot: numberisValid(): booleanshow(panelId: string, opts?: { cursor?: boolean }): HudResulttryShow(panelId: string, opts?: { cursor?: boolean }): UiResult<void>hide(panelId: string): HudResultcursor(on: boolean): HudResultset(id: string, value: string | number): HudResultset(fields: Readonly<Record<string, string | number>>): HudResultsetText(panelId: string, value: string): HudResulttext map; falls back to id == variable name.setText(fields: Readonly<Record<string, string>>): HudResultsetClass(panelId: string, className: string, on: boolean): HudResultsetMeter(meterName: string, percent: number): HudResultsetPool(poolName: string, entries: readonly (readonly string[])[]): HudResultsetDisabled(buttonId: string, disabled: boolean): HudResultforget(): voidHudLayoutA plugin-owned custom_hud_layout entity bound to one layout XML.
Created with CustomHudLayout.create. The entity is spawned on the first
SIGNON_ACTIVE client (never from OnMapStart — that segfaults). Drive it through
HudLayout.forSlot; slot-first methods stay for bulk loops.
import { CustomHudLayout } from "@s2script/cs2";
const layout = CustomHudLayout.create({
addons: ["YOUR_ADDON_ID"],
resource: "panorama/layout/custom_game/your_hud.xml",
buttons: ["ok_btn"],
});
layout.onClick("ok_btn", (p) => { p.hide("dialog"); p.cursor(false); });
const p = layout.forSlot(slot);
p.set({ title: "Hello", ok_label: "OK" });
p.show("dialog", { cursor: true });readonly spec: CustomHudSpec<ButtonId>readonly layout: CustomHudSpec<ButtonId>forSlot(slot: number): HudPlayerobservable: false keeps the viewer's own UI while spectating; true shows
the spectated player's version.ensure(): HudResultSIGNON_ACTIVE. Idempotent.status(): UiStatusshow(slot: number, panelId: string, opts?: { cursor?: boolean }): HudResulttryShow(slot: number, panelId: string, opts?: { cursor?: boolean }): UiResult<void>hide(slot: number, panelId: string): HudResultcursor(slot: number, on: boolean): HudResultset(slot: number, id: string, value: string | number): HudResultset(slot: number, fields: Readonly<Record<string, string | number>>): HudResultsetText(slot: number, panelId: string, value: string): HudResultsetText(slot: number, fields: Readonly<Record<string, string>>): HudResultsetClass(slot: number, panelId: string, className: string, on: boolean): HudResultsetMeter(slot: number, meterName: string, percent: number): HudResultcapacity(poolName: string): numbersetPool(slot: number, poolName: string, entries: readonly (readonly string[])[]): HudResultonClick(buttonId: string, handler: (player: HudPlayer) => void): voidbuttonId is a string the markup already declared. The handler receives the
clicking player's HudPlayer. Duplicate handlers throw. May coexist with
subscribeClick.subscribeClick(buttonId: ButtonId, handler: (player: HudPlayer) => void): UiSubscriptionsetDisabled(slot: number, buttonId: string, disabled: boolean): HudResultforget(slot: number): voidCustomHudLayoutNsLoad-window custom_hud_layout API. Throws after settle.
create<const ButtonId extends string = string>(spec: CustomHudSpec<ButtonId>): HudLayout<ButtonId>custom_hud_layout for spec. Same layout
resource is reused if you call this twice. Inline buttons literals constrain
HudLayout.subscribeClick ids.probe(): HudLayoutonClicked(handler: (view: CustomHudClickedView) => HookResultValue | void): voidreadonly kit: HudKits2script_lib.xml.toast(slot: number, spec: ToastSpec): HudResultreadonly PROBE: LayoutDescriptorhud<const ButtonId extends string = string>(descriptor: CustomHudSpec<ButtonId>): HudLayout<ButtonId>hud(descriptor?: CustomHudSpec): HudLayout<string>components(descriptor?: CustomHudSpec): HudKitdescriptor. Cached by layout resource — components(hudkit.spec)
and equivalent copies reuse the existing kit.createLayout(descriptor?: CustomHudSpec): HudResultonCustomHudClicked(handler: (view: CustomHudClickedView) => HookResultValue | void): voidHudCtxUiCustomHudLayoutLoad-window custom_hud_layout API (import { CustomHudLayout } from "@s2script/cs2").
Throws after settle.
uiVariantToast / footer-button colouring. ghost is the low-emphasis default for footers.
RowOne list row. Three columns: a is primary and flexes, b and c are right-aligned.
readonly id?: stringreadonly a: stringreadonly b?: stringreadonly c?: stringreadonly disabled?: booleanonPick still fires, so you can say WHY it is unavailable.readonly tone?: "good" | "warn" | "bad"disabled and with the
selection highlight, so an unaffordable-and-dangerous row can say both at once.
Requires a workshop addon carrying the .s2-li-* rules. On an older addon the class is set
and the client simply has no rule for it, so a tone degrades to an untinted row rather than
breaking the sheet — never make a tone the ONLY way a row communicates something.FooterButtonModalSpecreadonly title: string | ((slot: number) => string)readonly subtitle?: string | ((slot: number) => string)readonly rows: readonly Row[] | ((slot: number) => readonly Row[])readonly onPick?: (slot: number, index: number, row: Row, view: ModalView) => voidrow.id to revalidate the current domain object instead of
indexing a newly fetched collection with index.
Engine submission is not client acknowledgement. Click delivery currently has no render
revision, so a delayed click after the same panel is repainted cannot be distinguished.readonly detail?: (slot: number, row: Row | undefined, cursor: number) => readonly string[]readonly pageSize?: numberreadonly width?: "sm" | "md" | "lg" | "xl"md (560px).UiFocusOptionsOpt in to the shared modal/dashboard/MOTD focus stack; this does not control engine z-order.
readonly mode: "exclusive"readonly priority?: numberModalOpenResultA failed open leaves the modal closed and can be retried when the HUD is ready. { ok: true,
view } or { ok: false, error }.
ModalViewPer-player view of a claimed Modal.
readonly slot: numberisValid(): booleanopen(opts?: { cursor?: boolean; focus?: UiFocusOptions }): ModalViewtryOpen(opts?: { cursor?: boolean; focus?: UiFocusOptions }): ModalOpenResulttryOpenResult(opts?: { cursor?: boolean; focus?: UiFocusOptions }): UiResult<ModalView>close(): voidisOpen(): booleanrefresh(): voidinvalidate(): voidlastUpdateResult(): UiResult<void> | nulltryRefresh(): UiResult<void>page(delta: number): voidselect(index: number): voidcursor(): numberforget(): voidModalopen(slot: number, opts?: { cursor?: boolean; focus?: UiFocusOptions }): ModalViewslot and return the bound view. Default grabs the cursor. Throws on failure.tryOpen(slot: number, opts?: { cursor?: boolean; focus?: UiFocusOptions }): ModalOpenResulttryOpenResult(slot: number, opts?: { cursor?: boolean; focus?: UiFocusOptions }): UiResult<ModalView>setCursor(slot: number, on: boolean): voidclose(slot: number): voidisOpen(slot: number): booleanrefresh(slot?: number): voidslot to repaint every player who has it open. Synchronous; a
successful sync repaint fulfills pending invalidation intent.invalidate(slot?: number): voidslot for every open view.
Repeated invalidations of the same live component/client pair coalesce.tryRefresh(slot: number): UiResult<void>page(slot: number, delta: number): voidselect(slot: number, index: number): voidcursor(slot: number): numberforget(slot: number): voidforSlot(slot: number): ModalViewrelease(): voidopen after release throws.BadgeSpecreadonly corner?: "tl" | "tr" | "bl" | "br"readonly title?: stringreadonly accent?: "accent" | "good" | "warn" | "bad"BadgeViewPer-player view of a claimed Badge.
readonly slot: numberisValid(): booleanshow(data?: { title?: string; text?: string }): voidtryShow(data?: { title?: string; text?: string }): UiResult<void>hide(): voidBadgeA persistent corner element — the thing chat cannot be, because chat scrolls away.
show(slot: number, data?: { title?: string; text?: string }): BadgeViewhide(slot: number): voidforSlot(slot: number): BadgeViewrelease(): voidToastSpecreadonly title?: stringreadonly message?: stringreadonly variant?: Variantreadonly holdSeconds?: numberCalloutSpecHint-style overlay. Bottom-center, no cursor. Default hold 4s.
readonly title?: stringreadonly message?: stringreadonly variant?: "good" | "warn" | "bad"readonly holdSeconds?: numberBannerSpecCenter-top broadcast strip. One at a time, no cursor. Default hold 5s.
MotdSectionreadonly heading?: stringreadonly body?: stringMotdSpecFirst-join / rules overlay. Scrim + OK; not a Menu and not a third center sheet. Stays until OK or MotdHandle.close.
readonly focus?: UiFocusOptionsreadonly title: stringreadonly subtitle?: stringreadonly sections?: readonly MotdSection[]readonly note?: stringreadonly ok?: string"OK".readonly cursor?: booleanreadonly onClose?: (slot: number) => voidMotdHandlereadonly slot: numberisValid(): booleanclose(): voidDashTabOne dashboard tab. id is the grouping key (TopMenu tab / category id).
readonly id: stringreadonly title: stringDashRowOne option on the active dashboard tab.
readonly id: stringreadonly a: stringreadonly b?: stringreadonly disabled?: booleanDashboardSpecreadonly title: string | ((slot: number) => string)readonly subtitle?: string | ((slot: number, tabId: string) => string)readonly closeText?: stringreadonly tabs: readonly DashTab[] | ((slot: number) => readonly DashTab[])readonly rows: (slot: number, tabId: string) => readonly DashRow[]readonly onPick?: (slot: number, tabId: string, row: DashRow, view: DashboardView) => voidrow.id before acting.
The engine click payload has no client render revision, so it cannot identify a delayed click
from an older repaint of the same panel.readonly onClose?: (slot: number) => voidDashboardViewreadonly slot: numberisValid(): booleanopen(opts?: { tab?: string; cursor?: boolean; focus?: UiFocusOptions }): DashboardViewtryOpenResult(opts?: { tab?: string; cursor?: boolean; focus?: UiFocusOptions }): UiResult<DashboardView>close(): voidisOpen(): booleansetTab(tabId: string): voidrefresh(): voidinvalidate(): voidlastUpdateResult(): UiResult<void> | nulltryRefresh(): UiResult<void>DashboardTabbed hub over s2_dash on s2script_lib.xml. The legacy controller uses one spec at a time
(last configuration wins). Not a modal-pool slot and not a third s2_mN sheet.
open(slot: number, opts?: { tab?: string; cursor?: boolean; focus?: UiFocusOptions }): DashboardViewtryOpenResult(slot: number, opts?: { tab?: string; cursor?: boolean; focus?: UiFocusOptions }): UiResult<DashboardView>close(slot: number): voidisOpen(slot: number): booleansetTab(slot: number, tabId: string): voidrefresh(slot?: number): voidslot to repaint every player who has it open.invalidate(slot?: number): voidtryRefresh(slot: number): UiResult<void>forSlot(slot: number): DashboardViewOwnedDashboardAn independently disposable dashboard controller with explicit per-player surface claims. Construction is player-independent; tryOpenResult(slot) claims that player's dashboard root.
dispose(): voidReleased after their owner is disposed.HudKitPlayerreadonly slot: numberisValid(): booleantoast(spec: ToastSpec): HudResulttryOwnToast(spec: ToastSpec): UiResult<UiSurfaceHandle>callout(spec: CalloutSpec): HudResulttryOwnCallout(spec: CalloutSpec): UiResult<UiSurfaceHandle>motd(spec: MotdSpec): MotdHandletryOwnMotd(spec: MotdSpec): UiResult<UiSurfaceHandle>hideAll(): voidforget(): voidHudKitComponent handles and operations remain usable in later callbacks after initialization. The ambient hudkit requires plugin context for methods and layout/hud getters; only its static spec/descriptor data is readable at module top-level.
readonly spec: CustomHudSpecreadonly descriptor: CustomHudSpecensure(): HudResultreadonly layout: HudLayoutcustom_hud_layout, for anything the library does not cover. Throws before
plugin context exists — that is not pool exhaustion.readonly hud: HudLayoutmodal(spec: ModalSpec): Modal | nulltryModal(spec: ModalSpec): UiResult<Modal>dashboard(spec: DashboardSpec): DashboardtryOwnDashboard(spec: DashboardSpec): UiResult<OwnedDashboard>badge(spec?: BadgeSpec): Badge | nulltryBadge(spec?: BadgeSpec): UiResult<Badge>toast(slot: number, spec: ToastSpec): HudResultcallout(slot: number, spec: CalloutSpec): HudResultmotd(slot: number, spec: MotdSpec): MotdHandleforSlot(slot: number): HudKitPlayerhideAll(slot: number): voidforget(slot: number): voidbudget(): {
panelIds: number;
classNames: number;
variables: number;
declared: number;
warnAt: number;
cap: number;
}cap of 1024 (not one shared
3072), and all are shared by every plugin on the HUD entity — past the cap a name is refused
and its value simply never arrives. set spends from two vectors at once, charging its panel
id and its dialog variable name to different ledgers.
Interning is idempotent, so these climb only on a name's FIRST use; repainting is free.ComponentshudkitShared panel pool over s2script_lib.xml. Use from OnPluginStart or later callbacks.
Methods and layout/hud getters throw before the plugin context exists (module top-level).
Static spec/descriptor data is always readable. Modal/badge claims return null only when
their pool is exhausted.
Same object as CustomHudLayoutNs.kit.
import { hudkit } from "@s2script/cs2";
import { command, HookResult } from "@s2script/sdk";
export function OnPluginStart(): void {
const menu = hudkit.modal({
title: "Players",
rows: [{ a: "Alice" }, { a: "Bob" }],
buttons: [{ text: "Close", onClick: (_s, v) => v.close() }],
});
command("sm_players", cmd => {
const slot = cmd.callerSlot;
if (slot < 0) return HookResult.Handled;
menu?.forSlot(slot).open();
hudkit.forSlot(slot).toast({ title: "Saved", message: "loadout written" });
hudkit.callout(slot, { message: "Press E for the next corner" });
hudkit.banner(slot, { text: "Vote passed — changing map" });
hudkit.motd(slot, { title: "Server rules", sections: [{ heading: "Don't cheat", body: "VAC is on." }] });
return HookResult.Handled;
});
}