# Inventery plots: a cheat sheet for agents writing mods
> A plot is part of a shared 3D block world that runs your code in a wasm sandbox on the room server (the
> referee). People see it from outside, walk in, and play. Write TypeScript against "@inventery/host" (or any
> language compiled to wasm against the raw ABI). Full reference: plot/sdk/AGENTS.md and plot/sdk/host.d.ts
> (generated from plot/schema/host-api.mjs). Human docs: docs/developers/README.md. API names: plot/NAMING.md.
## Status
Runtime, SDK, harness and protocol are built and run headless (branch plot-runtime). Plots also run in a real room
with plot hosting, part of the core game (the local plots dev stack: browser and the iOS app). Not in the live world,
staging or production. In progress: client code (onFrame/predictStep in a Worker, the rollback relay). Planned:
inventery-mod dev, the creator API, publishing from the app.
## A package
- folder: plot.json + logic.ts (+ assets/). Build: node plot/sdk/build.mjs
(writes dist/logic.js).
- Lint: node plot/sdk/lint.mjs . Run headless: node docs/developers/examples/run.mjs [--players N].
- plot.json: kind ("runtime" | "mod"), namespace, id ".", version (semver), name, apiVersion: 1,
code: "dist/logic.js". Runtime only: stashItems (bool), lobbies ({size, area?} | null), worldgen
({generator: flat|hills|void|world, seed?, features?}), baseRules ({blocks, pvp, drops, pickup, hunger, mobs},
each true by default), rollback. Optional: depends, tickRate 20..60, movement "server"|"client", entry, channels
{name: {reliable, rate?}}, assets, registries, rating, tags. Unknown fields are flagged.
Coordinates in plot.json are relative to the plot's min corner; host calls take world coordinates.
- A plot = exactly one runtime + any number of mods, each its own package with its own plot.json. No runtime =
the default Inventery runtime (survival and build: no lobbies, no stash, every base rule on). The runtime decides
lobbies, stashItems, entry handling, game rules (deaths, win, reset), camera and movement, world gen, which
buttons get screen slots, which base-rule categories stay on, and extension points for mods (mods.sendEvent ->
onModEvent). It never replaces the base game's mechanics, the guarantees (Leave, entry protection, items given
back, the trusted layer) or anything outside the plot. Mods add content and world-event reactions. A mod calling
a runtime-only call gets HostError rule "runtimeOnly". Reusable runtimes let games be mods: Bedwars and Spleef
are mods on Team Minigame (plot/samples/team-minigame, README lists its events).
## The rules
1. Visible = in the scene. Onlookers run no plot code; they draw only the replicated scene. Use
scene.create/setComponent (Model, Light, Emitter, AudioSource, Billboard...) or audio.play for anything others
should see or hear.
2. Channels (network.send) are game logic only; onlookers never get them.
3. Base rules run inside the plot (on the player's lobby layer and in-plot inventory): breaking and placing with
tools, two-part beds and doors, base drops, PvP with weapons/knockback/armour, items on the ground picked up by
walking over them, hunger, plot entities' ai and the melee on them. Don't rebuild them. Filter with
onBlockBreak/onBlockPlace/onUse/onPlayerDamage/onEntityDamage
(return false to refuse; runtime first, then mods in load order; any refusal wins); listen with
onBlockBroken/onBlockPlaced (ev.changes: every cell changed). world.setDrops(block, stacks|null) (plot level),
world.dropItem(pos, item, n), world.placeBlock(pos, block, {facing}) (both halves), world.rotateBlock. A runtime
sets baseRules. false to own that category itself (a game that is its own server). Plot entities have
no base AI and no base melee: a plot moves them and calls content.damageEntity itself (an "attack" input
action, or onUse on the entity).
4. Contexts. Lobby hooks (onPlayerEnter, tickLobby, onBlockBreak, onUse, ...) act on their lobby. Plot level
(top-level code, init, chooseLobby, tickPlot, tickAllLobbies, onUpgrade) has no lobby; use
lobbies.runWithin(id, fn) (runtime), or lobbies.switchTo(id) inside tickAllLobbies. Wrong context: refused
"wrongContext". Closures defined inside init count as plot level for the lint: add handlers at top level.
5. Lobbies are siloed rooms of up to lobbies.size players: players, entities, block edits, storage, timers, UI,
audio, chat. A plot declares only the size. The platform puts arrivals in the fullest lobby that isn't full or
locked, makes new lobbies and suspends empty ones. lobbies.lock() closes one that has room (a round in
progress, a fixed party); unlock() reopens it. Full = players.length >= size. Without lobbies the plot is one
lobby "main". At plot level, world.setBlock/fillBox and scene.create({..},{shared:true}) write the shared base.
6. Entry protection (R4): for 10 s after entering you can't teleport, setMovement, setCamera, playCameraPath,
damage, setHealth, respawn, give, take or clearInventory that player (refused "playerProtected"). Do it in
onPlayerProtectionEnd. player.protectionSecondsLeft = seconds left.
7. Start countdown (optional, runtime): lobbies.startCountdown(opts?) runs until the newest member's entry
protection ends (or opts.minSeconds, if later); the base draws it; then onCountdownEnd(). By default only those
still in their entry protection are protected; {protectEveryone: true} protects every member until it ends (an
equal start). onCountdownPlayerLeft(p) if someone leaves meanwhile: startCountdown() again, cancelCountdown(),
or let it run. A room anyone can walk into (a 4-player puzzle) needs no countdown.
8. Stashed items (stashItems: true, R6): the player's real items, body and flight are set aside on entry and given
back on leaving; give/take act on the plot's own kit. Not stashed (R7): players try things with their real
items, and it's undone on leaving, unless they choose to keep their items: players.askToKeepItems ->
onKeepItemsAnswer. Admin creative is off in any plot.
9. Deaths: the runtime's onPlayerDeath returns true to handle it (players.respawn); else a soft death outside.
10. Memory is never saved. Restarts rerun top-level code, init, onLobbyLoad. Keep state in storage (get/set per
lobby, getPlayer/setPlayer per player in the lobby, getPlot/setPlot plot-wide, getPlayerProfile/
setPlayerProfile per player plot-wide) or use defineLobbyGame.
11. Determinism: time.now(), time.tickNumber(), time.random(); timers: time.startTimer(seconds, name, data) ->
onTimer.
12. Budgets: 2 ms CPU a tick (hard 8), 20 ms a rolling second, 32 MB memory, 1 MB storage. Over: refused
"overBudget" or "storageFull"; a crash restarts the sandbox, which rebuilds from storage.
13. Never: reach outside the plot, network, device, accounts, real money; draw over the settings button (top left;
its menu's first item is "Leave {Plot}", never an on-screen button), chat (top right), the protection or
countdown ring (top centre) or prompts; draw real-style nametags.
## Ids
"namespace:name". Add content with the local name (blocks.add("bed") -> "bedwars:bed"). Base: "inventery:stone"
(world.blockId("stone") also works). Base blocks are the engine's real list (plot/runtime/base-blocks.json:
"grass_block", "red_wool", "bed" and "bed_head" with _south/_west/_east facings...); older names ("grass",
"wool_red") still resolve. Every base block is also an item ("inventery:white_wool"), plus the base items
("inventery:iron_sword").
## Common calls
scene.create(components, {visibility?, parent?, shared?}) -> EntityId; scene.setComponent(id, "Light", v | null);
scene.remove(id); scene.setVisibility(id, "public" | "lobby" | "serverOnly" | {players: [...]})
world.blockId(name); world.getBlock(pos); world.setBlock(pos, id); world.fillBox(box, id); world.raycast(ray, opts)
world.placeBlock(pos, id, {facing?}); world.setDrops(id, stacks | null); world.dropItem(pos, item, count?)
players.list(); players.get(p) -> {id, name, pos, health, itemsStashed, keepsItems, protectionSecondsLeft, entity, ...};
players.teleport/setCamera/setMovement/respawn (runtime); players.damage/heal/give/take/inventory
ui.chat(to, text, {color?}); ui.showTitle(to, title, {subtitle?}); ui.setScoreboard(to, title, rows);
ui.showToast(to, {text}); ui.setBar(to, {id, kind, fill}); ui.setClock(to, {mode, seconds}) (all drawn as panels);
ui.setPanel(to, id, {kind: "box", anchor?, dir?, children: [{kind: "text", text, color?}, {kind: "bar", fill}, {kind: "space"}, ...]})
(Minecraft-style: dimmed rects, the chat's font; only buttons are native);
ui.setButton(to, {id, icon, label}) (runtime) -> onButtonPress; ui.drawOverlay(to, ops);
ui.showDialogue(to, speakerEntity | null, text, {name?, color?, voice?, choices?}) -> onDialogueChoice (the base NPC
dialogue, the tutorial's Map Keeper's: subtitles; with choices its plank box; the speaker speaks in the scene); ui.askForText(p, prompt) -> onTextSubmit.
to = PlayerId | PlayerId[] | "lobby".
input.read() -> InputEvent[] {player, action?, move?, look?, aimRay?, renderTime?} (in tickLobby)
network.send(channel, to, data) -> onMessage; network.raycastAtTime(time, ray, {ignore: [shooter]}) (lag
compensation, 500 ms)
lobbies.current(); lobbies.lock()/unlock(); lobbies.startCountdown({protectEveryone?}); lobbies.list/create/
runWithin/movePlayers/regenerateTerrain/setFeatured
content.addBlock/addItem/addRecipe/addEntityType/spawnEntity/addStructurePattern/addWorldgenFeature;
content.damageEntity(id, amount, {by?}) -> onEntityDeath; content.getEntity(id); EntityTypeDef.ai "hostile" |
"ranged" | "neutral" | "passive" | "none" runs the base mob AI (baseRules.mobs), players' swings are the base melee
(onEntityDamage may refuse), a mob's hit is onPlayerDamage(player, amount, null, entity);
mods.sendEvent(event, data) -> others' onModEvent
audio.play(sound, {at?, to?}); audio.playMusic(track); log.info(text)
## Every call (generated from the schema)
scene: create, setComponent, getComponent, remove, setParent, setVisibility, query, wakeProp
input: read
network: send, receive, pastPositions, raycastAtTime
assets: hashOf, create
storage: get, set, delete, keys, getPlayer, setPlayer, getPlot, setPlot, getPlayerProfile, setPlayerProfile
lobbies: current, lock*, unlock*, startCountdown*, cancelCountdown*, switchTo, list, get, create*, regenerateTerrain*, suspend*, resume*, delete*, movePlayers*, announce, runWithin*, exportData*, setFeatured*
world: bounds, plotChunks, isInsidePlot, monument, blockId, getBlock, setBlock, fillBox, raycast, groundHeight, replaceTerrainChunk, placeBlock, rotateBlock, setDrops, dropItem, setArrival*, setChestItems, getChestItems, takeFromChest, putInChest, getChestSlots
players: list, positions, get, teleport*, setMovement*, setCamera*, playCameraPath*, damage, heal, setHealth, respawn*, give, take, inventory, clearInventory, askToKeepItems*, setAvatar*, setRealNametagsVisible*
ui: setButton*, removeButton*, chat, showTitle, setScoreboard, removeScoreboard, setClock, removeClock, setBar, removeBar, showToast, drawOverlay, showDialogue, askForText, setPanel, removePanel
content: addBlock, addItem, addRecipe, addEntityType, spawnEntity, addStructurePattern, addWorldgenFeature, damageEntity, getEntity
mods: sendEvent, list
audio: play, stop, playMusic
time: now, tickNumber, random, startTimer, cancelTimer, setTickRate*
log: info, warn
subareas: enter, leave
sky: setForPlayer*
portal: set*
(* = runtime only)
## Every hook (generated)
init, chooseLobby*, onLobbyLoad, onPlayerEnter, onPlayerProtectionEnd, onCountdownEnd*, onCountdownPlayerLeft*, onPlayerLeave, tickLobby, tickPlot, tickAllLobbies, onTimer, onMessage, onBlockBreak, onBlockPlace, onBlockBroken, onBlockPlaced, onUse, onButtonPress*, onTextSubmit, onPlayerDamage, onPlayerDeath*, onKeepItemsAnswer*, onPropStartMoving, onPropStopMoving, onUpgrade, onDialogueChoice, onStructureComplete, onEntityDeath, onPlotChunksChanged, onModEvent, onEntityDamage
client: onFrame, predictStep, onInput, onServerMessage
## Minimal mod
import { scene, ui } from "@inventery/host";
import { blocks, events } from "@inventery/minecraft";
export { onBlockBroken } from "@inventery/minecraft";
const LANTERN = blocks.add("lantern", { texture: "textures/lantern.png", hardness: 0.5 });
events.blockBroken.on(LANTERN, (ev) => { // after the base game broke it (events.blockBreak: before, false refuses)
scene.create({ Transform: { pos: ev.pos }, Light: { color: "gold", intensity: 3, range: 6 }, Lifetime: { seconds: 0.5 } });
if (ev.by) ui.chat(ev.by, "The lantern flickers out.");
});
## Minimal round with a start countdown (runtime)
import { lobbies, players, ui } from "@inventery/host";
import { defineLobbyGame } from "@inventery/lobby";
const game = defineLobbyGame({
setup: () => ({ joined: [] as string[] }),
onPlayerEnter(s, p) {
s.joined.push(p);
const l = lobbies.current();
if (l.players.length >= l.size && !l.locked) { lobbies.lock(); lobbies.startCountdown({ protectEveryone: true }); }
},
onCountdownEnd(s) { for (const p of s.joined) players.teleport(p, [8, 40, 8]); ui.showTitle("lobby", "Go!"); },
});
export const { onLobbyLoad, onPlayerEnter, onCountdownEnd } = game;
// unlock() when the round is over, so the next players can join.
## Examples (build, lint and run clean)
docs/developers/examples/{lanterns, bedwars, dungeon, arena}. The full samples: plot/samples/ (team-minigame +
bedwars + spleef, story-dungeon, gltf-level, hidden-roles, frontier).