Event bus reference

The 8 discrete gameplay events and their payloads
View as Markdown

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

1register("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).

EventHook signatureFires
on_tickfn(tick)Once per game tick. tick is the current game tick counter.
on_player_joinfn(name, entity_id)Once per player join.
on_player_leavefn(name, entity_id)Once per player leave.
on_block_breakfn(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_placefn(x, y, z, state, player_id)Once per placed block.
on_entity_spawnfn(entity_id, type_id, x, y, z)Once per spawned entity. type_id is the wire entity-type id.
on_entity_deathfn(entity_id, type_id)Once per entity death.
on_damagefn(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.
  • 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:

plugins/gate_events/main.star
1MARKER = "gate_events:"
2
3# on_block_break payload: (x, y, z, state, player_id)
4def on_break(x, y, z, state, player_id):
5 chat("%s block broken at (%d,%d,%d) state=%d by entity %d" % (MARKER, x, y, z, state, player_id))
6
7# on_player_join payload: (name, entity_id)
8def on_join(name, entity_id):
9 chat("%s player %s joined as entity %d" % (MARKER, name, entity_id))
10
11register("on_block_break", on_break)
12register("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 goals and the get_state/set_state scratch API instead.