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.
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.
- The player runs the Deadworks launcher, which installs a small content addon,
dw_bootstrap.js, as ordinary game content before the game starts. - Your server encodes UI operations into Deadlock's closed caption (subtitle) user messages, per recipient.
- The bootstrap watches the caption pipeline and turns encoded caption text into real Panorama
panels with
$.CreatePanel. - A button click on the client issues a
dw_uiconsole 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.
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.
| Limit | Value |
|---|---|
| Concurrent captions | 6 |
| Characters per frame | around 4,000 |
| Throughput per player | around 16 KB per second |
| Caption pool recycle rate | around 21 times a second |
| Channel update rate | UI.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.
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
| Call | Returns |
|---|---|
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.MaxFramesPerSecond | The channel rate |
UI.Addon(slot, panelId), UI.AddonReady(slot, panelId) | Client addon load state |
UI.AddonStatusChanged | Event. On Failed, send a fallback. |
UI.ClientResync | Event, 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
| Call | What 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 / Animate | Change 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
Setgoes AFTERBuildLayout, never before. BuildLayout clears field values, so anything you set first is thrown away.- A
Setkey matching a Label id updates that Label automatically. This is the whole update mechanism. You do not address the label, you set the key. - A Button auto-creates its text Label only when it has no children. Add a child Label if you want to style the text.
- 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. - Pace to
MaxFramesPerSecond, useSetUnreliablefor per-frame, and always send the full field set. A dropped frame must never leave a stale field behind. AppendChildandRemoveChildrequire aUI.ClientResynchandler to rebuild on reconnect, or a reconnecting player loses those children permanently.- Avoid
^followed by a letter or digit in label text and ids. It is reserved by the encoding. - The launcher is required client-side.
- 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.
| Property | Example | Note |
|---|---|---|
flow-children | down / right | What UI.Vertical and UI.Horizontal set for you |
horizontal-align | left center right | |
vertical-align | top center bottom | center, never middle |
margin-top, margin-left, padding | 18px 52px 22px 52px | |
width | fill-parent-flow(1.0) | Panorama's flex equivalent |
background-color | #070707e6 | Eight digits: the last byte is alpha |
border | 2px solid #ffac11 | |
font-size, color | 18px, #fff1dc | |
transition | margin-left 0.42s ease-out 0s | Shorthand works. The client eases it at its own framerate. |
gradient | gradient(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
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
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,2and3for 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:
- Declare the addon names from your plugin:
public override IReadOnlyList<string> ContentAddons => .... Connecting clients are told to download them. - Set
fastdl_urlandextra_mapsunderserverbrowserin<exe dir>/configs/deadworks.jsonc. Read once at startup, so a running server keeps its old config. - Serve
<fastdl_url>/maps/<name>_<hash>.vpk.zstoff any static HTTP host you control. The hash is the first 16 lowercase hex digits of the SHA-256 of the uncompressed vpk, so a rebuilt file changes its published name. - The server mounts its own copy from
deadworks_mods/vpks/<name>.vpk. For local development, dropping a vpk in<game>/citadel/deadworks_mods/auto-mounts it at startup.
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);
| Message | Fields |
|---|---|
CCitadelUserMsg_HudGameAnnouncement | TitleLocstring, DescriptionLocstring |
CCitadelUserMsg_SetClientCameraAngles | PlayerSlot, CameraAngles |
CCitadelUserMsg_ChatMsg | Chat |
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.