Plugin system overview

Declare behavior once at load — Go runs the hot path

View as Markdown

Sulfur is a from-scratch Minecraft Java Edition 26.2 server (protocol 776) written in Go. Its plugin system is built around one core idea:

Plugins DECLARE behavior once, at load. Go runs the hot path.

A plugin’s script body runs exactly once when the plugin loads. During that single run it captures things into the server: event hooks (register), mob declarations (declare_mob), skill data (skill/mechanic/targeter/condition), and recipe matchers (set_recipe_matcher). After load, the Go server owns the tick loop and calls back into the captured hooks only at the exact moments they apply. An idle plugin costs the server essentially nothing.

Dual runtime

Sulfur ships two plugin runtimes, selected per plugin by the runtime field in plugin.toml:

RuntimeEngineWhere it runsAvailability
starlarkgo.starlark.net — pure Go, sandboxed, deterministicInline on the tick (hot path)Always built in
pythonCPython 3.14 embedded via gopy (cgo)Off-tick only, on a bounded worker poolOpt-in: go build -tags python

The default Sulfur binary is a pure-Go static build (CGO_ENABLED=0). The Python lane lives entirely behind the python build tag; without it, a runtime = "python" plugin is skipped gracefully at load with a log line. See Python runtime.

The Starlark sandbox

Starlark plugins are hard-sandboxed by construction:

  • No I/O surface. The Starlark universe has no filesystem, network, or eval builtin — open is simply undefined. The only app-specific globals a plugin sees are the ones the host injects (log, chat, register, math, the recipe builtins, and — for mob plugins — the declaration builtins).
  • Step budget. Every callback runs on a fresh thread with a 10,000,000-step execution budget. A runaway loop returns an error (“too many steps”) instead of hanging the server.
  • Safe dialect. Recursion is checked, while loops are off, and module globals are auto-frozen after the module body runs — captured hooks are immutable values that are safe to call from the tick goroutine.
  • Per-hook isolation. Each hook invocation is wrapped in a panic recover. One erroring or panicking hook is logged and skipped — it never aborts other hooks or kills the tick.
  • Capabilities. Anything that touches live game state (entities, world, navigation, skill mechanics) is gated by the manifest capabilities list, with least-privilege defaults and loud failure. See Handles & capabilities.

Dogfooding: vanilla is a plugin

The plugin API is not a side feature — Sulfur’s own vanilla content runs through it:

  • Every vanilla mob (pig, cow, sheep, chicken, wolf, cat, zombie, skeleton, creeper, spider, enderman, villager, and more — 28 mob plugins under plugins/mobs/) is declared with declare_mob, as a literal method-for-method port of the unobfuscated 26.2 jar’s AI goals.
  • Vanilla crafting is a bundled Starlark plugin (plugins/crafting/) that re-expresses the jar’s recipe-matching algorithm through the set_recipe_matcher seam.

If the API can express all of vanilla faithfully, it can express your custom content too.

What a plugin can do today

SurfaceMechanismReference
React to gameplay eventsregister(event, fn) — 8 discrete eventsEvent bus
Read/mutate entities, world, navigationcapability-gated handles passed to goal callbacksHandles & capabilities
Define custom mobs with AI goalsdeclare_mob(name, base_type, attributes, goals)declare_mob
Give mobs MythicMobs-class skillsskill / mechanic / targeter / conditionSkills
Drive crafting / add recipesset_recipe_matcher / set_recipe_remaining / recipes()Recipes
Heavy off-tick work (IO, aggregation)Python runtime, world bridge builtinsPython runtime

Hot reload

The server watches the plugins/ directory (pure-Go fsnotify). Saving a .star or .toml file triggers a debounced (150 ms) rebuild: a brand-new plugin manager is constructed off the tick goroutine and swapped in between ticks. A save with a syntax error keeps the last-good plugin set running — the broken build is logged and never swapped in.