A plugin already has a server brain (main.lua) and, since Set 12.5, can ship a client.lua that DRAWS its sprites. The next step is letting that client half do more than draw: react to input, open a UI like a radial wheel, hold a little local state, and message its own server part when something real happens. That's the substrate the category wheels — and the grid inventory — actually need. Here's how the two halves talk, and the one rule that keeps it stable.
A plugin is one thing the player installs, but it runs in two places. The split mirrors the game's own: the server is authoritative and owns the world; the client is presentation and input. A plugin gets to live on both sides, with a clear line between them.
Authoritative game logic — the world, the rules, the truth. Where it is today.
Presentation + interaction. Draws today; gains input + UI + a message channel.
The single most important constraint, and the thing that keeps this from turning into spaghetti:
An event sent from a plugin's client.lua can be heard by only that same plugin's server side. It cannot address another plugin. If a client action needs to affect another plugin, the receiving plugin's main.lua does that server-side, through the existing plugin channels (engine.require / engine.emit).
Why: the client is untrusted and replaceable; cross-plugin coupling over an untrusted wire is where things get fragile. Keeping the client→server edge private per plugin means each plugin owns its own boundary, and all cross-plugin logic stays on the authoritative side where it's already well-defined.
client.lua keeps its drawing API and gains a small, deliberate surface — enough for the wheel and the grid, not a second game engine on the client.
React to a bound control on the client (the same actions the keybind menu lists). The handler runs locally — no server hop unless the plugin chooses to send one.
The first client UI primitive: a radial of the player's HELD items in a category, resolved locally from the snapshot. The player picks; cb(item) fires on the client. Grid comes later.
A client.lua already has its own VM and _ENV, so "what block is selected" is just a local. No new API; client-only state never needs the server until an action lands.
Send a scalar/table payload to THIS plugin's server half. Server receives it via plugin.on_client(event, fn) — the private channel above.
The trowel's block wheel is client-drawn today but hard-wired. Rebuilt on this runtime it becomes an ordinary plugin — and the template every other wheel copies. (The trowel isn't a plugin yet; this is the shape it takes when it is.)
blocks/client.lua — input, wheel, local state, one message home-- Press the block-wheel key while holding the trowel: open a radial of the blocks -- you're carrying (category "block"), remember the pick locally, and — only when you -- actually place — tell our own server half to lay it down. local selected = nil on_input("blockwheel", function() if not holding_trowel() then return end wheel.show("block", function(item) -- item = the chosen block, or nil if cancelled if item then selected = item end -- purely client-side until you act end) end) on_input("place", function(aim) if selected then send("place", { block = selected, x = aim.x, y = aim.y }) end end)blocks/main.lua — receives its OWN client's message, validates, acts
-- Only this plugin's client can reach this handler. Never trust the payload: check the -- player actually holds that block + is allowed to build there, THEN mutate the world. plugin.on_client("place", function(pid, msg) if inv.count(pid, msg.block) < 1 then return end if not can_build_at(pid, msg.x, msg.y) then return end inv.take(pid, msg.block, 1) engine.setSolid(msg.x, msg.y, msg.block) end)The trip, side by side
on_input("blockwheel") runswheel.show("block", cb) — radial of held blocks, drawn + hit-tested locally from the snapshotcb("dirt") → client remembers selected = "dirt". No server hop.send("place", {block, x, y})plugin.on_client("place") fires — validates, takes the block, edits terrainclient.lua runs on the player's machine; a modified client can send any message it likes. So
send() is a request, never a fact. Every plugin.on_client handler validates
against the authoritative state (do they own it? are they allowed?) before it mutates anything —
exactly how the server treats input today. The client half makes the game feel instant; the server
half keeps it fair.
The wheel and the grid both sit on the same layer, so the layer is the real prerequisite. Build it bottom-up; each step is testable on its own.
on_input on the client, send() → the plugin-private server hook plugin.on_client. The client VM + drawing already exist; this adds input dispatch and the one wire home.
A radial client UI generalised from today's block wheel; options are the player's held items in a category (needs item categories exposed to the client — a small add to the item manifest).
Rebuild the block wheel on the runtime as the proof. The grid inventory is another client UI on the same layer (and it calls inventory.suppressHotbar()).
Give client.lua an input hook and a single, plugin-private wire back to its own server half (send → plugin.on_client), with the server always validating. Add the wheel as the first client UI on top, expose item categories to the client so it can fill the radial, and prove the whole thing by rebuilding the block wheel as a plugin. Everything cross-plugin stays server-side; the client edge stays private and untrusted-by-default. That runtime is what both the wheels and the grid have been waiting on.