Custom UI

You can draw real panels on a player's screen from the server, with no DLL injection, no CSDK and no manual vpk install. The only client requirement is the Deadworks launcher. This page is the whole system: how it gets there, what you can build, the bandwidth ceiling that shapes every design decision, and the gotchas that each cost a day.

This is the correct UI system

It is not the Jonas DLL-injection devkit. No injector, no VAC risk. Official since Deadworks v0.4.14. If you find notes anywhere in this project describing an injected shop, those describe the wrong system; the Bedwars shop is still on it and is the one outstanding port.

How a panel reaches the screen

The mechanism is genuinely strange and knowing it explains every limit below.

  1. The player runs the Deadworks launcher, which installs a small content addon, dw_bootstrap.js, as ordinary game content before the game starts.
  2. Your server encodes UI operations into Deadlock's closed caption (subtitle) user messages, per recipient.
  3. The bootstrap watches the caption pipeline and turns encoded caption text into real Panorama panels with $.CreatePanel.
  4. A button click on the client issues a dw_ui console command, which arrives back at the server with the player's identity attached.

The operations carried over that channel are build, set, style, clear, raw, precache, show, append, erase, loadxml, destroy, heartbeat, in the form panel_id | op | payload, token-encoded and LZ77 compressed.

Without the launcher, players see nothing at all

The official docs put it plainly: "Require Deadworks launcher, without it players see nothing." Your gamemode must still be playable with no HUD, because somebody will always join without it. Both of ours are: the HUD carries information, never the rules.

The bandwidth ceiling

This is the single most important section on the page. Every design choice follows from it.

LimitValue
Concurrent captions6
Characters per framearound 4,000
Throughput per playeraround 16 KB per second
Caption pool recycle ratearound 21 times a second
Channel update rateUI.MaxFramesPerSecond

Exceed it and the queue grows. It does not error; it falls behind, and a HUD that is a second behind is worse than no HUD.

UI.MaxFramesPerSecond is a double, not an int

The docs say int MaxFramesPerSecond { get; }. In the build we are on it comes back as a double. Cast it and clamp:

int cap = 30;
try { cap = (int)Math.Max(1, UI.MaxFramesPerSecond); } catch { }
int per = Math.Clamp(Config.HudUpdatesPerSecond, 1, cap);

The API

Namespace DeadworksManaged.Api.UI.

Entry point

CallReturns
UI.Panel(string id)A cached panel handle
UI.Container(id), UI.Vertical(id), UI.Horizontal(id)Layout nodes. Vertical flows down, Horizontal flows right.
UI.Label(id, text), UI.Button(id, text), UI.Image(id, src)Leaf nodes
UI.MaxFramesPerSecondThe channel rate
UI.Addon(slot, panelId), UI.AddonReady(slot, panelId)Client addon load state
UI.AddonStatusChangedEvent. On Failed, send a fallback.
UI.ClientResyncEvent, fires on reconnect. Required if you use AppendChild.

Building a tree

UI.Vertical("myhud")
  .WithStyle("horizontal-align", "center")
  .WithStyles(("margin-top", "96px"), ("padding", "5px 12px"))
  .WithTransition(...)        // v0.4.15+
  .WithHoverStyle(...)        // v0.4.15+
  .WithPressStyle(...)        // v0.4.15+
  .Add(
     UI.Label("timer", "0:00").WithStyle("font-size", "18px"),
     UI.Button("hide", "Hide").OnClick("hide"));

Sending and updating

CallWhat it does
BuildLayout(to, root)Send the tree and show it. Clears every existing field value.
Precache(to, root) then Show(to)Send without showing, then show
Set(to, key, value)Reliable. Latest wins.
SetUnreliable(to, key, value)May be dropped. For per-frame updates.
SetStyle / SetStyles / AnimateChange a style at runtime (v0.4.15+)
Build()Batch: chain .Set, .SetUnreliable, .SetStyle, .Clear, .SendRaw, then .SendTo(filter)
Clear(to), DestroyLayout(to), Reload(to)Teardown
AppendChild(to, parentId, child), RemoveChild(to, targetId)Dynamic children. Needs a ClientResync handler.
SendRaw(to, text)Straight to a client panel script's onRaw
LoadXml(to, xmlPath)Load a client-addon layout
On(eventName, handler)Server-side click handler

Recipients

RecipientFilter.All
RecipientFilter.Single(slot)
controller.Recipients        // this one player
e.Caller.Recipients          // inside a click handler

Handling a click

UI.Panel("round").On("hide", e => {
    // e.Caller, e.PanelId, e.EventName, e.Args, e.ArgAt(0, "fallback")
    UI.Panel("round").DestroyLayout(e.Caller.Recipients);
});

The nine gotchas

  1. Set goes AFTER BuildLayout, never before. BuildLayout clears field values, so anything you set first is thrown away.
  2. A Set key matching a Label id updates that Label automatically. This is the whole update mechanism. You do not address the label, you set the key.
  3. A Button auto-creates its text Label only when it has no children. Add a child Label if you want to style the text.
  4. Drive per-frame updates from OnGameFrame, not a timer. Timers are cancelled on map change, and a timer can never be paced to the channel rate.
  5. Pace to MaxFramesPerSecond, use SetUnreliable for per-frame, and always send the full field set. A dropped frame must never leave a stale field behind.
  6. AppendChild and RemoveChild require a UI.ClientResync handler to rebuild on reconnect, or a reconnecting player loses those children permanently.
  7. Avoid ^ followed by a letter or digit in label text and ids. It is reserved by the encoding.
  8. The launcher is required client-side.
  9. Mount addons before the game starts. Panorama initialises early.

Styling: Panorama, not web CSS

It looks like CSS and it is not. Kebab-case property names, a different box model, and an alpha byte on colours rather than an alpha function.

PropertyExampleNote
flow-childrendown / rightWhat UI.Vertical and UI.Horizontal set for you
horizontal-alignleft center right
vertical-aligntop center bottomcenter, never middle
margin-top, margin-left, padding18px 52px 22px 52px
widthfill-parent-flow(1.0)Panorama's flex equivalent
background-color#070707e6Eight digits: the last byte is alpha
border2px solid #ffac11
font-size, color18px, #fff1dc
transitionmargin-left 0.42s ease-out 0sShorthand works. The client eases it at its own framerate.
gradientgradient(linear, 0% 0%, 0% 100%, from(#a), to(#b))

Images

file://{images}/<addon>/<file>.vtex     // an image from your own content addon
s2r://...                                // the game's own art
Let Panorama do the animation, not the wire

Put a transition on the property in the layout and then have the server set the target value once. The motion runs at the client's framerate. The wrong way is to step the value over the network: that is four messages a frame to produce worse motion. A whole slide-in, overshoot, settle and slide-out is four style messages total.

A new panel shape can crash the client

This happened, and it is why we test by hand first

The first version of BattleBox's centre-screen announcement card crashed Amir's client outright on 2026-10-11. The server was fine; the client dropped and reported TIMEDOUT seconds later. We never identified the exact property.

What came out of it is a working practice rather than a fix:

  • Build new panels in levels, each adding one group of properties, selectable at runtime, so a crash can be pinned to a level instead of a redeploy per guess. BattleBox has /bbann 1, 2 and 3 for exactly this.
  • A new panel is off in the round flow until it has been fired by hand with a test command. A crash must never first happen in a real match.
  • Stick to the property set your HUD has already shipped unless you have a reason: horizontal-align, vertical-align, margin-*, padding, background-color, border, font-size, color. Those eight have run for weeks. Level 1 of the card is deliberately nothing but those, with no transition, no opacity and no percentage widths.

Patterns worth copying

Build on connect, and set the built flag there too

public override void OnClientFullConnect(ClientFullConnectEvent args) {
    int slot = args.Slot;
    WhenHeroReady(Players.FromSlot(slot), p => {
        UI.Panel(HudId).BuildLayout(RecipientFilter.Single(slot), HudLayout());
        _hudBuilt = true;   // without this, UpdateHud bails until the first round starts
    });
}

That flag was originally only set when a round began, so from connect until the first fight the HUD showed its placeholder text with no timer at all.

Poll until the hero exists

// Hero selection is human-paced. A 12 second cap silently broke joins in Bedwars;
// 240 attempts at 500ms is two minutes, which is long enough for a real person.
private void WhenHeroReady(CCitadelPlayerController? c, Action<CCitadelPlayerPawn> act, int attempts = 240) {
    if (c == null) return;
    Timer.Once(500.Milliseconds(), () => {
        var p = c.GetHeroPawn()?.As<CCitadelPlayerPawn>();
        int heroId = c.PlayerDataGlobal?.HeroID ?? 0;
        if (p != null && p.IsValid && heroId > 0) { act(p); return; }
        if (attempts > 1) WhenHeroReady(c, act, attempts - 1);
    });
}

Pace the update, and send every field every time

private void UpdateHud() {
    long now = Environment.TickCount64;
    if (now < _hudNextMs) return;
    _hudNextMs = now + (1000 / per);
    foreach (var c in Players.GetAll()) {
        int slot = c.EntityIndex - 1;
        UI.Panel(HudId).Build()
          .SetUnreliable("round", phase).SetUnreliable("timer", timer)
          .SetUnreliable("bar", bar).SetUnreliable("alive", alive)
          .SendTo(RecipientFilter.Single(slot));
    }
}

Cache a style before you resend it

// A style message is not free. Only send one when the value has actually changed,
// and make sure whatever decides the value is DETERMINISTIC: List.Sort is unstable,
// so a leaderboard of equal scores can reorder every frame and defeat the cache.
if (hex != _rowHex[i]) {
    _rowHex[i] = hex;
    UI.Panel(HudId).SetStyle(RecipientFilter.All, $"dot{i}", "color", hex);
}

A rebuild resets styles, so push them again

// BuildLayout re-sends the node tree, so every runtime SetStyle is undone and your
// cache now lies. Clear the cache on a rebuild to All, and on a per-player rebuild
// push the current values to THAT player only.
UI.Panel(HudId).BuildLayout(RecipientFilter.All, HudLayout());
for (int i = 0; i < _rowHex.Length; i++) _rowHex[i] = "";

A panel with no background is invisible when empty

// Build it once at connect, then flash a word by setting the text and clear it by
// setting an empty string. No BuildLayout churn, nothing to tear down, and with no
// background-color and no border there is genuinely nothing on screen when empty.
UI.Panel(FlashId).SetStyle(f, "flashtxt", "color", hex);
UI.Panel(FlashId).Build().Set("flashtxt", "CAPTURED").SendTo(f);
// ... and later
UI.Panel(FlashId).Build().Set("flashtxt", "").SendTo(f);

One card, two messages, per-player text

// The same card says ROUND WON to one squad and ROUND LOST to the other at the same
// instant: send the common fields to All, then override per player.
panel.BuildLayout(RecipientFilter.All, Layout());
panel.Build().Set("title", "ROUND OVER").SendTo(RecipientFilter.All);
foreach (var kv in perPlayer)
    panel.Build().Set("title", kv.Value).SendTo(RecipientFilter.Single(kv.Key));

Mode B: a client addon with your own JavaScript

Everything above is mode A, pure server-driven node trees with no client files. Mode B ships a vpk with XML and JS, which buys you real client-side logic. Use mode A unless you need JS.

Addon vpk layout, paths at the vpk root:

myaddon/panorama/layout/killfeed.xml
myaddon/panorama/scripts/killfeed.js

The XML must include the framework script first, then yours:

<include src="s2r://panorama/scripts/dw_addon.js" />
<include src="file://{resources}/scripts/killfeed.js" />

The panel script registers itself:

DW.registerPanel({
  init(panel) {},
  render(panel, state, changed) {},
  onRaw(panel, text) {},
  onDestroy(panel) {}
});

Panel methods available inside it: find(id), text(id, val), on(id, event, fn), onClick(id, fn), get(key, fallback) plus num / int / bool variants, set(key, val) (local only), state(), refresh(), send(event, ...args) to the server, and connected().

Load it from the plugin, then drive it exactly like a mode A panel:

UI.Panel("killfeed").LoadXml(c.Recipients, "file://{resources}/layout/killfeed.xml");
UI.Panel("killfeed").Build().Set("headline", h).SendTo(RecipientFilter.All);

Shipping the addon to players

A mode B addon is a content addon, and content addons now ride self-hosted FastDL. Full working setup is in 06_hosting/lobby_pool.md; the short version:

The hyphen trap

A launcher only acts on advertised names made of a-z, 0-9 and _. A hyphen is silently rejected. Our server happily advertises numba29-4b-2s-1m and every launcher skips it, so that map never downloads and nobody is told why. Name addons and maps with underscores.

The net messages, for when you do not want a panel

A handful of the game's own HUD elements are addressable without any launcher at all, which makes them the right choice for something every player must see.

var msg = new CCitadelUserMsg_HudGameAnnouncement {
    TitleLocstring = "BattleBox",
    DescriptionLocstring = "Squad 2 wins!"
};
NetMessages.Send(msg, RecipientFilter.All);
MessageFields
CCitadelUserMsg_HudGameAnnouncementTitleLocstring, DescriptionLocstring
CCitadelUserMsg_SetClientCameraAnglesPlayerSlot, CameraAngles
CCitadelUserMsg_ChatMsgChat

You can also hook messages in both directions, which is useful for suppressing something the game sends:

NetMessages.HookOutgoing<T>(ctx => { /* ctx.Message, ctx.Recipients */ return HookResult.Continue; });
NetMessages.HookIncoming<T>(ctx => { /* ctx.Message, ctx.SenderSlot */ return HookResult.Continue; });

The other UI: things drawn in the world

World text, beams and particles need no launcher and no client files, because they are entities. For anything attached to a place rather than a screen they are the better answer. Progress over an objective, a name over a downed player, a ring on the floor: all of it reads better in the world than in a corner of the HUD, and every player sees it. See World dressing.