Skip to content

Hooks: what you export ​

Hooks are named functions your package exports. All are optional. The host calls each in the context shown. The tables are generated from plot/schema/host-api.mjs by gen.mjs. Status: Built (headless, and in the plots dev stack), except client exports (In progress, at the end).

ts
export function onPlayerEnter(player: PlayerId) { /* ... */ }
export function tickLobby(dt: number) { /* ... */ }

"Who" is runtime for hooks only a plot's runtime package receives; a mod's export of those is never called.

Lifecycle ​

After a crash or a suspend, the host makes a new sandbox and runs top-level code, init, then onLobbyLoad for each lobby again. Your state comes back from storage.

HookContextWhoWhen
init()plotanyOnce when the package starts (and after a restart: rebuild from storage)
onLobbyLoad()lobbyanyA lobby was created (or loaded again after being suspended): set it up.
tickLobby(dt: number)lobbyanyEvery tick, once per lobby that isn't suspended.
tickPlot(dt: number)plotany
tickAllLobbies(dt: number, lobbies: LobbyId[])plotanyEvery tick, once, with the lobbies this package serves that aren't suspended, instead of tickLobby per lobby (the cheap path for many lobbies). Call lobbies.switchTo(id) before each lobby's work and skip it when that returns false.
onTimer(name: string, data: any)anyany
onUpgrade(oldVersion: string)plotanyThis package was updated (live, or a lobby's first run on the new version after its players left): migrate storage here. A trap rolls the package back automatically. (Register #79's onMigrate is this hook.)
onPlotChunksChanged(added: number[], removed: number[])plotanyThe monument's plot grew or shrank (flat [cx, cz, ...] lists). Edits and terrain there are dropped and the plot's entities there are removed. This runs before the affected players' onPlayerLeave (reason plotShrank)

Players and lobbies ​

Without chooseLobby, the platform puts an arriving player in the fullest lobby that isn't full or locked, and makes a new lobby when there's none. onCountdownEnd and onCountdownPlayerLeft only run if you started a countdown with lobbies.startCountdown.

HookContextWhoWhen
chooseLobby(player: PlayerId, lobbies: Lobby[]): LobbyId | nullplotruntimePick a lobby for an arriving player; null makes a new one. Default: the fullest lobby that isn't full or locked.
onPlayerEnter(player: PlayerId)lobbyanyA player crossed in; their 10 s entry protection has started (R4)
onPlayerProtectionEnd(player: PlayerId)lobbyanyA player's 10 s entry protection ended (R4): from now you may teleport, equip, damage or change their movement. Not called when a start countdown ends; that's onCountdownEnd.
onCountdownEnd()lobbyruntimeThe start countdown (lobbies.startCountdown) finished: everyone's protection is over and the plot takes over.
onCountdownPlayerLeft(player: PlayerId)lobbyruntimeA player left during the start countdown; call startCountdown() again, cancelCountdown(), or let it run.
onPlayerLeave(player: PlayerId, reason: "walked" | "leave" | "death" | "disconnect" | "moved" | "plotShrank")lobbyanyPlotShrank: the monument's plot shrank under them, so they're outside now (items, flight and creative restored)
onKeepItemsAnswer(player: PlayerId, keepsItems: boolean)lobbyruntimeThe player answered players.askToKeepItems.
onPlayerDamage(player: PlayerId, amount: number, by: PlayerId | null, entity: EntityId | null): booleanlobbyanyReturn false to refuse. by: the player who hit them (base PvP); entity: the plot entity whose base AI hit or shot them (EntityTypeDef.ai), else null.
onPlayerDeath(player: PlayerId, by: PlayerId | null): booleanlobbyruntimeReturn true if the plot handles it (its own respawn); otherwise a soft death to the entry point outside (R7)

World events ​

The base game's rules run inside the plot (Runtimes and mods), and these hooks wrap them. Filters (onBlockBreak, onBlockPlace, onUse, and onPlayerDamage above) run before the base game acts: they go to the runtime first, then mods in load order, and any false refuses. Listeners (onBlockBroken, onBlockPlaced) run after it, with every cell it changed. A runtime with a baseRules category off gets the same hooks; then an allowed break or place writes just the one cell.

HookContextWhoWhen
onBlockBreak(ev: BlockEvent): booleanlobbyany{pos, block, by}; return false to refuse.
onBlockPlace(ev: BlockEvent): booleanlobbyany{pos, block, by}; return false to refuse.
onBlockBroken(ev: BlockChangeEvent)lobbyanyAfter the base game broke a block onBlockBreak allowed: every cell it changed (a bed's two halves)
onBlockPlaced(ev: BlockChangeEvent)lobbyanyAfter the base game placed a block onBlockPlace allowed: every cell it wrote (a bed's two halves)
onUse(ev: UseEvent): booleanlobbyanyA block or entity was used (tapped): {pos?, block?, entity?, by}; return false to refuse.
onStructureComplete(name: string, match: StructureMatch)lobbyanyA structure pattern this package added was built: {origin, blocks, by}.
onEntityDamage(ev: EntityDamageEvent): booleanlobbyanyA player's base melee hit on a plot entity (manifest baseRules.mobs): {entity, type, amount, by}; return false to refuse (nothing happens). Its health after a hit: content.getEntity; a death is onEntityDeath.
onEntityDeath(ev: EntityDeathEvent)lobbyany{entity, type, by, pos}.
onPropStartMoving(id: EntityId)lobbyanyA resting prop started moving again.
onPropStopMoving(id: EntityId)lobbyanyA moving prop came to rest.
ts
interface BlockEvent      { pos: Vec3; block: BlockId; by: PlayerId | null }   // by: null when plot code did it
interface BlockChangeEvent { pos: Vec3; block: BlockId; by: PlayerId | null; changes: { pos: Vec3; was: BlockId; block: BlockId }[] }
interface UseEvent        { by: PlayerId; pos?: Vec3; block?: BlockId; entity?: EntityId }
interface StructureMatch  { name: string; origin: Vec3; blocks: Vec3[]; by: PlayerId | null }
interface EntityDeathEvent { entity: EntityId; type?: string; by: PlayerId | null; pos: Vec3 }

Input and messages ​

HookContextWhoWhen
onButtonPress(player: PlayerId, button: string)lobbyruntimeAn on-screen button (ui.setButton) was pressed.
onTextSubmit(player: PlayerId, text: string)lobbyanyThe player answered ui.askForText.
onDialogueChoice(player: PlayerId, choice: number)lobbyanyThe player picked a choice of ui.showDialogue.
onMessage(player: PlayerId, channel: string, data: Uint8Array)lobbyany
onModEvent(event: string, data: any)anyanyAn event from another package (mods.sendEvent)

Per-tick input (moves, look, actions, aim) is pulled, not pushed: call input.read() in tickLobby.

Client exports (In progress) ​

When your module has these, it also runs on the devices of the lobby's players (never onlookers'), in a Worker; the plots dev page runs them, and the room runs the runtime's predictStep as the referee:

ExportRoleWhen
onFrame(dt: number)present (client only)Every frame: local HUD, effects, prediction display.
predictStep(input: InputEvent, state: any): anysim (both sides, deterministic)Advance the predicted state one tick (a custom movement, rollback games); the server runs the same code to check it.
onInput(ev: InputEvent)present (client only)
onServerMessage(channel: string, data: Uint8Array)present (client only)