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 LayoutDescriptor. 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:
| Element | Attributes |
|---|---|
<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 hud.set(slot, 'feed_0_a', name) with no text map.
<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 (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.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 Hud.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; } Bind it
The descriptor names every id the plugin will touch. Unused markup is free; a name is interned the first time a setter sees it.
const KILLFEED: LayoutDescriptor = {
addons: ['YOUR_WORKSHOP_ID'],
resource: 'panorama/layout/custom_game/killfeed.xml',
hideClass: 's2-hide',
text: {
feed_0_a: 'feed_0_a',
feed_0_w: 'feed_0_w',
feed_0_v: 'feed_0_v',
feed_1_a: 'feed_1_a',
feed_1_w: 'feed_1_w',
feed_1_v: 'feed_1_v',
},
buttons: [],
meters: {},
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 ui.hud(KILLFEED) and setPool as on Mounting your own.
components(descriptor) is only for a layout that re-declares the same pooled ids as s2script_lib.xml. A new component is a new descriptor, not a call to components().badge.
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
hideClassin the XML. Server applies it per player. - Animated hide is two-phase.
visibility: collapsecannot be transitioned. PreferHud.showAnimated/hideAnimated/flash, and a generation counter so a second event does not yank the first mid-fade. - Reveal synchronously. Clear any
*-outclass, 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
- Mounting your own — descriptor + drive
- Default components
- HUD