Every plugin so far has hit the same wall: it lives on the server, so it can't ship a pixel of client art. We've dodged it with a fixed icon palette โ a plugin picks icon = "fire" and a tint, and the client draws from a set it already knows. That's why the campfire is a generic flame and every crop is the same sprig. This lays out how to give plugins real, moving graphics โ anchored on the idea of a plugin shipping a client.lua, exactly like the Lua-driven canvas animations already running on ericdube.com.
A directory plugin gains a second file: client.lua, loaded into a Fengari VM on the client. It registers a draw function per sprite name, using canvas primitives the engine provides โ the same fill_hsl / circle / line / fade vocabulary your banner animations use. The client calls it per entity, per frame, with the entity's snapshot fields. main.lua stays server-side game logic; client.lua is pure presentation. Neither can see the other's state โ the wire is still just scalars.
-- register a sprite the server spawns as `campfire` sprite("campfire", function(e, t) -- e = snapshot fields, t = seconds local flick = 0.6 + 0.4 * math.sin(t * 9 + e.x) fill_rgb(74, 51, 25, 1) -- the logs rect(e.x - 10, e.y - 2, 20, 4) fill_hsl(28, 95, 55 + flick * 8, 0.9) -- outer flame circle(e.x, e.y - 8 - flick * 3, 6 + flick) fill_hsl(48, 100, 80, 0.9) -- hot core circle(e.x, e.y - 7, 2.6) end)
Full procedural animation, authored in the same language and the same primitives you already use. A plugin becomes visually first-class โ the campfire flickers, a crop sways, a shield pulses.
Fengari joins the client bundle (~your banner cost), and it's Lua per entity per frame โ needs a budget (only custom sprites; cache/throttle off-screen). Sandbox it like the server VM: canvas ops only, no DOM.
Highest. It's the exact model on ericdube.com, lifted into the game โ setup/draw per sprite instead of per banner.
client.lua is the most powerful answer. These are what you'd reach for when you want less โ and a couple can live alongside it rather than instead of it.
The plugin names a client-provided icon and a tint; zero code, zero cost, a fixed vocabulary. It's the floor everything else builds above โ keep it as the effortless default for plugins that don't care.
-- in the item spec / server side { icon = "fire", tint = "#ff7a33" }
The plugin ships a JSON sprite: a list of shapes with optional keyframe tweens the client interprets. No code to run, so no sandbox and no per-frame VM โ but it can only express what the format anticipates. A middle rung that client.lua makes largely redundant (Lua data is this, plus logic).
{ "shapes": [
{ "circle": [0,-8,6], "hsl":[28,95,55],
"pulse": { "r":[6,8], "hz":2 } }
]}
The plugin ships PNGs (as files or data URIs); the client draws them, with an optional sprite-sheet for frame animation. Best for detailed, authored art a hand can't code โ worst for anything reactive. Pairs with client.lua: draw an image, then move/tint it in Lua (Set 12.5 wants this too).
-- client.lua can draw a shipped image too image("campfire.png", e.x - 16, e.y - 28, 32, 32)
Your instinct is the right one โ client.lua is the answer, and it's cheap to reach because the harness already exists. Skip the declarative format (C); Lua subsumes it. Keep B as the default, add D so authored art has a home.
cooking:campfire); the client dispatches to that plugin's registered draw(e, t). Base-game sprites stay JS โ only plugin ones cross into Lua.image(). Plugins ship art next to client.lua; Lua composes it (place, tint, animate).A few calls I'd want your read on before building:
fill_hsl, circle, line, rect, fade, line_widthโฆ), or a slightly richer set (paths, gradients, transforms)? Starting with yours is the fast path; we grow it as sprites ask for more.math.random and clock are free (no seed discipline). Confirm we're happy drawing purely from snapshot fields + wall-clock t.