> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.trysulfur.net/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.trysulfur.net/_mcp/server.

# Plugin system overview

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`:

| Runtime    | Engine                                                                                       | Where it runs                               | Availability                    |
| ---------- | -------------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------------- |
| `starlark` | [go.starlark.net](https://github.com/google/starlark-go) — pure Go, sandboxed, deterministic | **Inline on the tick** (hot path)           | Always built in                 |
| `python`   | CPython 3.14 embedded via gopy (cgo)                                                         | **Off-tick only**, on a bounded worker pool | Opt-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](/plugins/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](/plugins/handles-and-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

| Surface                                 | Mechanism                                                   | Reference                                                   |
| --------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------- |
| React to gameplay events                | `register(event, fn)` — 8 discrete events                   | [Event bus](/plugins/events)                                |
| Read/mutate entities, world, navigation | capability-gated handles passed to goal callbacks           | [Handles & capabilities](/plugins/handles-and-capabilities) |
| Define custom mobs with AI goals        | `declare_mob(name, base_type, attributes, goals)`           | [declare\_mob](/plugins/declare-mob)                        |
| Give mobs MythicMobs-class skills       | `skill` / `mechanic` / `targeter` / `condition`             | [Skills](/plugins/skills)                                   |
| Drive crafting / add recipes            | `set_recipe_matcher` / `set_recipe_remaining` / `recipes()` | [Recipes](/plugins/recipes)                                 |
| Heavy off-tick work (IO, aggregation)   | Python runtime, world bridge builtins                       | [Python runtime](/plugins/python-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.