Search docs
Search 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 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:

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 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-w0s2-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 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

s2script — Source 2 plugin framework

GitHub