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

# Event bus reference

Plugins subscribe to gameplay events with the `register` builtin at load time:

```python
register("on_block_break", my_hook)
```

`register(event_name, fn)` takes the event name string and any callable (a `def`, `lambda`, or
builtin). It validates the event name against the closed known-event set **at load** — a typo'd
name is a load error (`register: unknown event "..."`), never a hook that silently never fires.

Events are **discrete occurrences**: each fires once per real happening (a block breaks, a player
joins, a mob dies) — never per-entity-per-tick. The one per-tick event is `on_tick`, and it is
guarded by a zero-subscriber fast path: on a server where no plugin subscribes to an event,
dispatching it costs a single map read and zero allocation.

## The events

Hook arguments are positional, in the exact order listed. All payloads are **plain frozen
scalars** (ints, strings, floats) — never live entity or world handles (those exist only in the
capability-gated goal-callback API).

| Event             | Hook signature                    | Fires                                                                                                                                                                   |
| ----------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `on_tick`         | `fn(tick)`                        | Once per game tick. `tick` is the current game tick counter.                                                                                                            |
| `on_player_join`  | `fn(name, entity_id)`             | Once per player join.                                                                                                                                                   |
| `on_player_leave` | `fn(name, entity_id)`             | Once per player leave.                                                                                                                                                  |
| `on_block_break`  | `fn(x, y, z, state, player_id)`   | Once per block actually removed. `state` is the broken block's state id; `player_id` is the breaker's entity id.                                                        |
| `on_block_place`  | `fn(x, y, z, state, player_id)`   | Once per placed block.                                                                                                                                                  |
| `on_entity_spawn` | `fn(entity_id, type_id, x, y, z)` | Once per spawned entity. `type_id` is the wire entity-type id.                                                                                                          |
| `on_entity_death` | `fn(entity_id, type_id)`          | Once per entity death.                                                                                                                                                  |
| `on_damage`       | `fn(entity_id, amount)`           | Once per damage application. `amount` is a float — the **final post-mitigation** value, after armor/effects/absorption (the value where the jar computes final damage). |

## Dispatch semantics

* **Register-once.** Hooks are captured while the module body runs at load. There is no runtime
  subscribe/unsubscribe; a reload rebuilds the whole hook table.
* **Tick-goroutine dispatch (Starlark).** The server emits events at the discrete seams on the
  tick goroutine and calls Starlark hooks inline, in registration order.
* **Off-tick dispatch (Python).** For `runtime = "python"` plugins the event is *submitted* to a
  bounded off-tick pool and the tick continues immediately — see
  [Python runtime](/plugins/python-runtime).
* **Per-hook isolation.** Every hook call runs on a fresh, step-budgeted Starlark thread inside a
  panic recover. A hook that errors, panics, or exceeds the 10M-step budget is logged
  (`plugin "name" hook on_x error: ...`) and skipped; the remaining hooks and the tick continue.

## Example: reacting to events with chat

The bundled `gate_events` plugin (used by Sulfur's own end-to-end gate test) subscribes to two
events and reacts via `chat()` so the reaction lands on every player's screen:

```python title="plugins/gate_events/main.star"
MARKER = "gate_events:"

# on_block_break payload: (x, y, z, state, player_id)
def on_break(x, y, z, state, player_id):
    chat("%s block broken at (%d,%d,%d) state=%d by entity %d" % (MARKER, x, y, z, state, player_id))

# on_player_join payload: (name, entity_id)
def on_join(name, entity_id):
    chat("%s player %s joined as entity %d" % (MARKER, name, entity_id))

register("on_block_break", on_break)
register("on_player_join", on_join)
```

Adding your own state across events works through module-level mutable containers created before
the module freezes — but remember the payloads are value copies; there is no way to hold a live
reference to an entity from an event hook. For stateful per-mob behavior, use
[declare\_mob](/plugins/declare-mob) goals and the `get_state`/`set_state` scratch API instead.