Appearance
Host API: what you call
Every module of @inventery/host. The tables are generated from plot/schema/host-api.mjs by reference/gen.mjs (node docs/developers/reference/gen.mjs); the prose around them is hand-written. Status: Built (headless) unless noted. runtime marks calls only a runtime package may make (a mod is refused with runtimeOnly). Any call outside its context is refused with wrongContext; any call may be refused with badArguments or notSupported. Context and refusals: see the overview.
Type names (PlayerId, Vec3, Box, Visibility, Targets, ButtonDef, ...) are in plot/sdk/host.d.ts, with a doc comment each. The common ones:
ts
type PlayerId = string; // per-plot, never the account id; stable across visits
type LobbyId = string; // "main" when the plot has no lobbies; "draft" is the creator's
type Vec3 = [number, number, number];
type Box = { min: Vec3; max: Vec3 }; // block coordinates, max exclusive
type AssetRef = string; // a bundle path ("models/bed.glb") or a content hash ("h:" + 32 hex)
type Color = string; // "#rrggbb", "#rrggbbaa" or a palette name ("gold")
type Visibility = "public" | "lobby" | "serverOnly" | { players: PlayerId[] };
type Targets = PlayerId | PlayerId[] | "lobby";scene
The replicated scene: what others see. Nothing replicates unless it's written here. Components: Components.
ts
const id = scene.create({ Transform: { pos: [8, 65, 8] }, Model: { asset: "models/chest.glb" } }, { visibility: "lobby" });
scene.setComponent(id, "Transform", { pos: [8, 66, 8] });| Call | Context | Refuses | What |
|---|---|---|---|
scene.create(components: Components, opts?: CreateOpts): EntityId | any | wrongContext outsidePlot | A new entity in the current lobby (or the shared base, with opts.shared at plot level) |
scene.setComponent(id: EntityId, component: string, value: any) | any | wrongContext playerBody | Replace one component; null removes it. |
scene.getComponent(id: EntityId, component: string): any | any | A component's value, or null. | |
scene.remove(id: EntityId) | any | wrongContext playerBody | |
scene.setParent(id: EntityId, parent: EntityId | null) | any | wrongContext | |
scene.setVisibility(id: EntityId, visibility: Visibility) | any | ||
scene.query(opts?: QueryOpts): EntityId[] | any | This lobby's entities (plus the shared base's) | |
scene.wakeProp(id: EntityId) | lobby | Turn a resting prop back into a moving entity (protocol.md, resting props); onPropStartMoving runs. |
input
This tick's inputs from this lobby's players. Call input.read() in tickLobby.
| Call | Context | Refuses | What |
|---|---|---|---|
input.read(): InputEvent[] | lobby | The inputs since the last read (each is returned once) |
network
Custom channels for game logic (never visuals: onlookers never receive them) and lag compensation. Channels are declared in plot.json ("channels": { "shop": { "reliable": true } }). Messages arrive in onMessage or network.receive.
ts
network.send("shop", p, JSON.stringify({ bought: "sword" }));
const hit = network.raycastAtTime(ev.renderTime ?? time.now(), ev.aimRay, { players: true, ignore: [ev.player] });| Call | Context | Refuses | What |
|---|---|---|---|
network.send(channel: string, to: Targets, data: Uint8Array | string) | lobby | overBudget wrongContext | |
network.receive(channel: string): Message[] | lobby | ||
network.pastPositions(time: number): PastPositions | lobby | Where the lobby's players were at a past server time (the last 500 ms are kept; older times are clamped) | |
network.raycastAtTime(time: number, ray: Ray, opts?: RaycastOpts): Hit | null | lobby | A raycast against the lobby as it was at a past server time (the last 500 ms are kept): hit tests that are fair to a lagging shooter. |
assets
Bundle files, and assets your code makes at run time (meshes, materials, sounds), named by content hash.
| Call | Context | Refuses | What |
|---|---|---|---|
assets.hashOf(ref: AssetRef): AssetRef | any | A bundle path's content hash (and a check that the file exists) | |
assets.create(kind: "mesh" | "material" | "sound" | "texture" | "model", data: any): AssetRef | any | overBudget | An asset made at runtime; returns its content hash (the same data gives the same hash). A mesh is {positions, indices?, colors? | color?}; a textured mesh (mesh terrain: world.replaceTerrainChunk) adds uvs (two a vertex) and texture {tiers: [bundle PNG or JPEG paths, finest first: the low graphics level gets the second], avg?: "#rrggbb" (shown until a tier arrives), mask?, doubleSided?}. |
storage
The plot's own saved data, as JSON values. Four scopes: this lobby's (get, set), one player's in this lobby (getPlayer, setPlayer), the whole plot's (getPlot, setPlot), and one player's across the whole plot (getPlayerProfile, setPlayerProfile: a profile that follows them into any lobby). 1 MB per plot plus blocks.
ts
storage.set("round", 3);
storage.setPlayer(p, "kills", (storage.getPlayer<number>(p, "kills") ?? 0) + 1);
storage.setPlayerProfile(p, "chapter", 2);
storage.setPlot("season", 4);| Call | Context | Refuses | What |
|---|---|---|---|
storage.get(key: string): any | lobby | ||
storage.set(key: string, value: any) | lobby | storageFull | |
storage.delete(key: string) | lobby | ||
storage.keys(prefix?: string): string[] | lobby | ||
storage.getPlayer(player: PlayerId, key: string): any | lobby | ||
storage.setPlayer(player: PlayerId, key: string, value: any) | lobby | storageFull | |
storage.getPlot(key: string): any | any | The whole plot's, shared by every lobby. | |
storage.setPlot(key: string, value: any) | any | storageFull | |
storage.getPlayerProfile(player: PlayerId, key: string): any | any | One player's across the whole plot (a profile that follows them into any lobby: story progress, ranked stats) | |
storage.setPlayerProfile(player: PlayerId, key: string, value: any) | any | storageFull |
lobbies
A lobby is a siloed room of up to lobbies.size players. The platform puts arrivals in the fullest lobby that isn't full or locked, creates lobbies as needed and suspends empty ones; lock() and unlock() close and reopen one; the start countdown is optional. See Lobbies. Most of these are the runtime's.
| Call | Context | Refuses | What |
|---|---|---|---|
lobbies.current(): Lobby | lobby | ||
lobbies.lock() runtime | lobby | Take no newcomers, even with room (a round in progress, a fixed party) | |
lobbies.unlock() runtime | lobby | Take newcomers again (for the next round) | |
lobbies.startCountdown(opts?: CountdownOpts): number runtime | lobby | Optional: a base-drawn countdown that ends when the newest member's 10 s entry protection ends (or after opts.minSeconds, whichever is later), then onCountdownEnd. Members past their protection stay unprotected unless opts.protectEveryone (R4). Returns countdownEndsAt. | |
lobbies.cancelCountdown() runtime | lobby | ||
lobbies.switchTo(id: LobbyId | null): boolean | any | Inside tickAllLobbies only: carry on in lobby id's context (null: back to plot level). The previous lobby's writes are saved here; false means skip this lobby (it stopped, or the tick's CPU budget is spent: false from then on) | |
lobbies.list(): Lobby[] | any | ||
lobbies.get(id: LobbyId): Lobby | null | any | ||
lobbies.create(opts?: LobbyCreateOpts): LobbyId runtime | plot | ||
lobbies.regenerateTerrain(id: LobbyId, seed?: number) runtime | plot | lobbyNotEmpty | Drop the lobby's block edits and generate its terrain again (a new seed if given) |
lobbies.suspend(id: LobbyId) runtime | plot | ||
lobbies.resume(id: LobbyId) runtime | plot | ||
lobbies.delete(id: LobbyId) runtime | plot | lobbyNotEmpty | |
lobbies.movePlayers(players: PlayerId[], to: LobbyId) runtime | plot | playerProtected | |
lobbies.announce(text: string, opts?: ChatOpts) | plot | A chat line to every player in every lobby. | |
lobbies.runWithin(id: LobbyId, fn: () => any): any runtime | plot | Run fn in that lobby's context (read and write its storage, entities, edits) | |
lobbies.exportData(id: LobbyId): LobbyData runtime | plot | ||
lobbies.setFeatured(id: LobbyId) runtime | plot | The lobby distant viewers, the map and the feed see. |
A Lobby is { id, players, size, locked, countdownEndsAt?, countdownProtectsEveryone?, suspended, region?, neverSuspend, featured }. It's full when players.length >= size.
world
Blocks and mesh terrain, inside the plot only (reads may reach one chunk outside). Edits in a lobby hook go to that lobby's layer; at plot level they go to the shared base.
What players break and place is done by the base game's rules (unless baseRules.blocks is off), on their lobby's layer; your hooks filter it. These calls work with those rules: placeBlock places a block as the base game does (both halves of a bed or door, in a facing), rotateBlock turns a directional block, setDrops replaces a block's base drops in this plot, and dropItem leaves an item lying in the lobby for the base pickup.
ts
const STONE = world.blockId("stone");
world.fillBox({ min: [0, 64, 0], max: [16, 65, 16] }, STONE);
const hit = world.raycast({ origin: [0, 70, 0], dir: [0, -1, 0] }, { maxDistance: 20 });
world.placeBlock([2, 65, 8], world.blockId("bed"), { facing: "east" }); // foot here, head at x 3| Call | Context | Refuses | What |
|---|---|---|---|
world.bounds(): Box | any | The box around the plot's chunks (full height). A plot is a monument's chunks (register #102), which needn't fill the box: use isInsidePlot. Its min corner moves when the plot grows west or down; manifest coordinates stay relative to the corner at load. | |
world.plotChunks(): number[] | any | The plot's chunks as flat [cx, cz, cx, cz, ...] (16×16 columns, full height) | |
world.isInsidePlot(pos: Vec3): boolean | any | ||
world.monument(): MonumentInfo | null | any | The ASDFGHJKL this plot belongs to (its two blocks are the base game's: build around them, never over them); null for a dev plot. | |
world.blockId(name: string): BlockId | any | A block's runtime id by name ("stone", "oak_planks", "air", or "<namespace>:<name>") | |
world.getBlock(pos: Vec3): BlockId | any | ||
world.setBlock(pos: Vec3, block: BlockId) | any | outsidePlot overBudget | In a lobby: its edit layer. At plot level: the shared base (published to every lobby) |
world.fillBox(box: Box, block: BlockId): number | any | outsidePlot overBudget | |
world.raycast(ray: Ray, opts?: RaycastOpts): Hit | null | any | ||
world.groundHeight(x: number, z: number): number | null | any | The y a player would stand at in this column (blocks and mesh terrain, in this context's layer) | |
world.replaceTerrainChunk(chunk: TerrainChunk) | any | outsidePlot overBudget | Mesh terrain: replace a chunk's mesh (budgeted; the latest wins) |
world.placeBlock(pos: Vec3, block: BlockId, opts?: BlockStateOpts): number | any | outsidePlot overBudget | A block as the base game places it, in the state opts asks for (its directional variant: bed_east for bed facing east): a bed's foot here and its head one step the way it faces, a door's lower half here and its upper half on top; any other block as setBlock. Returns the cells written. |
world.rotateBlock(block: BlockId, turns: number): BlockId | any | The block turned quarter turns clockwise seen from above (its directional variant: beds, doors, stairs, gates, ladders, furnaces, chests, wall torches); itself if it has none. | |
world.setDrops(block: BlockId, drops: ItemStack[] | null) | plot | What a block drops when broken in this plot, instead of the base game's drop rules ([] for nothing: beds in a bed game); null goes back to the base rules. | |
world.dropItem(pos: Vec3, item: string, count?: number): EntityId | lobby | outsidePlot | An item lying in the lobby (the lobby and its onlookers see it), picked up the base way by whoever walks over it (a keeper never picks up a new base item); a generator's ingots. |
world.setArrival(pos: Vec3, yaw?: number) runtime | plot | outsidePlot | Where a portal's arrival puts you in this plot (its lobby area, the start), facing yaw (radians, atan2(dx, dz); default 0). The room then does as when you walk in: your lobby is chosen, your items are set aside, your 10 s entry protection starts. Without it, a portal puts you just inside the plot by its monument. Runtime only. |
world.setChestItems(pos: Vec3, items: ItemStack[] | null) | lobby | outsidePlot | A chest's contents in this lobby (a lobby's chests are its own, never the world's: each lobby has its own box at pos, saved with its edits and gone with its terrain, lobbies.regenerateTerrain). Up to 27 base-item stacks ("inventery:<name>", 1 to 64 each; never Nimgots), in slot order; null empties it. It replaces what's there: what plot code puts in is the plot's (loot), so a player just trying the plot takes it as theirs until they leave, a keeper never (R7: keepers take out only real items keepers put in); in a stashing plot it's part of the kit. Set it whenever the chest should be refilled (a lobby's load, a new round): a box can be set before its chest block is there. |
world.getChestItems(pos: Vec3): ItemStack[] | lobby | outsidePlot | What's in the chest at pos in this lobby now, in slot order (players open chests and move stacks in and out with the base chest screen, manifest baseRules.chests) |
world.takeFromChest(player: PlayerId, pos: Vec3, slot: number): number | lobby | outsidePlot wrongContext | A base transaction: the whole stack in the chest's slot (0 to 63) into the player's inventory, as the base chest screen's tap moves it (what doesn't fit stays; "Inventory full"), under the item rules (stashed kit, keepers' ledger, never Nimgots). Real stacks, wear and enchantments kept; nothing is made or lost, and it's undone with the hook if the hook fails. The chest is this lobby's (in a sub-area, its parent lobby's). Returns how many moved. |
world.putInChest(player: PlayerId, pos: Vec3, slot: number): number | lobby | outsidePlot wrongContext | A base transaction: the stack in the player's hand into the chest's slot (0 to 63; onto the same item up to a full stack, into an empty slot whole), as the base chest screen moves it, under the item rules. Atomic with the hook, like takeFromChest. Returns how many moved. |
world.getChestSlots(pos: Vec3): (ItemStack | null)[] | lobby | outsidePlot | The chest's box slot by slot (null for an empty slot; 27 long, or up to 64 once takeFromChest or putInChest used a slot past 26; [] for a chest never opened). The chest is this lobby's (in a sub-area, its parent lobby's) |
players
The players in the current lobby. Calls that move, change the movement of, hurt or change the items of a player are refused with playerProtected during their entry protection (and during a start countdown with protectEveryone).
| Call | Context | Refuses | What |
|---|---|---|---|
players.list(): PlayerId[] | lobby | ||
players.positions(): number[] | lobby | X, y, z for each player of players.list(), in order, flat: the cheap read for per-tick checks. | |
players.get(id: PlayerId): Player | null | any | ||
players.teleport(id: PlayerId, pos: Vec3) runtime | lobby | playerProtected outsidePlot | |
players.setMovement(id: PlayerId, def: MovementDef) runtime | lobby | playerProtected | How the player moves: walk, swim, fly, a vehicle... |
players.setCamera(id: PlayerId, def: CameraDef | null) runtime | lobby | playerProtected | |
players.playCameraPath(id: PlayerId, path: CameraPath) runtime | lobby | playerProtected | Play a cinematic camera path for this player. |
players.damage(id: PlayerId, amount: number, opts?: DamageOpts) | lobby | playerProtected | |
players.heal(id: PlayerId, amount: number) | lobby | ||
players.setHealth(id: PlayerId, health: number, max?: number) | lobby | playerProtected itemsStashed | |
players.respawn(id: PlayerId, pos: Vec3) runtime | lobby | playerProtected outsidePlot | The plot's own respawn (inside a plot, death is the plot's; R7) |
players.give(id: PlayerId, item: string, count?: number) | lobby | playerProtected itemsStashed | Into the plot's own inventory when the plot stashes items (R6); the real inventory otherwise (taken back on leaving unless they keep their items, R7) |
players.take(id: PlayerId, item: string, count?: number): number | lobby | playerProtected itemsStashed | |
players.inventory(id: PlayerId): ItemStack[] | lobby | What the plot can see: its own kit when it stashes items (R6); the real items otherwise (R7) | |
players.clearInventory(id: PlayerId) | lobby | playerProtected | |
players.askToKeepItems(id: PlayerId) runtime | lobby | itemsStashed | Show the base "keep your items?" prompt (plots that don't stash); the answer arrives in onKeepItemsAnswer. |
players.setAvatar(id: PlayerId, model: AssetRef | null) runtime | lobby | ||
players.setRealNametagsVisible(visible: boolean) runtime | lobby | Show or hide real players' trusted nametags (anonymity); a plot can never add a real-style tag. |
ui
Native UI, drawn by the app in each platform's style, per player, never seen by onlookers. show* calls go away on their own; set* calls stay until removed. Targets is a player id, a list, or "lobby". Buttons are the runtime's (ui.setButton; a mod hands its buttons to the runtime, as Bedwars does on Team Minigame). Nothing here can cover the settings button (its menu holds Leave), chat or the other reserved controls.
ts
ui.showTitle("lobby", "Round 2", { subtitle: "Fight!", color: "gold", stay: 2 });
ui.setBar(p, { id: "hp", kind: "resource", label: "Shield", fill: 0.6, color: "aqua" });
ui.drawOverlay(p, [{ op: "rect", x: 20, y: 80, w: 120, h: 40, color: "#000000aa" }, { op: "text", x: 28, y: 92, text: "Ammo 12" }]);| Call | Context | Refuses | What |
|---|---|---|---|
ui.setButton(to: Targets, def: ButtonDef) runtime | lobby | Add or update an on-screen button; presses arrive in onButtonPress. | |
ui.removeButton(to: Targets, id: string) runtime | lobby | ||
ui.chat(to: Targets, text: string, opts?: ChatOpts) | lobby | ||
ui.showTitle(to: Targets, title: string, opts?: TitleOpts) | lobby | ||
ui.setScoreboard(to: Targets, title: string, rows: ScoreboardRow[]) | lobby | ||
ui.removeScoreboard(to: Targets) | lobby | ||
ui.setClock(to: Targets, def: ClockDef) | lobby | A countdown or stopwatch on screen (time.startTimer is the plot's own timer) | |
ui.removeClock(to: Targets) | lobby | ||
ui.setBar(to: Targets, def: BarDef) | lobby | ||
ui.removeBar(to: Targets, id: string) | lobby | ||
ui.showToast(to: Targets, def: ToastDef) | lobby | ||
ui.drawOverlay(to: Targets, ops: DrawOp[]) | lobby | A raw 2D draw list (rects, images, text, hit regions) in screen points. | |
ui.showDialogue(to: Targets, speaker: EntityId | null, text: string, opts?: DialogueOpts) | lobby | An NPC line in the base game's NPC dialogue, the tutorial's Map Keeper's: subtitles over play, a word at a time, or with choices its conversation box. From a speaker entity (it speaks in the scene for everyone who sees it: its Speech component) or the plot (null) | |
ui.askForText(to: PlayerId, prompt: string) | lobby | A text box, labelled "Typing to {Plot}" by the base; the answer arrives in onTextSubmit. | |
ui.setPanel(to: Targets, id: string, node: PanelNode) | lobby | Add or replace a Minecraft-style panel: dimmed rects with text in the chat's font, laid out as a tree of boxes (PanelNode). The scoreboard, clock, bars, titles and toasts are drawn as panels too. | |
ui.removePanel(to: Targets, id: string) | lobby |
content
The registries. Ids get your package's namespace. Most people use these through @inventery/minecraft.
| Call | Context | Refuses | What |
|---|---|---|---|
content.addBlock(def: BlockDef): BlockId | plot | ||
content.addItem(def: ItemDef): string | plot | ||
content.addRecipe(def: RecipeDef) | plot | ||
content.addEntityType(def: EntityTypeDef): string | plot | ||
content.spawnEntity(type: string, pos: Vec3, opts?: CreateOpts): EntityId | lobby | outsidePlot | An entity of an added type, with its model, collider and health. |
content.addStructurePattern(pattern: StructurePattern) | plot | ||
content.addWorldgenFeature(feature: WorldgenFeature) | plot | A world-gen feature, applied when lobbies are generated. | |
content.damageEntity(entity: EntityId, amount: number, opts?: DamageOpts) | lobby | Hurt a spawned entity; at 0 health it dies (onEntityDeath, its drops) | |
content.getEntity(entity: EntityId): EntityInfo | null | any | A spawned entity's type and health (for a boss bar) |
mods
Extension points a runtime offers its mods.
| Call | Context | Refuses | What |
|---|---|---|---|
mods.sendEvent(event: string, data?: any) | any | Calls onModEvent(event, data) in every other package that exports it. | |
mods.list(): string[] | any | The installed packages' ids, in load order. |
audio
Sounds are scene-level: heard by whoever their to visibility allows (default everyone in range, onlookers quieter). Music is heard inside and close by only.
| Call | Context | Refuses | What |
|---|---|---|---|
audio.play(sound: AssetRef, opts?: PlayOpts): number | lobby | ||
audio.stop(handle: number) | lobby | ||
audio.playMusic(track: AssetRef | null) | lobby | Heard inside and close by only (decided); null stops it. |
time
Determinism: no wall clock in game code. Math.random and Date.now in JS packages are wired to these.
| Call | Context | Refuses | What |
|---|---|---|---|
time.now(): number | any | Server time in seconds (the tick clock) | |
time.tickNumber(): number | any | ||
time.random(): number | any | Seeded per plot, lobby and tick; recorded for replay. | |
time.startTimer(seconds: number, name: string, data?: any): number | any | overBudget | A timer kept by the host (it survives restarts); calls onTimer(name, data) in the same context. |
time.cancelTimer(timer: number) | any | ||
time.setTickRate(hz: number) runtime | plot | overBudget | 20 by default, up to 60 within the CPU quota (decided) |
log
Debug lines, shown in the dev tools and the runner (run.mjs prints them).
| Call | Context | Refuses | What |
|---|---|---|---|
log.info(text: string) | any | ||
log.warn(text: string) | any |
subareas
A sub-area is a lobby with its own region: a chest's room, a shop's interior. It's the one way a mod moves a player between lobbies. It's scoped to your package's own sub-areas, entered at the player's own action (call it from their onUse), and always has a way back: subareas.leave, Leave (which leaves the sub-area first), death, a disconnect, a room restart, or leaving its region all bring them back to where they stood. Only your package's hooks run in it. Its blocks are its own layer, and its chests are its parent lobby's (world.getChestSlots, world.takeFromChest, world.putInChest). Example: plot/samples/chest-rooms.
| Call | Context | Refuses | What |
|---|---|---|---|
subareas.enter(player: PlayerId, opts: SubAreaOpts): LobbyId | lobby | wrongContext outsidePlot | Move a player of this lobby into this package's sub-area for opts.key (made on first use: its onLobbyLoad runs, to build it), standing at opts.at; where they stood is where they go back to. Call it from their own action (onUse, onMessage); from a sub-area, it's left first. |
subareas.leave(player: PlayerId) | lobby | wrongContext | A player of this sub-area back to the lobby and the spot they came from (their look and camera as before) |
sky: the sky inside your plot
What your players see overhead, and the light they see by, while they're inside the plot (its lobbies and draft), over the manifest's sky; onlookers outside always keep the world's. By default it's the world's lighting; light changes the light model's settings (a fixed time of day, the sun's colour and strength, the ambient floor), and every surface in the plot (blocks, models, mesh terrain) lights through what you set, so nothing looks pasted in (register #110, #116). It crossfades over fadeSeconds on crossing in and out, and on every change. Example: plot/samples/tropical-island.
| Call | Context | Refuses | What |
|---|---|---|---|
sky.setForPlayer(id: PlayerId, def: SkyDef | null) runtime | lobby | This player's sky and light while inside, over the manifest's sky (crossfaded over def.fadeSeconds); null goes back to the plot's own (the manifest's sky, else the world's). It ends when they leave. Runtime only. |
portal: your plot's portal
How your plot looks on Explore's map, in the feed and at the spawn: its plot portal's title (also its name), subtitle and the feed card's line, in the game's title fonts and palette (register #124). The manifest's portal is the plot's own; portal.set changes it while the plot runs (set it again in init). A published creator plot with one gets its destination portal built at its arrival spot (world.setArrival) once it has run; a portal you lit inside the plot already is restyled instead. A travel portal to it at the spawn is an admin's choice (/plotfeature). /plotportal in game shows the published look, the draft's (what publishing would make it) and the portal standing now.
| Call | Context | Refuses | What |
|---|---|---|---|
portal.set(def: PortalDef | null) runtime | any | The plot portal's title, subtitle and feed line from now on, for the whole plot (from any lobby's hook too), over the manifest's portal (null goes back to it). The portal is the plot's destination portal, built at world.setArrival's spot once the plot has run (a published creator plot whose runtime has a portal; samples get theirs from /plotsamples); a portal the owner lit inside the plot already is restyled instead. It holds while the plot runs; set it again in init. Runtime only. |