Plugins run server-side only — they can't ship a line of client code. So a UI framework can't be "call these draw functions." It has to be a declarative vocabulary: the plugin describes what it wants in data, and the engine's client renders it — exactly how a plugin item already picks an icon key and a tint instead of shipping art.
Four directions for that vocabulary, from a one-liner HUD bar to a full retained widget tree. Each shows the Lua a plugin writes and what the player would see. Pick one (or a layering) and we build it.
Declare a readout once and bind it to a value. The plugin never "draws" — it writes a number with setStat (which hunger already does), and the bound widget updates itself. A tiny fixed widget set: bar, counter, timer, icon-value.
-- declare once, at load plugin.hud("food", { kind = "bar", label = "Food", max = 100, color = "#7bd88f", icon = "food", }) plugin.hud("wave", { kind = "counter", label = "Wave" }) -- then just push values (per player) engine.setStat(pid, "food", 62) -- bar fills itself engine.setStat(pid, "wave", 3)
Almost no API and impossible to misuse. It's the food bar generalised — ships in an afternoon.
Read-only. No buttons, menus, or layout — purely a corner of status meters.
Trivial. Values already ride the snapshot's hud; only the widget declarations are new.
Every tick, the plugin re-describes the whole panel — the Dear ImGui shape. A button call both draws the button and returns whether it was clicked. Flexible and code-like; you write UI as plain control flow.
-- runs every tick, for each player near a campfire plugin.ui(pid, function(u) u.panel("Campfire", function(p) p.text("Nearby produce: " .. n) if n > 0 and p.button("Cook all") then cook_all(pid) end if p.button("Leave") then u.close() end end) end)
Maximum flexibility with the least ceremony — arbitrary layout as normal Lua, no ids to manage.
Chatty: the panel is re-declared and re-diffed every tick, and a click is a server round-trip, so buttons feel a frame behind.
Medium–high. Needs a per-tick UI channel + retained-on-client diffing to stay affordable.
Build the panel once, by id; push updates only when something changes; interactions come back as named actions routed like a control. The general answer — efficient on the wire and genuinely stateful.
-- build once; opens on a key (a control) local fire = plugin.panel("campfire", { title = "Campfire", open = "KeyF", }) fire.text("status", "Nothing to cook") fire.button("cook", { label = "Cook all", action = "cook" }) -- update only what changed fire.set("status", n .. " produce ready") plugin.on("ui:campfire:cook", function(pid) cook_all(pid) end)
Cheap after the first frame, stateful, and interactions are just controls — the same routing as a keybind. Scales to real menus.
The largest API surface: ids, an element vocabulary, a diff protocol, action wiring. Most to design and get right.
Higher up front, lowest at runtime. A create/update/remove protocol over welcome + snapshot.
The engine owns a handful of prefab UIs — a radial menu (the block wheel already is one), a list menu, a toast, a bar group. The plugin picks a template and fills its slots with data. The look stays consistent because the plugin never lays anything out.
plugin.menu("campfire", { template = "radial", title = "Campfire", open = "KeyF", options = { { id = "cook", label = "Cook all", icon = "fire" }, { id = "plant", label = "Plant", icon = "seed" }, { id = "douse", label = "Douse", icon = "crop" }, }, }) plugin.on("menu:campfire", function(pid, choice) if choice == "cook" then cook_all(pid) end end)
Consistent, polished, hard to make ugly — every plugin's menu matches the game. Small API, reuses the block wheel.
Only as expressive as the prefab set. Anything outside the templates needs a new engine-side template first.
Low–medium. One selection message back; templates are built once, in the client.
These aren't mutually exclusive — they're rungs. Each is useful on its own, and later ones subsume earlier needs. A staged path gets food's UI shipping now while leaving room to grow.
Everything here rides one shared idea from Set 12.2: a declared control (a name + a default key, rebindable and conflict-checked in the settings menu) whose presses route to the server. A menu is just a control that opens a template; a button is a control fired from inside one. Build that spine and all four directions become the same plumbing wearing different clothes.