Search docs
Search guides, modules and symbols
Creating a layout

Creating a layout

A plugin cannot invent panels at runtime. It can toggle CSS classes and set dialog-variable strings on panels that already exist on the client. A custom component is therefore a fixed XML slot plus the classes and {s:…} variables the server will drive.

Mounting your own binds that markup with a CustomHudSpec. This page is how to write the markup.

Files#

panorama/layout/custom_game/<name>.xml     ← source .xml, never .vxml / .vxml_c
panorama/styles/custom_game/<name>.css     ← source .css, never .vcss

The include line is load-bearing. Wrong scheme or extension compiles clean and the stylesheet never attaches:

<include src="file://{resources}/styles/custom_game/killfeed.css" />

Not s2r://, no panorama/ prefix, and the source .css. A resolved include makes the compiler print the .css a second time — that is the tell.

resourcecompiler will not stop you from using illegal attributes. The client is the only CSS validator: it pops a dialog naming file, line, column, property, and value. Load once after every CSS change.

What you can put in the XML#

custom_hud_layout exposes a closed set. From Valve’s point_script.d.ts:

ElementAttributes
<Panel>id, class, hittest
<Label>id, class, hittest, text
<Image>id, class, hittest, src
<Button>id, class

No onactivate, no style=, no client-side script. Events are ignored with no error. Every offset and colour lives in a class.

The root <Panel> must not have an id — hard compile error. Put a theme class on the first child instead.

A panel does not render for a player until that player has per-player state. Do not put hideClass in the markup: removing a markup-declared class is not the same as clearing a per-player one. Declare the hide class in CSS; the server applies it with hide().

Dialog variables#

Text is {s:name} on a <Label>. The first argument of the engine setter is a panel id, so every label that carries a {s:} needs its own id.

Prefer id equals variable name. Then the plugin can layout.forSlot(slot).set('feed_0_a', name) with no text map — or p.set({ feed_0_a: name }).

<Label id="feed_0_a" class="kf-name" text="{s:feed_0_a}" />

An unset {s:…} renders empty. A correctly-loaded production layout looks like a blank frame until the plugin fills it. Ship a literal-text probe (CustomHudLayout.probe() / the shipped s2script_hud.xml paints “S2SCRIPT PROBE OK”) when you want to eyeball mount with no plugin attached.

Never generate names dynamically (row_${page}_${i}). Interning is per name, forever, into three × 1024 vectors shared by every plugin on the entity. Reuse a fixed pool and change content.

A kill-feed row#

Two stacked rows, three labels each. The server fills them with setPool. CSS uses flow, not flex.

<root>
  <styles>
    <include src="file://{resources}/styles/custom_game/killfeed.css" />
  </styles>

  <Panel class="kf-root">
    <Panel id="feed_0" class="kf-row">
      <Label id="feed_0_a" class="kf-name" text="{s:feed_0_a}" />
      <Label id="feed_0_w" class="kf-weapon" text="{s:feed_0_w}" />
      <Label id="feed_0_v" class="kf-name" text="{s:feed_0_v}" />
    </Panel>
    <Panel id="feed_1" class="kf-row">
      <Label id="feed_1_a" class="kf-name" text="{s:feed_1_a}" />
      <Label id="feed_1_w" class="kf-weapon" text="{s:feed_1_w}" />
      <Label id="feed_1_v" class="kf-name" text="{s:feed_1_v}" />
    </Panel>
  </Panel>
</root>
.kf-root {
	width: 100%;
	height: 100%;
}

.kf-row {
	horizontal-align: right;
	vertical-align: center;
	flow-children: right;
	padding: 8px 12px;
	margin-bottom: 4px;
	background-color: #0d1219cc;
}

.kf-name {
	color: #e8f0f4ff;
	font-size: 16px;
}
.kf-weapon {
	color: #d9aa45ff;
	font-size: 14px;
	margin: 0px 10px;
}

.s2-hide {
	opacity: 0;
}

Layout is flow-based: flow-children: right | down, width / height as fit-children or fill-parent-flow(w), plus position, align, ignore-parent-flow. These silently do nothing and the compiler does not warn:

display · flex / grid and companions · top / right / bottom / left · calc() · var() · rgb() / rgba() · @media · :not() · ::before / ::after

Colours are #rrggbbaa. Widths that change at runtime are step classes (s2-w0…s2-w10) — the server cannot set a style property.

Buttons are real <Button id="…"> elements. The id is the buttonId HudLayout.onClick receives. Clicks reach the server; a CSS :disabled or grey class does not block them — call setDisabled.

Hover-to-reveal is the only client-side interactivity:

.kf-tip-host:hover .kf-tip {
	opacity: 1;
}

Buy menu and scoreboard#

CS2 puts HUD_BUYMENU_VISIBLE and HUD_SCOREBOARD_VISIBLE on an ancestor panel. Use descendant selectors — not server class writes:

.HUD_SCOREBOARD_VISIBLE .my-overlay,
.HUD_BUYMENU_VISIBLE .my-overlay {
	visibility: collapse;
}

Custom layouts always draw above the base HUD; z-index only orders custom content. The visibility class does not expose the native buy menu’s purchase controls.

The shipped library CSS hides passive toasts, badges, banners, and callouts while either base panel is open. Interactive sheets (modals, MOTD, dashboard) stay visible: CSS cannot release server-owned cursor capture. Do not hide a cursor-capturing panel with CSS alone.

Compile and republish the workshop stylesheet to deliver CSS changes. Updating the server bundle alone does not update clients’ workshop assets.

Bind it#

The spec names every id the plugin will touch. Unused markup is free; a name is interned the first time a setter sees it. hideClass defaults to "s2-hide"; observable defaults to false; text / buttons / meters default empty.

const KILLFEED: CustomHudSpec = {
	addons: ['YOUR_WORKSHOP_ID'],
	resource: 'panorama/layout/custom_game/killfeed.xml',
	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'] }
		]
	}
};

Then CustomHudLayout.create(KILLFEED) and forSlot / setPool as on Mounting your own.

hudkit is the shared panel pool (toast, badge, modal, callout, banner, motd, the vote rail, and the TopMenu dashboard on s2script_lib.xml). A new component is a new spec plus CustomHudLayout.create, not a call to hudkit.badge. hudkit.motd is the shipped MOTD overlay; hudkit.dashboard is the shipped admin hub — dedicated roots, not pooled s2_mN sheets. Do not invent a second MOTD type or fold those surfaces into the modal pool on this primitive.

The shipped lib’s stylesheet include must stay file://{resources}/styles/custom_game/s2script_lib.css. An s2r:// include compiles clean and ships unstyled. Lib source (XML + CSS, including six s2_m0–s2_m5 sheets, s2_vote*, and s2_dash) is in examples/hud-lab/workshop/; republish the same workshop item (3790153369) after compiling on Windows Workshop Tools, before deploying a runtime whose modal pool is 6. Do not add a second layout.

Traps#

  • Blank ≠ failed load. Unset {s:…} is empty. Use a literal-text probe to prove mount.
  • Root panel has no id. Theme class goes on the first child.
  • Do not put hideClass in the XML. Server applies it per player.
  • Animated hide is two-phase. visibility: collapse cannot be transitioned. Prefer Hud.showAnimated / hideAnimated / flash, and a generation counter so a second event does not yank the first mid-fade.
  • Reveal synchronously. Clear any *-out class, then clear hide, in the same call. A failed hide is obvious; a failed reveal is an invisible panel capturing input.

Deep compile traps live in the monorepo example examples/hud-lab/workshop/PANORAMA.md.

See also#