Meme Wars · design notes

Plugins get a client half.

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.

Exploratory · scoping the client-plugin runtime · the wheel is its first consumer
The shape

Two halves, one plugin

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.

main.lua · server

The brain

Authoritative game logic — the world, the rules, the truth. Where it is today.

  • systems, blueprints, items, config (all current)
  • owns the inventory, the terrain, who has what
  • talks to OTHER plugins (engine.require / emit)
  • receives this plugin's own client messages, and validates them
client.lua · client

The face & hands

Presentation + interaction. Draws today; gains input + UI + a message channel.

  • draw(e,t) sprites (Set 12.5 — already here)
  • new: react to input (a key, a click)
  • new: open client UI — the wheel is the first
  • new: send an event to its OWN server half
The one rule

A client half only talks to its own server half

The single most important constraint, and the thing that keeps this from turning into spaghetti:

Messages are plugin-private

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.

What the client half can call

The client runtime

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.

Input

on_input(action, fn)

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.

Client UI

wheel.show(category, cb)

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.

State

— just Lua locals —

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.

Message home

send(event, data)

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 first consumer

The block wheel, as a plugin

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
clientUser presses C → blocks/client.lua's on_input("blockwheel") runs
clientwheel.show("block", cb) — radial of held blocks, drawn + hit-tested locally from the snapshot
clientPlayer picks Dirt → cb("dirt") → client remembers selected = "dirt". No server hop.
clientLater, user clicks to place → send("place", {block, x, y})
serverblocks/main.lua's plugin.on_client("place") fires — validates, takes the block, edits terrain

The client is untrusted — the server is still the referee

client.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.

Building it

The runtime first, then its consumers

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.

runtime

client.lua logic + the message channel

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.

wheel

wheel.show(category, cb)

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).

consumers

the block wheel, then the grid

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()).

The short version

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.