Appearance
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.
| Hook | Context | Who | When |
|---|---|---|---|
init() | plot | any | Once when the package starts (and after a restart: rebuild from storage) |
onLobbyLoad() | lobby | any | A lobby was created (or loaded again after being suspended): set it up. |
tickLobby(dt: number) | lobby | any | Every tick, once per lobby that isn't suspended. |
tickPlot(dt: number) | plot | any | |
tickAllLobbies(dt: number, lobbies: LobbyId[]) | plot | any | Every 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) | any | any | |
onUpgrade(oldVersion: string) | plot | any | This 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[]) | plot | any | The 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.
| Hook | Context | Who | When |
|---|---|---|---|
chooseLobby(player: PlayerId, lobbies: Lobby[]): LobbyId | null | plot | runtime | Pick a lobby for an arriving player; null makes a new one. Default: the fullest lobby that isn't full or locked. |
onPlayerEnter(player: PlayerId) | lobby | any | A player crossed in; their 10 s entry protection has started (R4) |
onPlayerProtectionEnd(player: PlayerId) | lobby | any | A 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() | lobby | runtime | The start countdown (lobbies.startCountdown) finished: everyone's protection is over and the plot takes over. |
onCountdownPlayerLeft(player: PlayerId) | lobby | runtime | A player left during the start countdown; call startCountdown() again, cancelCountdown(), or let it run. |
onPlayerLeave(player: PlayerId, reason: "walked" | "leave" | "death" | "disconnect" | "moved" | "plotShrank") | lobby | any | PlotShrank: the monument's plot shrank under them, so they're outside now (items, flight and creative restored) |
onKeepItemsAnswer(player: PlayerId, keepsItems: boolean) | lobby | runtime | The player answered players.askToKeepItems. |
onPlayerDamage(player: PlayerId, amount: number, by: PlayerId | null, entity: EntityId | null): boolean | lobby | any | Return 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): boolean | lobby | runtime | Return 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.
| Hook | Context | Who | When |
|---|---|---|---|
onBlockBreak(ev: BlockEvent): boolean | lobby | any | {pos, block, by}; return false to refuse. |
onBlockPlace(ev: BlockEvent): boolean | lobby | any | {pos, block, by}; return false to refuse. |
onBlockBroken(ev: BlockChangeEvent) | lobby | any | After the base game broke a block onBlockBreak allowed: every cell it changed (a bed's two halves) |
onBlockPlaced(ev: BlockChangeEvent) | lobby | any | After the base game placed a block onBlockPlace allowed: every cell it wrote (a bed's two halves) |
onUse(ev: UseEvent): boolean | lobby | any | A block or entity was used (tapped): {pos?, block?, entity?, by}; return false to refuse. |
onStructureComplete(name: string, match: StructureMatch) | lobby | any | A structure pattern this package added was built: {origin, blocks, by}. |
onEntityDamage(ev: EntityDamageEvent): boolean | lobby | any | A 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) | lobby | any | {entity, type, by, pos}. |
onPropStartMoving(id: EntityId) | lobby | any | A resting prop started moving again. |
onPropStopMoving(id: EntityId) | lobby | any | A 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
| Hook | Context | Who | When |
|---|---|---|---|
onButtonPress(player: PlayerId, button: string) | lobby | runtime | An on-screen button (ui.setButton) was pressed. |
onTextSubmit(player: PlayerId, text: string) | lobby | any | The player answered ui.askForText. |
onDialogueChoice(player: PlayerId, choice: number) | lobby | any | The player picked a choice of ui.showDialogue. |
onMessage(player: PlayerId, channel: string, data: Uint8Array) | lobby | any | |
onModEvent(event: string, data: any) | any | any | An 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:
| Export | Role | When |
|---|---|---|
onFrame(dt: number) | present (client only) | Every frame: local HUD, effects, prediction display. |
predictStep(input: InputEvent, state: any): any | sim (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) |