DEV Community

Eric Mollenthiel
Eric Mollenthiel

Posted on

Every no-code builder generates code. I shipped an interpreter instead.

Every no-code builder I have used ends the same way: you drag boxes around, you press
Save, and somewhere a template engine writes source code you are now responsible for.

I spent a few months building a plugin builder for Minecraft servers, and the obvious
version of it generates Java. Blocks in, .java out, Gradle, a jar you download. I got
close enough to that design to see where it ends, then threw it away and wrote an
interpreter instead.

This is what that decision actually costs and actually buys, with the parts that only
became obvious after the engine existed.

The domain, in four lines

A Minecraft server plugin is a jar built against the Paper API. It hooks events (a player
joins, a block breaks, someone types in chat) and reacts.

Twice a year Minecraft ships a version, the API moves, and every plugin on every server
breaks at once. Plugin authors spend their lives recompiling. Server owners spend theirs
waiting. Fifteen years of this, and it is the single loudest complaint in the ecosystem.

That release cycle is not a detail of my domain. It is the thing that decides the
architecture.

Why generating Java is the trap

Generated code looks free at the design stage. You already know the shape of the output,
templates are easy, and the result is a normal plugin that behaves like every other plugin
on the server. The build is a solved problem.

Then you write down what happens on the day the API moves.

Every jar you have ever generated is frozen. The generator can be fixed in an afternoon,
but the fix reaches nobody: the broken code is already sitting in other people's server
directories. To ship it you have to regenerate and recompile every plugin of every user,
which means you need the original inputs of all of them, a build farm, a queue, and a
migration story for the ones whose regenerated output no longer compiles. A bug in a
template becomes an outage with a per-customer fan-out.

And that is the good day. On the bad day you are compiling and executing generated code in
production, which means a JDK, Gradle, a worker pool and a cache in the serving path, and
an attack surface shaped like "arbitrary Java, from the internet, run on a game server".
Minecraft in particular had a real supply chain attack in 2023 (fractureiser), spread
through plugin jars. Becoming another distribution channel for opaque bytecode was not a
neutral choice.

What I shipped instead

The studio produces a versioned JSON spec: trigger, conditions, actions. One hand-written
Paper plugin, the runtime, reads specs and executes them. Nothing generates Java anywhere.

A complete plugin:

{
  "specVersion": 1,
  "name": "Welcome",
  "rules": [{
    "name": "Welcome message",
    "trigger": { "type": "player.join" },
    "conditions": [
      { "type": "player.has_permission", "params": { "permission": "nimblock.vip" } }
    ],
    "actions": [
      { "type": "player.send_message", "params": { "message": "&6Welcome {player}!" } }
    ]
  }]
}
Enter fullscreen mode Exit fullscreen mode

Saving that writes a row. Installing it drops a file in the server volume and hot reloads.
No build step exists to be slow, so nothing has to be made fast.

The comparison that decided it:

Generate and compile Interpret a spec
Save a plugin a Gradle build, tens of seconds a database write
Install on a running server rebuild, redeploy, restart drop the JSON, hot reload
Production infrastructure JDK, Gradle, cache, workers, queue none
Attack surface compiling and running generated code closed grammar, no arbitrary Java
Fix an engine bug recompile every plugin of every user publish one jar
Minecraft ships a version every generated jar is broken the runtime absorbs it once

The last row is the whole product. When the API moves, I fix one plugin, everyone's rules
keep running, and a spec written today should still run in three years untouched.

The part I did not see coming: the grammar becomes data

Here is the second-order effect, and it turned out to matter more than the build times.

When the grammar is a data file instead of a code generator, it can be the single source of
truth for both sides of the system. Mine is one catalogue-1.json: 9 triggers, 8
conditions, 14 actions. The JSON Schema is derived from it. The PHP validator in the studio
reads it. The Java engine reads the same file, copied into the jar at build time.

That buys two things a code generator cannot have.

Nothing can be offered and not implemented. The worst possible bug in a builder like
this is silent: an action the editor accepts, that nothing executes. The user places it,
the panel says fine, and nothing happens in game with no message anywhere. So the runtime
has a test that compares the catalogue to the handler registry in both directions:

@Test
void everyActionHasAHandler() {
    assertEquals(new TreeSet<>(catalogue.actionIds()), new TreeSet<>(new Actions(null).ids()));
}
Enter fullscreen mode Exit fullscreen mode

Nothing in the catalogue without a handler, nothing handled outside the catalogue. With
generated code that test does not exist, because there is no registry to compare against:
you would be diffing templates.

The catalogue can carry a type system. Each trigger declares what it provides, each
action declares what it requires:

{ "id": "schedule.repeat", "context": [] }
{ "id": "player.heal",     "requires": ["player"] }
{ "id": "cancel_event",    "requires": ["cancellable"] }
Enter fullscreen mode Exit fullscreen mode

So "heal the player" under "every N seconds" is refused as you write it, not at runtime,
because that trigger provides no player. cancel_event only exists under a cancellable
trigger. A typo'd {palyer} is rejected instead of landing in someone's chat as literal
text. That is roughly a type checker, expressed in a data file, and it exists only because
the grammar is data rather than a template.

Third rule, less about types and more about failure modes: a spec is accepted or rejected
as a whole
. A protection plugin that loaded half its rules would fail silently in game,
in the worst way, so it does not load half.

The bill

An interpreter is not free, and the price is paid by the user, not by me.

The grammar is closed. Whatever is not in those 9 triggers, 8 conditions and 14 actions is
impossible, not "on the roadmap". Conditions do not nest either: all / any plus a not
per condition, no boolean tree. That last one is a genuine v1 limitation rather than a
principled stand, and it has an ugly reason: nested booleans draw badly as blocks.

If you need a guild system with its own database and its own GUI, this is the wrong tool
and it always will be. If you need "when a player joins for the first time, give them a kit
and announce it", it is two minutes. Knowing which side of that line you are on before you
invest is worth more than a longer feature list, so the full grammar is a public page and
you do not need an account to read it.

The escape hatch is a single "run as console" action, with exactly the power of the server
console and no more.

Three things the interpreter forced me to get right

Command registration is not hot. Paper only accepts new command names during its
COMMANDS lifecycle event, so a rule that declares a new command exists only after a
restart, while everything else in the same spec is live immediately. Rather than guess, the
agent records what Paper actually registered at boot; the difference with what the
installed specs declare is the pending list the panel shows. Same field left null tells
me a server predates the studio, so a reload would only produce a baffling "unknown
command" and the panel asks for a restart instead.

Cancellation is applied before the rule's actions, not after. For an ordinary rule the
order is unobservable. But chat arrives on another thread, and the remaining actions have
to hop back to the main thread, which is far too late to cancel anything. Applying it first
is the only version where chat moderation rules work at all.

A failing action stops its rule, never the server. Unknown item name, deleted world, a
numeric comparison on text: those are spec mistakes. They are worth one line in the console
and nothing more.

The obvious objection

"Fine, but my server is not hosted by you."

Right, and this is the part where the design pays off again rather than the part where it
falls apart: an export does not need a JDK either. The runtime jar is already compiled, so
exporting is injecting the specs as resources, rewriting plugin.yml, and rezipping.
ZipArchive in PHP, on the order of a hundred milliseconds, no build server anywhere. One
jar per account containing all of its specs, never one jar per plugin, so installing two of
them on the same external server cannot collide.

To be straight with you: that is designed and costed, not shipped. It is the next batch. I
am saying it here because it is the standard objection to a hosted builder, and because
"the escape route is cheap" is a consequence of the interpreter, not a promise bolted on
afterwards.

Where it is

It is live and free. The stack is Symfony 8 and PostgreSQL 18 on a single box, Paper
servers in rootless Podman containers, one network per server, filtered egress. The machine
holds twelve 1 GB servers, so there are twelve slots, which is a hardware limit rather than
a growth-hacking number.

What I want back is not signups. It is the list of things you tried to express and could
not, because that list is what decides whether the closed grammar was the right call or
just the easy one.

Top comments (0)