# HatchWorld place scripts API v1, sha256 bf3f450b08fb0d61055eeab0fed5d82191dd93872059a3ad6a4064f7ebf3666b # HatchWorld place scripts — guide for script authors (API v1) Followed by the exact types (`space.d.ts`, `spaceClient.d.ts`); together the contract: what is in neither does not exist. ## 1. A place and its code - A **place** is a room players walk into (a building on the map, or virtual): an **owner**, **pieces** (3D models on the floor, pictures and videos on walls), **doors**, **players** inside (uids only — no names, no positions). - Up to two scripts, written together, each **one class**. **server** (`export default class XSpace extends SpaceScript` from `'@hatchworld/space@1'`) — the authority: reacts to events, keeps state, decides, tells the phones. **client** (`export default class XClient extends SpaceClient` from `'@hatchworld/space-client@1'`) — presentation on each phone inside; sends actions up; decides nothing: the truth is what the server confirmed. - `on` methods subscribe to the events below, no others: another `on`, a handler written as a field (`onX = () => …`) or a default export that is not such a class fails the publish. No constructor needed. - **Server: a new instance per event** — fields die with it; keep everything in `this.state` (≤ 64 KB JSON, the place's memory) or `e.player.state` (≤ 4 KB per player per place). **Client: one instance per visit** — fields last while the player is inside; nothing outlives the visit. - **Our system script runs first** in both (same context and API) and does §4: do not reuse its ids or re-implement it. - **No** `setTimeout`, `setInterval`, `fetch`, `eval`, `Function`, other imports, network, files, DOM. Timers: `this.runTimeout(name, ms, payload)` — a **name and a JSON payload**, never a closure — come back to `onTimer(e)`. - In a handler `Date.now()` is the event time (`e.at`), `Math.random()` is seeded. A handler **may run twice** for one event after a crash: be idempotent (check state before acting). - Per event: 50 ms CPU, 16 MB memory — keep handlers small; a script that keeps failing or is slow is switched off (the owner sees why). ## 2. Output - `server` (required — a client part alone is never activated) and `client` only if the place needs presentation on the phone (buttons, moving things, plates, bars); phones already show server `ui.say` emits. - Each part ≤ 64 KB, checked before activation by `tsc --strict` (handler parameters are typed for you) and a compile run: call only what the types declare. - **Statics of the server class** are its manifest (omit unneeded ones; an empty `[]`/`{}` is allowed): - `capabilities = ['world.write', 'llm']` — only what you use; calls without theirs are dropped; an unknown name fails the publish. `world.write`: every `this.world.*` call but reads; `llm`: `this.ask`; `shared`: `this.shared.*`; `notify`: `this.notify`. - `assets = { chair: { m: '', f: '.glb' } }` (`Asset`) — the only model files the script may stand (`addPiece`, `addPieceFor`). `m` is given by the owner (his upload or a shared catalogue model) — never invent a hash. Keys `[a-zA-Z0-9_]` ≤ 40 chars; ≤ 64 files. No item is created: nothing lands in anybody's inventory. - `texts = { key: { en: '…', ru: '…' } }` — **every text a player sees is a key here** (except an LLM answer), English and the owner's language at least; shown in the reader's language (English if missing). Keys `[a-zA-Z0-9_.]` ≤ 40 chars, not starting with `space.` (ours); strings ≤ 300 chars; ≤ 200 keys. - `commands = [{ name: 'open_chest', examples: ['open the chest', 'открой сундук'] }]` — phrases a player may type into the place's field; a match arrives as `onPlayerAction` with `e.payload.name === 'open_chest'`. Phones without that field send nothing: give the action a button too. ≤ 16 commands, ≤ 8 examples of ≤ 80 chars. - `shared`, `boards`, `thresholds` — §3. ## 3. Server — `SpaceScript` Every event has `name, at, seq, space, instance, occupants {count, uids}`, in the owner's script also `e.sys` (what the system script did in it); player events also `uid, isOwner, player {state, lends}`. A uid anywhere in this API is a `PlayerId`: an opaque string to compare and store, never a number to parse — it will become a 16-character public id. Handlers (payloads: the types): - `onPlayerEnter`; `onPlayerLeave` (`payload.explicit` — walked out or was put out; `reason: 'kick'` — the owner put him out); `onPlayerAction` — a phone's `this.net.action(name, target, payload)` or a matched command (`payload {name, target, payload}`); `onTimer`; `onLlmResult` — the answer to `ask`; `onPlaceMessage` — a message from another place. - `onPlayerAnswer` — a player answered a platform sheet (§5); only answers the platform checked arrive. Anyone who may enter may answer (≤ 20 a minute): check that `id` is one you asked, keep "already answered" in `e.player.state`. - `onScriptActivated` — this version switched on (`payload.from` null the first time); reaches only a copy of the place with someone in it (an empty place gets nothing). So start a **lasting behaviour** (a looping animation, a ticking timer) in BOTH it and `onPlayerEnter` when `e.occupants.count === 1` (the newcomer is counted); starting again is safe (a new `animate` replaces the piece's motion). NEVER keep a "running" flag in `this.state`: it outlives every motion, timer and version, so the behaviour never starts again. - `onNearby` — someone near the place on the street, not inside: rings of about 100, 50, 20 m around its point; once per player, place and ring per hour; ≤ 60 a minute per place; `uid` only of a player visible on the map; never metres. Only for a place with a map point (a building's room or under the open sky) and a class with `onNearby`. It reaches the place as a whole, not a copy with people: answer with `this.notify` or by preparing the room (`this.state`), not with a sheet. - `onSharedThreshold` — `static thresholds = { visits: [10, 100, 1000] }` (names from `static shared`, ≤ 8 ascending marks each): each mark fires ONCE for the place, ever — going below and back up does not fire it again. Members: - `this.state.get/set/delete/keys`; `e.player.state.get/set`; `e.player.lends` — his own "my things may be taken" switch, on unless he turned it off. - `this.emit(name, payload, { to?: uid })` — to the phones inside now (all or one), best effort; ≤ 16 per event, payload ≤ 4 KB; `sys:` names are ours. `'ui.say'` `{text}` shows a plate without client code; `'ui.ask' | 'ui.choice' | 'ui.pick' | 'ui.show'` `{id, …its options}` open a platform sheet (§5) on the `to` phone (`ui.show` may go to all; example F). - `this.runTimeout(name, delayMs, payload)` / `runInterval(name, ms, payload)` → id; `clearRun(id)`. ≤ 32 timers; intervals under 1000 ms are raised to 1000. - `this.world.pieces()` / `doors()` / `rules()` — read-only. Piece `{id, name?, kind ('scan' model | 'image' | 'video'), at?, noLend?, hidden?, trophy?, …}`: address it by `id` (10 chars a-z0-9, kept when it moves); `name` (≤ 64 chars, a catalogue model's English name, or absent) is the owner's: with several pieces find by name, not `kind`. Door id: its index as a string, `'0'` first. `rules().lend` — the owner's own such switch; `noLend: true` — he marked the piece "not to be taken"; a stored `lendable` is legacy, means nothing. - `this.world.space()` — the place's SHAPE (`SpaceShape`, in the frame of a piece's `at`; wall `i` runs `floor[i]` → `floor[i + 1]`); `null` without a stored outline — always check. Not `this.space`, which is WHO the place is. **Room writes** (`setPiece`, `removePiece`, `addPiece`, a kept end pose) change the stored room: one write a second per place — the ones of one event go together, a later event's wait their turn (not dropped); everyone inside sees them live. - `this.world.setPiece(id, {at?, as?, hidden?})`, `removePiece(id)`. `hidden: true` puts a piece away (kept, in `pieces()` with `hidden: true`, not drawn); `false` shows it. - `this.world.addPiece(id, {asset, at, as?, hidden?})` — a NEW floor piece of a `static assets` file; a fixed `id` per thing (`'trophy1'`); the same id and file again only moves it / resets `as`, `hidden` (safe to repeat); ≤ 200 pieces per room. A thing that **appears later** — add it up front with `hidden: true`, or at the moment; one that **comes and goes** — `setPiece(id, {hidden})`, not add/remove; a **shelf of goods** — one asset per item, each its own id; take away — `removePiece(id)`. - `addPieceFor(uid, id, {asset, at, as?})` / `removePieceFor(uid, id)` — NOT a room write (no 1-per-second limit): a **personal** floor piece only that player sees, shown on his every visit here; nobody else learns it exists; never in `pieces()`. The same id replaces his (safe to repeat; ids are per player, room pieces untouched). ≤ 16 per player per place. `piecesFor(e.uid)` — his list as the event began; `null` for any other uid. A display, NOT an item: nothing lands in his inventory, nothing can be traded. For a reward only its earner sees, a shelf of what he collected here, a decoration "bought" with this place's points. - `animate(id, spec, {layer?, keep?})` / `stopAnimation(id, {layer?, hold?})` — a piece's motion (`MotionSpec`), played in sync by every phone. Units: metres; `rot` in TURNS (0.25 = a quarter turn, 0.5 = half); `scale` a factor; `spin` radians per second. A looping spec runs until stopped; never animate with loops or timers. ≤ 16 calls per event, ≤ 32 running. - **`keep: true`** — without it a motion is a show: whoever comes in later or reloads sees the piece where it stood before. Use `keep` for whatever must stay moved (a door opened, a bed raised, a lid up, a lamp moved): the end pose is a room write at once, the motion still plays. Only `loop: 'none'`, no `spin`/`bob`, a floor piece; `by` counts from where it stands now; prefer `to` for toggles (example D). `stopAnimation(id, {hold: true})` keeps a kept motion where it froze. - `pitch`/`roll` tilt a floor piece (turns, ±0.5), live only: `keep` with a tilt is refused. - A piece ON A WALL (a picture, a screen) moves by `WallMotionSpec`; `wall` moves it to another wall (`to`, `loop: 'none'` only; it jumps, then slides). `keep` works for `u`, `v`, `wall`, is refused with `roll` or `scale`. Mixing floor and wall keys is refused. - **Compose, don't invent a trajectory.** A piece runs up to 4 LAYERS at once (`{layer: 'sway'}`, names `[a-z0-9_]`, default `'main'`); what you see is the stored pose plus every layer's part (scale multiplies). The same layer again replaces its motion; `stopAnimation(id)` without `layer` stops all of them. ≤ 32 layers in a room. `osc: {x|z|y|rot|pitch|roll|scale: {amp, periodMs, phase?}}` sways a key as a sine (`loop: 'repeat'`, `ms` = its longest `periodMs`); a circle is `x` and `z` with the same period, `phase` 0.25 apart; a figure eight — half the period on one of them. `amp`: `x`/`z` ≤ 50 m (a circle up to 50 m across the room), `y` ≤ 5 m. `face: 'travel'` (+ `rotOffset` turns) turns the piece's nose along the way it moves — one such layer a piece. `follow: {target: {player: uid} | {piece: id}, offset?, lagMs, minDist?}` trails someone in the room or another piece (`loop: 'repeat'`, never `keep`, no circles of pieces following each other): `lagMs` 0..5000 how slowly it catches up, `minDist` 0..10 m it stays back, `offset` each of x z y −10..10 m. `keep` only in `'main'`, and only when no other layer is `loop: 'none'`. A move with `keep` never cancels the piece's own running motion (a spin/sway in `'main'` keeps playing around the new place). Physics (`drop`/`push`/`throw`) plays in `'main'`, other layers stay. **`'main'` is for moves** (`to`/`by`/`path`, `keep`, physics, the place's agent); a piece's own motion (spin, sway, bob, circle) goes in a layer named for it (example I). - `look(target, spec, {keep?})` / `stopLook(target, {hold?})` — the colour of `'walls'` (all together; one wall alone is refused), `'floor'`, `'ceil'` or `'light'` (lamps) on a schedule (`LookSpec`). `colors` are blended over `ms`; then `pulse` steps one colour per beat — a disco is one colour, `ms: 0`, `loop: 'none'` and a pulse; a pulse needs `loop: 'none'`. The same target replaces its look; ≤ 8 running, ≤ 16 calls per event. `stopLook` keeps the colour on screen, `{hold: false}` brings the room's own back. `keep: true` stores the last colour (walls the owner coloured one by one keep theirs; for `'light'` also the last `gains` strength). Newcomers see running looks. - **Physics** (the platform computes, the script asks): `drop(id, {from?: {y}})` — falls, bounces to rest; `push(id, {dir, speed})` — slides with friction off walls and pieces; `throw(id, {dir, speed, up})` — flies, lands, bounces, slides. `dir` in turns (0 = +x, 0.25 = +z, counter-clockwise from above); `speed`, `up` 0..10 m/s. Floor pieces only; ≤ 4 calls per event; ≤ 8 s per throw. Every phone plays the same path; the end pose is a room write (like `keep`). Pieces are round obstacles of about 0.2 m, as tall as the ceiling: a throw bounces off them, never over. - **Effects** (in sync, names only from the types' lists): `sound(name, {gain?, at?})` ≤ 4 per event; `music(name)` / `stopMusic()` — one track per room, a new one replaces it, newcomers hear it in time; `burst(preset, {at | piece, count?})` ≤ 2 per event, gone in about 3 s; `highlight(pieceId, on)` ≤ 4 at once. Sound may be off on a phone: never the only signal. - A new script version stops the old looks, music and highlights. - `this.ask(requestId, prompt, {maxTokens ≤ 300})` (`llm`) → `onLlmResult`, same `requestId`, already moderated, in the asker's words (show it as is). ≤ 1 per event, prompt ≤ 4 KB, a daily cost cap per place (`error: 'quota'`). - `this.space` — `{id, owner, name, …}`; `console.log(…)` — the owner's log, ≤ 1 KB per event. - **Counters** (`shared`): declare the names you read in `static shared` (≤ 16); `incr`, `max`, `set` write; `get(name)` is **as this event began** (add this event's writes yourself, example F). Shared by every room of the project and every visitor. **Boards**: `static boards` (≤ 4); `board(n).submit(uid, score)` keeps each player's BEST; `top()` (10 best) and `rankOf(e.uid)` as the event began. To send them to phones copy them: `top().map((r) => ({ uid: r.uid, score: r.score }))`. - **A push** (`notify`): `this.notify(uid, 'textKey', { values })` opens this place, in his language. `values` only strings and finite numbers (`time` = a server-clock ms, shown as his local HH:MM), else the push is dropped whole. Shares the event's emit caps. Delivered only to the owner or a player who has been here and has not turned its pushes off; ≤ 3 a player and 100 a place per day, ≤ 1 to the same player per 5 minutes from this place; only once the text judge allows the words. Nothing is reported back: write it as "maybe". Declared but NOT working yet (dropped): `world.spawn/moveTo/despawn`, hot intervals. ## 3½. Live props — shapes for a game (server writes, phones read) A **live prop** is a plain shape the script stands in the room (a brick, a ball, a post, a goal line): a record of the place, NOT a piece of the stored room — no asset, no one-write-a-second limit, never in `pieces()`. It stays (an empty room too) until removed; a new script version puts out all of the old one's. `world.write` for every call but `props()`. - `this.world.prop(id, {shape, size?, color?, at, parent?, hidden?, hit?, players?})` — creates it or replaces it whole (safe to repeat). `shape` `'box' | 'sphere' | 'cylinder' | 'cone' | 'plane'`; `size` `[x, y (height), z]`, each 0.02..5 m, default `[0.3, 0.3, 0.3]`; `color` `'#rrggbb'`, default `'#ffc43d'`; `at {x, z, y?, rot?, pitch?, roll?}` — a floor piece's pose: metres, turns, `y` 0..20, `pitch`/`roll` ±0.5. A `plane` lies on the floor face up, `size[0]` × `size[2]` (`size[1]` unused, still in bounds); stand it up with `pitch: 0.25`. - `setProp(id, patch)` — only the fields given, live (a brick changes colour, a thing hides); `at` replaces the whole pose; `false` turns a flag off; an id no prop holds is refused. `removeProp(id)` — its children go with it; a missing one is nothing to do. - `props()` — `{id, shape, size, color, at, parent?, hidden?, hit?, players?}`, defaults filled in, **as the event began** (this event's calls are not in it). A launched prop stays where it was launched from in its record; where it struck is `onPropHit`'s `at`. - Ids `[a-z0-9]{1,16}`, ONE namespace with the room's pieces: an id a piece holds is refused (`prop_id`), and `addPiece` on a prop's id. ≤ 64 props in a room (`registry_cap`), ≤ 64 prop calls an event (`prop_cap`): build a whole level in ONE event — the room gets it as one packet. - **Compound**: `parent: ''` — `at` is from the parent's drawn pose (place, turn, tilt; a child's `y` −20..20); parent → child → grandchild at most (`prop_depth`), no cycle, no missing parent (`prop_parent`). A child rides with its parent: `follow` of its own and physics are refused (`child_linked`). - **Everything taking a piece's id takes a prop's**: `animate`/`stopAnimation` with layers and `keep` (the end pose goes into the prop's record; a tilt may be kept, `scale` not: `keep_scale`), `drop`/`push`/`throw`, `follow` (by a prop and of one), `highlight`, `burst({piece})`, `sound({at: {piece}})`, the phone's `scene.label({piece})`. An effect at a prop removed in the same event is refused: use a point. - **`launch(id, {dir, speed})`** — the prop flies along at its own height, no friction, bouncing off walls, pieces and props with its WHOLE speed until stopped (`stopAnimation(id)`, `removeProp`, or a new launch). `dir` in turns as a throw's, `speed` 0..10 m/s; one of the 4 physics calls an event; a room piece is refused (`piece`). The platform flies it on by itself while someone is inside. - **In a flight's way**: a sphere, cylinder or cone is a circle of its larger side across; a box its turned rectangle; a plane a line `size[0]` long along `rot`, whatever its tilt; a piece a circle of about 0.2 m; all as tall as the ceiling and where they are STORED (`at`, a kept end), not where a running motion draws them. `hidden` only stops the drawing: it still stands in the way — remove it. - **`hit: true`** on a prop: the flyer striking it is **`onPropHit(e)`**, `payload {id, other, at, t}` — `id` the flyer, `other` `{prop}` (the one struck) or `{player: uid}`, `at` the flyer's point `{x, z, y}`, `t` ms into its flight. Walls, pieces, props without `hit` bounce it silently (`other.piece` is declared, never comes). After the handler the platform flies it on from the hit with the bounced speed, among the props as the handler left them (a removed brick is out of the way); the handler's `launch`/`stopAnimation`/`removeProp` of the flyer replaces that. - **`players: r`** (0.2..2 m) on the flyer, a root prop not hidden, at most 2.1 m high: a player who comes within `r` of its edge bounces it (the platform does) and is an `onPropHit` with `other.player` — his uid only, never his position; once per 0.5 s a player. His body is the paddle: no `net.action` steering (60 a minute). The touch is known a frame late: keep such a ball **≤ 3 m/s**, faster it visibly jumps after it. `touch_stale` in the journal means the ball was NOT in an open flight when touched (it rested, was removed, or its flight ended) — not that it was too fast: launch it again. - **≤ 1 reported hit a second on average** per room: hits are the platform's timers, 60 a minute; past that they wait for the next minute and the game stalls. Mark `hit` only what the game must hear (bricks, a goal line). ## 4. What the system script already does - Greets each newcomer once per visit, tracks presence, keeps motion bookkeeping and live room changes. - The owner's **Throw out**: he taps a player → button → that guest is put out. - **Taking things for a while** — ON BY DEFAULT: a guest taps a piece → **Take** → carries it ≥ 60 s → walks out → it is his for 24 h, standing by itself in his home (his earliest place; a model on a free floor spot, a picture on a wall; the owner gets a push opening that home), or where he places it. Then it comes home by itself; its lender can always take it back by going there. Refused `not_lendable` when the owner's switch is off (`rules().lend` false) or the piece is `noLend`; `reciprocity` when the taker's own is off (`e.player.lends` false). Taking back one's own trophy is never refused. The owner's script sees it in `e.sys`: `sys:theft.started|stopped|failed|escaped|reclaimed|refused`, `sys:loan.returned`. - Sends a player his personal pieces as he comes in and after each change. Never send them yourself. - Reserved button ids and action names: `take`, `kick`. - System-only (a TypeError in your script): every `sys.*`, e.g. `sys.placeLoan` (stands a loan this place's owner holds here as his trophy) and the `{homeOf: uid}` address (a player's home) of `sys.send` / `sys.notify`. ## 5. Client — `SpaceClient` Handlers: `onTap(e)` (`e.target` `'piece'` with `e.id` — a live prop too, then `e.prop === true` —, `'player'` with `e.uid`, `'door'`, `'floor'`); `onServerEmit(e)` — server emits and **button presses** (`e.name === 'ui.press'`, `e.payload {id, target}`); `onTimer(e)`. Members (texts are keys of the server's `static texts`): - `this.space.viewer {uid, isOwner}`; `this.world.pieces()/doors()/rules()/space()` as on the server; `this.world.props()` — the live props as this phone has them, renewed before your handlers hear the event that changed them; `this.system.now()` — server time, the same on every phone. - `this.scene.shell.set({wall?, floor?, ceil?, light?, …})` — the room's look; `scene.door.state(doorId, 'open' | 'idle')`. - `this.scene.piece.move(id, {x?, z?, y?, rot?, scale?}, {ms?})` — a floor piece, smoothly (`rot` in turns); `piece.add(id, {m | asset, at?, as?})` (an unknown `asset` is dropped), `piece.remove(id)` — this phone only; for all guests and lasting: the server's `addPiece`. - `this.scene.label(id, textKey, at, {ttl?, time?})` — a sign at a point or over `{piece: id}`, this phone only. The same id replaces it (resend from a timer to count down); `label(id, '')` takes it down; `ttl` ms ends it. ≤ 16 at once, text cut at 36 characters; all go when the player leaves. - `this.ui.say(textKey, {ms?, time?})` — a plate (one at a time, ≥ 3 s apart); `ui.toast(textKey)`; `ui.bar(id, {until, label?})` (≤ 2); `ui.button(id, labelKey, {on: 'piece'|'player'|'screen', target?})` (≤ 4, gone after 5 s or on press). - `this.net.action(name, target, payload)` → the server's `onPlayerAction`; ≤ 60 per minute. - `this.system.runTimeout/runInterval` (≥ 50 ms) → `onTimer`; `system.clearRun`. - **Platform sheets** `ui.ask` (a line of text), `ui.choice` (an option), `ui.pick` (a person), `ui.show` (a card of results) — drawn by the platform, one at a time (a new one replaces it); the answer goes to the SERVER's `onPlayerAnswer`, never back here. Declared but NOT working yet (dropped): `scene.highlight`, `scene.creature.*`, `scene.frame.photo`, tap target `'pet'`. ## 6. Patterns Two pipes only: server `this.emit` → client `onServerEmit`; client `this.net.action` → server `onPlayerAction`. Never trust the client: the server checks who acts (`e.uid`, `e.isOwner`) and its own state before changing anything. **A. A doorbell: a button and signs on the phone; the server counts rings, opens the door for 10 s.** ```js import { SpaceClient } from '@hatchworld/space-client@1'; export default class BellClient extends SpaceClient { onTap(e) { if (e.target === 'floor') this.scene.label('hint', 'ringBell', { x: 0, z: 1 }, { ttl: 5000 }); if (e.target !== 'piece' || !e.id) return; this.ui.button('ring', 'ringBell', { on: 'piece', target: e.id }); this.scene.label('name:' + e.id, 'bell', { piece: e.id }, { ttl: 10000 }); } onServerEmit(e) { const p = e.payload; if (!p || typeof p !== 'object' || Array.isArray(p)) return; if (e.name === 'door') this.scene.door.state('0', p.state === 'open' ? 'open' : 'idle'); if (e.name === 'ui.press' && p.id === 'ring' && typeof p.target === 'string') this.net.action('ring', p.target); } } ``` ```js import { SpaceScript } from '@hatchworld/space@1'; export default class BellSpace extends SpaceScript { static texts = { ringBell: { en: 'Ring the bell', ru: 'Позвонить' }, bell: { en: 'The bell', ru: 'Колокол' }, rang: { en: 'Ding! Come in.', ru: 'Дзынь! Входите.' } }; onPlayerAction(e) { if (e.payload.name !== 'ring') return; this.state.set('rings', Number(this.state.get('rings') ?? 0) + 1); this.emit('ui.say', { text: 'rang' }); this.emit('door', { state: 'open' }); this.runTimeout('close-door', 10000); } onTimer(e) { if (e.payload.name === 'close-door') this.emit('door', { state: 'idle' }); } } ``` **B. A chest opens by a typed command, closes after 30 s (server, `world.write`).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class ChestSpace extends SpaceScript { static capabilities = ['world.write']; static commands = [{ name: 'open_chest', examples: ['open the chest', 'открой сундук'] }]; static texts = { opened: { en: 'The chest opens', ru: 'Сундук открылся' } }; onPlayerAction(e) { if (e.payload.name !== 'open_chest' || this.state.get('open') === true) return; const chest = this.world.pieces().find((p) => p.kind === 'scan'); if (!chest) return; this.state.set('open', true); this.world.animate(chest.id, { by: { y: 0.3 }, ms: 800, ease: 'out', loop: 'none' }); this.emit('ui.say', { text: 'opened' }); this.runTimeout('close', 30000, { id: chest.id }); } onTimer(e) { if (e.payload.name !== 'close') return; const data = e.payload.data; if (data && typeof data === 'object' && !Array.isArray(data) && typeof data.id === 'string') { this.world.animate(data.id, { by: { y: -0.3 }, ms: 800, ease: 'out', loop: 'none' }); } this.state.set('open', false); } } ``` **C. An oracle answers with an LLM (server, `llm`).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class OracleSpace extends SpaceScript { static capabilities = ['llm']; static texts = { silent: { en: 'The oracle is silent.', ru: 'Оракул молчит.' } }; onPlayerAction(e) { const q = e.payload.payload; if (e.payload.name !== 'ask' || typeof q !== 'string') return; this.ask(`q:${e.uid}:${e.seq}`, `Answer in one short sentence: ${q}`, { maxTokens: 80 }); } onLlmResult(e) { const to = e.payload.requestId.split(':')[1]; this.emit('ui.say', { text: e.payload.ok ? e.payload.text : 'silent' }, { to }); } } ``` **D. A tap raises a bed, the next lowers it, and it stays; the owner moves a piece to the middle (`world.write`).** ```js import { SpaceClient } from '@hatchworld/space-client@1'; export default class BedClient extends SpaceClient { onTap(e) { if (e.target === 'piece' && e.id) this.net.action('bed', e.id); } } ``` ```js import { SpaceScript } from '@hatchworld/space@1'; export default class BedSpace extends SpaceScript { static capabilities = ['world.write']; onPlayerAction(e) { const id = e.payload.target; if (typeof id !== 'string') return; if (e.payload.name === 'bed') { const up = this.state.get('up:' + id) === true; this.state.set('up:' + id, !up); // A kept lift stays within 2.2 m. this.world.animate(id, { to: { y: up ? 0 : 2 }, ms: 1500, ease: 'inOut', loop: 'none' }, { keep: true }); } const shape = this.world.space(); if (e.payload.name === 'to_middle' && e.isOwner && shape) { // The bounds' middle may fall outside an L-shaped floor. const b = shape.bounds; this.world.setPiece(id, { at: { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 } }); } } } ``` **E. A riddle solved: a cup in the middle for all, one only the solver sees (`world.write`).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class RiddleSpace extends SpaceScript { static capabilities = ['world.write']; static assets = { cup: { m: '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0', f: '.glb' } }; static texts = { solved: { en: 'Solved! A cup just for you.', ru: 'Разгадано! Кубок — только вам.' } }; onPlayerAction(e) { const answer = e.payload.payload; if (e.payload.name !== 'answer' || typeof answer !== 'string' || answer.trim().toLowerCase() !== 'echo') return; if (this.state.get('solved') !== true) { this.state.set('solved', true); const shape = this.world.space(); const b = shape ? shape.bounds : { minX: 0, maxX: 0, minZ: 0, maxZ: 0 }; this.world.addPiece('cup1', { asset: 'cup', at: { x: (b.minX + b.maxX) / 2, z: (b.minZ + b.maxZ) / 2 } }); } if ((this.world.piecesFor(e.uid) ?? []).some((p) => p.id === 'mine')) return; this.world.addPieceFor(e.uid, 'mine', { asset: 'cup', at: { x: 0, z: 1 }, as: 'stand' }); this.emit('ui.say', { text: 'solved' }, { to: e.uid }); } } ``` **F. The question of the day: everyone votes once, everyone sees the split (server only).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class PollSpace extends SpaceScript { static capabilities = ['shared']; static shared = ['pizza', 'sushi']; static texts = { question: { en: 'Pizza or sushi?', ru: 'Пицца или суши?' }, pizza: { en: 'Pizza', ru: 'Пицца' }, sushi: { en: 'Sushi', ru: 'Суши' }, split: { en: 'Votes', ru: 'Голоса' } }; onPlayerEnter(e) { if (e.player.state.get('voted') === true) return; this.emit('ui.choice', { id: 'poll', prompt: 'question', options: ['pizza', 'sushi'] }, { to: e.uid }); } onPlayerAnswer(e) { if (e.payload.id !== 'poll' || e.payload.kind !== 'choice' || e.player.state.get('voted') === true) return; const pick = e.payload.value === 0 ? 'pizza' : 'sushi'; e.player.state.set('voted', true); this.shared.incr(pick, 1); // get() is as this event began: add this vote. const pizza = Number(this.shared.get('pizza') ?? 0) + (pick === 'pizza' ? 1 : 0); const sushi = Number(this.shared.get('sushi') ?? 0) + (pick === 'sushi' ? 1 : 0); const all = Math.max(1, pizza + sushi); this.emit('ui.show', { id: 'split', title: 'split', rows: [ { label: 'pizza', value: pizza, share: pizza / all }, { label: 'sushi', value: sushi, share: sushi / all } ] }, { to: e.uid }); } } ``` **G. Beaten on the board: the old leader gets a push to come back (server only).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class RecordSpace extends SpaceScript { static capabilities = ['shared', 'notify']; static boards = ['taps']; static texts = { beaten: { en: 'Your record was beaten: {score}. Take it back?', ru: 'Твой рекорд побили: {score}. Вернёшь?' } }; onPlayerAction(e) { if (e.payload.name !== 'tap') return; const taps = Number(e.player.state.get('taps') ?? 0) + 1; e.player.state.set('taps', taps); const board = this.shared.board('taps'); const leader = board.top()[0]; board.submit(e.uid, taps); if (leader && leader.uid !== e.uid && taps === leader.score + 1) { this.notify(leader.uid, 'beaten', { values: { score: taps } }); } } } ``` **H. The owner's commands start a disco and bring the room back (server only).** ```js import { SpaceScript } from '@hatchworld/space@1'; export default class DiscoSpace extends SpaceScript { static capabilities = ['world.write']; static commands = [ { name: 'disco', examples: ['party time', 'дискотека'] }, { name: 'calm', examples: ['stop the party', 'хватит'] } ]; onPlayerAction(e) { if (!e.isOwner) return; if (e.payload.name === 'disco') { this.world.look('walls', { colors: ['#6a1b9a'], ms: 2000, ease: 'inOut', loop: 'none', pulse: { bpm: 120, colors: ['#6a1b9a', '#00bcd4', '#ff4081'] } }); this.world.look('light', { colors: ['#ff4081'], gains: [1.6], ms: 2000, ease: 'inOut', loop: 'none' }); } if (e.payload.name === 'calm') { this.world.stopLook('walls', { hold: false }); this.world.stopLook('light', { hold: false }); } } } ``` **I. Pieces are classes: fish that spin and bob on their own, a shoal moved as a group (server).** ```js import { SpaceScript } from '@hatchworld/space@1'; // One class per kind of piece: all it does on its own lives in it, in its own layers. class Fish { /** @param {SpaceScript} space @param {string} id */ constructor(space, id) { this.space = space; this.id = id; } start() { const w = this.space.world; w.animate(this.id, { spin: { radPerSec: -0.8 }, ms: 1000, ease: 'linear', loop: 'repeat' }, { layer: 'spin' }); w.animate(this.id, { osc: { y: { amp: 0.1, periodMs: 1500 } }, ms: 1500, ease: 'linear', loop: 'repeat' }, { layer: 'bob' }); } /** @param {number} x @param {number} z */ moveTo(x, z) { // A move goes in 'main' and keeps; the spin and the bob go on around the new place. this.space.world.animate(this.id, { to: { x, z }, ms: 1500, ease: 'inOut', loop: 'none' }, { keep: true }); } } // A group is a class over its members. class Shoal { /** @param {Fish[]} fish */ constructor(fish) { this.fish = fish; } start() { for (const f of this.fish) f.start(); } /** @param {number} x @param {number} z */ gather(x, z) { this.fish.forEach((f, i) => f.moveTo(x + i * 0.5, z)); } } export default class Aquarium extends SpaceScript { static capabilities = ['world.write']; shoal() { return new Shoal(this.world.pieces().filter((p) => p.name === 'Fish').map((p) => new Fish(this, p.id))); } onScriptActivated() { this.shoal().start(); } onPlayerEnter(e) { if (e.occupants.count === 1) this.shoal().start(); } onPlayerAction(e) { const shape = this.world.space(); if (e.payload.name !== 'gather' || !e.isOwner || !shape) return; const b = shape.bounds; this.shoal().gather((b.minX + b.maxX) / 2, (b.minZ + b.maxZ) / 2); } } ``` **J. A companion for every player: it comes in with him, follows him, leaves with him (server).** Its model is a `static assets` file; ≤ 32 motions in a room, so ≤ 32 companions at once. ```js import { SpaceScript } from '@hatchworld/space@1'; class Pet { /** @param {SpaceScript} space @param {string} uid */ constructor(space, uid) { this.space = space; this.uid = uid; // Piece ids are [a-z0-9]{1,16}: no '_', so 'p' + the uid's last 15 digits. this.id = 'p' + uid.slice(-15); } /** @param {number} x @param {number} z */ come(x, z) { const w = this.space.world; w.addPiece(this.id, { asset: 'pet', at: { x, z } }); w.animate(this.id, { follow: { target: { player: this.uid }, lagMs: 600, minDist: 2 }, ms: 1000, ease: 'linear', loop: 'repeat' }, { layer: 'follow' }); } go() { // Stop its motions first: they hold a room's motion slot until stopped. this.space.world.stopAnimation(this.id); this.space.world.removePiece(this.id); } } export default class PetRoom extends SpaceScript { static capabilities = ['world.write']; static assets = { pet: { m: '0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c4b5a69788796a5b4c3d2e1f0', f: '.glb' } }; onPlayerEnter(e) { const shape = this.world.space(); const b = shape ? shape.bounds : { minX: 0, maxX: 0, minZ: 0, maxZ: 0 }; new Pet(this, e.uid).come((b.minX + b.maxX) / 2, (b.minZ + b.maxZ) / 2); } onPlayerLeave(e) { new Pet(this, e.uid).go(); } } ``` **K. A 3D brick breaker of live props (§3½): bricks, a ball, a goal line, three lives; the player's body is the paddle.** ```js import { SpaceClient } from '@hatchworld/space-client@1'; export default class ArcanoidClient extends SpaceClient { onTap(e) { // A live prop is tapped as a piece; e.prop tells it from the room's pieces. if (e.target === 'piece' && e.prop === true && e.id === 'start') this.net.action('start', 'start'); } onServerEmit(e) { if (e.name !== 'level') return; // The props as this phone has them (already renewed): never read 'sys:props'. if (this.world.props().some((p) => p.id === 'start')) this.scene.label('hint', 'tapStart', { piece: 'start' }, { ttl: 8000 }); } } ``` ```js import { SpaceScript } from '@hatchworld/space@1'; const COLORS = ['#e53935', '#fb8c00', '#fdd835']; const BRICKS = 18; // 3 rows of 6 const Y = 0.35; // the ball's and the bricks' height // A brick is a box marked hit: the ball striking it is onPropHit. class Brick { /** @param {SpaceScript} s @param {string} id */ constructor(s, id) { this.s = s; this.id = id; } /** @param {number} x @param {number} z @param {number} w @param {string} color */ put(x, z, w, color) { this.s.world.prop(this.id, { shape: 'box', size: [w, 0.3, 0.25], color, at: { x, z, y: Y }, hit: true }); } // Effects at the hit's point: one at a prop removed in the same event is refused. /** @param {{x: number, z: number, y: number}} at */ break(at) { this.s.world.removeProp(this.id); this.s.world.burst('sparks', { at, count: 30 }); this.s.world.sound('coin', { at }); } } class Ball { /** @param {SpaceScript} s @param {number} x @param {number} z */ constructor(s, x, z) { this.s = s; this.x = x; this.z = z; } // players: a player within 0.5 m of its edge bounces it; hidden until served. put() { this.s.world.prop('ball', { shape: 'sphere', size: [0.25, 0.25, 0.25], color: '#ffffff', at: { x: this.x, z: this.z, y: Y }, players: 0.5, hidden: true }); } serve() { this.s.world.setProp('ball', { at: { x: this.x, z: this.z, y: Y }, hidden: false }); // With players in the game at most 3 m/s; 0.25 = towards +z, the bricks. this.s.world.launch('ball', { dir: 0.25 + (Math.random() - 0.5) * 0.15, speed: 3 }); } stop() { this.s.world.stopAnimation('ball'); this.s.world.setProp('ball', { hidden: true }); } } // The visible paddle: a disc of the ball's reach under the player. A root prop (a child may // not follow); in a flight's way it stands at its stored `at`, so that is behind the goal line. class Paddle { /** @param {SpaceScript} s @param {string} uid */ constructor(s, uid) { this.s = s; this.uid = uid; } /** @param {number} x @param {number} z */ follow(x, z) { this.s.world.prop('paddle', { shape: 'cylinder', size: [1, 0.02, 1], color: '#00e5ff', at: { x, z } }); this.s.world.animate('paddle', { follow: { target: { player: this.uid }, lagMs: 0 }, ms: 1000, ease: 'linear', loop: 'repeat' }, { layer: 'follow' }); } } class Level { /** @param {SpaceScript} s */ constructor(s) { this.s = s; const shape = s.world.space(); this.b = shape ? shape.bounds : { minX: -3, maxX: 3, minZ: -3, maxZ: 3 }; this.cx = (this.b.minX + this.b.maxX) / 2; this.ball = new Ball(s, this.cx, (this.b.minZ + this.b.maxZ) / 2); } // The whole level in one event (≤ 64 prop calls); every call replaces: safe to repeat. build() { const w = this.s.world, b = this.b; const step = Math.min(0.6, (b.maxX - b.minX) / 7); for (let r = 0; r < 3; r++) { for (let c = 0; c < 6; c++) new Brick(this.s, `k${r}${c}`).put(this.cx + (c - 2.5) * step, b.maxZ - 0.5 - r * 0.4, step - 0.1, COLORS[r]); } // The goal line: a strip on the floor; in a flight's way a line along x, marked hit. w.prop('goal', { shape: 'plane', size: [Math.min(5, b.maxX - b.minX), 0.02, 0.1], color: '#00e5ff', at: { x: this.cx, z: b.minZ + 0.8 }, hit: true }); w.prop('start', { shape: 'cone', size: [0.3, 0.5, 0.3], color: '#43a047', at: { x: b.minX + 0.4, z: b.minZ + 0.4 } }); for (let i = 0; i < 3; i++) w.prop(`life${i}`, { shape: 'sphere', size: [0.15, 0.15, 0.15], color: '#e91e63', at: { x: b.maxX - 0.3 - i * 0.25, z: b.minZ + 0.3, y: 1 } }); this.ball.put(); this.s.emit('level', { bricks: BRICKS }); } /** @param {string} uid */ start(uid) { this.build(); new Paddle(this.s, uid).follow(this.cx, this.b.minZ + 0.2); this.s.state.set('score', 0); this.s.state.set('lives', 3); this.s.state.set('player', uid); this.ball.serve(); } /** @param {string} id @param {{x: number, z: number, y: number}} at */ brick(id, at) { const left = this.s.world.props().filter((p) => p.id.startsWith('k')); if (!left.some((p) => p.id === id)) return; // already broken (a handler may run twice) new Brick(this.s, id).break(at); const score = Number(this.s.state.get('score') ?? 0) + 1; this.s.state.set('score', score); if (left.length === 1) this.end('won', score); } miss() { const lives = Number(this.s.state.get('lives') ?? 0) - 1; if (lives < 0) return; this.s.state.set('lives', lives); this.s.world.removeProp(`life${lives}`); if (lives > 0) { // Stopped, it is not flown on from the line; served again from the middle. this.ball.stop(); this.s.emit('ui.say', { text: 'missed' }); this.s.runTimeout('serve', 1500); } else this.end('over', Number(this.s.state.get('score') ?? 0)); } /** @param {string} text @param {number} score */ end(text, score) { this.ball.stop(); this.s.state.set('lives', 0); this.s.world.sound(text === 'won' ? 'fanfare' : 'whoosh'); this.s.emit('ui.say', { text }); const uid = this.s.state.get('player'); if (typeof uid === 'string') { this.s.emit('ui.show', { id: 'score', title: 'score', rows: [{ label: 'bricks', value: score, share: score / BRICKS }] }, { to: uid }); } } } export default class Arcanoid extends SpaceScript { static capabilities = ['world.write']; static texts = { missed: { en: 'Missed! Next ball…', ru: 'Мимо! Следующий мяч…' }, won: { en: 'All bricks down!', ru: 'Все кирпичи сбиты!' }, over: { en: 'Game over', ru: 'Игра окончена' }, score: { en: 'Your score', ru: 'Ваш счёт' }, bricks: { en: 'Bricks', ru: 'Кирпичи' }, tapStart: { en: 'Tap the cone to play', ru: 'Коснитесь конуса, чтобы играть' } }; onScriptActivated() { new Level(this).build(); } onPlayerEnter(e) { if (e.occupants.count === 1) new Level(this).build(); } onPlayerAction(e) { if (e.payload.name === 'start') new Level(this).start(e.uid); } onPropHit(e) { const other = e.payload.other; // A player bounced it (the platform did); just a sound. if ('player' in other) this.world.sound('pop', { at: { piece: 'ball' } }); else if ('prop' in other && other.prop === 'goal') new Level(this).miss(); else if ('prop' in other && other.prop.startsWith('k')) new Level(this).brick(other.prop, e.payload.at); } onTimer(e) { if (e.payload.name === 'serve' && Number(this.state.get('lives') ?? 0) > 0) new Level(this).ball.serve(); } } ``` ## 6½. A one-off action (`OnceScript`) — asked by the place's agent, never published For a thing done once ("move the sofa to the window", "say hello to everyone here"): a default-export class extending `OnceScript` whose one method `run()` returns the answer (JSON, ≤ 4 KB). Runs once in the place's actor with an event's limits; not a version: no `onX` handlers, no timers (dropped), nothing stays running. Rights are the server's, not the code's: the owner may read, `world.*` writes, `shared` and `emit` (`ui.say` etc.), never `ask`, `notify` or anything else; declare `capabilities` (`['world.write']`, `['shared']`) for what you use. Reads: `world.pieces/doors/rules/space()`, `shared.get`, boards' `top/rankOf`, `state.get/keys` (a guest's run sees an empty state). Too many runs a minute for one player or for the place: `rate_limited`, try later. Move a piece in `'main'` with `keep` (as below); never stop or replace its other layers — they are its own. ```js import { OnceScript } from '@hatchworld/space@1'; export default class MoveSofa extends OnceScript { static capabilities = ['world.write']; run() { const sofa = this.world.pieces().find((p) => p.name === 'Sofa'); if (!sofa) return { done: false, reason: 'no sofa here' }; this.world.animate(sofa.id, { to: { x: 2, z: -1 }, ms: 1500, ease: 'inOut', loop: 'none' }, { keep: true }); return { done: true, id: sofa.id }; } } ``` ## 7. Rules that keep a script alive - Check every input (`typeof`, presence): payloads come from phones. Narrow a `Json` value (state, payloads) before use: `Number(x ?? 0)`; `x && typeof x === 'object' && !Array.isArray(x)` before reading `x.field`; `typeof x === 'string'`. Helper functions need typed parameters (JSDoc `@param`) — prefer the checks inline. - Every piece the script drives is a class (example I): its own motions, in its own named layers, and its own reactions live in it; a group is a class over its members. Never scatter `world.animate` calls on one piece across handlers. - Small state; never store per-frame data. - On the phone read live props only through `this.world.props()`, never from the `'sys:props'` emits (one over 4 KB never reaches your handler). - No personal data; no names (you only have uids). - Write for a room of up to 24 people at once. ===== space.d.ts ===== // Types a space script is checked against at publish time (tsc --noEmit). // The shape is Minecraft Bedrock's Script API on purpose (afterEvents.X.subscribe, // system.runTimeout), the lifecycle is ours: an actor sleeps between events, so // timers carry a name and a payload, never a closure. // The closed catalogs these names come from: ./index.js. // Design: ch/docs/spaces/runtime/architecture.md §9 (server), §20 (client). // The sandbox gives scripts a global console that only logs (<= 1 KB per event, §9). // Declared as an interface so it merges with a host lib's Console instead of clashing. interface Console { log(...data: unknown[]): void; } declare var console: Console; declare module '@hatchworld/space@1' { export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; export type Surface = 'room' | 'street'; export type Behavior = 'idle' | 'patrol' | 'follow' | 'moveTo'; // Meters from the room's anchor; scripts never see lng/lat (§20.2). export interface Point { x: number; z: number; y?: number; } export interface Occupants { readonly count: number; readonly uids: readonly PlayerId[]; } export interface KeyValueState { get(key: string): Json | undefined; set(key: string, value: Json): void; } // The actor's blob, <= 64 KB (§9). export interface SpaceState extends KeyValueState { delete(key: string): void; keys(): string[]; } // What the system script told the owner's script about this event (§9.9): // each sys.emit(name, payload) of the system script, in order, the name // prefixed with 'sys:' (e.g. 'sys:visit', 'sys:theft.started'). export interface SysEvent { readonly name: string; readonly payload: Json; } // What every handler receives (§5.3). Inside a handler Date.now() equals `at`. export interface EventBase { readonly name: string; readonly at: number; readonly seq: number; readonly space: string; readonly instance: number | null; // null for the director readonly occupants: Occupants; // Present in the owner's script only; the system script runs first (§9.9). readonly sys?: readonly SysEvent[]; } // Events about one person: who he is, his per-project row (<= 4 KB) and lends, his own "my things may be taken" switch. export interface PlayerEventBase extends EventBase { readonly uid: PlayerId; readonly isOwner: boolean; readonly player: { readonly state: KeyValueState; readonly lends: boolean }; } export interface PlayerEnterEvent extends PlayerEventBase {} // How he went (geo-stream LEAVE_*): explicit — he chose to go or was put // out; reason 'kick' — the owner put him out (never an escape). export interface PlayerLeaveEvent extends PlayerEventBase { readonly payload?: { readonly explicit: boolean; readonly reason?: 'leave' | 'kick' | 'socket' | (string & {}) }; } // What net.action named on the phone. 'take' (target: piece id) is the // theft's (§16 Э2, §20.8); 'kick' (target: uid) is the owner's, any guest // (system moderation). The set stays open. export type ActionName = 'take' | 'kick' | (string & {}); export interface PlayerActionEvent extends PlayerEventBase { readonly payload: { readonly name: ActionName; readonly target: string | null; readonly payload: Json }; } export interface TimerEvent extends EventBase { // data is what runTimeout/runInterval was given. readonly payload: { readonly name: string; readonly dueAt: number; readonly late: number; readonly data: Json }; } export type LlmResult = | { readonly requestId: string; readonly ok: true; readonly text: string } | { readonly requestId: string; readonly ok: false; readonly refused?: boolean; readonly reasons?: readonly string[]; readonly error?: string }; export interface LlmResultEvent extends EventBase { readonly payload: LlmResult; } export interface ScriptActivatedEvent extends EventBase { readonly payload: { readonly from: number | null; readonly to: number }; } // Director only: a shared counter crossed a threshold declared in the manifest. export interface SharedThresholdEvent extends EventBase { readonly payload: { readonly name: string; readonly value: number; readonly threshold: number }; } // Director only, street surface: computed by geo-stream, no distance in meters. // uid is present only for someone who shows himself on the map (§20.10). export interface NearbyEvent extends EventBase { readonly payload: { readonly uid?: PlayerId; readonly bucket: 'far' | 'near' | 'here'; readonly count: number }; } // What another place sent this one with sys.send: `from` is the sender's // room key, `name` as sent with 'sys:' in front when the system sent it. export interface PlaceMessageEvent extends EventBase { readonly payload: { readonly from: string; readonly name: string; readonly payload: Json }; } export interface AfterEventSignal { subscribe(callback: (event: E) => void): (event: E) => void; } export interface WorldAfterEvents { readonly playerEnter: AfterEventSignal; readonly playerLeave: AfterEventSignal; readonly playerAction: AfterEventSignal; readonly timer: AfterEventSignal; readonly llmResult: AfterEventSignal; readonly scriptActivated: AfterEventSignal; readonly sharedThreshold: AfterEventSignal; readonly nearby: AfterEventSignal; readonly placeMessage: AfterEventSignal; } // A piece held on loan from another room: that room's key, the piece's id // there, when it goes home (ms), and the lender — the uid owning `from`, // written by the server on save, never by a client. export interface Trophy { readonly from: string; readonly pieceId: string; readonly until: number; readonly lender?: string; } // A snapshot of the room config, read only. `noLend`: the owner marked the // piece "not to be taken" (`lendable` is legacy data and decides nothing). export type Piece = { readonly id: string; readonly noLend?: true; readonly hidden?: true; readonly trophy?: Trophy } & { readonly [key: string]: Json }; // The room owner's switches, read only. lend: his "my things may be taken" // (on unless he turned it off); without it the host refuses any loan. export interface Rules { readonly lend: boolean; } export type Door = { readonly id: string } & { readonly [key: string]: Json }; // A pose in the room's frame: meters from the anchor, rot in TURNS (0.25 = a quarter turn); any subset. export interface MotionPose { x?: number; z?: number; y?: number; rot?: number; scale?: number; } export type MotionEase = 'linear' | 'inOut' | 'out'; export type MotionLoop = 'none' | 'repeat' | 'pingpong'; // What world.animate takes; the phones play it. The host adds startAt (the // event's `at`, the base's clock), a script never gives the time. Out of // bounds is dropped with the reason (dropped:spec_). export interface MotionSpec { to?: MotionPose; by?: MotionPose; ms: number; // 0..600 000 ease: MotionEase; loop: MotionLoop; spin?: { radPerSec: number }; // |radPerSec| <= 20 bob?: { amp: number; periodMs: number }; // amp 0..5 m, periodMs 100..600 000 } export interface World { readonly afterEvents: WorldAfterEvents; pieces(): readonly Piece[]; doors(): readonly Door[]; rules(): Rules; setPiece(id: string, change: PieceChange): void; // PieceChange, addPiece: spaceClient.d.ts removePiece(id: string): void; // Capability 'world.write'. Up to 16 calls per event and 32 running // motions per room; a known id replaces its motion. Everyone inside // hears 'sys:motion' { id, spec }, whoever comes in later gets // 'sys:motion.snapshot' { motions: [{ id, spec }] }. keep: AnimateOptions. animate(id: string, spec: MotionSpec, options?: AnimateOptions): void; // Everyone inside hears 'sys:motion.stop' { id, hold }. stopAnimation(id: string, options?: { hold?: boolean }): void; spawn(id: string, spec: { behavior: Behavior; params?: Json }): void; moveTo(id: string, point: Point): void; despawn(id: string): void; } export type TimerId = string; export interface System { runTimeout(name: string, delayMs: number, payload?: Json): TimerId; // ms < 1000 needs the 'hot' capability. runInterval(name: string, ms: number, payload?: Json): TimerId; clearRun(id: TimerId): void; } export interface Space { readonly id: string; readonly project: string; readonly instance: number | null; readonly owner: string; readonly name: string; readonly surface: Surface; readonly state: SpaceState; // Best effort, only to whoever is in the instance when it plays out (§9.3). // The director without `instance` sends to every instance. emit(name: string, payload?: Json, options?: { to?: PlayerId; instance?: number }): void; } export interface Shared { incr(name: string, delta: number): void; max(name: string, value: number): void; set(name: string, value: Json, options?: { ifVersion?: number }): void; get(name: string): Json | undefined; } export interface Llm { // The answer arrives as an llmResult event with the same requestId. ask(requestId: string, prompt: string, options?: { maxTokens?: number }): void; } // Operations of our own system script (§9.9, §16 Э2). Any other script // calling them gets a TypeError. export interface Sys { // Puts the player out of the room. kick(uid: PlayerId, reason: string): void; // Marks the piece as the thief's until `untilMs`; `to` null and 0 hand it back. // The host drops a loan (not a hand-back) if the owner or `to` turned lending off, or the piece is noLend. loan(pieceId: string, to: PlayerId | null, untilMs: number): void; // A push to a player wherever he is: the text is a push i18n key // ('space.push.*'), values fill its {placeholders} — `time`, a // server-clock moment in ms, in the reader's local time, as ui.say's. // link: the place the push opens, a key or HomeOf (spaceClient.d.ts). Queued once per (room, dedupe). notify(notice: { to: string; key: string; values?: { [name: string]: string | number }; link?: string | HomeOf; dedupe: string }): void; // An event to another place, a key or HomeOf: it gets placeMessage { from: this // room's key, name: 'sys:' + name, payload }. Appended once per (this // room, dedupe); shares the emit cap and its payload bound. send(to: string | HomeOf, name: string, payload: Json | undefined, options: { dedupe: string }): void; // Tells the owner's script, in this same event, as e.sys 'sys:'. emit(name: string, payload?: Json): void; } // A place's server script is one class (ch docs/spaces/ai/INDEX.md Д8): on subscribes to that event // of index.js EVENTS, any other on fails the load. A new instance takes every event: a field dies with // it, what lasts goes to this.state / e.player.state. Members are shortcuts to the module's own objects. export abstract class SpaceScript { readonly state: SpaceState; // space.state readonly world: World; readonly shared: Shared; readonly space: Space; readonly emit: Space['emit']; readonly ask: Llm['ask']; readonly runTimeout: System['runTimeout']; readonly runInterval: System['runInterval']; readonly clearRun: System['clearRun']; onPlayerEnter?(e: PlayerEnterEvent): void; onPlayerLeave?(e: PlayerLeaveEvent): void; onPlayerAction?(e: PlayerActionEvent): void; onTimer?(e: TimerEvent): void; onPlayerAnswer?(e: PlayerAnswerEvent): void; // PlayerAnswerEvent: spaceClient.d.ts onLlmResult?(e: LlmResultEvent): void; onScriptActivated?(e: ScriptActivatedEvent): void; onSharedThreshold?(e: SharedThresholdEvent): void; onNearby?(e: NearbyEvent): void; onPlaceMessage?(e: PlaceMessageEvent): void; // Read into the manifest at publish, bounds in index.js LIMITS (COMMANDS_*, TEXT_*): ai/architecture.md Д5, ai/script-author.md §5.2. static capabilities?: readonly string[]; // index.js CAPABILITIES; an unknown name fails the publish static commands?: readonly { readonly name: string; readonly examples: readonly string[] }[]; static texts?: { readonly [key: string]: { readonly [lang: string]: string } } | {}; // { chestOpened: { en, ru } }; `| {}`: an empty table static assets?: { readonly [key: string]: Asset } | {}; // ASSETS_MAX, ASSET_KEY_MAX_CHARS; Asset: spaceClient.d.ts static shared?: readonly string[]; static boards?: readonly string[]; static thresholds?: Thresholds; // Thresholds: spaceClient.d.ts } export const world: World; export const system: System; export const space: Space; export const shared: Shared; export const llm: Llm; export const sys: Sys; } // The client subset (§20.2–§20.4): presentation and local reaction on the phone. Its base class SpaceClient: spaceClient.d.ts. // It decides nothing; the truth is only what the server confirmed with an emit. declare module '@hatchworld/space-client@1' { import type { Json, Point, Surface, Behavior, Piece, Door, Rules, AfterEventSignal, TimerId, PlayerId } from '@hatchworld/space@1'; export interface ClientSpace { readonly id: string; readonly project: string; readonly instance: number | null; readonly owner: string; readonly name: string; readonly surface: Surface; // Who runs this copy of the script: the phone's own player. readonly viewer: { readonly uid: PlayerId; readonly isOwner: boolean }; } // A server emit as it reaches the phone (activity 'space', §20.5). export interface ServerEmitEvent { readonly name: string; readonly payload: Json; readonly seq: number; } // A client timer coming due; payload is what runTimeout/runInterval was given. export interface ClientTimerEvent { readonly id: TimerId; readonly name: string; readonly payload: Json; } export interface ClientWorld { readonly afterEvents: { readonly serverEmit: AfterEventSignal; readonly timer: AfterEventSignal; }; pieces(): readonly Piece[]; doors(): readonly Door[]; rules(): Rules; // Who is in the room, from the last snapshot the host gave. occupants(): readonly Json[]; } export interface ClientSystem { // Ticks no faster than CLIENT_TICK_MIN_MS; the script never gets a frame (§20.3). runTimeout(name: string, delayMs: number, payload?: Json): TimerId; runInterval(name: string, ms: number, payload?: Json): TimerId; clearRun(id: TimerId): void; // Server time, shared by every phone in the space. now(): number; } export interface Scene { readonly shell: { set(look: { wall?: Json; floor?: Json; ceil?: Json; byWall?: Json; light?: Json; hide?: Json }): void; }; readonly piece: { move(id: string, to: { x?: number; z?: number; y?: number; rot?: number; scale?: number }, options?: { ms?: number }): void; add(id: string, spec: { m: string; at?: Point; as?: string } | { asset: string; at?: Point; as?: string }): void; remove(id: string): void; }; readonly door: { state(door: string, state: 'open' | 'idle'): void; }; readonly frame: { photo(wall: string, slot: number, blobHash: string | null): void; }; label(id: string, text: string, at: LabelAt, options?: LabelOptions): void; readonly creature: { spawn(id: string, spec: { behavior: Behavior; params?: Json }): void; moveTo(id: string, point: Point): void; despawn(id: string): void; }; highlight(id: string, on: boolean): void; } export interface Ui { say(text: string, options?: { ms?: number; // Server-clock moment in ms; when text is a space.* i18n key it fills {time} in the reader's local time. time?: number; }): void; toast(text: string): void; button(id: string, label: string, options: { on: 'piece' | 'player' | 'screen'; target?: string }): void; bar(id: string, options: { until: number; label?: string }): void; choice(id: string, options: readonly string[]): void; } export type TapTarget = 'piece' | 'door' | 'floor' | 'player' | 'pet'; export interface TapEvent { readonly target: TapTarget; readonly id: string | null; readonly uid?: PlayerId; readonly at?: Point; } export interface Input { onTap(target: TapTarget, callback: (event: TapEvent) => void): void; } export interface Net { // Arrives on the server as a playerAction event (§5.1). action(name: string, target: string | null, payload?: Json): void; } export const space: ClientSpace; export const world: ClientWorld; export const system: ClientSystem; export const scene: Scene; export const ui: Ui; export const input: Input; export const net: Net; } ===== spaceClient.d.ts ===== // The base class of a place's client script, `SpaceClient` (ch docs/spaces/ai/INDEX.md // Д8, script-author.md §2.2). Its own file because space.d.ts is at the 400-line // module cap; this block merges into the `@hatchworld/space-client@1` module // declared there (the publish tsc reads both files, ch-server spaces scriptPublish.js). // For the same reason it also carries world.space() and SpaceShape (end of file). // // import { SpaceClient } from '@hatchworld/space-client@1'; // export default class TavernClient extends SpaceClient { // onServerEmit(e) { this.ui.toast(e.name); } // onTap(e) { if (e.target === 'piece' && e.id) this.net.action('take', e.id); } // } declare module '@hatchworld/space-client@1' { import type { SpaceShape, Point } from '@hatchworld/space@1'; // A method on subscribes to that event of index.js CLIENT_EVENTS; any // other on fails the load, and so does a handler written as a field. // Unlike the server's SpaceScript (a new instance per event), the client // script lives the whole visit, so there is ONE instance per visit: its // fields last while the player is in the place and die when he leaves. // Nothing on the phone outlives the visit; what must, the server keeps. // Members are shortcuts to the module's own objects. export abstract class SpaceClient { readonly scene: Scene; readonly ui: Ui; readonly net: Net; readonly world: ClientWorld; readonly system: ClientSystem; readonly space: ClientSpace; readonly input: Input; // world.afterEvents.serverEmit onServerEmit?(e: ServerEmitEvent): void; // world.afterEvents.timer onTimer?(e: ClientTimerEvent): void; // input.onTap for every TapTarget; e.target tells them apart. onTap?(e: TapEvent): void; } // Merges into ClientWorld (space.d.ts): the same shape the server reads. export interface ClientWorld { space(): SpaceShape | null; } // Platform sheets (tasks S1-S3), merged into Ui. Drawn by the platform, not // by the script: the script cannot fake them or read what is typed until // the server checked it. The answer is NOT returned here: it reaches the // SERVER script as onPlayerAnswer { id, kind, value }. Texts are keys of // static texts (or space.*); options at most 8 (CHOICE_OPTIONS_MAX). // The server script opens the same sheets on one phone with // this.emit('ui.ask' | 'ui.choice' | 'ui.pick' | 'ui.show', { id, ...options }, { to: uid }). export interface Ui { // A line of text, at most 280 chars (ANSWER_TEXT_MAX_CHARS), judged by // the platform's text judge before the server script sees it. ask(id: string, prompt: string, options?: { placeholder?: string }): void; // One of the options; the answer's value is the option's index. choice(id: string, options: readonly string[], opts: { prompt?: string }): void; // A person: 'here' — someone in this room now, 'friends' — one of the // asker's friends. The value is that player's PlayerId; the platform // draws names and faces, the script only ever holds PlayerIds. pick(id: string, prompt: string, options: { from: 'here' | 'friends' }): void; // A card of results the script fills: a title and rows (label, an // optional number and an optional share 0..1 drawn as a bar). The same // id replaces it; ttl in ms, or until the player closes it. show(id: string, card: { title: string; rows: readonly { label: string; value?: number; share?: number }[]; ttl?: number }): void; } // scene.label's place and options (here for the same cap). A label is a // card in the room on this phone only, one per id: the same id again // replaces its text, place and ttl; an empty text takes it down. At most // 16 at once (a new id past that is dropped); the shown text is cut at 36 // characters. Text as ui.say's: a key of static texts or space.*. // Point metres of the room: x, z on the floor, y the height above // it (1.45 when absent). // { piece } over that piece: over a standing model's top (and along with // it while it moves), at a wall piece's frame; a piece lent or // hidden keeps its label where its record puts it. export type LabelAt = Point | { piece: string }; export interface LabelOptions { ttl?: number; // ms until it goes by itself time?: number; // server-clock ms, fills the text's {time} in the reader's local time } } // world.space() for both modules, here for the same 400-line cap. It is the // place's SHAPE; the `space` object is WHO the place is (id, owner, state). declare module '@hatchworld/space@1' { // A player's answer to a platform sheet (tasks S1-S3). Only answers the // server checked arrive: a text passed the text judge (a refused one never // arrives, the player is told), a pick is someone in this room or the // answerer's friend, a choice is an index 0..7. Anyone who may enter may // answer, at most 20 a minute (ANSWERS_PER_MIN); the script still checks // that the id is one it asked. export interface PlayerAnswerEvent extends PlayerEventBase { readonly payload: { readonly id: string; readonly kind: 'text' | 'choice' | 'player'; readonly value: string | number; // text, option index, or a PlayerId }; } export interface WorldAfterEvents { readonly playerAnswer: AfterEventSignal; } // Capability 'notify' (task S4). A push to one player who has been in this // place: `text` a key of static texts, shown in his language; values fill // its {placeholders} (`time` a server-clock moment in ms, his local time); // the push opens this place. At most 100 a place and 3 a recipient a day // (NOTIFY_PER_PLACE_PER_DAY, NOTIFY_PER_RECIPIENT_PER_DAY), and none to a // player who turned this place's pushes off; past that: dropped. export interface Space { notify(uid: PlayerId, text: string, options?: { values?: { [name: string]: string | number } }): void; } export interface SpaceScript { readonly notify: Space['notify']; } // Counters and boards (task S5). Names a class declares in `static shared` // (<= 16) are read into every event, so shared.get(name) returns their // value as this event began (writes of this event are not in it); any other // name: undefined. A board (`static boards`, <= 4) keeps each player's best // score; top() its 10 best as the event began, rankOf(uid) the event // player's own place (1-based) or null — anyone else: null. export interface Board { submit(uid: PlayerId, score: number): void; top(): readonly { readonly uid: PlayerId; readonly score: number }[]; rankOf(uid: PlayerId): number | null; } export interface Shared { board(name: string): Board; } // `static thresholds = { visits: [100, 1000] }`: names of static shared, // each with at most 8 finite numbers in ascending order // (THRESHOLDS_PER_NAME). The first write after which the counter is at or // over a threshold gives the director ONE onSharedThreshold { name, // threshold, value } — once for good: going under and back over is no // second event. A threshold already behind the counter when declared // fires on the next write. export type Thresholds = { readonly [name: string]: readonly number[] }; // Metres in the frame a piece's `at` is in: x east, z north, from the // room's anchor; polygons as [x, z] points, the first not repeated at the end. export interface SpaceShape { // 'room': built from the room's stored outline. 'point': reserved for a // place that is a mark on the map (ch docs/spaces/map.md) — height // null, the shape around its point; no place is one yet. readonly kind: 'room' | 'point'; readonly height: number | null; // the room's ceiling, metres // The outer outline in wall order: floor[i] -> floor[i + 1] (the last // back to floor[0]) is wall i, the `wall` a door and a wall piece carry. readonly floor: readonly (readonly [number, number])[]; readonly holes: readonly (readonly (readonly [number, number])[])[]; // courtyards, not floor readonly area: number; // square metres of floor, holes taken out readonly bounds: { readonly minX: number; readonly maxX: number; readonly minZ: number; readonly maxZ: number }; } // Merges into World (space.d.ts). null: the place has no stored outline. export interface World { space(): SpaceShape | null; } // world.animate's third argument (here for the same cap). keep: true — the // piece STAYS where the motion ends: the end pose is written into the // stored room at once (a world.* write, <= 1/s per place), so whoever // comes in later or reloads sees it there. Only a floor piece, only // loop 'none' without spin or bob; otherwise dropped:keep_loop | // keep_spin | keep_bob | keep_piece | keep_pose (nothing to keep). // `by` counts from where the piece stands now, also part way through // an earlier kept motion. stopAnimation with hold keeps where it froze. export interface AnimateOptions { keep?: boolean; } // A floor piece's tilt (merges into MotionPose, here for the same cap): // turns, -0.5..0.5 in `to` and in `by`; the turn is rot, then pitch about // the piece's own x, then roll about its own z. export interface MotionPose { pitch?: number; roll?: number; } // A wall piece's pose: u metres along the wall from floor[i] to the // frame's centre, 0 or more (metres, NOT WallFrame's 0..1); v the centre's // height, 0..20 m; roll turns in the wall's plane, counter-clockwise seen // from inside the room, -0.5..0.5; scale the frame's w x h factor; wall // the wall's number 0..63, `to` only, the wall it plays on. export interface WallMotionPose { u?: number; v?: number; roll?: number; scale?: number; wall?: number; } // world.animate for a piece on a wall: no spin, no bob. A floor key // (x z y rot pitch) in a wall spec, or u v wall in a floor spec, drops it: // spec_to | spec_by. export interface WallMotionSpec { to?: WallMotionPose; by?: Omit; ms: number; // 0..600 000 ease: MotionEase; loop: MotionLoop; } export interface World { animate(id: string, spec: WallMotionSpec, options?: AnimateOptions): void; } // The room's look (world.look, here for the same cap): the colour of the // walls, floor, ceiling or lamps walking through `colors` ('#rrggbb', // 1..8) spread evenly over `ms`, eased over the whole run, looped as a // motion; the colour is a function of the time since the host's startAt, // never "from the current colour". All walls are one target. export type LookTarget = 'walls' | 'floor' | 'ceil' | 'light'; // After the run (loop 'none' only), one colour per beat, no blend, on a // beat grid from startAt: bpm 30..180, colors 2..8. export interface LookPulse { bpm: number; colors: readonly string[]; } export interface LookSpec { colors: readonly string[]; ms: number; // 0..600 000 ease: MotionEase; loop: MotionLoop; pulse?: LookPulse; // 'light' only: the lamps' strength per colour, as many as colors, // 0.35..2; the pulse holds the last one. gains?: readonly number[]; } export interface World { // Capability 'world.write'. Up to 16 calls per event and 8 looks per // room; the same target replaces its look. Everyone inside hears // 'sys:look' { target, spec, keep? }. keep: true — the end colour (and // gain) is written into the stored room. Out of bounds: dropped with // the reason (spec_); { wall: i }: not_supported. look(target: LookTarget, spec: LookSpec, options?: { keep?: boolean }): void; // 'sys:look.stop' { target, hold }: hold (default) keeps the colour on // screen, hold: false brings the room's own back. stopLook(target: LookTarget, options?: { hold?: boolean }): void; } // The room's effects (effects.js, here for the same cap), played by every // phone inside; nothing is written into the stored room. Out of bounds: // dropped with the reason (name, gain, at, piece, preset, count, on, color). export type SoundName = 'chime' | 'ding' | 'pop' | 'whoosh' | 'coin' | 'fanfare'; export type MusicName = 'party' | 'mystery'; export type BurstPreset = 'confetti' | 'sparks' | 'hearts' | 'bubbles'; // A point of the room, or over a piece (`piece` its id). export type SoundAt = Point | { piece: string }; export interface World { // Capability 'world.write', as all five. A short sound: gain 0..1 // (default 1), `at` places it; up to 4 per event, the phone plays up // to 6 a second per room. 'sys:sound' { name, gain, at? }. sound(name: SoundName, options?: { gain?: number; at?: SoundAt }): void; // Background music, one per room, a new one replaces it, in sync on // every phone: 'sys:music' { name, gain, startAt }; gain 0..1 (default 1). music(name: MusicName, options?: { gain?: number }): void; // 'sys:music.stop' {}. stopMusic(): void; // Particles at a point or a piece (exactly one), count 1..200 (default // 80); up to 2 per event, a burst lives up to 3 s, at most 600 live // particles from scripts per room. 'sys:burst' { preset, at? | piece?, count }. burst(preset: BurstPreset, options: { at: Point; count?: number } | { piece: string; count?: number }): void; // Lights a piece (on) or puts it out: color '#rrggbb' (default // '#ffc43d'); up to 4 lit per room, 16 calls per event. The phone puts // it out itself when the script changes and when the player leaves. // 'sys:highlight' { id, on, color }. highlight(pieceId: string, on: boolean, options?: { color?: string }): void; } // Simple physics (physics.js, here for the same cap): the SERVER works the // flight out at the call, the phones play it as a path of constant // acceleration segments from t (ms from startAt, whole, ascending, first // 0): x z y m, vx vz vy m/s (each -30..30), ax az ay m/s^2 (each -50..50), // rot turns, w turns/s (|w| <= 20/2pi); p = p0 + v*tau + a*tau^2/2, tau s. export interface MotionPathSegment { t: number; x: number; z: number; y: number; rot?: number; w?: number; vx: number; vz: number; vy: number; ax: number; az: number; ay: number; } // 1..16 segments, the last at rest (v, a, w 0; t <= ms): the kept end // pose. Floor piece, loop 'none', no to/by/spin/bob, ease unused; else spec_path. export interface MotionSpec { path?: readonly MotionPathSegment[]; } export interface World { // Capability 'world.write'; a floor piece only; up to 4 calls of the // three per event, a flight up to 8 s. The room hears 'sys:motion' { id, // spec } with spec.path; the end pose is kept (a world.* write). dir: // turns, as rot. Out of bounds: dropped (from, dir, speed, up). // Falls from from.y (0..20 m, default where it is), bounces, settles. drop(id: string, options?: { from?: { y: number } }): void; // Slides along the floor (speed 0..10 m/s) with friction, off walls and pieces. push(id: string, options: { dir: number; speed: number }): void; // Flies (speed 0..10, up 0..10 m/s) under gravity, bounces, slides, stops. throw(id: string, options: { dir: number; speed: number; up: number }): void; } // A file the script may stand in its place (`static assets`, here for the // same cap): m the sha256 of a model file, f its format. The publish // refuses a file that does not exist, has not passed moderation, or is // neither the place owner's nor public. Activation lets whoever may enter // the place read it; script.json carries the active `assets` to the phone. export interface Asset { readonly m: string; // '.splat' | '.ksplat' | '.ply' | '.spz' | '.sog' | '.glb' | '.hwm', checked at // publish; a string here, as a class static in JavaScript cannot narrow it. readonly f: string; } // world.setPiece's change. hidden: true puts the piece away (stored, kept // in pieces() with hidden: true, not drawn), false shows it again. export interface PieceChange { at?: Point; as?: string; hidden?: boolean; } // world.addPiece's spec: `asset` a key of the class's static assets, `at` // on the floor, `as` the mount, hidden: true stands it put away. export interface AddPieceSpec { asset: string; at: Point; as?: string; hidden?: boolean; } export interface World { // Capability 'world.write', the setPiece road (a world.* write, <= 1/s // per place): a new floor piece `id` ([a-z0-9]{1,16}) of the file the // key names. The same id with the same file moves it / sets as and // hidden again; any other piece under that id: dropped id_taken. An // unknown key: asset; a full room (200 pieces): too_many_pieces. addPiece(id: string, spec: AddPieceSpec): void; } // A piece kept for ONE player (world.addPieceFor, here for the same cap): // what piecesFor returns and his phone gets in 'sys:pieces.personal' // { pieces } — his whole list, as he comes in and after each change. export interface PersonalPiece { readonly id: string; readonly kind: 'scan'; readonly m: string; readonly f: string; readonly at: { readonly x: number; readonly z: number; readonly y?: number }; readonly as?: string; } // world.addPieceFor's spec: `asset` a key of static assets, `at` on the // floor, `as` the mount: 'frame' | 'stand' | 'hologram' | 'world'. export interface AddPieceForSpec { asset: string; at: Point; as?: string; } export interface World { // Capability 'world.write'. A floor piece only player `uid` sees, // kept for him in this place and shown each time he comes in; never in // pieces(), never told to anyone else, not an item (nothing lands in // his inventory), not a room write (no 1/s). The same id again replaces // his piece under it (safe to repeat). At most 16 per player per place: // a new id past that is dropped personal_full. Unknown key: asset; // another mount: mount; uid not digits: uid. addPieceFor(uid: PlayerId, id: string, spec: AddPieceForSpec): void; removePieceFor(uid: PlayerId, id: string): void; // The event's own player (e.uid) only, as the event began: this // event's adds and removes are not in it yet. null for anyone else. piecesFor(uid: PlayerId): readonly PersonalPiece[] | null; } // A player's home place, as an address (here for the same cap): his // earliest created place, resolved by the host when the op plays. Taken // by sys.send (no home: nothing is sent, dropped no_home) and as // sys.notify's link (no home: the place that sends it). export interface HomeOf { readonly homeOf: PlayerId; } // A frame on a wall, as a wall piece's `at` says it: wall i (SpaceShape // floor), u the centre along it from floor[i] (0..1), z the centre's // height above the floor, w x h the frame, in metres. export interface WallFrame { wall: number; u: number; z: number; w: number; h: number; } // sys.placeLoan's options: `at` a spot on the floor, used for a model; // `frame` a frame on a wall, used for a picture or a video. export interface PlaceLoanOptions { at?: Point; frame?: WallFrame; } // Merges into Sys (space.d.ts): the system script's only, like the rest. export interface Sys { // A piece the owner of THIS place holds on loan (`from` the lending // place's key, `pieceId` its id there) stood here as his trophy. The // host reads the loan and the piece; the script names neither: a // running loan to this place's owner or dropped rev_refused no_loan. // Trophy as a save keeps it (until the loan's, lender the server's). // A model on the floor at `at`; a picture or a video in `frame`, whole // on its wall (0.5 m from each end, between floor and ceiling), // off every door (the way out in the middle of the longest wall too) // and every other framed piece of that wall; // its slot the wall's first free. Otherwise refused: at (no spot of // its kind), wall, overlap; a world piece (mount 'world', the room // itself): mount. Standing already: nothing. A world.* write (<= 1/s // per place). placeLoan(loan: { from: string; pieceId: string }, options?: PlaceLoanOptions): void; } } ===== agentApi.d.ts ===== // The base class of code the place's AI agent runs ONCE in a place (agentApi.js, // RUN_ONCE_PATH). Its own file because space.d.ts and spaceClient.d.ts are both // at the 400-line module cap; this block merges into the `@hatchworld/space@1` // module declared there. The run-once check reads all three files. // // import { OnceScript } from '@hatchworld/space@1'; // export default class Count extends OnceScript { // run() { return { visits: this.shared.get('visits') }; } // } // // The same world / shared / state / space as a SpaceScript, but no events or // timers; emit (ui.say etc.) is the owner's only: run() is called once and what it returns (JSON, at most // 4 KB, agentApi.js RUN_ONCE_RESULT_MAX_BYTES) is the answer. Reads always // work; writes only on the capabilities agentApi.js onceCapabilities() grants. declare module '@hatchworld/space@1' { // Who a player is to a script (here because space.d.ts and spaceClient.d.ts // are at the cap). TODAY it is his uid, a decimal string; it WILL become his // public id (ch docs/spaces/sdk/devkit.md §5 В5): 16 characters of base64url // (index.js PLAYER_ID_RE), one for every place, never changing. Compare and // store it as an opaque string; never parse it as a number. export type PlayerId = string; export abstract class OnceScript { readonly state: SpaceState; // space.state; empty in a guest's run readonly world: World; readonly shared: Shared; readonly space: Space; // Narrowed by the host: the owner's to 'world.write' and 'shared', a guest's to none. static capabilities?: readonly string[]; // To the room now; the owner's only (a guest's is dropped, agentApi.js onceDrop). emit(name: string, payload?: Json, options?: { to?: PlayerId; instance?: number }): void; abstract run(): unknown; } // Composed motion (motion.js; here because space.d.ts and spaceClient.d.ts // are at the cap). layer [a-z0-9_]{1,16}, default 'main'; at most 4 a // piece (layer_max); the same layer replaces its motion; the pose drawn is // the stored one plus every layer's part (scale: times). keep only in // 'main' with no other loop 'none' layer (keep_layers). stopAnimation // without layer stops them all. export interface MotionLayerOptions { layer?: string; } export interface AnimateOptions extends MotionLayerOptions {} export interface MotionStopOptions extends MotionLayerOptions { hold?: boolean; } // A sway: amp * sin(2pi (t / periodMs + phase)); scale's factor 1 + that. // amp x z 0..50 m, y 0..5 m, rot 0..0.5 turns, pitch roll 0..0.5, scale 0..0.9; // periodMs 100..600 000, phase 0..1. loop 'repeat', ms = the longest // periodMs, no to/by/path in the same spec. Else spec_osc. export interface MotionOscKey { amp: number; periodMs: number; phase?: number; } export interface MotionOsc { x?: MotionOscKey; z?: MotionOscKey; y?: MotionOscKey; rot?: MotionOscKey; scale?: MotionOscKey; pitch?: MotionOscKey; roll?: MotionOscKey; } // Goes after a player or a piece (never itself): offset each -10..10 m, // lagMs 0..5000, minDist 0..10 m. loop 'repeat', nothing else in the spec // but face. Else spec_follow. export interface MotionFollow { target: { player: PlayerId } | { piece: string }; offset?: { x?: number; z?: number; y?: number }; lagMs: number; minDist?: number; } // face 'travel': rot follows the summed x/z velocity, + rotOffset turns; // not with spin, one such layer a piece. Else spec_face. export interface MotionSpec { osc?: MotionOsc; follow?: MotionFollow; face?: 'travel'; rotOffset?: number; } export interface World { stopAnimation(id: string, options?: MotionStopOptions): void; // The child rides on the parent (floor pieces, at most 2 deep, no // cycle); its stored pose becomes relative. link_* / child_linked. link(childId: string, parentId: string): void; unlink(childId: string): void; } } ===== props.d.ts ===== // Live props (props.js; ch docs/spaces/runtime/props.md §2.1): simple shapes a // server script stands in its room for a game, records of the place's system, // never the stored room. Its own file because space.d.ts and spaceClient.d.ts // are both at the 400-line module cap; this block merges into the // `@hatchworld/space@1` module declared there, as agentApi.d.ts does. // // this.world.prop('b1', { shape: 'box', size: [0.5, 0.3, 0.25], color: '#c62828', // at: { x: 1, z: 2, y: 0.15 }, hit: true }); // this.world.launch('ball', { dir: 0.25, speed: 3 }); // onPropHit(e) { if ('prop' in e.payload.other) this.world.removeProp(e.payload.other.prop); } declare module '@hatchworld/space@1' { // One draw call a shape on the phone, so the list is closed (props.js PROP_SHAPES). export type PropShape = 'box' | 'sphere' | 'cylinder' | 'cone' | 'plane'; // Metres and turns, as a floor piece's pose; relative to the parent's when // there is one. y 0..20 m (a child's -20..20 m, from its parent), pitch and // roll -0.5..0.5 turns, absent ones 0. export interface PropAt { x: number; z: number; y?: number; rot?: number; pitch?: number; roll?: number; } // A body makes the prop move until it comes to rest, bouncing off the // room's walls, its pieces and the props without a body (props.js BODY_*). // Without one a prop stands, an obstacle. Two moving bodies pass through // each other; one at rest is an obstacle. At most 8 shared moving bodies a room. export interface BodySpec { bounce?: number; // 0..1, the share of the speed a bounce keeps; default 0.5 friction?: number; // 0..1, on the floor; default 0.4 gravity?: boolean; // default true; false keeps its height } export interface PropSpec { shape: PropShape; // x, y (height), z, each 0.02..5 m; default [0.3, 0.3, 0.3]. A plane // lies on the floor face up, size[0] x size[2]; stood up with pitch 0.25. size?: [number, number, number]; color?: string; // '#rrggbb'; default '#ffc43d' at: PropAt; parent?: string; // another prop's id; at most 2 deep, no cycle hidden?: boolean; hit?: boolean; // a flying prop striking this one is a propHit players?: number; // 0.2..2 m: this ball striking a player is a propHit body?: BodySpec | false; // false: no body; never on a child (body_child) } // setProp: only the fields given; `at` replaces the whole pose and `body` // the whole body; false turns a flag off, and `body: false` stops the prop // where it is, standing again. `at` on a body moves it, keeping its speed. export type PropPatch = Partial; // A prop as world.props() gives it: as the event began, defaults filled in. // A body's `at` is where it is at that moment, `v` its velocity then (m/s), // `moving` whether it moves. export interface Prop { readonly id: string; readonly shape: PropShape; readonly size: readonly [number, number, number]; readonly color: string; readonly at: Readonly; readonly parent?: string; readonly hidden?: true; readonly hit?: true; readonly players?: number; readonly body?: Readonly>; readonly v?: { readonly x: number; readonly z: number; readonly y: number }; readonly moving?: boolean; // The phone's only (ClientWorld.props()): the server moment, ms, a // body's `at` and `v` are of, as the last packet gave them. readonly t0?: number; } // A flying prop (`id`) struck a prop marked hit, a piece or a player // (only his PlayerId); `at` where the flyer was, `t` the server time of the // contact, ms, as event.at. export interface PropHitEvent extends EventBase { readonly payload: { readonly id: string; readonly other: { readonly prop: string } | { readonly piece: string } | { readonly player: PlayerId }; readonly at: { readonly x: number; readonly z: number; readonly y: number }; readonly t: number; }; } export interface WorldAfterEvents { readonly propHit: AfterEventSignal; } export interface SpaceScript { onPropHit?(e: PropHitEvent): void; } export interface World { // Capability 'world.write', as all four. Up to 64 props a room and 64 // calls an event (props.js PROPS_MAX, PROPS_PER_EVENT); the room hears // 'sys:props' { set?, del? } once an event. An id is a piece id // [a-z0-9]{1,16}, not one a piece of the room holds. Out of bounds: // dropped with the reason (prop_, registry_cap, prop_cap). // Creates the prop or replaces it whole. prop(id: string, spec: PropSpec): void; setProp(id: string, patch: PropPatch): void; // Its children go with it. removeProp(id: string): void; props(): readonly Prop[]; // Flies along the floor at the prop's own height, no friction, bouncing // off walls, pieces and props with its whole speed (0..10 m/s; with // players in the game at most 3 m/s). dir: turns, as rot. Stopped by // stopAnimation or removeProp. Not a child's (child_linked). launch(id: string, options: { dir: number; speed: number }): void; } } // The phone's side (props.md §2.2): it only reads the props; no scene op of // its own. Merges into the client module, as spaceClient.d.ts does. declare module '@hatchworld/space-client@1' { import type { Prop } from '@hatchworld/space@1'; export interface ClientWorld { // The place's props as this phone sees them, renewed after every // 'sys:props' packet; the pose's keys besides x and z filled with 0. // A body: the server's state only (at, v and t0 of the last packet), // not where it is drawn now. props(): readonly Prop[]; } export interface TapEvent { // The tap struck a live prop: target 'piece', id the prop's id. readonly prop?: true; } }