Skip to content

SDK reference for agents ​

A plot is a claimed area of the Inventery world with its own game. Its code runs sandboxed on the room server; other players see it from outside only through the replicated scene. This page is the whole contract. Sections marked "generated" come from plot/schema/host-api.mjs (node plot/sdk/gen.mjs); the schema wins if they ever disagree.

Packages: Runtime, Mods and data packs ​

  • Runtime (one per plot; none = the default Inventery runtime: no lobbies, no stash, every base rule on): lobby size, whether items are stashed, what happens on entry, game rules, camera and movement, the base world-gen recipe, the on-screen buttons, which base-rule categories stay on, and extension points for mods. It never replaces the base game's mechanics (they run inside, below) or the guarantees (Leave, entry protection, your real items back).
  • Mod (any number): namespaced registries (blocks, items, recipes, entity types; a glTF boss is an entity type with a .glb model), structure patterns, world-gen features, reactions to world events.
  • Each package has its own memory, namespace, storage and budget slice. Ids are namespaced by the manifest's namespace (bedwars:bed; base content is inventery:stone); names you add are local ("bed"). Mods meet through registries, world events and mods.sendEvent, never through each other's code. Runtime-only calls and hooks are marked "runtime" below; a Mod calling one is refused with rule runtimeOnly, and a Mod's runtime-only hook is never called.

A data pack is a mod with no code: plot.json with "registries": "registries" and no code; the folder's JSON arrays (the registries row of the plot.json table) are added before any code runs. Names are local; ids you reference (drops, recipe keys, places) are namespaced. build.mjs and lint.mjs check every entry and name the file, index and field (registries/blocks.json[2].hardness: must be a number). Example: plot/test/fixtures/sdk/lanterns/. A package with code may have registries too.

Files ​

myplot/
  plot.json      the manifest (table below)
  logic.ts       the code (TypeScript or JS); its named exports are the hooks (none for a data pack)
  registries/    data-pack JSON (optional)
  assets/        models (.glb), textures, sounds, levels: referenced by bundle path ("models/bed.glb")
  dist/          built by sdk/build.mjs: logic.js, manifest.json (content hashes), <namespace>-<version>.inv

Imports: @inventery/host (the host API), and the optional libraries @inventery/minecraft (registries, events, structures, and the minigame pieces: maps as data, generators, a shop, the void, undoing a round's edits) and @inventery/lobby (defineLobbyGame: one state object per lobby; game.state() reaches it from event handlers, game.wrap(hook) saves it after one). For types, add <sdk>/host.d.ts to your tsconfig files and map @inventery/<lib> to <sdk>/lib/<lib>.ts in paths; it passes tsc --strict.

Lobbies ​

A lobby is a siloed room of up to lobbies.size players (plot.json) over your plot. It isn't a waiting room: it has no game state of its own. The platform puts each arriving player in the fullest lobby that isn't full or locked, makes a new lobby when there's none, and suspends empty ones. A lobby is full when players.length >= size; lobbies.lock() closes one that still has room (a round in progress, a fixed party) and lobbies.unlock() opens it again (the next round). chooseLobby(player, lobbies) replaces the platform's choice if you need to. A plot with "lobbies": null has one shared lobby, "main".

The start countdown is optional: lobbies.startCountdown(opts?) runs until the newest member's entry protection ends (or opts.minSeconds pass, if later), the base draws the countdown, then onCountdownEnd runs. By default it protects only those still in their own entry protection; { protectEveryone: true } protects every member until it ends (an equal start). A player leaving during it calls onCountdownPlayerLeft (call startCountdown() again, cancelCountdown(), or let it run). Use it for a round that should start fair; a puzzle room for four that anyone can walk into needs none of it.

Sub-areas (subareas.enter / subareas.leave, any package, mods too) are the one way a mod moves a player between lobbies. A sub-area is a lobby with its own region: a chest's room, a shop's interior, a dungeon under a trapdoor. subareas.enter(player, {key, region, at}) from the player's own action (their onUse) puts them in your package's sub-area for that key (made on first use: its onLobbyLoad runs, so build it there), standing at at; everyone you send in with the same key shares it. Only your package's hooks run in it (the runtime's don't), its blocks are its own layer, and its chests are its parent lobby's (world.getChestSlots, world.takeFromChest, world.putInChest move their real stacks there, as base transactions). The way back is guaranteed: subareas.leave(player), and the platform brings them back to where they stood on Leave (the settings menu's "Leave {name}" leaves the sub-area first), death, a disconnect, a room restart, or when they leave its region. The platform never puts an arriving player in one. Example: plot/samples/chest-rooms.

The base game runs inside your plot ​

By default the base game runs inside your plot and your hooks filter it; turn categories off to own them yourself.

  • On (the default): breaking and placing are the base game's (tools and break times, drops, beds and doors as two halves facing the way they were placed, a block placed only from what the player holds), PvP is the base game's (weapons, crits, knockback, armour, the hurt cooldown), items lie on the ground and are picked up by walking over them, hunger runs. All of it on the player's lobby layer and their in-plot inventory (the kit when you stash items). Your hooks are filters around it: onBlockBreak/onBlockPlace/onUse/onPlayerDamage come first (return false to refuse); onBlockBroken/onBlockPlaced come after with every cell the base game changed (both halves of a bed). world.setDrops(block, []) changes what a block drops in your plot; world.dropItem(pos, item, count) drops an item (a generator); world.placeBlock(pos, block, { facing }) places a bed or door as the base game does; world.rotateBlock(block, turns) turns a directional block (structures.build turns them with a rotated piece). Death is still yours (onPlayerDeath). Entities too: an entity type's ai ("hostile", "ranged", "neutral", "passive") runs the base game's mob behaviour for it, after only its lobby's players and never a protected one, its hits reaching onPlayerDamage(player, amount, null, entity); a player's swing at an entity is the base melee (weapon damage, crits, knockback, invulnerability), which onEntityDamage can refuse; at 0 health it dies (onEntityDeath, its drops). A boss with its own moves has no ai (move it yourself; the melee still hurts it). A type can be a base creature: content.addEntityType({ name: "knight", base: "zombie" }) is the base zombie (its model, skin and animations through the base mob renderer, its hit and death sounds, its AI, health, damage, speed, hit box and loot); set health, damage, speed, drops, collider or model to override (a model replaces only the look), ai: "none" to make it stand. An unknown base is badArguments listing the names. Every entity with health gets the base game's feedback on the page: the hit sound (by its collider's height) and red flash, and at death the groan, the fall onto its side and the puff; a kill gives the base XP and a swing's enchantments apply.
  • Off, by category (plot.json baseRules, runtime only): { "blocks": false } and only your hooks decide a break or placement (the one cell they allowed is written; nothing is taken from the kit: take it yourself with players.take); "pvp": false no base hits between players inside (do your own with input and players.damage); "drops": false nothing drops; "pickup": false lying items stay until you remove them; "hunger": false no food, effects or breath inside; "mobs": false no base AI or melee on your entities (move them and hurt them with content.damageEntity; a page's swing reaches you as the entity:hit input action). A game that is its own server turns them all off.

The rules that matter to you ​

  1. Visible things go in the scene. Onlookers never run plot code: they draw only the replicated scene. Anything others should see or hear is scene.create/scene.setComponent (Model, Light, Emitter, AudioSource, Billboard...) or audio.play. Client code may only shape the local player's own view and feel. The lint checks this.
  2. Channels are for game logic. network.send never reaches onlookers; visual state sent on it is invisible outside.
  3. Every call has a context. Lobby hooks (onPlayerEnter, tickLobby, ...) act on their lobby. Plot-level code (init, chooseLobby, tickPlot, onUpgrade, top-level code) has no lobby: use lobbies.runWithin(id, fn) (in tickAllLobbies, lobbies.switchTo(id)). Wrong context = refused with wrongContext. A callback (an event handler, a timer's) runs in the context of whoever calls it. Lobbies are siloed: players, entities, edits, storage, timers, UI. What your hooks create in a plot without lobbies lives in "main". The shared base (opts.shared, plot-level world.setBlock) is written at plot level (init); from a lobby hook it's read-only.
  4. Visibility of nodes, sounds and messages: "public" (default; onlookers too), "lobby" (the players in this lobby), { players: [...] }, "serverOnly" (no one). Use them for hidden roles (a knife only the impostor sees: { parent: players.get(imp)!.entity, visibility: { players: [imp] } }), scares, fog of war.
  5. Entry protection (R4). For 10 s after a player enters (and during a start countdown with protectEveryone), calls that move, harm, change the movement of or change the items of them are refused with playerProtected: teleport, setMovement, setCamera, playCameraPath, damage, setHealth, respawn, give, take, clearInventory. You may draw, play sound, show UI and run logic. Act in onPlayerProtectionEnd or onCountdownEnd; player.protectionSecondsLeft says how long is left.
  6. Stashed items and kept items. stashItems: true (R6): the player's real items and body are set aside while they're inside and given back when they leave; you only see the kit you give. stashItems: false (R7): players bring their real items in to try things out (what they win or lose is undone when they leave) unless they choose to keep their items (players.askToKeepItems, answered in onKeepItemsAnswer); you can't force it. Item ids are namespaced: base items are inventery:<name> (inventery:iron_ingot, inventery:white_wool).
  7. The trusted layer is not yours. Leave isn't a button on screen: it's "Leave {your plot}", the first item of the base game's settings menu. The settings button, chat, the base's own controls, Back and system gestures always go to the base; the protection ring, the countdown, the "keep your items?" prompt, "Typing to {name}" and real nametags are drawn over everything. You can hide real nametags (players.setRealNametagsVisible(false)) but never draw one.
  8. Determinism. Sim code is replayed exactly. In a JS package Math.random() is time.random() (seeded per plot, lobby and tick) and Date.now()/new Date() read time.now() (the tick clock): both are safe. Not deterministic: performance.now, setTimeout (use time.startTimer), crypto randomness, WeakRef. Client present code (onFrame) may use the wall clock; predictStep also runs on the device, so derive its randomness from the input.
  9. Memory is never saved. Restarts make a new isolate and call init, then each lobby's onLobbyLoad: rebuild from storage. The host keeps the scene, edits, storage and timers (time.startTimer survives restarts; the scene is saved with the plot, so a room restart keeps it too, with the same entity ids), so init and onLobbyLoad run again on a built world: build the shared base idempotently (check a storage.getPlot flag; fills count against the edit budget), and before rebuilding an entity check whether it's still there by the id you kept (content.getEntity(id) for a spawned one, scene.getComponent(id, "Transform") for others) so a restored entity and a rebuilt one never both exist. An update calls onUpgrade(oldVersion): migrate storage there (a trap rolls back).
  10. A failed hook rolls back the host, not your memory. Its storage, scene and edit writes are discarded; your variables keep what it did: clear caches on a throw, or keep storage as the truth. A rolled-back lifecycle hook is retried once next tick; still write one-shot gifts idempotently (a storage flag, re-checked in tickLobby).
  11. Budgets. CPU 2 ms a tick per plot (hard 8), 20 ms a rolling second; memory 32 MB; storage 1 MB plus blocks; 20 Hz ticks (up to 60 with tickRate); channel bandwidth and asset sizes. Over a limit: refused with overBudget or storageFull; over the tick's CPU the per-tick hooks are skipped and lifecycle hooks deferred (never dropped); then the plot is slowed, then suspended. See "CPU in practice".

Examples (the Minecraft layer first) ​

A Bedwars bed (a Mod or a Runtime): the base game's bed (two halves, broken whole), and a rule on breaking it.

ts
import { ui, storage, world } from "@inventery/host";
import { events } from "@inventery/minecraft";
export { onBlockBreak, onBlockBroken } from "@inventery/minecraft";   // re-export the hooks whose events you use

// placed once at plot level: the foot here, the head one step east (both written, as the base game places a bed)
world.placeBlock([10, 41, 4], world.blockId("bed"), { facing: "east" });

const teamAt = (pos: number[]) => (storage.get<Record<string, string> | null>("beds") ?? {})[pos.join(",")]; // "x,y,z" -> team
events.blockBreak.on((ev) => {                                                  // before: return false to refuse
  const teams = storage.get<Record<string, string> | null>("teams") ?? {};      // player -> team
  return !(ev.by !== null && teamAt(ev.pos) === teams[ev.by]);                 // your own bed: refused
});
events.blockBroken.on((ev) => {                                                 // after: the base took both halves
  const team = teamAt(ev.pos);
  if (team) ui.showTitle("lobby", `${team} bed destroyed!`, { color: team });
});

Minigame pieces: a map as data, generators on its marks, a shop on buttons and the void (swords, pickups and block rules are the base game's).

A game on a reusable runtime (Runtime + Mod): plot/samples/team-minigame/ is a runtime for team games (lobbies, teams, countdown, spawns, respawn or out, win, reset; its README and api.ts are the extension points), and a game is a mod on it: plot/samples/bedwars/ is Bedwars in about 60 lines (beds, generators, the shop: logic.ts + map.json), plot/samples/spleef/ Spleef in about 25. A mod can't place buttons (ui.setButton is the runtime's), so the game hands shop().buttons() to the runtime, which passes presses back. The example below is the same pieces in a runtime of your own.

ts
import { world } from "@inventery/host";
import type { PlayerId } from "@inventery/host";
import { structures, generators, shop, combat, edits } from "@inventery/minecraft";
export { onBlockBroken, onBlockPlaced } from "@inventery/minecraft";   // so edits.reset() knows what players changed

// Layers bottom-up, rows north to south; lower-case keys are marks (named positions the build returns).
const MAP = {
  keys: { W: "white_wool", i: { mark: "iron", block: "oak_planks" }, s: { mark: "spawn" } },
  pieces: { island: [["WWW", "WiW", "WWW"], ["   ", " s ", "   "]] },
  place: [{ piece: "island", at: [4, 40, 4], tag: "red", keys: { W: "red_wool" } },
          { piece: "island", at: [20, 40, 4], rotate: 2, tag: "blue", keys: { W: "blue_wool" } }],
};
// At the top level: plot level with the load's CPU allowance (init has the 8 ms of any call). once: written once
const b = world.bounds();
const M = structures.build(MAP, [b.min[0], 0, b.min[2]], { once: "map" });   // M.red.spawn[0], M.blue.iron[0]...
generators.add({ at: structures.find(M, "iron"), item: "inventery:iron_ingot", every: 1, cap: 32 });
const SHOP = shop([{ button: "wool", item: "inventery:white_wool", count: 16, price: ["inventery:iron_ingot", 4] }]);

export function onCountdownEnd() { generators.start(); }                 // per lobby; generators.stop() clears the drops
export function onPlayerProtectionEnd(p: PlayerId) { SHOP.show(p); }
export function onButtonPress(p: PlayerId, button: string) { SHOP.buy(p, button); }
export function tickLobby() {
  generators.tick();                                                     // ingots dropped on time, picked up by walking
  combat.voidBelow(28);
}
export function onTimer(name: string) { if (name === "roundOver") { edits.reset(); generators.stop(); } }
  • structures.build(bp, origin, { once? }): Marks stamps a Blueprint (keys, pieces, place with at, rotate quarter turns clockwise, per-copy keys, tag) and returns marks by tag then name; structures.find(marks, name) lists one mark across every copy. With once, the blocks are written only when the blueprint changed (the flag mc:built:<once> in plot storage keeps it and the marks). A key can be a mix, { mix: { stone_bricks: 6, mossy_stone_bricks: 2 } }, picked per position (dappled walls). A big map (thousands of blocks) passes perTick (and once): it's written a little each tick by structures.tick() from tickPlot, inside the edit budget and the tick's CPU, resumed after a restart; structures.built(once) says when it's done (an idle plot is suspended: a locked neverSuspend lobby keeps it awake meanwhile). version (a hash your generator wrote) saves hashing a big blueprint at every load; clear: Box[] leaves boxes alone (the monument). Import a big map as text (import t from "./map.json" with { type: "text" }, then JSON.parse): QuickJS compiles a 120 KB object literal in ~360 ms, past the load's allowance, and parses the same JSON in ~30.
  • generators.add({ at: Vec3 | Vec3[], item, every, cap? }) (plot level); start(), stop() and tick() in a lobby. Each drop is world.dropItem (onlookers see it), picked up the base way by whoever walks over it.
  • shop(offers) with { button, item (an id or a function of the buyer), count?, price: [item, amount], label?, icon? } returns { buttons(), show(to), hide(to), buy(player, button) }; each button shows the item it sells as its icon (icon overrides it; an item that depends on the buyer shows white wool); buy takes the price and gives the item, or toasts what's missing.
  • PvP is the base game's; refuse a hit in onPlayerDamage(player, amount, by) (teammates). combat.voidBelow(y, who?) kills players below y.
  • blocks.oneOf(ev, names) in a blockPlace handler: is the placed block one of these (the base takes it from the kit).
  • edits.set(pos, block), edits.original(pos) (the block before this round: air means a player placed it) and edits.reset() (the lobby's layer back to the shared base); edits.setMany(cells, block, was?) and edits.fill(box, block, was) change many cells with one storage write (a door opened, a beam lit). Re-export onBlockBroken/onBlockPlaced from the library (or wrap them) so player edits are remembered (every cell).

A round with a start countdown (a Runtime): code for one lobby; state kept per lobby in storage. When the lobby is full it's locked and the countdown starts; when the round is over it's unlocked for the next one.

ts
import { lobbies, players, ui, time } from "@inventery/host";
import { defineLobbyGame } from "@inventery/lobby";

const game = defineLobbyGame({
  setup: () => ({ players: [] as string[] }),
  onPlayerEnter(s, p) {
    s.players.push(p);
    if (s.players.length === lobbies.current().size) {
      lobbies.lock();                                                   // no newcomers mid-round
      lobbies.startCountdown({ protectEveryone: true });                // everyone protected; base countdown
    }
  },
  onCountdownPlayerLeft(s, p) { s.players = s.players.filter((x) => x !== p); },      // let the countdown run on
  onCountdownEnd(s) {                                                   // the countdown is over: no more R4
    for (const p of s.players) { players.teleport(p, [8, 120, 8]); players.give(p, "inventery:white_wool", 32); }
    ui.setClock("lobby", { mode: "countdown", seconds: 60, label: "Round" });
    time.startTimer(60, "roundOver");
  },
  onTimer(s, name) { if (name === "roundOver") { ui.showTitle("lobby", "Round over"); lobbies.unlock(); } },
  onPlayerLeave(s, p) { s.players = s.players.filter((x) => x !== p); },
});
export const { onLobbyLoad, onPlayerEnter, onCountdownPlayerLeft, onCountdownEnd, onTimer, onPlayerLeave } = game;

Lag-compensated hits, a cutscene and an NPC (a Runtime): the shooter's own body is ignored; network.pastPositions(t) is data only (where everyone was), network.raycastAtTime is the hit test.

ts
import { input, network, players, ui } from "@inventery/host";

export function tickLobby(dt: number) {
  for (const ev of input.read()) if (ev.action === "fire" && ev.aimRay && ev.renderTime !== undefined) {
    const hit = network.raycastAtTime(ev.renderTime, ev.aimRay, { players: true, maxDistance: 100, ignore: [ev.player] });
    if (hit?.player) players.damage(hit.player, 25, { by: ev.player, cause: "rifle" });
  }
}
export function onCountdownEnd() {
  for (const p of players.list()) {
    players.playCameraPath(p, { keys: [{ t: 0, pos: [0, 90, 0], target: [8, 70, 8] }, { t: 4, pos: [8, 74, 0], target: [8, 70, 8] }] });
    ui.showDialogue(p, null, "Ready to fight?", { name: "Warden", choices: ["Yes", "Not yet"] });
  }
}
export function onDialogueChoice(player: string, choice: number) { if (choice === 0) ui.showToast(player, { text: "Go!" }); }

Raw API and other languages. The libraries are a few host calls each; ports can call the host directly. Raw wasm guests (C/C++, Rust, C#) use the raw ABI v1 through inventery.h, inventery.rs or Inventery.cs: typed wrappers for every call, op ids, component ids, and a ready dispatcher (INV_PLOT(.tick_lobby = tick_lobby), inventery_plot!(Game; tick_lobby), Plot.Hooks = new Hooks { TickLobby = ... }). Set "code": "logic.wasm". Don't export inv_fuel or inv_oom (reserved).

glTF levels (mesh terrain). Today a level is imported at build time (plot/samples/gltf-level/import-level.mjs with plot/gltf/: static nodes become 16x16 chunks, dynamic nodes entity records), then published at plot level: assets.create("mesh", { positions, indices, colors }) and world.replaceTerrainChunk({ cx, cz, mesh }). Mesh data: positions x, y, z per vertex in world block coordinates (add world.bounds().min), indices 3 per triangle, colors one 0xRRGGBB per triangle (or color: "#rrggbb" for all); the ref is the mesh's content hash (the same data gives the same ref), and a mesh ref is also a Model.asset. cx, cz are world x, z / 16, the chunk wholly inside the plot. replaceTerrainChunk is budgeted, 8 chunks a second, publishing included: on overBudget keep your place in storage and retry with time.startTimer(1, ..). Planned: plot.json content.terrain (a .glb published as terrain with no code).

Things that trip authors up ​

  • Coordinates. plot.json (entry, lobbies.area) is relative to the plot's min corner; host calls take world coordinates (world.bounds().min is the corner). Ground height: world.groundHeight(x, z).
  • Per-player data. storage.getPlayer/setPlayer are per lobby; storage.getPlayerProfile/setPlayerProfile follow the player across lobbies.
  • Rounds and groups. A lobby has no rounds of its own: lobbies.lock() keeps newcomers out while a round runs and lobbies.unlock() lets them in for the next. There's no party: to keep a group together, lock the lobby a few seconds after the first entry.
  • Taps on your overlay. A ui.drawOverlay op { op: "hit", id, x, y, w, h } is a tap region: a tap there reaches you as an input event with action: "hit:<id>" and pointer: [x, y] (where it landed, in screen points), in onInput and input.read(). The trusted layer's controls and the base's settings button are tested first, so a region over them never gets their taps, and none arrive while the "keep your items?" sheet is up.
  • What's native on screen (register #112). Only buttons (ui.setButton: round, or a capsule with a label, banner or fill, as the base's own controls; SwiftUI in the iOS app) and the trusted layer. Everything else is drawn by the page in the game's Minecraft style, the same in the app: ui.setPanel(to, id, node) is the one primitive, a tree of PanelNodes (box with children, dir "column" or "row", gap, pad, align, bg, edge; text, icon, bar, space; the root's anchor and x, y), drawn as dimmed rects with text in the chat's font and kept off the trusted controls, the base's own and your buttons. ui.setScoreboard, setClock, setBar, showTitle and showToast are panels built for you. A row with a { kind: "space" } spacer pushes what follows to the right edge: ui.setPanel(p, "kills", { kind: "box", anchor: "topLeft", minWidth: 70, children: [{ kind: "box", dir: "row", children: [{ kind: "text", text: "Kills" }, { kind: "space" }, { kind: "text", text: "3", color: "#ff5555" }] }] }).
  • Dialogue is the base's. ui.showDialogue(to, speaker, text, { name, color, voice, choices }) is said by the base game's NPC dialogue, the one the tutorial's Map Keeper talks through: a line is subtitles over play, a word at a time, the speaker's name over them in its colour (nothing to tap: the player keeps playing); a line with choices is asked in the dialogue's plank box with the choices as its buttons (the answer: onDialogueChoice). Pass the speaker's entity: it speaks in the scene for everyone who sees it (its Speech component; its head lifts and it gestures, or a model with no bones nods and sways). voice is a sound asset played as the line starts. A line takes about 0.3 + 0.04 x letters seconds a word to be said (a question: its letters / 28): wait that long before the next, or the next cuts it off.
  • Props block players. A static entity with a Collider (a statue, a car, a wall) stops players walking, as a box: size in blocks, standing on the entity's position, turned with its Transform. A "mesh" collider is the model's own shape instead (a collision mesh the engine makes from it once: solid boxes following its outline, through the Transform's scale and rotation; give it to a car or a statue so players walk round its real shape and a tap hits its body, not its bounds); leave size out and the host fills in the bounds box. Capsules and spheres are the box for now. A static body under 0.25 tall and 0.5 across (a cup) never blocks: it is only tapped and hit. kinematic and dynamic bodies never block walking either; they keep their box for taps, hits and raycasts. A glTF Model placed with no Collider gets a box from its mesh bounds: static when you create it in the shared base, kinematic in a lobby (a game object or NPC, tapped and hit but never in the way). A plot of blocks keeps its normal block walk, then steps against the props. A tap or click on a static entity counts as a use (onUse, once per tap); on another one it is a swing (entity:hit).
  • Labels over heads. A Billboard draws at its entity's origin: put it on a child (parent: player.entity, y 2.3).
  • Blocks and items. A block without model is drawn as a cube of flat BlockDef.color. An added block is also an item with the same id (bedwars:bed) unless an ItemDef of that name overrides it.
  • Lobby state in event handlers. @inventery/minecraft handlers aren't defineLobbyGame hooks: export const onBlockBreak = game.wrap(mcOnBlockBreak) and read game.state() in the handler.
  • Rolled-back hooks never leak into defineLobbyGame's state. When the host undoes a hook (refused, cut off for CPU, trapped, or a refusable event another package refused), its storage writes go, and the next hook reads the state again from storage (each save also writes a version under "<key>#v"; a hook that never finished drops the cache). Keep everything that must survive in the state, not in other module variables.
  • Your plot's sky, light and water. The manifest's sky (and sky.setForPlayer for one player) sets the sky and the light model's settings (light: a fixed time of day, the sun's colour and strength, the ambient floor) for your players while they're inside; onlookers keep the world's. A big water surface from a model goes on real water blocks: give its entity Material.blendWithBlockWater (no Collider) and it's drawn over the blocks, fading out toward the plot's edge, while the blocks stay the swimming and the sounds. Example: plot/samples/tropical-island.

Colliders ​

The room and the page collide with the same shapes (plot/gltf/prop-collision.mjs); what each means:

  • Collider: { shape: "box", size: [w, h, d], offset?: [x, y, z], body: "static" }: a box standing on the position (moved by offset, its bottom centre from the position), turned with the Transform. Blocks a walk unless it's small (under 0.25 tall and 0.5 across).
  • Collider: { shape: "mesh", body: "static" } on a glTF model: the model's own triangles as drawn, less its foliage, simplified past 1024 triangles. Use it for scenery: rocks, trunks, statues, cars.
  • body: "kinematic" / "dynamic": tapped, hit, raycast, never in the way.
  • A glTF Model with no Collider: the host fits a box to its solid part (real bounds and offset, scaled): static in the shared base, kinematic in a lobby or when nothing of it is solid.
  • Foliage (alpha-masked or translucent materials: leaves, fronds, grass) is walk-through. A palm blocks at its trunk; a model that's only leaves blocks nothing. For solid leaves give a box Collider.
  • No fallbacks: a "mesh" collider whose model isn't a mesh the room has blocks nothing and logs a warning. inv test flags colliders bigger than their model and leaves under solid boxes; inv test --walk finds invisible walls; /debug hitboxes shows what collides in game.

Your plot's portal ​

How your plot shows on Explore's map, in the feed and at the spawn: give the runtime's plot.json a portal (PortalDef: title, subtitle, line for the feed card, titleFont, subtitleFont, titleColors, subtitleColor), and call world.setArrival in init so the portal stands where players should come out. Once the plot is published and has run, its destination portal is built there with your title and line; portal.set(def) changes it while the plot runs. A travel portal at the spawn comes only from an admin's /plotfeature. In game, /plotportal shows the published look, your draft's and the portal standing.

Debug overlays: /debug ​

In your creator plot (its draft lobby too), you and your invited testers can type /debug fps for a panel of frame rate and frame times (p50, p95, max), draw calls and triangles (with your plot's share), the plot pass's LOD levels, animated meshes and anything over budget, chunks, position, ping and the room's tick; and /debug hitboxes for every collider drawn as a wireframe: players, mobs, items, your static colliders (green), kinematic and dynamic ones (orange) and mesh colliders (blue, their triangles). /debug lists them, /debug off turns them off. They're remembered on your device and hide when you leave the plot; admins have them anywhere.

CPU in practice ​

  • A host call costs about 1 to 7 µs; players.get and storage.set are the dear ones (JSON). A refused call costs much more: test first, don't catch refusals as a check.
  • Don't do work every tick: check triggers at 2 to 5 Hz (if (time.tickNumber() % 5) return;), cache what doesn't change in isolate memory, and use players.positions() (flat x, y, z) for per-tick position checks.
  • Many lobbies: export tickAllLobbies (one call for all of them; defineLobbyGame makes it from your tickLobby) and keep saveOnTick: false. The lint's costly-call-every-tick warning flags JSON calls made every tick.

Hooks (generated) ​

Export them by name from logic.ts. All optional; the host calls only those you export.

HookCtxPkgWhen
init()plotOnce when the package starts (and after a restart: rebuild from storage)
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.
onLobbyLoad()lobbyA lobby was created (or loaded again after being suspended): set it up.
onPlayerEnter(player: PlayerId)lobbyA player crossed in; their 10 s entry protection has started (R4)
onPlayerProtectionEnd(player: PlayerId)lobbyA 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")lobbyPlotShrank: the monument's plot shrank under them, so they're outside now (items, flight and creative restored)
tickLobby(dt: number)lobbyEvery tick, once per lobby that isn't suspended.
tickPlot(dt: number)plot
tickAllLobbies(dt: number, lobbies: LobbyId[])plotEvery 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
onMessage(player: PlayerId, channel: string, data: Uint8Array)lobby
onBlockBreak(ev: BlockEvent): booleanlobby{pos, block, by}; return false to refuse.
onBlockPlace(ev: BlockEvent): booleanlobby{pos, block, by}; return false to refuse.
onBlockBroken(ev: BlockChangeEvent)lobbyAfter the base game broke a block onBlockBreak allowed: every cell it changed (a bed's two halves)
onBlockPlaced(ev: BlockChangeEvent)lobbyAfter the base game placed a block onBlockPlace allowed: every cell it wrote (a bed's two halves)
onUse(ev: UseEvent): booleanlobbyA block or entity was used (tapped): {pos?, block?, entity?, by}; return false to refuse.
onButtonPress(player: PlayerId, button: string)lobbyruntimeAn on-screen button (ui.setButton) was pressed.
onTextSubmit(player: PlayerId, text: string)lobbyThe player answered ui.askForText.
onPlayerDamage(player: PlayerId, amount: number, by: PlayerId | null, entity: EntityId | null): booleanlobbyReturn 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)
onKeepItemsAnswer(player: PlayerId, keepsItems: boolean)lobbyruntimeThe player answered players.askToKeepItems.
onPropStartMoving(id: EntityId)lobbyA resting prop started moving again.
onPropStopMoving(id: EntityId)lobbyA moving prop came to rest.
onUpgrade(oldVersion: string)plotThis 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.)
onDialogueChoice(player: PlayerId, choice: number)lobbyThe player picked a choice of ui.showDialogue.
onStructureComplete(name: string, match: StructureMatch)lobbyA structure pattern this package added was built: {origin, blocks, by}.
onEntityDeath(ev: EntityDeathEvent)lobby{entity, type, by, pos}.
onPlotChunksChanged(added: number[], removed: number[])plotThe 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)
onModEvent(event: string, data: any)anyAn event from another package (mods.sendEvent)
onEntityDamage(ev: EntityDamageEvent): booleanlobbyA 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.

Client exports (generated) ​

Optional; typed as ClientModule in host.d.ts. The same module runs in a Worker on the device of each player in the lobby (onlookers never run it, so nothing others should see may depend on it). Client code affects only the local player: their HUD, effects, prediction and feel; the server stays in charge. sim exports (predictStep) run on both sides (the server runs the same code to check it) and must be deterministic; present exports (onFrame, onInput, onServerMessage) run on the client only.

Players can read your client code. The build makes a second bundle, dist/client.js, from your client exports and everything they import (tree-shaken; a client.ts beside logic.ts is its entry when you want the split explicit), and every participant's device downloads it. Anything in that import graph (constants, tables, answers, other players' roles) is visible to players: keep server logic and secrets in modules only your room hooks import. A raw wasm module can't be split, so all of it ships.

How the device runs it (plot/client/worker/host.mjs has the details): in a Worker, 1 ms a frame on Low, 2 ms on Medium; over budget the frame's prediction is skipped, and 5 overruns in 10 s stop your code on that device for good (the player sees the room's state only). Players can switch it off ("Run Plot Code"). In onFrame/onInput/ onServerMessage you get: your own player (players.get), your view of the scene (scene.query, getComponent), local-only nodes (scene.create and friends on your own nodes, drawn for this player alone), ui.drawOverlay, audio.play, input.read, network.send/receive on your channels, the wall clock. Everything else is notSupported. In predictStep only time and log answer.

predictStep(input, state) returns the next state. The room calls it for each of the player's inputs, starting from { pos: [x, y, z] } (where they were when they entered, or when their controller changed or the room moved them), and the device runs the same calls ahead of the room, rewinding to the room's state when they differ. With a custom controller (players.setMovement(p, { kind: "custom" })) the state's pos is where the player is. Keep it pure: same input and state, same result.

Rollback games: @inventery/rollback (defineRollback({ channel, setup, step, frames, delay })) simulates every player on every device. The room relays the lobby's inputs to its players only when plot.json opts in: "rollback": { "relay": ["buttons"] } (fields of InputEvent: default move, look, buttons; action, pointer and aimRay only when you list them, since every player then sees them).

Client exportRoleWhen
onFrame(dt: number)presentEvery frame: local HUD, effects, prediction display.
predictStep(input: InputEvent, state: any): anysimAdvance the predicted state one tick (a custom movement, rollback games); the server runs the same code to check it.
onInput(ev: InputEvent)present
onServerMessage(channel: string, data: Uint8Array)present

The host API (generated) ​

import { scene, ... } from "@inventery/host". Ctx: where a call works (lobby, plot, any); a call outside its context is refused with wrongContext. Pkg runtime: a Mod is refused with runtimeOnly.

  • scene: the replicated scene: what others see. Nothing replicates unless it's written here (protocol.md decision 1)
  • input: this tick's inputs from this lobby's players
  • network: custom channels for game logic (never visuals: onlookers never receive them) and lag compensation
  • assets:
  • storage: the plot's own saved data, JSON values. get/set/delete/keys are this lobby's; getPlayer/setPlayer one player's in this lobby; getPlot/setPlot the whole plot's; getPlayerProfile/setPlayerProfile one player's across the whole plot
  • lobbies: lobbies are siloed rooms of up to lobbies.size players with stable ids (R3, R3a). The platform puts arrivals in the fullest lobby that isn't full or locked, makes new ones as needed and suspends empty ones. Calls on the current lobby are lobby calls; the rest are plot-level
  • world: blocks and mesh terrain, inside the plot only (reads one chunk outside are allowed). Edits go to the current lobby's layer
  • players: the players in the current lobby. Nothing here reaches a player outside the plot
  • ui: the player's screen (register #112): buttons are native (the base's round control buttons, capsules with a label or banner: SwiftUI in the app); everything else is drawn by the page in the game's Minecraft style (panels: dimmed rects with text in the chat's font), and dialogue is the base NPC dialogue. Per player, never seen by onlookers. show* calls go away on their own; set* stay until removed
  • content: the Minecraft layer's registries (namespaced by the package: "bedwars:bed"). Mods meet through these and world events, not through each other's code
  • mods: extension points a runtime offers its mods
  • audio:
  • time: determinism: the tick clock and seeded randomness (no wall clock in sim code)
  • log:
  • subareas: a sub-area is a lobby with its own region (mod-model.md R3a: a chest's room, a shop's interior): the one way a mod moves a player between lobbies, scoped to its own sub-areas, at the player's own action, with a guaranteed way back. Its players walk its region with the base walk; its blocks are its own layer; its chests are its parent lobby's. Leave, death, a disconnect or the region lost bring them back to where they stood
  • sky: the sky and light a plot's players see while inside (register #116; the manifest's sky is the plot's own)
  • portal: the plot portal's title and feed line (register #124; the manifest's portal is the plot's own)
CallCtxPkgRefusesWhat
scene.create(components: Components, opts?: CreateOpts): EntityIdanywrongContext, outsidePlotA new entity in the current lobby (or the shared base, with opts.shared at plot level)
scene.setComponent<K extends ComponentName>(id: EntityId, component: K, value: NonNullable<Components[K]> | null): voidanywrongContext, playerBodyReplace one component; null removes it.
scene.getComponent<K extends ComponentName>(id: EntityId, component: K): NonNullable<Components[K]> | nullanyA component's value, or null.
scene.remove(id: EntityId)anywrongContext, playerBody
scene.setParent(id: EntityId, parent: EntityId | null)anywrongContext
scene.setVisibility(id: EntityId, visibility: Visibility)any
scene.query(opts?: QueryOpts): EntityId[]anyThis lobby's entities (plus the shared base's)
scene.wakeProp(id: EntityId)lobbyTurn a resting prop back into a moving entity (protocol.md, resting props); onPropStartMoving runs.
input.read(): InputEvent[]lobbyThe inputs since the last read (each is returned once)
network.send(channel: string, to: Targets, data: Uint8Array | string)lobbyoverBudget, wrongContext
network.receive(channel: string): Message[]lobby
network.pastPositions(time: number): PastPositionslobbyWhere 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 | nulllobbyA 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.hashOf(ref: AssetRef): AssetRefanyA bundle path's content hash (and a check that the file exists)
assets.create(kind: "mesh" | "material" | "sound" | "texture" | "model", data: any): AssetRefanyoverBudgetAn 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.get(key: string): anylobby
storage.set(key: string, value: any)lobbystorageFull
storage.delete(key: string)lobby
storage.keys(prefix?: string): string[]lobby
storage.getPlayer(player: PlayerId, key: string): anylobby
storage.setPlayer(player: PlayerId, key: string, value: any)lobbystorageFull
storage.getPlot(key: string): anyanyThe whole plot's, shared by every lobby.
storage.setPlot(key: string, value: any)anystorageFull
storage.getPlayerProfile(player: PlayerId, key: string): anyanyOne 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)anystorageFull
lobbies.current(): Lobbylobby
lobbies.lock()lobbyruntimeTake no newcomers, even with room (a round in progress, a fixed party)
lobbies.unlock()lobbyruntimeTake newcomers again (for the next round)
lobbies.startCountdown(opts?: CountdownOpts): numberlobbyruntimeOptional: 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()lobbyruntime
lobbies.switchTo(id: LobbyId | null): booleananyInside 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 | nullany
lobbies.create(opts?: LobbyCreateOpts): LobbyIdplotruntime
lobbies.regenerateTerrain(id: LobbyId, seed?: number)plotruntimelobbyNotEmptyDrop the lobby's block edits and generate its terrain again (a new seed if given)
lobbies.suspend(id: LobbyId)plotruntime
lobbies.resume(id: LobbyId)plotruntime
lobbies.delete(id: LobbyId)plotruntimelobbyNotEmpty
lobbies.movePlayers(players: PlayerId[], to: LobbyId)plotruntimeplayerProtected
lobbies.announce(text: string, opts?: ChatOpts)plotA chat line to every player in every lobby.
lobbies.runWithin<R>(id: LobbyId, fn: () => R): RplotruntimeRun fn in that lobby's context (read and write its storage, entities, edits)
lobbies.exportData(id: LobbyId): LobbyDataplotruntime
lobbies.setFeatured(id: LobbyId)plotruntimeThe lobby distant viewers, the map and the feed see.
world.bounds(): BoxanyThe 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[]anyThe plot's chunks as flat [cx, cz, cx, cz, ...] (16×16 columns, full height)
world.isInsidePlot(pos: Vec3): booleanany
world.monument(): MonumentInfo | nullanyThe 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): BlockIdanyA block's runtime id by name ("stone", "oak_planks", "air", or "<namespace>:<name>")
world.getBlock(pos: Vec3): BlockIdany
world.setBlock(pos: Vec3, block: BlockId)anyoutsidePlot, overBudgetIn a lobby: its edit layer. At plot level: the shared base (published to every lobby)
world.fillBox(box: Box, block: BlockId): numberanyoutsidePlot, overBudget
world.raycast(ray: Ray, opts?: RaycastOpts): Hit | nullany
world.groundHeight(x: number, z: number): number | nullanyThe y a player would stand at in this column (blocks and mesh terrain, in this context's layer)
world.replaceTerrainChunk(chunk: TerrainChunk)anyoutsidePlot, overBudgetMesh terrain: replace a chunk's mesh (budgeted; the latest wins)
world.placeBlock(pos: Vec3, block: BlockId, opts?: BlockStateOpts): numberanyoutsidePlot, overBudgetA 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): BlockIdanyThe 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)plotWhat 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): EntityIdlobbyoutsidePlotAn 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)plotruntimeoutsidePlotWhere 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)lobbyoutsidePlotA 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[]lobbyoutsidePlotWhat'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): numberlobbyoutsidePlot, wrongContextA 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): numberlobbyoutsidePlot, wrongContextA 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)[]lobbyoutsidePlotThe 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.list(): PlayerId[]lobby
players.positions(): number[]lobbyX, y, z for each player of players.list(), in order, flat: the cheap read for per-tick checks.
players.get(id: PlayerId): Player | nullany
players.teleport(id: PlayerId, pos: Vec3)lobbyruntimeplayerProtected, outsidePlot
players.setMovement(id: PlayerId, def: MovementDef)lobbyruntimeplayerProtectedHow the player moves: walk, swim, fly, a vehicle...
players.setCamera(id: PlayerId, def: CameraDef | null)lobbyruntimeplayerProtected
players.playCameraPath(id: PlayerId, path: CameraPath)lobbyruntimeplayerProtectedPlay a cinematic camera path for this player.
players.damage(id: PlayerId, amount: number, opts?: DamageOpts)lobbyplayerProtected
players.heal(id: PlayerId, amount: number)lobby
players.setHealth(id: PlayerId, health: number, max?: number)lobbyplayerProtected, itemsStashed
players.respawn(id: PlayerId, pos: Vec3)lobbyruntimeplayerProtected, outsidePlotThe plot's own respawn (inside a plot, death is the plot's; R7)
players.give(id: PlayerId, item: string, count?: number)lobbyplayerProtected, itemsStashedInto 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): numberlobbyplayerProtected, itemsStashed
players.inventory(id: PlayerId): ItemStack[]lobbyWhat the plot can see: its own kit when it stashes items (R6); the real items otherwise (R7)
players.clearInventory(id: PlayerId)lobbyplayerProtected
players.askToKeepItems(id: PlayerId)lobbyruntimeitemsStashedShow the base "keep your items?" prompt (plots that don't stash); the answer arrives in onKeepItemsAnswer.
players.setAvatar(id: PlayerId, model: AssetRef | null)lobbyruntime
players.setRealNametagsVisible(visible: boolean)lobbyruntimeShow or hide real players' trusted nametags (anonymity); a plot can never add a real-style tag.
ui.setButton(to: Targets, def: ButtonDef)lobbyruntimeAdd or update an on-screen button; presses arrive in onButtonPress.
ui.removeButton(to: Targets, id: string)lobbyruntime
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)lobbyA 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[])lobbyA raw 2D draw list (rects, images, text, hit regions) in screen points.
ui.showDialogue(to: Targets, speaker: EntityId | null, text: string, opts?: DialogueOpts)lobbyAn 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)lobbyA text box, labelled "Typing to {Plot}" by the base; the answer arrives in onTextSubmit.
ui.setPanel(to: Targets, id: string, node: PanelNode)lobbyAdd 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.addBlock(def: BlockDef): BlockIdplot
content.addItem(def: ItemDef): stringplot
content.addRecipe(def: RecipeDef)plot
content.addEntityType(def: EntityTypeDef): stringplot
content.spawnEntity(type: string, pos: Vec3, opts?: CreateOpts): EntityIdlobbyoutsidePlotAn entity of an added type, with its model, collider and health.
content.addStructurePattern(pattern: StructurePattern)plot
content.addWorldgenFeature(feature: WorldgenFeature)plotA world-gen feature, applied when lobbies are generated.
content.damageEntity(entity: EntityId, amount: number, opts?: DamageOpts)lobbyHurt a spawned entity; at 0 health it dies (onEntityDeath, its drops)
content.getEntity(entity: EntityId): EntityInfo | nullanyA spawned entity's type and health (for a boss bar)
mods.sendEvent(event: string, data?: any)anyCalls onModEvent(event, data) in every other package that exports it.
mods.list(): string[]anyThe installed packages' ids, in load order.
audio.play(sound: AssetRef, opts?: PlayOpts): numberlobby
audio.stop(handle: number)lobby
audio.playMusic(track: AssetRef | null)lobbyHeard inside and close by only (decided); null stops it.
time.now(): numberanyServer time in seconds (the tick clock)
time.tickNumber(): numberany
time.random(): numberanySeeded per plot, lobby and tick; recorded for replay.
time.startTimer(seconds: number, name: string, data?: any): numberanyoverBudgetA 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)plotruntimeoverBudget20 by default, up to 60 within the CPU quota (decided)
log.info(text: string)any
log.warn(text: string)any
subareas.enter(player: PlayerId, opts: SubAreaOpts): LobbyIdlobbywrongContext, outsidePlotMove 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)lobbywrongContextA player of this sub-area back to the lobby and the spot they came from (their look and camera as before)
sky.setForPlayer(id: PlayerId, def: SkyDef | null)lobbyruntimeThis 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.set(def: PortalDef | null)anyruntimeThe 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.

Types (generated) ​

  • EntityId: number
  • PlayerId: string A per-plot player id (never the account id); stable across visits to this plot.
  • LobbyId: string A lobby's stable id, "like a file" (R3). The one lobby of a plot without lobbies is "main".
  • BlockId: number A runtime block id (base blocks by name through world.blockId(name), your own from content.addBlock)
  • AssetRef: string A bundle path ("models/bed.glb") or a content hash ("h:" + 32 hex chars)
  • Vec3: [number, number, number]
  • Quat: [number, number, number, number] X, y, z, w.
  • Color: string "#rrggbb", "#rrggbbaa", or a palette name ("red", "gold", the chat colour table)
  • Box: { min: Vec3; max: Vec3 } Block coordinates, min inclusive, max exclusive.
  • Targets: PlayerId | PlayerId[] | "lobby" Who a UI call is for; "lobby" is every player in the current lobby.
  • Visibility: "public" | "lobby" | "serverOnly" | PlayersSet Who sees a node, sound or message (mod-model.md, visibility sets). public: everyone, onlookers included; lobby: the players in this lobby; players: those listed; serverOnly: no one (the server keeps it)
  • PlayersSet: { players: PlayerId[] }
  • Transform (component 1): { pos: Vec3; rot?: Quat; scale?: Vec3 } Relative to the parent, in world block coordinates when there's none.
  • Model (component 2): { asset: AssetRef; tint?: Color; detailBias?: number } A cuboid model, a glTF mesh or an item model. detailBias: above 0 keeps more detail at a distance. A glTF model made with no Collider gets one fitted to its solid part (see Collider)
  • Pose (component 3): { clip?: string; time?: number; blendClip?: string; blendTime?: number; blend?: number; overrides?: BoneOverride[] }
  • BoneOverride: { bone: string; rot?: Quat; offset?: Vec3; scale?: Vec3 }
  • Material (component 4): { params: number[]; color?: Color; emissive?: Color; blendWithBlockWater?: BlockWaterBlend } BlendWithBlockWater: the Model is a water surface drawn over the plot's real water blocks (BlockWaterBlend; register #117)
  • Light (component 5): { color: Color; intensity: number; range: number; cone?: number }
  • Emitter (component 6): { particles: AssetRef; rate?: number; burst?: number; seed?: number; to?: Vec3; color?: Color } Particles in one of the core game's designs, drawn by every viewer's page: particles is "inventery:" and one of mote, flame, fire, smoke, spark, spell, enchant, glint, crit, heart, note, stars, confetti, drip (plot/protocol/particles.mjs; no other name is taken). rate: particles a second while it's there (up to 30); burst: that many at once when a viewer first sees it, and again whenever burst or seed changes (up to 40: change seed to fire it again, or make a short-lived entity with a Lifetime); to: each particle flies there (a world point: a tracer, a beam of sparks); color tints it (not fire, heart or confetti). Viewers' pages cap the plots' particles alive at once; the harness doesn't draw them.
  • AudioSource (component 7): { sound: AssetRef; volume?: number; pitch?: number; loop?: boolean; range?: number }
  • Nametag (component 8): { text: string; color?: Color } A plot's own label; never in the trusted style (the room sets trusted tags on real players only)
  • Collider (component 9): { shape: "box" | "capsule" | "sphere" | "mesh"; size?: Vec3; body: "static" | "kinematic" | "dynamic"; restAfterSeconds?: number; offset?: Vec3 } Size is in blocks, standing on the entity's position and turned with its Transform; offset moves the box (its bottom centre from the position, before the turn). A "mesh" collider is its model's own shape: the model's own triangles, as drawn, less its foliage (alpha-masked or translucent materials: leaves, fronds, grass, which you walk through), simplified past 1024 triangles; it blocks only through that shape, on the room and the page alike (until a client has it, the client lets the player through; a model with nothing solid, or that isn't a mesh, blocks nothing, and the room warns you); size, its box for taps and raycasts, is filled in when missing. capsule and sphere are the box for now. A static body blocks players walking (not one under 0.25 tall and 0.5 across: a cup is only tapped and hit); a tap on it is a use (onUse). Kinematic and dynamic bodies never block walking: they are for taps, hits and raycasts. A glTF Model made with no Collider gets a box fitted to its solid part (its real bounds, scaled, with their offset; the foliage left out): static in the shared base, kinematic in a lobby or when nothing of it is solid. restAfterSeconds: a dynamic body that stays still this long becomes a resting prop.
  • Controller (component 10): { kind: "walk" | "swim" | "fly" | "vehicle" | "mount" | "glide" | "rail" | "fixed" | "custom"; owner?: "server" | "client"; speed?: number; gravity?: number; jump?: number }
  • Velocity (component 11): { v: Vec3 }
  • Billboard (component 12): { text: string; color?: Color; size?: number } Text drawn in the scene, facing the viewer (a public board)
  • Lifetime (component 13): { seconds: number } The room removes the entity after this long.
  • Custom (component 14): { schema: string; bytes: Uint8Array } Plot-defined; drawn by no one; read by the plot's client code (the lobby's players only)
  • Speech (component 15): { at: number; dur: number } The entity is speaking: from server time at (time.now()) for dur seconds. Scene state, so everyone who sees the entity sees it speak (its head lifts with the speech and it gestures; a model with no bones nods and sways whole). ui.showDialogue sets it on its speaker; a plot may set it itself for speech with no dialogue.
  • Components: { Transform?: Transform; Model?: Model; Pose?: Pose; Material?: Material; Light?: Light; Emitter?: Emitter; AudioSource?: AudioSource; Nametag?: Nametag; Collider?: Collider; Controller?: Controller; Velocity?: Velocity; Billboard?: Billboard; Lifetime?: Lifetime; Custom?: Custom; Speech?: Speech }
  • CreateOpts: { visibility?: Visibility; parent?: EntityId; shared?: boolean; health?: number } Shared: a plot-level entity in the shared base, seen by every lobby (plot-level code only); health: content.spawnEntity only, this entity's health and maximum instead of its type's (a monster scaled to the party)
  • QueryOpts: { near?: Vec3; radius?: number; has?: string[] }
  • InputEvent: { player: PlayerId; sequence: number; tick: number; move?: number[]; look?: number[]; buttons?: number; action?: string; pointer?: number[]; aimRay?: Ray; renderTime?: number } One player's input for a tick. move: [x, z] stick; look: [yaw, pitch]; action: a plot-defined action name.
  • Ray: { origin: Vec3; dir: Vec3 }
  • Hit: { point: Vec3; normal: Vec3; distance: number; block?: BlockId; entity?: EntityId; player?: PlayerId }
  • RaycastOpts: { maxDistance?: number; blocks?: boolean; terrain?: boolean; entities?: boolean; players?: boolean; ignore?: (PlayerId | EntityId)[] } Ignore: players or entities the ray passes through (the shooter's own body)
  • Message: { from: PlayerId; bytes: Uint8Array }
  • PastPositions: { time: number; players: PastPlayer[] } The lobby's players as they were at a past time (the last 500 ms are kept); for a hit test use network.raycastAtTime.
  • PastPlayer: { player: PlayerId; pos: Vec3; min: Vec3; max: Vec3 }
  • Player: { id: PlayerId; name: string; pos: Vec3; look: number[]; lobby: LobbyId; health: number; maxHealth: number; itemsStashed: boolean; keepsItems: boolean; protectionSecondsLeft: number; runsPlotCode: boolean; entity: EntityId } ItemsStashed: this plot stashes the player's real items and body (R6); keepsItems: the player chose to keep what they win (R7, plots that don't stash); protectionSecondsLeft: seconds left of their entry protection, or of a start countdown that protects everyone (R4); runsPlotCode: their device runs the plot's client code.
  • ItemStack: { item: string; count: number }
  • MovementDef: { kind: "walk" | "swim" | "fly" | "vehicle" | "mount" | "glide" | "rail" | "fixed" | "custom"; owner?: "server" | "client"; speed?: number; gravity?: number; jump?: number; maxSpeed?: number }
  • CameraPath: { keys: CameraKey[]; loop?: boolean } A cinematic path: the camera moves through the keys, then returns to the player's camera.
  • CameraKey: { t: number; pos: Vec3; target: Vec3; fov?: number }
  • DialogueOpts: { name?: string; color?: Color; voice?: AssetRef; choices?: string[] } An NPC line in the base game's NPC dialogue (docs/npc-dialogue.md), the same one the tutorial's Map Keeper speaks through. name and color: the speaker's name over the words; voice: a sound asset played as the line starts (its loudness moves the speaker); choices: asked in the dialogue's conversation box (the Tutorial kobold's), the answer arriving in onDialogueChoice.
  • CameraDef: { mode: "first" | "third" | "fixed" | "orbit" | "top"; pos?: Vec3; target?: Vec3; fov?: number; distance?: number }
  • DamageOpts: { by?: PlayerId; cause?: string }
  • Lobby: { id: LobbyId; players: PlayerId[]; size: number; locked: boolean; countdownEndsAt?: number; countdownProtectsEveryone?: boolean; suspended: boolean; region?: Box; neverSuspend: boolean; featured: boolean; subArea?: SubAreaInfo } A room of up to size players over the plot (R3). It's full when players.length >= size; locked: takes no newcomers even with room (lobbies.lock); countdownEndsAt: only while a start countdown runs (lobbies.startCountdown); countdownProtectsEveryone: that countdown protects every member (opts.protectEveryone); suspended: empty and asleep.
  • SubAreaInfo: { key: string; parent: LobbyId; owner: string } A sub-area lobby's (subareas.enter): the key its package made it for, the lobby its players came from (whose chests it shares) and the package that owns it.
  • SubAreaOpts: { key: string; region: Box; at: Vec3; look?: number[]; name?: string; camera?: CameraDef } Key: what the sub-area is for, in this package ("chest:12,64,-3"): one lobby per key; region: its space inside the plot (min inclusive, max exclusive), which the platform keeps the players in; at: where the player stands in it; look: [yaw, pitch] on entering; name: what Leave and the trusted layer call it; camera: the camera while inside (set on entering, the player's own again on leaving)
  • CountdownOpts: { protectEveryone?: boolean; minSeconds?: number } ProtectEveryone: protect every member until the countdown ends (an equal start), not only those still in their entry protection; minSeconds: the countdown runs at least this long.
  • LobbyCreateOpts: { id?: LobbyId; size?: number; region?: Box; neverSuspend?: boolean; locked?: boolean; seed?: number } Seed: this lobby's world-gen seed (a lobby is seed + edits); default derived from the plot and lobby ids.
  • LobbyData: { id: LobbyId; lobby: any; storage: any; edits: BlockEdit[]; entities: any[] } A lobby exported as plain data: its Lobby value and timers, storage, block edits and entities.
  • BlockEdit: { pos: Vec3; block: BlockId }
  • ButtonDef: { id: string; side?: "left" | "right"; priority?: number; icon?: string; label?: string; image?: AssetRef; cooldown?: number; toggled?: boolean; banner?: string; fill?: number; group?: string } A native on-screen button (mod-model.md, native UI bindings). banner/fill extend it horizontally; group merges adjacent buttons.
  • TitleOpts: { subtitle?: string; color?: Color; fadeIn?: number; stay?: number; fadeOut?: number }
  • ScoreboardRow: { label: string; value?: string; color?: Color }
  • ClockDef: { mode: "countdown" | "stopwatch"; seconds: number; label?: string }
  • BarDef: { id: string; kind: "boss" | "resource"; label?: string; fill: number; color?: Color }
  • ToastDef: { text: string; icon?: string }
  • PanelNode: { kind: "box" | "text" | "icon" | "bar" | "space"; children?: PanelNode[]; dir?: "column" | "row"; gap?: number; pad?: number; align?: "start" | "center" | "end"; bg?: Color; edge?: boolean; minWidth?: number; text?: string; color?: Color; size?: number; icon?: string; fill?: number; width?: number; anchor?: "top" | "topLeft" | "topRight" | "left" | "right" | "center" | "bottom" | "bottomLeft" | "bottomRight"; x?: number; y?: number } A piece of a Minecraft-style panel (ui.setPanel), sized in GUI pixels (the hotbar's pixel). box: its children in a column (or a row) with gap between them and pad inside, aligned start, center or end across; on a dimmed rect like the chat's (bg: its colour, "#00000080" at the root, none inside, "none" for none; edge: a 1 px brighter frame; minWidth). text: one line in the chat's font (color; size 1, the GUI's text, or 2; with no size, the chat's own text size, a little smaller). icon: an item or block's icon (as ButtonDef.icon). bar: a flat bar width wide filled to fill (0-1) in color, with its text as a label. space: an empty gap (width x size). The root's anchor (default top) places the panel inside the screen's free area (clear of the safe areas, the trusted controls and the button columns), x and y nudging it; panels at the same anchor stack.
  • ChatOpts: { color?: Color; bold?: boolean; italic?: boolean }
  • DrawOp: { op: "rect" | "image" | "text" | "hit"; x: number; y: number; w?: number; h?: number; color?: Color; text?: string; image?: AssetRef; id?: string; size?: number }
  • PlayOpts: { at?: Vec3 | EntityId; volume?: number; pitch?: number; loop?: boolean; to?: Visibility }
  • BlockDef: { name: string; model?: AssetRef; texture?: AssetRef; color?: Color; hardness?: number; solid?: boolean; drops?: ItemStack[] } Name is local ("bed"); the id is namespaced by the package ("bedwars:bed"). hardness: how long it takes to break, enforced by the page's timing and the room's dig check as for a base block with no tool (below 0 never breaks; none breaks at once)
  • ItemDef: { name: string; model?: AssetRef; texture?: AssetRef; stack?: number; places?: string }
  • RecipeDef: { pattern?: string[]; keys?: any; ingredients?: string[]; result: ItemStack } Shaped (pattern + keys) or shapeless (ingredients); ids namespaced ("inventery:stick")
  • EntityTypeDef: { name: string; model?: AssetRef; health?: number; collider?: Collider; ai?: string; drops?: ItemStack[]; damage?: number; speed?: number; base?: string } A mob or object type; a glTF boss is an entity type whose model is a .glb. base: a base game creature it is (items.js MOBS, by name: "zombie", "skeleton", "phantom", "spider", "wolf", "pig"...; any other name is badArguments listing them): its model, skin and animations (the base mob renderer), its hit and death sounds and death fall, its AI, health, damage, speed, collider and drops, all the base creature's unless the type sets them (health, damage, speed, drops, collider; a model replaces only its look, drawn with the base hit flash and death). With base, model is optional; ai "none" makes it stand still, another ai gives it that behaviour instead of its own. ai: the base game's mob behaviour the room runs for it (base rules, manifest baseRules.mobs): "hostile" (a zombie's: hunts the nearest player within 16 blocks and hits them in melee), "ranged" (a skeleton's: keeps its distance and shoots), "neutral" (a wolf's: wanders, fights back when hit), "passive" (a pig's: wanders, flees when hit), or "none"/absent (it stands where the plot puts it); anything else is badArguments. The AI walks the lobby's blocks and mesh terrain, stays inside the plot, and targets only that lobby's players (never one still protected or away); its hits go through onPlayerDamage (entity: its id). damage: a hit's (or shot's) damage, default the base creature's (3; a passive one's 0); speed: blocks a second when chasing or fleeing, default the base creature's (hostile 1.7, ranged 1.4, neutral 3, passive 2.6). A type with an ai runs at 20 Hz while its lobby has players inside. Every type with health takes the base melee (a player's entity:hit: weapon damage, crits, knockback for one with an ai, the half second of invulnerability); onEntityDamage may refuse a hit. On the page it's the base game's feedback: the hit sound (pitched by the collider's height) and the red flash, and at 0 health the death groan, the fall onto its side and the puff. drops: items it leaves where it dies; each stack lies on the ground as a world-style item (the lobby and onlookers see it, with the bob and spin) and is picked up by the base rules (baseRules.pickup: the base reach and delay, stack limits with the overflow left lying, the pickup sound; not while protected or away), gone after 5 minutes; never Nimgots, and a player who keeps their items doesn't pick up real base items.
  • StructurePattern: { name: string; layers: string[][]; keys: any; anchor?: string } A block shape (layers bottom-up, rows of key characters); when it's built in the world, onStructureComplete runs in the package that added it.
  • WorldgenFeature: { name: string; kind: "ore" | "tree" | "structure" | "scatter" | "biome" | "loot"; data: any }
  • BlockEvent: { pos: Vec3; block: BlockId; by: PlayerId | null }
  • BlockChange: { pos: Vec3; was: BlockId; block: BlockId }
  • BlockChangeEvent: { pos: Vec3; block: BlockId; by: PlayerId | null; changes: BlockChange[] } What the base game did after a break or placement it ran: every cell it changed (both halves of a bed or door)
  • BlockStateOpts: { facing?: "north" | "south" | "east" | "west"; open?: boolean; top?: boolean; right?: boolean } A directional block's state: facing (a bed's: foot to head; a door's or stair's: the way it was placed looking), open (doors, gates), top (upside-down stairs), right (a door hinged on the right)
  • UseEvent: { by: PlayerId; pos?: Vec3; block?: BlockId; entity?: EntityId }
  • StructureMatch: { name: string; origin: Vec3; blocks: Vec3[]; by: PlayerId | null }
  • EntityDeathEvent: { entity: EntityId; type?: string; by: PlayerId | null; pos: Vec3 }
  • EntityDamageEvent: { entity: EntityId; type: string; amount: number; by: PlayerId | null } A base hit on a plot entity (base melee, manifest baseRules.mobs): amount before its invulnerability (a hit within half a second of the last lands only what it adds)
  • MonumentInfo: { pos: Vec3; name: string } Pos: the monument's lower block, in world coordinates.
  • EntityInfo: { type: string; health: number; maxHealth: number; pos: Vec3 }
  • TerrainChunk: { cx: number; cz: number; mesh: AssetRef; collision?: AssetRef }
  • SkyDef: { skybox?: AssetRef; top?: Color; horizon?: Color; fog?: Color; turn?: number; fadeSeconds?: number; light?: LightDef } The sky and light a plot's players see while inside it (its lobbies and draft; register #116); onlookers outside keep the world's sky and light. skybox: an equirectangular PNG in the bundle (2:1, drawn round the viewer, turned by turn radians about +y); top and horizon: a flat two-colour sky instead of the world's gradient (under the skybox, or alone); with no skybox and no top or horizon it's the world's own sky, with only the fog changed. fog: the colour the distance fades into (the world's fog is mixed toward it). The skybox dims with the time of day as the world's sky does (at night its stars and moon show through). light: the light model's settings while inside (LightDef). fadeSeconds: the crossfade on crossing in and out and on a change (default 1.5)
  • LightDef: { time?: number; sun?: Color; sunStrength?: number; ambient?: number } The world's one light model's settings for a plot's players while inside (register #110: every surface, blocks, glTF models, mesh terrain and entities, still lights through the same model; a plot only changes its settings). time: a fixed time of day, 0 sunrise, 0.25 noon, 0.5 sunset, 0.75 midnight, that holds the sun, moon, stars and the sky's brightness there (the world's clock runs on for everything else); sun: the sky light's colour (white is the world's); sunStrength: its strength (default 1, clamped 0..2); ambient: the light floor, clamped to 0..1, the darkest a surface gets; it replaces the world's while inside, so a plot can be darker than the world.
  • BlockWaterBlend: { center: Vec3; solidRadius: number; fadeRadius: number; opacity?: number; edgeColor?: Color } Drawing only: the Model is a water surface over the plot's real water blocks (register #117). center is in the model's own space, moved, turned and scaled by its Transform (the blend follows the model); the radii are horizontal distances in blocks from it (scaled by the Transform's x scale). Drawn at opacity (default 0.9, clamped to 0..1) within solidRadius, fading to nothing at fadeRadius (a smooth radial gradient, a circle round the centre; a fadeRadius under solidRadius counts as equal), so past it only the block water shows and runs on into the world's ocean. Across the ring its colour turns toward edgeColor (the world water's look, when given) and an animated surface's waves settle to the block water's level, so there's no seam. Drawn after the block water, just above its surface; it never collides (no Collider is made, and a given one is ignored), never reaches movement, prediction, taps or raycasts: the water blocks stay the physics, swimming and sounds.
  • PortalDef: { title: string; subtitle?: string; line?: string; titleFont?: string; subtitleFont?: string; titleColors?: string[]; subtitleColor?: string } The plot portal's look (register #124): its title (also its name on Explore's map, at most 32 characters), the subtitle under it (at most 32), and line, the feed card's white line under the title (at most 160). titleFont: a headline font (jersey-10, notepen, pixolde, pixolde-italic, piacevoli, alkhemikal, scriptorium, brilliant-strength, jersey-15, pixel-arcade, silkscreen-bold, kenney-blocks, kenney-pixel-square, kenney-pixel-square-bold; default jersey-10); subtitleFont: a subtitle font (monocraft, jersey-10, pixel-arcade, kenney-pixel-square, kenney-pixel-square-bold, microserif, silkscreen; default monocraft); titleColors: one palette colour, or two for a gradient from the top to the bottom (white, grey, gold, red, magenta, purple, aqua; default grey); subtitleColor: one (default white). An unknown font or colour is the default; a title or line the chat filter catches isn't used.

plot.json (generated) ​

FieldType
kind"runtime" | "mod"a plot runs exactly one Runtime (none = the default Inventery runtime) and any number of Mods
namespacestringthe namespace of this package's ids ("bedwars" makes "bedwars:bed"); "inventery" is the base game's
depends?string[]package ids ("<owner>.<name>") this one needs; they load first
worldgen?{ generator: string; seed?: number; features?: string[] }runtime only: the base world-gen recipe (a lobby is seed + edits)
id${string}.${string}"<owner>.<name>": lowercase letters, digits, _ and -
versionstringsemver ("1.0.0"); onUpgrade(oldVersion) runs when it changes
namestringshown by the trusted layer ("Typing to {name}", the "keep your items?" prompt)
apiVersion1the host API version it was built for (1)
code?stringthe module the room loads: "dist/logic.js" (built by sdk/build.mjs) or a raw-ABI ".wasm"; a data pack (a mod with no code) leaves it out
stashItems?booleanruntime only. R6: true = the player's real items and body are set aside while they're inside
lobbies?{ size: number; area?: Box } | nullruntime only. R3: players per lobby (the platform makes and fills lobbies) and the lobby area (relative to the plot's min corner); null = no lobbies (one shared lobby, "main")
tickRate?number20 (default) to 60 Hz, within the CPU quota
baseRules?{ blocks?: boolean; pvp?: boolean; drops?: boolean; pickup?: boolean; hunger?: boolean; mobs?: boolean; chests?: boolean; furnaces?: boolean }runtime only. The base game runs inside your plot by default and your hooks filter it; set a category false to own it yourself: blocks, pvp (only a player's hit on a player: falls, creatures, explosions and starving always hurt as the base does), drops and pickup (on, they run the world's own item pipeline in the lobby: base reach and delay, stack limits with the overflow left lying, the pickup sound, bob and spin, 5 minutes), hunger, mobs (entity types' ai and the base melee on plot entities), chests (the base chest screen on the lobby's own boxes), furnaces (the base furnace on the lobby's own furnaces, smelting on its own tick)
movement?"server" | "client"who owns player movement (default "server")
entry?Vec3where soft deaths and Leave put you, relative to the plot's min corner (default: where you entered)
rollback?{ relay?: ("move" | "look" | "buttons" | "action" | "pointer" | "aimRay")[] }runtime only: opts in to the rollback relay (the lobby's inputs, only these fields, to its other players; default move, look, buttons). Every listed field is visible to every player
channels?{ [name: string]: { reliable: boolean; rate?: number } }custom channels for game logic (never visuals)
assets?stringthe bundle folder (models, textures, sounds, levels), usually "assets"
content?{ blocks?: unknown[]; props?: unknown[]; terrain?: unknown[] }published static content (tier 1). content.terrain is Planned (see "glTF levels" in AGENTS.md)
registries?stringa folder of JSON definitions the host registers before any code runs: blocks.json (BlockDef[]), items.json (ItemDef[]), recipes.json (RecipeDef[]), entities.json (EntityTypeDef[]), structures.json (StructurePattern[]), worldgen.json (WorldgenFeature[]). A data pack is a mod with registries and no code
rating?stringthe content rating
tags?string[]search tags (MANIFEST lists them inside the rating entry)
models?{ maxTris?: number; lods?: number[] }how the bundle's glTF models are served: decimated past maxTris (default 8000, at most 131072), far LODs at these fractions of the model (default [0.25], up to three, finest first)
water?{ blend: "radial"; inner: number; outer: number; opacity?: number; edgeColor?: string }a model water surface's blend into the world's block water (register #117), for the plot's code to give its Material.blendWithBlockWater: the model's own water inside inner blocks of its centre, a smooth radial gradient to the block water at outer
sky?SkyDefthe sky and light players see while inside the plot (register #116): { skybox?: an equirectangular PNG or JPEG in the bundle, top?, horizon?, fog?: colours, turn?: radians, fadeSeconds?, light?: { time?, sun?, sunStrength?, ambient? } }; onlookers outside keep the world's
portal?PortalDefthe plot portal's look (register #124): { title, subtitle?, line?: the feed card's line, titleFont?, subtitleFont?, titleColors?, subtitleColor? }; a published creator plot gets its destination portal built at its arrival spot, styled so

Build, lint, test ​

node plot/sdk/build.mjs <plotDir>          # validate plot.json, bundle logic.ts -> dist/logic.js, hash artifacts
node plot/sdk/build.mjs pack <plotDir>     # also write dist/<namespace>-<version>.inv (manifest + content-hashed blobs)
node plot/sdk/lint.mjs <plotDir>           # rule, file:line and a fix for each finding; exit 1 on errors

Lint rules: visible-in-scene, determinism, lobby-call-at-plot-level, plot-call-in-lobby, runtime-only, channel-visuals, invisible-model and costly-call-every-tick (warnings), manifest, registries.

The headless harness (plot/harness/) runs a room with your plot and scripted clients speaking the real protocol:

js
const h = await createHarness({ plots: [{ dir: "plot/samples/team-minigame", mods: ["plot/samples/bedwars"],
  origin: [x, y, z], manifest: { lobbies: { size: 2 } } }], seed: 1 });   // dir: the runtime, mods: its mods;
                                                              // manifest: overrides the runtime's plot.json
const a = h.client("ana", { at: [x, y, z] }); await a.walkTo([x, y, z]); a.pressButton("shop"); await h.run(seconds);
await h.renderOnlooker({ eye, target, w, h }); await h.renderPlayerView(a);   // PNGs of what each one decoded
h.meters();                                                   // CPU a tick per plot, memory, bytes a second

CPU is charged as it is in the room (CPU time, so a busy test machine's preemption isn't charged); pass cpu: "lenient" for timing-heavy tests on a loaded machine. Clients also have c.useBlock(pos), c.useEntity(idOrRay), c.chooseDialogue(index), c.answerKeepItems(yes) and c.answerText(text). Renders show only what the client actually decoded: if the onlooker render looks empty, your visuals aren't in the scene.

With the creator tools (inv, docs/creator.md):

inv test <runtime dir> --mod <mod dir> --mod <another> --chunks 6x6   # a runtime with its mods, in a 6 x 6 chunk plot
inv push <dir>                       # into your plot's draft: a runtime too (register #123)
inv export --plot <id> out.invplot   # the whole plot as published: runtime, mods, data packs, hand-built blocks
inv import out.invplot --plot <id>   # onto a plot's draft (the blocks go into the plot at publish)

inv test alone runs <dir> on the default runtime in a 48 x 96 x 48 box; give the runtime and --mod for each mod (in order; a mod's depends must be there), and --chunks for the plot's real size, or a map built for 6 x 6 chunks logs outsidePlot refusals. Each inv push reloads the plot's draft instance: your draft's whole package set (runtime, mods, registries, data packs) and the runtime's manifest (lobbies, sky, baseRules, worldgen) run there, seen by you and the testers you invite (inv invite), while the published plot and its players carry on untouched until inv publish. A new build of a mod whose registries didn't change live-migrates in the running draft (onUpgrade); anything else starts a fresh draft instance, and whoever is in it walks into the new one (items back in between). The draft has half the plot's memory and storage, and only the CPU the published plot leaves; its crashes are its own (5 in 10 minutes stop the draft, never the plot; the next push starts it again). inv push answers with the draft's status and its last error: read them. Its storage starts as a copy of the published plot's and is dropped at publish.

Errors and refusals ​

A refused call throws HostError (e.rule, e.call, e.message); isHostError(e, "playerProtected") tests it. Catch the ones you expect; an uncaught one fails the hook and its writes are rolled back. Rules (generated):

RuleMeans
wrongContexta lobby call at plot level outside lobbies.runWithin, a plot-level call inside a lobby hook, or a write to something outside the current lobby (another lobby's player or entity, the shared base from a lobby)
outsidePlota position, box or chunk outside the plot (reads may reach one chunk outside)
playerProtectedthe player is protected: their 10 s entry protection, or a start countdown with protectEveryone, is running, or they're away (check player.protectionSecondsLeft; act in onPlayerProtectionEnd or onCountdownEnd)
itemsStashedthe player's real items are off limits here: the plot stashes items (so there's nothing to keep), or the player keeps what they win and the call would touch base economy items or their real max health
overBudgetover a limit: channel messages, block edits per tick, timers, runtime assets, terrain chunk replacements, or the tick rate
storageFullthe package's storage is over its quota (its share of the plot's 1 MB; delete keys you don't need)
lobbyNotEmptythe lobby still has players (move them out or wait until it's empty)
playerBodythe entity is a real player's body, owned by the room: use the players module instead
notSupportedthe call isn't implemented by this host version (any call may raise it)
runtimeOnlya Mod package made a call reserved to the plot's Runtime package (lobbies, movement, camera, buttons, keeping items, tick rate)
badArgumentsa missing or mistyped argument, or a value out of range; the message says which (any call may raise it)

Hook failures (from the isolate): trap (an exception or crash: writes rolled back; the package restarts after 1 s, 5 s, 30 s; a mod's crash takes out that mod alone, while the runtime's 5 crashes in 10 minutes stop the plot and release its players), overrun (over the CPU budget for the call), oom (over 32 MB). Build errors name the plot.json field or the file:line:column of the bundling problem. Creator API refusals (inv push, publish, invite; docs/creator.md): needs_confirm (409) when the plot isn't a creator plot yet: its owner says yes to their Soft-Bot, or --convert makes it one (only on the owner's say-so); sample_plot (403) for a /plotsamples plot, which no token writes to; not_your_plot (403) for a plot the token doesn't cover (a token covers the plots its owner had when it was issued: /creatortoken again after a new one).

Generated sections come from plot/schema/host-api.mjs; the schema wins if they ever disagree.