The grid inventory works, but its plugin file is nearly empty — the screen is engine code the plugin just switches on. The fix isn't a UI toolkit the plugin assembles widget by widget. It's a handful of fat, purpose-built widgets — a modal, an icon-grid — that the engine renders and drives, while the plugin owns the data, the keybind, and every event. "Everything that's inventory" moves back into the plugin. Here's the surface, the one hard part (client draws, server owns), and the whole inventory rewritten on top of it.
Two short files. The engine never mentions "inventory" — it only knows "a modal with an icon-grid." The plugin knows everything else: what's in the cells, what a click means, what a drag means, and the key that opens it. This is the target the API below is reverse-engineered from.
grid-inventory/client.lua — the face: builds the widgets, forwards intent-- The engine gives us a modal + an icon-grid. We fill the cells from OUR player's synced -- inventory, and turn clicks / drags into messages to our own server half. No drawing here. local win = ui.modal({ title = "Inventory" }) local grid = win:icon_grid({ cols = 8, mutable = true, -- mutable → the engine runs drag-between-cells cells = function() return self().inventory.slots end, -- server-owned, arrives in the snapshot }) grid:on_click(function(i, cell) if cell then send("drop", { slot = i }) end -- click a filled cell → drop one end) grid:on_move(function(from, to) send("move", { from = from, to = to }) -- dragged a stack — let the server rule on it end) on_input("grid-inventory.open", function() win:toggle() end)grid-inventory/main.lua — the brain: owns the truth, validates every message
-- The optional UI plugin. It suppresses the row hotbar, claims the open key, and routes its -- client's intents into the always-on inventory model (which owns slots + the rules). local inv = engine.require("inventory") inv.suppressHotbar() plugin.control("open", { default = "KeyI", label = "Inventory", desc = "Open your inventory" }, function() end) plugin.on_client("drop", function(pid, msg) inv.drop_slot(pid, msg.slot) end) -- validated inside inventory plugin.on_client("move", function(pid, msg) inv.move_slot(pid, msg.from, msg.to) end)
That's the whole thing. The inventory model (slots, counts, the move/drop rules) lives
in the always-on inventory plugin; grid-inventory is purely the UI that renders it and
reports intent. Both halves are Lua. The engine contributed a modal and a grid.
Three ways a plugin can own a rich screen. The first two were mine; the third is the call.
The plugin declares panel › row › label › slot as data; the engine lays it out and routes
events. Flexible, but it's a whole framework: a layout pass, a widget catalog, event plumbing.
Extend client.lua's sprite drawing up to full panels; the plugin paints + hit-tests every pixel itself. Tiny engine, but every plugin re-derives layout, theming, and drag by hand.
The engine ships a few complete widgets — modal, icon-grid — that already know how to render, hover, click, and drag. The plugin configures one and handles its events.
The insight in Option 3: an inventory grid isn't arbitrary UI — it's a known, reusable shape. So the engine ships that shape whole, tuned by a few options, and the plugin never touches a pixel or a layout number. The same two widgets reskin the settings menu and the radial "template menus" later; we grow the catalog only when a real screen needs a shape it doesn't have.
A dimmed full-screen container the engine draws + centres. Holds one or more child widgets. The plugin drives its lifecycle from the keybind.
win:icon_grid({…}) — add a grid, returns its handlewin:open() · win:close() · win:toggle() · win:is_open()A grid of cells, each drawn from an { item, count } (the engine resolves the icon +
tint from the item manifest it already has). Empty cells are real, drawn wells.
cells — a function returning the current array (pulled each frame, so it's always live)mutable — if true, the engine runs pick-up-and-drop between cells and fires on_movegrid:on_click(fn(i, cell)) — a cell was clicked (cell is its object, or nil)grid:on_move(fn(from, to)) — a stack was dragged between cellsCells carry item names, not icons — the client already has the item
manifest from welcome, so the grid looks up the art itself. That keeps the plugin's data
small and the rendering consistent with the hotbar and the wheel.
This is the piece your sketch didn't name, and it's what makes it robust. A macro-widget is rendering + input, so it runs in client.lua — on the untrusted machine. The inventory slots are authoritative, so they live in main.lua. The widget never mutates them directly; it reports intent, the server rules, and the change comes back down in the next snapshot.
on_input → win:toggle(). The engine draws the modal + grid.cells() → self().inventory.slots — the server-owned slots that rode the snapshot.on_move(3, 0).send("move", {from=3, to=0}) — plugin-private, to its own server half.on_client("move") → inv.move_slot — validates (real slots? your player?) and swaps.self().inventory.slots updates → the grid redraws. Done.A modified client can send("move") anything. So move_slot / drop_slot
validate against the authoritative slots before touching them — exactly how every other client message
is treated. Optionally the engine can show the move optimistically for feel and let the server correct
it; even without that, the round-trip is one snapshot (~33ms) and reads as instant.
One small new runtime accessor falls out of this: self() in
client.lua — a read-only view of the local player's synced state, including inventory.slots.
It's generally useful (any client half that wants to reflect its own player), and it's how the grid's
cells stay live without the plugin pushing them.
ui.modal + icon_grid as client-side widgets (dim, panel, cells, hover, click, drag)on_click / on_move back into the plugin's client.luaself() — expose the local player's synced state to client.luaslots in the snapshot (next to today's counts)inventory (core): the slots model + move_slot / drop_slot rulesgrid-inventory client.lua: build the modal + grid, forward click/movegrid-inventory main.lua: the open keybind, suppress the hotbar, validate messagesOption A's engine screen isn't wasted — its rendering (the panel, the slot
wells, the icon + count, the hover) becomes the icon_grid widget's reference implementation.
We're moving that code behind the widget boundary and handing the plugin the controls, not rewriting it.
self()Lift Option A's rendering into ui.modal + icon_grid (read-only, click-to-drop);
add the client accessor for the local player's synced state. Engine work, done once.
Rewrite grid-inventory as the two Lua files above (still Option A's read-only view + drop). main.lua stops being empty; the engine stops knowing "inventory". Same feel, real ownership.
Flip mutable = true: inventory gains a real slots array (persisted with
the world), move_slot validates swaps, and on_move is already wired. Arrangeable
slots + a true hotbar assignment, with no new UI code.
Ship two fat widgets — a modal and an icon-grid — that the engine renders and drives, exposed to
client.lua with a cells source and on_click / on_move events. The
widgets are the client half; the inventory model stays authoritative on the server and validates every
move over the plugin-private channel. The inventory becomes ~30 lines of Lua that own the whole thing,
and Option B is just mutable = true. The engine's UI catalog grows one shape at a time,
only when a screen needs one it doesn't have.
Widget handles as method calls (win:icon_grid{}, grid:on_move()) — that reads naturally in Lua and matches OO-ish tables. Good, or do you want flat functions?
Does the modal need more than the icon-grid in v1? A title + a grid covers the inventory. A close button, a label row, tabs (Crops/Food/…) — add now, or grow the catalog when a screen actually needs them?
Optimistic moves? Show the drag result instantly and let the server correct, or wait one snapshot for the server's word? I'd wait first (simpler, already instant-feeling) and add optimism only if it feels laggy.