@homie-rocks/studio 0.43.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +16 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +78 -0
- package/guides/game/RULES.md +737 -0
- package/guides/game/SKILL.md +392 -0
- package/lib/authoring-guides.mjs +27 -0
- package/lib/rules-check-worker.mjs +4 -3
- package/lib/rules-guard.mjs +2 -1
- package/lib/scaffold.mjs +9 -2
- package/lib/template-history.json +83 -0
- package/lib/worker-build.mjs +39 -6
- package/lib/worker-config.mjs +2 -2
- package/netplay/netplay.ts +3 -0
- package/package.json +5 -3
- package/rules/collision.ts +6 -6
- package/rules/core.ts +33 -11
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +81 -0
- package/rules/live.ts +73 -0
- package/rules/math.ts +10 -7
- package/rules/rules.ts +28 -6
- package/rules/types.ts +4 -3
- package/rules/view.ts +24 -4
- package/starters/hero-rush-3d/src/move.ts +4 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/room.mjs +15 -5
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
# Rules reference (contract 2)
|
|
2
|
+
|
|
3
|
+
A rules game has `src/rules.ts`, `src/move.ts`, `src/view.ts`, `game.json`,
|
|
4
|
+
`tunables.json`, `index.html` and `map/main.json`. The rules default-export `defineRules` from
|
|
5
|
+
`@homie-rocks/studio/rules`. The movement file exports `move = defineMove({...})`
|
|
6
|
+
from the same package. Import `move` into the rules. Pictures and controls belong in
|
|
7
|
+
view.ts; shared movement belongs in move.ts; all game decisions belong in rules.ts.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
11
|
+
import { move } from './move';
|
|
12
|
+
export default defineRules({
|
|
13
|
+
contract: 2, space: { dims: 2 }, move,
|
|
14
|
+
shared: { total: f.u32() },
|
|
15
|
+
shapes: { events: { point: {} }, commands: {}, effects: {} },
|
|
16
|
+
entities: {
|
|
17
|
+
runner: {
|
|
18
|
+
player: { away: 'think', leave: 'bot' },
|
|
19
|
+
fields: { score: f.u16({ score: true }) },
|
|
20
|
+
input: { ax: f.i8(), ay: f.i8() },
|
|
21
|
+
body: { shape: 'circle', radius: 0.5, maxSpeed: 6 },
|
|
22
|
+
think() { return { ax: 0, ay: 0 }; },
|
|
23
|
+
on: { point(world, self) { self.score += 1; } },
|
|
24
|
+
onRoom: { roundStart(world, self) { self.score = 0; } },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
room: {
|
|
28
|
+
rounds: { seconds: 60, breakSeconds: 3 }, bots: { keep: 4 },
|
|
29
|
+
join(ctx, player) {
|
|
30
|
+
const spots = ctx.map.spots('start');
|
|
31
|
+
return { kind: 'runner', at: spots[player.seat % spots.length] };
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The map must contain a nonempty `spots.start` list for that example:
|
|
38
|
+
`{"bounds":{"min":[-12,-7],"max":[12,7]},"boxes":[],"spots":{"start":[[-5,0],[5,0]]}}`.
|
|
39
|
+
Declare `players.max` in game.json (1–32); use `entry: "src/view.ts"` and
|
|
40
|
+
`room: { host: "server" }`. Only public tunables are visible in move.ts.
|
|
41
|
+
|
|
42
|
+
`index.html` is the browser entry document; include `<script type="module" src="./assets/main.js"></script>`. The manifest names the source, but HTML loads this relative built bundle. Never point HTML at /src/view.ts: that URL is not served. The build rewrites assets/main.js to its hashed bundle. A minimal manifest is:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{"id":"keepers","name":"Keepers","entry":"src/view.ts","players":{"min":1,"max":4},"room":{"host":"server"}}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`id` is the folder identifier and `name` is the displayed name, `players.min` the advertised minimum and `players.max` the seat cap. `entry` names the browser source. Optional `blurb`, `tags` and `cover` describe the game on its page. `room` sets server hosting and optional `tickHz` (the default is 20). `room.bots: { keep: 4 }` in **rules.ts** asks the host to maintain four total occupied bodies when its policy allows bots; it is not four extra bots. Companion policies belong to the server's room/server configuration, not a player's frame or the game manifest. Set the server policy to `hybrid` with `aiSeats`, or `beginner` with `guides`; these reserve server-owned AI bodies, and `bots: 'off'` disables ordinary fill bots.
|
|
49
|
+
|
|
50
|
+
For example, `homie-studio servers new keepers "Practice" --policy beginner --guides 1` creates a beginner server with one guide. Use `homie-studio servers set keepers <server> --policy hybrid --ai 1` to change an existing server through its owner controls.
|
|
51
|
+
|
|
52
|
+
Server movement is the default and predicts immediately in the local browser using
|
|
53
|
+
its guarded `move`. Bots, AI and away people are always moved on the server.
|
|
54
|
+
`body.move: 'owner'` is an existing compatibility option that accepts bounded position
|
|
55
|
+
claims and cannot enforce walls against a modified browser; do not use it for new games.
|
|
56
|
+
A non-player entity may use `world.sweep(self, delta)` instead of a move function.
|
|
57
|
+
Bodies require radius and maxSpeed. In 2D use circle; in 3D use sphere, capsule or
|
|
58
|
+
axis-aligned box, with full height for capsule/box. `sweep: true` adds automatic map
|
|
59
|
+
collision; explicit `ctx.map.sweep` moves and returns a contact for sliding.
|
|
60
|
+
|
|
61
|
+
### Level data and three dimensions
|
|
62
|
+
|
|
63
|
+
Declare `map: './map'` in rules and put static geometry in `map/main.json`. Both
|
|
64
|
+
host and predictor compile the same data; the view reads it as `room.map`.
|
|
65
|
+
Coordinates in JSON are arrays; runtime Vec3 values have x/y/z. Bounds have finite,
|
|
66
|
+
increasing min/max. Named spots are lists; test length before indexing or modulo.
|
|
67
|
+
Rules use `world.map.spot(name)` / `spots(name)`; the view uses `room.map.spots[name]`.
|
|
68
|
+
|
|
69
|
+
With `space: { dims: 3 }`, z is up and pos is the body's feet. Three.js renders
|
|
70
|
+
`(x,z,y)`. Gravity, jumping and facing belong in move, animation in the view.
|
|
71
|
+
Use continuous sweeps, then slide the remainder along the contact tangent.
|
|
72
|
+
`world.sweep` includes entity bodies. Movement sees the map plus declared live colliders through `ctx.world` (also exposed on `ctx.map`).
|
|
73
|
+
|
|
74
|
+
| Map list (at most 4,096 solids in all) | JSON shape |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| boxes | `{ "min": [0,0,0], "max": [2,2,1] }` |
|
|
77
|
+
| circles (vertical columns in 3D) | `{ "at": [4,0,0], "r": 1 }` |
|
|
78
|
+
| spheres | `{ "at": [0,0,2], "r": 1 }` |
|
|
79
|
+
| capsules | `{ "at": [0,0,0], "r": 0.5, "height": 2 }` |
|
|
80
|
+
| heightTiles | `{ "at": [0,0,0], "size": [2,2], "heights": [0,1,0,1] }` |
|
|
81
|
+
|
|
82
|
+
Height tiles support the foot point, have open edges and no underside; use boxes
|
|
83
|
+
for platforms and ceilings. A rendered mesh is not a collider. Draw solid geometry
|
|
84
|
+
from room.map, including spheres/capsules/heightTiles. Changing the map changes
|
|
85
|
+
state compatibility; it is static build data, not a per-room saved copy.
|
|
86
|
+
|
|
87
|
+
`tunables.json` contains public movement/view values under `public`; other keys are
|
|
88
|
+
private rules values. Both a plain value and a record with `value` are accepted:
|
|
89
|
+
```json
|
|
90
|
+
{"public":{"speed":{"value":6}},"enemyHealth":{"value":3}}
|
|
91
|
+
```
|
|
92
|
+
Rules read `world.tune.speed` and `world.tune.enemyHealth`; movement reads
|
|
93
|
+
`ctx.tune.speed`. Do not put secrets in public values.
|
|
94
|
+
|
|
95
|
+
Maps use metres. Boxes have `min`/`max`, circles have `at`/`r`, and spots are named
|
|
96
|
+
lists of coordinates: `{"boxes":[{"min":[-1,-1],"max":[1,1]}],
|
|
97
|
+
"circles":[{"at":[4,0],"r":1}],"spots":{"start":[[-5,0],[5,0]]}}`.
|
|
98
|
+
Include bounds as above. A missing spot name returns an empty list, so check its
|
|
99
|
+
length before taking an entry or using modulo.
|
|
100
|
+
|
|
101
|
+
## Stationary games and live apps
|
|
102
|
+
|
|
103
|
+
A player's kind currently requires a body and a named move handler even when the
|
|
104
|
+
interface is a board, quiz or poll. Use `body: { shape: 'circle', radius: 0.1,
|
|
105
|
+
maxSpeed: 0 }` and `defineMove({ participant() {} })` for a `participant` kind.
|
|
106
|
+
The anchor need not be drawn. Use commands for choices; validate whose turn it is,
|
|
107
|
+
then send an event to a room handler to update shared state. Do not fabricate movement
|
|
108
|
+
or a score to satisfy a test.
|
|
109
|
+
The toolkit derives the stationary movement probe from all declared player kinds
|
|
110
|
+
having maxSpeed 0. Movement checks then say N/A; prove actual command feedback,
|
|
111
|
+
turns and shared results yourself. This is not a pass for the game's controls.
|
|
112
|
+
Native DOM buttons can use `onclick`. Keep them outside `guardGestures`' `touch`
|
|
113
|
+
selector (its default is `canvas`): that selector suppresses native touch clicks.
|
|
114
|
+
If a gameplay button must be inside that surface, handle its touch pointer
|
|
115
|
+
explicitly as well as mouse/keyboard activation, without firing twice. Prove it
|
|
116
|
+
with a real touchscreen tap in Chrome; calling `element.click()` does not test
|
|
117
|
+
touch delivery.
|
|
118
|
+
Use `rounds: { seconds: 0, breakSeconds: 0 }` for manual rounds, and no fill bots
|
|
119
|
+
for an app. A wall/TV watches without taking a seat. Each interactive phone
|
|
120
|
+
takes a seat (up to 32); shared state is replicated, public and transient. An app's
|
|
121
|
+
lasting, authorized business data belongs in `createAppRecords`, not in room fields.
|
|
122
|
+
|
|
123
|
+
## State and field types
|
|
124
|
+
|
|
125
|
+
`f` declares state, event/command/effect payloads, ask state and guide views.
|
|
126
|
+
Each field starts at its zero value unless supplied `{ init: value }`.
|
|
127
|
+
`{ score: true }` marks one integer field on a player's kind for round ranking.
|
|
128
|
+
State belongs only in `fields`, `motion`, or `shared`; never a changing module variable.
|
|
129
|
+
|
|
130
|
+
| Declaration | Value, range and example |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| `f.u8()` | Integer 0–255; `hp: f.u8({ init: 100 })` |
|
|
133
|
+
| `f.u16()` | Integer 0–65535; `score: f.u16({ score: true })` |
|
|
134
|
+
| `f.u32()` | Integer 0–4294967295; `total: f.u32()` |
|
|
135
|
+
| `f.i8()` | Integer -128–127; `ax: f.i8()` |
|
|
136
|
+
| `f.i16()` | Integer -32768–32767; `points: f.i16()` |
|
|
137
|
+
| `f.i32()` | Integer -2147483648–2147483647; `balance: f.i32()` |
|
|
138
|
+
| `f.fix()` | Multiples of 1/4096, ±2147483648; `fuel: f.fix({ init: 1 })` |
|
|
139
|
+
| `f.bit()` | Boolean; `alive: f.bit({ init: true })` |
|
|
140
|
+
| `f.press()` | Input-only one-shot boolean button; `fire: f.press()` |
|
|
141
|
+
| `f.tick()` | Absolute tick, 0–4294967295; `until: f.tick()` |
|
|
142
|
+
| `f.ticks()` | Tick duration, same range; `cooldown: f.ticks()` |
|
|
143
|
+
| `f.vec3()` | Float32 vector `{x,y,z}`; `target: f.vec3()` |
|
|
144
|
+
| `f.dir()` | Normalized ground direction; `aim: f.dir()` |
|
|
145
|
+
| `f.ref()` | Entity ID text, at most 24 characters; `victim: f.ref()` |
|
|
146
|
+
| `f.text(40)` | Text, declared maximum 1–4096 characters; `label: f.text(40)` |
|
|
147
|
+
| `f.list(f.u8(), 8)` | Ordered list, readonly on read: copy and assign to edit; maximum 1–1024; `cards: f.list(f.u8(), 8)` |
|
|
148
|
+
| `f.map(f.u16(), 32)` | Text-keyed map, maximum 1–1024 entries; `scores: f.map(f.u16(), 32)` |
|
|
149
|
+
| `f.struct({ hp: f.u8() })` | Fixed named fields; `status: f.struct({ hp: f.u8() })` |
|
|
150
|
+
|
|
151
|
+
Vector writes may omit z (it becomes zero); reads contain all three coordinates.
|
|
152
|
+
Integer writes round and clamp, not wrap. Guard against underflow explicitly:
|
|
153
|
+
`self.hp = Math.max(0, self.hp - damage)`. Inputs may contain only integer types,
|
|
154
|
+
fix, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
|
|
155
|
+
A scalar is one cell; a vector four; a text is `1 + ceil(max/64)`;
|
|
156
|
+
a list is `1 + max * itemCells`; a map is `1 + max * (1 + itemCells)`;
|
|
157
|
+
a struct is one plus the cells of its fields. Map keys are at most 32 characters.
|
|
158
|
+
|
|
159
|
+
A field holds only what its type can hold. A live room never stops for a value
|
|
160
|
+
that does not fit: it stores the nearest thing the type holds, says nothing, and
|
|
161
|
+
the game plays on. The build's play watches the same writes, and refuses the
|
|
162
|
+
build for the ones that lose what the handler wrote, naming the file and line of
|
|
163
|
+
the write.
|
|
164
|
+
|
|
165
|
+
| Written by a handler | A live room stores | The build |
|
|
166
|
+
| --- | --- | --- |
|
|
167
|
+
| A whole number outside its range, a fraction in a whole-number field | The value rounded and held to the nearest end of the range. `Infinity` is the top of the range and `-Infinity` the bottom, so `Infinity` may stand for "never" in a whole-number field | Builds. One `info:` line a field says how often it was held to its range |
|
|
168
|
+
| `NaN` in any number or vector | `0` | Refused: `runner.fields.score was written NaN`. Check the divisor before dividing, and that what is read exists |
|
|
169
|
+
| `Infinity` or `-Infinity` in a `fix` or a vector; a number past 3e38 in a vector | `0` | Refused |
|
|
170
|
+
| A list or a map with more entries than declared, a text longer than declared | The first entries, the first keys, the first characters: the rest is dropped | Refused: `shared.history was written a map of 9 keys for a size of 8`. Remove old entries first, or declare the size the game needs |
|
|
171
|
+
| A map key longer than 32 characters, or `constructor`, `prototype`, `__proto__` | The map without that entry | Refused |
|
|
172
|
+
| A `Map`, a `Set`, or anything else that is not a list where a list is declared, or not a plain object where a map or a struct is | An empty list, map or struct | Refused. Use `Map` and `Set` inside a handler, and copy their entries into a list or a plain object before storing them |
|
|
173
|
+
| A 257th effect in one tick | Nothing: the effect is dropped | Refused |
|
|
174
|
+
| A key returned by `think` that is not one of the kind's inputs | Nothing: the key is ignored | Refused |
|
|
175
|
+
| `undefined` (an absent list entry, a field left out of `spawn`, `join` or `think`) | The zero of the type: `0`, `''`, `false`, an empty list | Builds |
|
|
176
|
+
| `-0` | `0`. There is one zero, so `1 / self.speed` has the same sign before and after a restart | Builds |
|
|
177
|
+
| An object with properties the struct does not declare | The declared fields; the others are dropped | Builds |
|
|
178
|
+
| A number a 32-bit float cannot hold exactly, in a vector | The nearest 32-bit float | Builds |
|
|
179
|
+
|
|
180
|
+
A list, map or struct may also be changed in place (`self.cards.push(2)`,
|
|
181
|
+
`world.shared.seen[key] = 1`). The handler runs on, and the field is held to its
|
|
182
|
+
type when the handler ends, by the same rules: a live room cuts a list that grew
|
|
183
|
+
past its size then, and keeps every other change the handler made. The build
|
|
184
|
+
names the handler and says the field was changed in place, because there is no
|
|
185
|
+
single line that wrote it.
|
|
186
|
+
|
|
187
|
+
A browser runs `move` for its own body and holds what `move` leaves by the same
|
|
188
|
+
rules, in silence. The build plays that step too and refuses `NaN` in a position
|
|
189
|
+
or a velocity.
|
|
190
|
+
|
|
191
|
+
Everything a field holds is saved and restored exactly, including the order of a
|
|
192
|
+
map's keys.
|
|
193
|
+
|
|
194
|
+
Reading an absent map key throws, even with `??` afterward. Test membership first:
|
|
195
|
+
`const score = key in self.scores ? self.scores[key] : 0;`.
|
|
196
|
+
Query and event lists are readonly. Assignment into declared state copies them:
|
|
197
|
+
`self.cards = event.cards;`. To edit a readonly list, copy first:
|
|
198
|
+
`const cards = [...event.cards]; cards.push(2); self.cards = cards;`.
|
|
199
|
+
The compiler treats declared lists as readonly on read; use the same copy-and-assign
|
|
200
|
+
pattern for changes to your own lists.
|
|
201
|
+
|
|
202
|
+
Every entity has readonly `id`, `kind`, `pos`, `grounded`, and `input`; writable
|
|
203
|
+
`vel`, `heading` and its declared fields; and a `motion` record containing writable
|
|
204
|
+
movement state. Players also have readonly `seat`, `owner`, `driver`
|
|
205
|
+
(`person`, `bot`, `ai`), `away`, and `goal`. These facts come from the server.
|
|
206
|
+
Do not add a role, policy, owner or guide field to client input as a source of authority.
|
|
207
|
+
|
|
208
|
+
## Handler slots
|
|
209
|
+
|
|
210
|
+
| Slot | Arguments and purpose |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| `room.start` | `(world)` once when a match starts; initialize shared state or spawn objects |
|
|
213
|
+
| `room.join` | `(ctx, player)` returns `{kind, at, heading?, fields?, motion?}` for a player body |
|
|
214
|
+
| `room.on.name` | `(world, event)` room event, including built-ins below |
|
|
215
|
+
| `entities.kind.tick` | `(world, self)` each tick for each such entity |
|
|
216
|
+
| `entities.kind.think` | `(world, self)` returns partial input for bots or away players; missing values become zero |
|
|
217
|
+
| `entities.kind.on.name` | `(world, self, event)` a targeted event |
|
|
218
|
+
| `entities.kind.commands.name` | `(world, self, data)` a command declared in `shapes.commands` |
|
|
219
|
+
| `entities.kind.onRoom.name` | `(world, self, event)` an announcement or round event |
|
|
220
|
+
| `entities.kind.guide.view` | `(world, readonlySelf)` returns the declared `shapes.view` data |
|
|
221
|
+
| `entities.kind.guide.floor` | `(world, self, readonlyView)` returns local guide decisions; may write declared self state |
|
|
222
|
+
| `asks.name.floor` | `(readonlyState)` returns picks for that ask |
|
|
223
|
+
| `move.kind` | `(body, input, ctx)` writes the body and returns nothing |
|
|
224
|
+
|
|
225
|
+
For example `commands: { vote(world, self, e) { self.choice = e.choice; } }`
|
|
226
|
+
requires `shapes.commands.vote: { choice: f.u8() }` and `fields.choice`.
|
|
227
|
+
A non-player body may use tick plus `world.sweep` without declaring a move function.
|
|
228
|
+
A player's body requires its named move function. Player bodies come from room.join,
|
|
229
|
+
never world.spawn, and leave through seat changes, never world.despawn.
|
|
230
|
+
|
|
231
|
+
`room.join` can read only `map`, `shared`, `tune`, `math`, and `round`.
|
|
232
|
+
Entity handlers can change only their own state. Room handlers alone change shared
|
|
233
|
+
state, announce, end rounds and finish matches. Guide views read query capabilities;
|
|
234
|
+
movement cannot query entities, roll dice, send events or read fields/shared state.
|
|
235
|
+
|
|
236
|
+
## Built-in events
|
|
237
|
+
|
|
238
|
+
These names need no declaration in shapes. Event data is readonly.
|
|
239
|
+
|
|
240
|
+
| Event | Where | Data and example |
|
|
241
|
+
| --- | --- | --- |
|
|
242
|
+
| `arrive` | entity `on` | `{why: 'join' | 'spawn'}`; `arrive(world, self) { self.hp = 100; }` |
|
|
243
|
+
| `leave` | entity `on` | Only for `leave: 'despawn'`; `{}`; `leave(world, self) { world.sendRoom('departed', { id: self.id }); }` |
|
|
244
|
+
| `takeover` | entity `on` | `{}`; `takeover(world, self) { self.motion.frozen = false; }` |
|
|
245
|
+
| `seatJoined` | room `on` | `{seat,id,driver,owner,took}`; `seatJoined(world, e) { world.shared.lastSeat = e.seat; }` |
|
|
246
|
+
| `seatAway` | room `on` | `{seat,id,away}`; `seatAway(world, e) { world.shared.absent = e.away; }` |
|
|
247
|
+
| `seatLeft` | room `on` | `{seat,id}`; `seatLeft(world, e) { world.shared.lastSeat = e.seat; }` |
|
|
248
|
+
| `roundStart` | room `on`, entity `onRoom` | `{n}`; `roundStart(world, self) { self.score = 0; }` in entity scope |
|
|
249
|
+
| `roundOver` | room `on`, entity `onRoom` | `{n,results}`; results have `seat,id,driver,score,place` |
|
|
250
|
+
| `undeliverable` | sender's entity `on` or room `on` | `{to,event,data}`; `undeliverable(world, self) { self.target = ''; }` |
|
|
251
|
+
| `answer` | ask caller's entity `on` or room `on` | `{ask,by,picks,why}`; a union discriminated by `ask`; check `e.ask` to get that ask's typed `picks` (choice names, booleans or numbers) |
|
|
252
|
+
|
|
253
|
+
`onRoom` receives declared announcements and round events, not seat events.
|
|
254
|
+
An event sent this tick arrives on a later tick. A removed target causes
|
|
255
|
+
undeliverable to return to the sender. A room event handler is `(world,e)`,
|
|
256
|
+
not `(world,self,e)`.
|
|
257
|
+
|
|
258
|
+
## World calls, costs and limits
|
|
259
|
+
|
|
260
|
+
Costs are step-budget units, plus the guard's call/argument costs. Each handler
|
|
261
|
+
gets one quarter of `room.budget.tick`; loops and calls consume budget too.
|
|
262
|
+
Let `N` be the number of entities, `S` the map's boxes + circles + four boundaries,
|
|
263
|
+
`C` the cells copied in a payload, and `Q` pending events/timers. Copy cost is `6C`.
|
|
264
|
+
Snapshot output costs four units per cell each tick. A scalar field write costs
|
|
265
|
+
three; a vector write costs 24; copying a collection costs six per copied cell.
|
|
266
|
+
These costs bound work, not elapsed wall time.
|
|
267
|
+
|
|
268
|
+
| Call | Scope, cost, limits | Correct example |
|
|
269
|
+
| --- | --- | --- |
|
|
270
|
+
| `ticks(seconds)` | All clocks; 1; positive seconds round to at least one tick, otherwise zero | `world.after(world.ticks(2), 'ready', {})` |
|
|
271
|
+
| `random()` | Entity/room; 1; seeded and saved, [0,1) | `const n = Math.floor(world.random() * 6);` |
|
|
272
|
+
| `send(id,event,data?)` | Entity/room; 16 + 6C; declared events only | `world.send(other.id, 'hit', { damage: 1 })` |
|
|
273
|
+
| `sendRoom(event,data?)` | Entity/room; 16 + 6C | `world.sendRoom('finished', { by: self.id })` |
|
|
274
|
+
| `announce(event,data?)` | Room only; 16 + 6C, delivery visits entities | `world.announce('reset', {})` |
|
|
275
|
+
| `sendArea(area,event,data?)` | Entity/room; 20 + 2N + 6C; 64 areas/tick, reach 64m | `world.sendArea({sphere:{at:self.pos,r:2}}, 'hit', {damage:1})` |
|
|
276
|
+
| `after(ticks,event,data?)` | Entity/room; 16 + 6C; wait clamped 1–2147483647; targets caller | `world.after(20, 'ready', {})` |
|
|
277
|
+
| `emit(effect,at,data?)` | Declared effect; 10 + 6C; at is position or ID; 256 effects a tick, and the next one throws | `world.emit('spark', self.pos, {})` |
|
|
278
|
+
| `spawn(kind,at,fields?)` | Entity/room; 10 + copy cost; 2048 entities, non-player kinds only | `world.spawn('coin', self.pos, { value: 2 })` |
|
|
279
|
+
| `despawn(self)` | Own non-player entity; constant guard cost | `world.despawn(self)` |
|
|
280
|
+
| `place(self,at,options?)` | Own entity; 82; options vel/heading; clamps to bounds, changes movement revision | `world.place(self, world.map.spots('start')[0])` |
|
|
281
|
+
| `near(at,r,kind?)` | Entity/room/guide; 20 + 2N + returned views; radius ≤64 | `const coins = world.near(self.pos, 2, 'coin');` |
|
|
282
|
+
| `inBox({min,max},kind?)` | Entity/room/guide; 20 + 2N + returned views; width/height ≤128 | `world.inBox({min:{x:0,y:0,z:0},max:{x:2,y:2,z:0}}, 'coin')` |
|
|
283
|
+
| `ray(from,dir,max)` | Entity/room/guide; 20 + 4(S+N); max ≤64; optional hit | `const hit = world.ray(self.pos, self.heading, 10);` |
|
|
284
|
+
| `sweep(self,delta,options?)` | Own entity; 20 + 4(S+N); ignore up to 16 entity IDs | `world.sweep(self, world.math.scale(self.vel, world.dt))` |
|
|
285
|
+
| `ask(name,state)` | Entity/room; 522 + floor(Q/4) + 6C; same pending ask name returns false | `world.ask('route', { target: self.pos })` |
|
|
286
|
+
| `goalDone(ok?)` | Entity; constant guard cost; clears goal | `world.goalDone(true)` |
|
|
287
|
+
| `finish()` | Room only; constant guard cost; ends match | `world.finish()` |
|
|
288
|
+
| `round.end()` | Room only; constant guard cost; requests end-of-round transition | `world.round.end()` |
|
|
289
|
+
| `map.spot(name)` | Readonly map; constant guard cost; undefined if absent | `const p = world.map.spot('home'); if (p) world.place(self, p);` |
|
|
290
|
+
| `map.spots(name)` | Readonly map; constant guard cost; empty list if absent | `const starts = world.map.spots('start');` |
|
|
291
|
+
|
|
292
|
+
Events and timers share a 16,384-entry queue. Avoid setting the same distant timer
|
|
293
|
+
every tick: use a saved `until` field or set the next timer when the last fires.
|
|
294
|
+
Area forms are `{sphere:{at,r}}`, `{box:{min,max}}`, `{cone:{at,dir,r,angle}}`.
|
|
295
|
+
A hit has `at`, `normal`, optionally `entity`; ray also has `dist`.
|
|
296
|
+
A returned view costs `16 + number of fields + number of motion fields`.
|
|
297
|
+
world.near returns nearest first and includes self when self matches the kind and radius.
|
|
298
|
+
Bots and away players do not send commands: implement their actions in tick using think input.
|
|
299
|
+
Queries return readonly views; send events to make recipients change themselves.
|
|
300
|
+
Math vectors are readonly too: replace `d.x += n` with
|
|
301
|
+
`d = { ...d, x: d.x + n }`, or use `world.math.add`/`scale`.
|
|
302
|
+
|
|
303
|
+
World properties: `tick`, `dt`, `tune`, `shared`, `map`, `math`, `stage`, `level`,
|
|
304
|
+
`levelMax`, `round: {n,phase,endsAt}`. Phase is `live` or `over`.
|
|
305
|
+
`rounds.seconds: 0` means no automatic deadline: your room must call round.end.
|
|
306
|
+
A positive duration ends automatically. Round results arrive two ticks after the
|
|
307
|
+
ending transition, then the declared break permits a new round.
|
|
308
|
+
|
|
309
|
+
`world.math` supplies deterministic sin/cos/tan/atan/atan2/asin/acos/exp/log/pow/hypot,
|
|
310
|
+
rad/deg/clamp/lerp, vector vec/add/sub/scale/dot/cross,
|
|
311
|
+
len/dist/norm/clampLen/lerpVec/dir/angle, and constants PI/TAU.
|
|
312
|
+
Each math call costs four units plus guard costs. Use Math.abs/min/max/floor/ceil/round/sqrt for those elementary operations; use world.math for transcendental functions.
|
|
313
|
+
Do not use `**`, `Date`, timers, async/await, network, global randomness or loose `==`.
|
|
314
|
+
Use `===`, `world.tick`, `world.after`, `world.random` and `world.math.pow`.
|
|
315
|
+
|
|
316
|
+
## Movement
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { defineMove } from '@homie-rocks/studio/rules';
|
|
320
|
+
export const move = defineMove({
|
|
321
|
+
runner(body, input, ctx) {
|
|
322
|
+
const M = ctx.math;
|
|
323
|
+
const stick = M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1);
|
|
324
|
+
body.vel = M.scale(stick, 6);
|
|
325
|
+
ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Movement's `ctx` has tick, dt, ticks, math, public tune, map.name, map.spot,
|
|
331
|
+
map.spots, and `world.sweep/support/overlaps` (also on map). Sweeps test static shapes plus declared live colliders, cost
|
|
332
|
+
`20 + 4S` plus copying, writes body.pos, and returns an optional hit.
|
|
333
|
+
Movement may write pos, vel, heading, grounded and declared motion; never return
|
|
334
|
+
an updated body. Normalize diagonals and respect maxSpeed. Collision movement needs a sweep; setting velocity alone does not move a body.
|
|
335
|
+
Put freezes, boost expiry and knockback in declared motion. Initialize a late arrival
|
|
336
|
+
in on.arrive too: onRoom.roundStart already happened before it joined.
|
|
337
|
+
|
|
338
|
+
## The view
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
342
|
+
const room = openRoom();
|
|
343
|
+
room.on('spark', effect => {
|
|
344
|
+
// Effect fields are flat: effect.at, effect.id, effect.tick, effect.damage.
|
|
345
|
+
// Use fields actually declared in shapes.effects.spark.
|
|
346
|
+
});
|
|
347
|
+
function draw() {
|
|
348
|
+
room.each('runner', entity => {
|
|
349
|
+
// Own entity.pos is predicted; remote poses are interpolated. entity.mine marks yours.
|
|
350
|
+
// Draw entity.score and other declared fields here.
|
|
351
|
+
});
|
|
352
|
+
const me = room.me; // your entity, or null while waiting / watching
|
|
353
|
+
const shared = room.shared;
|
|
354
|
+
requestAnimationFrame(draw);
|
|
355
|
+
}
|
|
356
|
+
draw();
|
|
357
|
+
room.input({ ax: 127, ay: 0 }); // held movement; send new values on control changes
|
|
358
|
+
// room.command('vote', { choice: 2 }); // only if shapes.commands declares vote
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
The testing probe assumes world X points right and world Y points up. If the canvas draws positive Y downward, use `openRoom({ screenBasis: () => ({ right: [1, 0], up: [0, -1] }) })`, as Gem Rush does. A moving camera can return its current axes; the controls check uses those axes to judge movement on screen.
|
|
362
|
+
|
|
363
|
+
`room.me` and `room.shared` are properties. `room.each(kind, callback)` visits that
|
|
364
|
+
kind. `room.on(effectName, callback)` returns an unsubscribe function. The payload
|
|
365
|
+
has the declared fields at top level and `tick`; `at` and `id` are optional: an
|
|
366
|
+
effect names a position or an entity. Check `if (effect.at)` before reading coordinates,
|
|
367
|
+
or resolve `effect.id` through room.get. There is no `data` wrapper. `room.command(name, data)` addresses the person's own entity. Use
|
|
368
|
+
`room.round`, `room.roster`, and `room.status` for UI. `room.round?.secondsLeft` is the displayed countdown. `room.tune` holds public tunables, and `room.map` holds the map bounds, boxes, circles and named spots. Always handle a null `me`.
|
|
369
|
+
|
|
370
|
+
Roster rows are `{ seat, name, driver, score, me }`, where driver is `'person'`,
|
|
371
|
+
`'bot'` or `'ai'`; there is no roster `bot` boolean. Keep AI visibly labelled.
|
|
372
|
+
|
|
373
|
+
For each companion entity, `room.askButtons(entity.id)` returns `{k,args,text}` buttons from its vocabulary and offered view. Draw their text, then call `room.ask(entity.id, button.k, button.args)` when pressed. The server validates the request and passes it to that companion's floor as `view.asks`; an accepted goal reaches its `think` handler.
|
|
374
|
+
|
|
375
|
+
## Check and repair
|
|
376
|
+
|
|
377
|
+
Run `homie-studio build <id>`. Static checks run first; declaration-derived strict
|
|
378
|
+
TypeScript checks rules/move/view before the game is played. Read all continuation lines.
|
|
379
|
+
The generated compiler project is `.studio/types/<id>/tsconfig.json`.
|
|
380
|
+
Import `GameWorld` and `GameSelf<'runner'>` as types for checked helper parameters.
|
|
381
|
+
|
|
382
|
+
Then the build plays the game. It joins people to a room on the real server
|
|
383
|
+
runtime, has them steer at random, hold every control at one end and then the
|
|
384
|
+
other, send every declared command with values its shape allows, and send nothing
|
|
385
|
+
at all (seat 1 never presses anything). People go away and come back, give up
|
|
386
|
+
seats and take them again, bots are switched off and on, the room is full for one
|
|
387
|
+
round and small for the next, and every 40 seconds the room restarts as a deploy
|
|
388
|
+
restarts it. A game with companions is then played a second, shorter time with
|
|
389
|
+
AI companions seated and people asking them for what the vocabulary offers. Bots,
|
|
390
|
+
companions and people who are away use the game's own `think`; a person who is
|
|
391
|
+
present has their body stepped by the game's `move`, as their browser steps it.
|
|
392
|
+
|
|
393
|
+
The build is refused only for something the runtime proved during that play:
|
|
394
|
+
|
|
395
|
+
| Proven | The message names |
|
|
396
|
+
| --- | --- |
|
|
397
|
+
| A handler threw, including a passed limit of the room | File, line, handler, and the tick of the play |
|
|
398
|
+
| A handler wrote a value the room had to change and lose (the table under State and field types: `NaN`, a list past its size) | File and line of the write, handler, field, what a live room stores instead, and the tick |
|
|
399
|
+
| A tick used its whole budget | The handler that used the most |
|
|
400
|
+
| The server held a body back because its `move` outran `body.maxSpeed` | The kind's `move` |
|
|
401
|
+
| A room rebuilt from its save did not save or play the same | The first field that differed |
|
|
402
|
+
|
|
403
|
+
Everything else is an `info:` line and never fails the build. One line says how
|
|
404
|
+
much was played: ticks, seconds of the room's clock, rounds finished, how often
|
|
405
|
+
the room was rebuilt from its save, and what the room held at the end (watch the
|
|
406
|
+
entity and waiting-event counts for a leak). A handler that never ran is named
|
|
407
|
+
with its line; that is information, not proof that play cannot reach it.
|
|
408
|
+
|
|
409
|
+
The play is the same on every computer: it is counted, and no clock is read. The
|
|
410
|
+
first room is played for 18,000 ticks (fifteen minutes of the room's clock at 20
|
|
411
|
+
ticks a second; to the end of one round if a round is longer, up to 36,000), or
|
|
412
|
+
until the room has used 280,000,000 budget units, whichever comes first. The
|
|
413
|
+
units are the ones a tick's budget is counted in. The second room of a game with
|
|
414
|
+
companions is played for 2,400 ticks, or an eighth as many units. What each person's browser uses running `move` is counted too. A
|
|
415
|
+
light game uses its ticks; a heavy game uses its units and so covers fewer
|
|
416
|
+
seconds, which the line says. `homie-studio build <id> --long-check` plays eight
|
|
417
|
+
times as much. Play that was not reached is not checked: a fault that needs an
|
|
418
|
+
hour of play is found by the long check or by playing.
|
|
419
|
+
|
|
420
|
+
Timers have no cancellation call: guard a stale timer with its round number, or
|
|
421
|
+
use one recurring timer.
|
|
422
|
+
|
|
423
|
+
Fix the named field or handler. Declare missing fields; copy readonly lists; check
|
|
424
|
+
map and key presence before reading; bound repeated queries; keep state in fields;
|
|
425
|
+
clamp the stick's length or declare the body's real top speed. If a message says
|
|
426
|
+
the check itself stopped, or that the fault is in Homie's save, the game is not at
|
|
427
|
+
fault: report it.
|
|
428
|
+
|
|
429
|
+
Then run local dev and `homie-studio check <id> --url <preview>` and inspect the
|
|
430
|
+
requested behavior. Two real browsers must join the same room and finish a round.
|
|
431
|
+
The generated check is finite testing, not a proof of every possible future input.
|
|
432
|
+
|
|
433
|
+
## Server companions and decisions
|
|
434
|
+
|
|
435
|
+
Declare `guide.view` and `think` on a player kind and the vocabulary in
|
|
436
|
+
`agents.json`. The server reserves the highest seats for AI companions under hybrid
|
|
437
|
+
and beginner policies, including with ordinary bots off. One seat remains available
|
|
438
|
+
for a person. Those bodies run `think`; input cannot grant guide authority.
|
|
439
|
+
|
|
440
|
+
`guide.view` reads the world and returns `shapes.view`. It cannot mutate state.
|
|
441
|
+
`guide.floor` can use entity-handler operations and write its own fields. Its third
|
|
442
|
+
argument includes the declared view, `goal` (or null), and `asks`, up to four
|
|
443
|
+
validated requests with `k`, `args`, `from` and `at`. Return `{goal, args, say, sayArgs}` (every field is optional, so `{}` declines to act)
|
|
444
|
+
using names from the vocabulary. The compiler checks the returned `goal` and `say` names and the exact `args` and `sayArgs` each declares against `agents.json`; a request forwards unchanged as `{ goal: ask.k, args: ask.args }`. Goals and arguments are also validated at run time against that
|
|
445
|
+
vocabulary, current offered choices and present players, including after restore. A floor decision outside them is discarded whole in live play, before rules can receive a goal; the build refuses it with the floor's line, the rejected name and the allowed arguments.
|
|
446
|
+
`self.goal` is null or `{goal,args,from,at,asked,state}`; `world.goalDone(ok)` clears it.
|
|
447
|
+
Views refresh at most every two seconds; active goals and companion pacing prevent
|
|
448
|
+
repeated floor calls on every tick. Requests can interrupt an active goal.
|
|
449
|
+
|
|
450
|
+
A game's own `asks` declare state, decision questions and a mandatory local
|
|
451
|
+
`floor(state)`. `world.ask` queues an answer for the calling entity or room.
|
|
452
|
+
Only one request per declared ask name can be pending. A valid remote answer wins;
|
|
453
|
+
a missing answer falls back after five seconds. Every floor must answer all the
|
|
454
|
+
questions with their declared types: booleans for yes/no, declared option names
|
|
455
|
+
for choices, and integer indices from zero through the last score level. `answer` carries `ask`, `by`, `picks` and an
|
|
456
|
+
optional fallback `why`. The build's generated play uses local floors on the next
|
|
457
|
+
tick, without a network request. With guides or a vocabulary declared, it plays a
|
|
458
|
+
second room with AI bodies reserved, which runs guide views, floors and validated
|
|
459
|
+
goals beside people, and rebuilds that room from its save as it does the first.
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
### Questions and answers
|
|
463
|
+
|
|
464
|
+
`asks` belongs at the top level of `defineRules`, beside `entities` and `room`.
|
|
465
|
+
Every ask has declared `state`, `questions` and a required local `floor(state)`.
|
|
466
|
+
Questions have these shapes:
|
|
467
|
+
|
|
468
|
+
| Type | Required properties | Answer |
|
|
469
|
+
| --- | --- | --- |
|
|
470
|
+
| `choice` | `instructions`, `criteria: ['hold', 'rush']` or `{hold: 'Stay', rush: 'Advance'}` | One declared option name |
|
|
471
|
+
| `noul` (yes/no) | `instructions`; `yes-no` is also accepted | `true` or `false` |
|
|
472
|
+
| `score` | `instructions`, `criteria: ['calm', 'brisk', 'wild']` | Integer `0` through `criteria.length - 1` |
|
|
473
|
+
|
|
474
|
+
`instructions` is a nonempty sentence saying what to decide. The floor must return
|
|
475
|
+
one pick for every question, including a score within its declared levels. Answers
|
|
476
|
+
return only to their caller: room asks to room `on.answer`, entity asks to that
|
|
477
|
+
entity's `on.answer`. A handler receiving several asks checks `e.ask` before
|
|
478
|
+
reading ask-specific picks; no cast is needed. Literal calls in each scope narrow
|
|
479
|
+
its answer union; shared helpers conservatively contribute their asks to each scope.
|
|
480
|
+
|
|
481
|
+
### A complete companion and decision example
|
|
482
|
+
|
|
483
|
+
This example uses a server room with up to four seats. Put the following vocabulary
|
|
484
|
+
in `agents.json`. `v` is 1; `persona` is optional. `goals` entries have `about` and
|
|
485
|
+
optional `args`; `lines` have `text` and optional `args`; `asks` have button `text`
|
|
486
|
+
and optional `goal`, `say`, `args` or `leave: true`. Arguments are `'player'` (a
|
|
487
|
+
present person's seat), `'view.<field>'` (one offered value or object id from that
|
|
488
|
+
view field), or an explicit list of allowed strings. Text placeholders use argument
|
|
489
|
+
names. The game never chooses identities or policy from input.
|
|
490
|
+
|
|
491
|
+
<!-- companion-example:agents -->
|
|
492
|
+
```json
|
|
493
|
+
{
|
|
494
|
+
"v": 1,
|
|
495
|
+
"persona": "A helpful companion.",
|
|
496
|
+
"goals": {
|
|
497
|
+
"guard": { "about": "Wait here" },
|
|
498
|
+
"follow": { "about": "Follow a person", "args": { "seat": "player" } },
|
|
499
|
+
"visit": { "about": "Visit a place", "args": { "place": "view.places" } }
|
|
500
|
+
},
|
|
501
|
+
"lines": { "ready": { "text": "Ready!" } },
|
|
502
|
+
"asks": {
|
|
503
|
+
"follow": { "text": "Follow me", "goal": "follow", "args": { "seat": "player" } },
|
|
504
|
+
"visit": { "text": "Visit {place}", "goal": "visit", "args": { "place": "view.places" } }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Put this in `src/rules.ts`. It includes movement, so no separate `move.ts` is needed.
|
|
510
|
+
|
|
511
|
+
<!-- companion-example:rules -->
|
|
512
|
+
```ts
|
|
513
|
+
import { defineRules, defineMove, f } from '@homie-rocks/studio/rules';
|
|
514
|
+
|
|
515
|
+
export default defineRules({
|
|
516
|
+
contract: 2,
|
|
517
|
+
space: { dims: 2 },
|
|
518
|
+
move: defineMove({
|
|
519
|
+
walker(body, input, ctx) {
|
|
520
|
+
const stick = ctx.math.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1);
|
|
521
|
+
body.vel = ctx.math.scale(stick, 2);
|
|
522
|
+
ctx.map.sweep(body, ctx.math.scale(body.vel, ctx.dt));
|
|
523
|
+
},
|
|
524
|
+
}),
|
|
525
|
+
shapes: { view: { places: f.list(f.text(12), 2) } },
|
|
526
|
+
shared: { tactic: f.text(8), pace: f.u8(), advance: f.bit() },
|
|
527
|
+
asks: {
|
|
528
|
+
plan: {
|
|
529
|
+
state: { round: f.u16() },
|
|
530
|
+
questions: {
|
|
531
|
+
tactic: { type: 'choice', instructions: 'Choose a tactic.', criteria: ['hold', 'rush'] },
|
|
532
|
+
pace: { type: 'score', instructions: 'Choose a pace.', criteria: ['calm', 'brisk', 'wild'] },
|
|
533
|
+
advance: { type: 'noul', instructions: 'Should the party advance?' },
|
|
534
|
+
},
|
|
535
|
+
floor(state) {
|
|
536
|
+
return { tactic: 'hold', pace: 1, advance: state.round > 1 };
|
|
537
|
+
},
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
entities: {
|
|
541
|
+
walker: {
|
|
542
|
+
player: { away: 'think', leave: 'bot' },
|
|
543
|
+
input: { ax: f.i8(), ay: f.i8() },
|
|
544
|
+
body: { shape: 'circle', radius: 0.3, maxSpeed: 2 },
|
|
545
|
+
guide: {
|
|
546
|
+
view() { return { places: ['camp'] }; },
|
|
547
|
+
floor(world, self, view) {
|
|
548
|
+
const ask = view.asks[0];
|
|
549
|
+
if (ask) return { goal: ask.k, args: ask.args, say: 'ready' };
|
|
550
|
+
return { goal: 'guard' };
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
think(world, self) {
|
|
554
|
+
const goal = self.goal;
|
|
555
|
+
if (!goal || goal.goal === 'guard') return { ax: 0, ay: 0 };
|
|
556
|
+
const target = goal.goal === 'follow'
|
|
557
|
+
? world.near(self.pos, 64, 'walker').find(p => p.seat === goal.args.seat)?.pos
|
|
558
|
+
: world.map.spot(goal.args.place);
|
|
559
|
+
if (!target) { world.goalDone(false); return { ax: 0, ay: 0 }; }
|
|
560
|
+
if (world.math.dist(self.pos, target) < 0.5) {
|
|
561
|
+
world.goalDone(true);
|
|
562
|
+
return { ax: 0, ay: 0 };
|
|
563
|
+
}
|
|
564
|
+
const step = world.math.clampLen(world.math.sub(target, self.pos), 1);
|
|
565
|
+
return { ax: Math.round(step.x * 127), ay: Math.round(step.y * 127) };
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
room: {
|
|
570
|
+
rounds: { seconds: 30, breakSeconds: 5 },
|
|
571
|
+
join(ctx) { return { kind: 'walker', at: ctx.map.spots('start')[0] }; },
|
|
572
|
+
on: {
|
|
573
|
+
roundStart(world) { world.ask('plan', { round: world.round.n }); },
|
|
574
|
+
answer(world, e) {
|
|
575
|
+
if (e.ask === 'plan') {
|
|
576
|
+
world.shared.tactic = e.picks.tactic;
|
|
577
|
+
world.shared.pace = e.picks.pace;
|
|
578
|
+
world.shared.advance = e.picks.advance;
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
Use a map with a `start` and `camp` spot. For example, `map/main.json`:
|
|
587
|
+
|
|
588
|
+
<!-- companion-example:map -->
|
|
589
|
+
```json
|
|
590
|
+
{
|
|
591
|
+
"bounds": { "min": [-12, -8], "max": [12, 8] },
|
|
592
|
+
"boxes": [], "circles": [],
|
|
593
|
+
"spots": { "start": [[0, 0]], "camp": [[4, 0]] }
|
|
594
|
+
}
|
|
595
|
+
```
|
|
596
|
+
|
|
597
|
+
Set `players.max` to 4 and `entry` to `src/view.ts` in `game.json`. Draw walkers
|
|
598
|
+
with `room.each('walker', ...)`, and their buttons with `room.askButtons(id)` as
|
|
599
|
+
above. Select a beginner policy with one guide or a hybrid policy with an AI seat
|
|
600
|
+
in the server configuration. A person remains a person; the server alone assigns
|
|
601
|
+
companions and admits their goals. For an enum argument, for example,
|
|
602
|
+
`"style": ["quiet", "bold"]`, both its goal and ask declare the same argument.
|
|
603
|
+
|
|
604
|
+
Map fields use insertion order, kept across saves. They follow plain JavaScript:
|
|
605
|
+
integer-index keys enumerate first in numeric order; other keys keep insertion
|
|
606
|
+
order. The save writes the live order and restores it in that order, so the server,
|
|
607
|
+
browser and restored room agree, including when reading the first key.
|
|
608
|
+
To remove a key, assign a replacement map without it: `const next: Record<string, number> = {}; for
|
|
609
|
+
(const key of Object.keys(world.shared.items)) { if (key !== removed)
|
|
610
|
+
next[key] = world.shared.items[key]; } world.shared.items = next;` In-place
|
|
611
|
+
`delete` is not supported.
|
|
612
|
+
|
|
613
|
+
An `undeliverable` handler receives a discriminated union of the declared events:
|
|
614
|
+
a check such as `e.event === 'give'` narrows `e.data` to the `give` payload. Views
|
|
615
|
+
read the same typed companion goal from player entities. The view's `goal` event
|
|
616
|
+
retains its runtime envelope `{slot, goal, prev, askAt}`; `goal` and `prev` contain
|
|
617
|
+
the vocabulary's typed arguments and a completion `state`.
|
|
618
|
+
|
|
619
|
+
The goal on an entity and on a `goal` event has the same declared goal and arguments,
|
|
620
|
+
and always has a boolean `asked` and a completion `state`. Entity goals are active;
|
|
621
|
+
completion clears them. Event and floor goals can also describe a completed goal. The entity's and floor view's `at` is a simulation tick;
|
|
622
|
+
the event's `at` and `askAt` use the companion clock in milliseconds.
|
|
623
|
+
|
|
624
|
+
`room.on('say', e => ...)` supplies `text: string`, `seat: number | null` and
|
|
625
|
+
`slot: number` (the companion slot, or -1 for legacy player speech). Companion
|
|
626
|
+
speech also supplies its vocabulary `line` and `args`. `room.on('ask', e => ...)`
|
|
627
|
+
supplies `slot`, `k`, `args`, `from` and `at`; legacy requests also have `ask`.
|
|
628
|
+
Speech text is safe to assign to `textContent`; it is not HTML.
|
|
629
|
+
`room.askButtons(id)` returns a union of the vocabulary's requests plus `text`.
|
|
630
|
+
Narrow `button.k` before reading an argument that only that request declares.
|
|
631
|
+
`room.ask(id, k, args)` is checked against the same requests: an unknown name or missing or wrongly typed arguments do not compile, and `room.ask(id, button.k, button.args)` needs no narrowing.
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
## Message guide: repair the cause
|
|
635
|
+
|
|
636
|
+
Read the full message, including file/line, handler, tick, seed and measured values.
|
|
637
|
+
Never patch node_modules, the guard, generated declarations or a probe to make a
|
|
638
|
+
check green. Never rename rules.ts away or fall back to old-style hosting.
|
|
639
|
+
|
|
640
|
+
| Message family | Next action |
|
|
641
|
+
| --- | --- |
|
|
642
|
+
| index.html: load the built view | Use `<script type="module" src="./assets/main.js"></script>`; game/app.json entry remains src/view.ts. |
|
|
643
|
+
| Syntax/global/import/load-time guard | Move DOM, audio, clocks, network and dependencies into view. Use local pure helpers, world math/random/timers in handlers, literal module constants and declared state. No mutable module closures, classes, try/catch or computed prototype access. |
|
|
644
|
+
| Linked module has an uncounted function or unchecked operation | This is the final verification of the built artifact. Preserve the source and full diagnostic as a toolkit reproduction; never remove that verification or patch its output. Check that the installed toolkit and build are current and consistent. |
|
|
645
|
+
| Unknown field/handler/payload or TypeScript | Read generated types and the declaration; fix spelling, declare the bounded shape, narrow kind/event/ask before using its fields. Do not cast away capability errors. |
|
|
646
|
+
| Missing key/property | Test `key in object` or list length before reading; `??` does not guard the read. |
|
|
647
|
+
| Readonly/entity/shared capability | Copy and reassign own collections; send a declared event to another entity; sendRoom for a room handler to change shared. |
|
|
648
|
+
| Handler threw / invalid return / companion decision | Fix the named handler's argument or vocabulary name. Use only declared options and available player/quest values; handle empty queries. |
|
|
649
|
+
| Value lost / NaN / list, map, text or effect overflow | Check divisor and absent inputs; bound/splice collections before storing, or declare their actual bounded size. Emit fewer effects. Integer rounding/clamping info alone is not failure. |
|
|
650
|
+
| Budget exhausted | Reduce repeated scans, range, per-tick work and growth; schedule bounded work over ticks. Inspect the most expensive named handler. Keep the default budget unless actual game requirements justify measured tuning. |
|
|
651
|
+
| maxSpeed held movement | Normalize stick and diagonal input; check metres/seconds and acceleration. Declare normal maximum running speed accurately. Knocks/dashes use motion; don't multiply the limit to hide a broken step. |
|
|
652
|
+
| Save/replay differs | Eliminate undeclared state and nondeterministic operations. If explicitly a Homie save or check-internal fault, preserve the reproduction and report it; don't rewrite correct game state to evade it. |
|
|
653
|
+
| info: coverage, clamps, unreached handler, counts | Not a failure. Exercise important unreached behavior yourself; growing entity/event counts suggest a leak. `--long-check` extends deterministic play. No claim that finite smoke proves every future input. |
|
|
654
|
+
| Browser wire allowance / oversized frame | Reduce declared state/payloads or rate. Browser-hosted excess fails; server-only warnings still deserve inspection. No hand-packed replacement transport. |
|
|
655
|
+
| host-failed | Rules did not start: read its reason and server log, rebuild the complete site with this game's rules and view from the same build. No silent browser fallback. |
|
|
656
|
+
| host-fault / tick-failed / room-over (budget, fault, overrun) | Inspect the named build/kind/handler and tick counters. Repair bounded work or the fault; overrun may be runner load and is not itself evidence of nondeterministic game code. The ended room stays ended; use a fresh room after fixing. |
|
|
657
|
+
| restore loop | Inspect repeated host restarts and other rooms sharing the isolate. The third restored failure ends the match; do not keep retrying the saved failure. |
|
|
658
|
+
| stale / room-stale / changed state, rematch | Reload into the current build. Same state shape restores; incompatible state starts a fresh match. Rules builds use automatic hashes, not manual netplay.version. |
|
|
659
|
+
| connecting / reconnecting / offline / full / closed | Display the helper's true link/standing. Test the actual URL and running dev server; offline practice is not evidence of a shared room. A full room watches/waits; closed errors show their reason. |
|
|
660
|
+
| kicked, muted, room-closed, agents-off, agent-pace | Respect the owner/server policy and retry delay. Never change identity or bypass policy to make testing pass. |
|
|
661
|
+
| Two-browser check failed / no round | Read screenshots, console and room facts. Verify same server room and real win/round transition. A turn game needs its actual inputs exercised, not dummy motion/rounds. App checks exercise their declared action and reconnect, without rounds. |
|
|
662
|
+
|
|
663
|
+
`room.probe` adds truthful frame/mechanic/render counters to the built-in rules probe.
|
|
664
|
+
Use the normal build, local dev and two-browser check, then play the requested mechanic
|
|
665
|
+
for a minute with delay. Movement should respond before the authoritative response;
|
|
666
|
+
score and other shared outcomes still arrive with the network. Milestone 1 supports
|
|
667
|
+
up to 32 seats; neither frame rate nor feel is guaranteed by a passing build.
|
|
668
|
+
|
|
669
|
+
Room state is saved automatically (default movementSeconds: 1, plus round end,
|
|
670
|
+
pause and finish). After 60 seconds with no person the room ends even if screens or
|
|
671
|
+
AI remain. Use browser createSaves for player-owned character progress; it is not
|
|
672
|
+
server-verified money. All replicated fields are public, including disguised roles.
|
|
673
|
+
Use a separate authorized records service for private or lasting app records.
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
## Spatial delivery (0.44.0; milestone 2 slice 1)
|
|
677
|
+
|
|
678
|
+
For a server game with a larger map, the studio's AI can set
|
|
679
|
+
`"room": { "view": { "radiusM": 32 } }` in game.json to send each player only
|
|
680
|
+
entities within that many metres of its body (3D includes height). This is the
|
|
681
|
+
studio's choice: omit it or use null for the whole room. Rules and move do not
|
|
682
|
+
change. The player's own body is always sent. Before a body exists, its view has
|
|
683
|
+
no entities. Watchers still receive the whole room.
|
|
684
|
+
|
|
685
|
+
`room.each` visits visible entities; `enter`/`leave` mean coming into or out of
|
|
686
|
+
view, not spawning or despawning. Remove departed meshes and recreate returning
|
|
687
|
+
ones. Prediction and compact snapshot recovery are handled by the runtime. Live
|
|
688
|
+
collision geometry bypasses visual interest filtering, so a sweep or dash can
|
|
689
|
+
reach a collider beyond the view radius without predicting through it. Its entity
|
|
690
|
+
may be absent from room.each while its geometry still blocks movement.
|
|
691
|
+
Shared state, effects, rosters and watcher state are still public: this is not a
|
|
692
|
+
hidden-information feature. Browser hosting sends the whole room.
|
|
693
|
+
|
|
694
|
+
A request for hundreds of players also needs the next Gates release: 0.44.0's
|
|
695
|
+
public build/join path still has the milestone 1 restriction of 32 seats. Do not
|
|
696
|
+
silently clamp a requested bigger game or claim this setting raises admission.
|
|
697
|
+
When Gates ship, `players.max` will choose the room's layout automatically; the
|
|
698
|
+
studio will not create or find infrastructure by hand. The slice plan and local
|
|
699
|
+
300-client measurements are in docs/rooms-milestone-2-notes.md in Homie's repo.
|
|
700
|
+
## Live collision geometry (0.44.1)
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
cover: {
|
|
704
|
+
fields: { size: f.vec3({init: {x: 3, y: .3, z: 2.6}}), solid: f.bit({init: true}) },
|
|
705
|
+
body: {shape: 'box', radius: .5, height: 2.6, maxSpeed: 0},
|
|
706
|
+
collider: {size: 'size', enabled: 'solid'},
|
|
707
|
+
// Handlers change self.size/self.solid, world.place(self, at), or despawn.
|
|
708
|
+
}
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
`collider: true` uses the body's shape and dimensions. The optional `size` field
|
|
712
|
+
supplies full axis-aligned box dimensions in metres, including rectangular 2D
|
|
713
|
+
walls (use a circle body with a size field). Nonpositive width/depth disables it.
|
|
714
|
+
`enabled` names a bit field; false removes it from movement collision. Collider
|
|
715
|
+
entities are non-player bodies; players use server movement (omit `body.move: 'owner'`). Their declared state and position save and restore
|
|
716
|
+
with the room; their refs stay opaque. Collision geometry is sent to all players
|
|
717
|
+
in the room, independently of visual interpolation and spatial interest, including in compact
|
|
718
|
+
updates and on reload/rejoin. This includes all live geometry, rather than a
|
|
719
|
+
speed-based margin that could miss an authored dash or sweep.
|
|
720
|
+
|
|
721
|
+
`ctx.world.sweep(body, delta)` moves and updates grounded, returning an optional
|
|
722
|
+
hit with at/normal and the collider's entity ref. `support(body, distance=.002)`
|
|
723
|
+
queries downwards without moving, returning at/normal/dist/entity, or undefined.
|
|
724
|
+
Use a longer distance for dive height and a short one before jumping. `overlaps(body)`
|
|
725
|
+
checks whether the body is inside geometry (for example, whether a pad is blocked).
|
|
726
|
+
2D support is the ground plane. All calls charge the movement budget; convex casts
|
|
727
|
+
have a bounded iteration count. The number of colliders is bounded by the room's
|
|
728
|
+
entity and snapshot budgets. Games without colliders send no extra snapshot data.
|
|
729
|
+
|
|
730
|
+
Every movement phase freezes one world for all movers; edits from handlers take
|
|
731
|
+
effect on the next movement tick. A snapshot carries a tick-stamped collision
|
|
732
|
+
revision for that next tick. Prediction selects revisions by effective tick, and
|
|
733
|
+
replays pending input after rebasing to the authoritative pose. It holds the latest
|
|
734
|
+
known geometry beyond received ticks: a client cannot predict an unseen remote
|
|
735
|
+
build/destruction. Once that update arrives, replay uses its geometry immediately.
|
|
736
|
+
Moving platforms use world.place in their handlers; sweep/support see each tick's
|
|
737
|
+
position. Passenger carrying is explicit movement logic, not automatic physics.
|