@umicat/phaser-sdk 1.0.0

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.
Files changed (62) hide show
  1. package/SDK-GUIDE.md +1726 -0
  2. package/dist/core/Transport.d.ts +28 -0
  3. package/dist/core/Transport.js +7 -0
  4. package/dist/core/Umicat.d.ts +45 -0
  5. package/dist/core/Umicat.js +60 -0
  6. package/dist/core/UmicatGame.d.ts +43 -0
  7. package/dist/core/UmicatGame.js +64 -0
  8. package/dist/core/UmicatScene.d.ts +19 -0
  9. package/dist/core/UmicatScene.js +38 -0
  10. package/dist/core/transports/LocalStorageTransport.d.ts +22 -0
  11. package/dist/core/transports/LocalStorageTransport.js +78 -0
  12. package/dist/core/transports/PostMessageTransport.d.ts +28 -0
  13. package/dist/core/transports/PostMessageTransport.js +105 -0
  14. package/dist/editor/EditorBridge.d.ts +114 -0
  15. package/dist/editor/EditorBridge.js +2608 -0
  16. package/dist/editor/EditorOverlayScene.d.ts +333 -0
  17. package/dist/editor/EditorOverlayScene.js +1896 -0
  18. package/dist/editor/EditorState.d.ts +251 -0
  19. package/dist/editor/EditorState.js +197 -0
  20. package/dist/gamedata/GameDataModule.d.ts +45 -0
  21. package/dist/gamedata/GameDataModule.js +59 -0
  22. package/dist/index.d.ts +43 -0
  23. package/dist/index.js +43 -0
  24. package/dist/orientation.d.ts +5 -0
  25. package/dist/orientation.js +4 -0
  26. package/dist/protocol.d.ts +807 -0
  27. package/dist/protocol.js +3 -0
  28. package/dist/realtime/RealtimeModule.d.ts +93 -0
  29. package/dist/realtime/RealtimeModule.js +115 -0
  30. package/dist/realtime/UmicatRoom.d.ts +197 -0
  31. package/dist/realtime/UmicatRoom.js +353 -0
  32. package/dist/recording/RecordingManager.d.ts +11 -0
  33. package/dist/recording/RecordingManager.js +59 -0
  34. package/dist/saves/SavesModule.d.ts +23 -0
  35. package/dist/saves/SavesModule.js +37 -0
  36. package/dist/scene/EditorMode.d.ts +17 -0
  37. package/dist/scene/EditorMode.js +22 -0
  38. package/dist/scene/EntityRegistry.d.ts +39 -0
  39. package/dist/scene/EntityRegistry.js +103 -0
  40. package/dist/scene/GameConfig.d.ts +60 -0
  41. package/dist/scene/GameConfig.js +50 -0
  42. package/dist/scene/HudRuntime.d.ts +131 -0
  43. package/dist/scene/HudRuntime.js +1224 -0
  44. package/dist/scene/Prefabs.d.ts +92 -0
  45. package/dist/scene/Prefabs.js +175 -0
  46. package/dist/scene/Rules.d.ts +73 -0
  47. package/dist/scene/Rules.js +164 -0
  48. package/dist/scene/SceneLoader.d.ts +118 -0
  49. package/dist/scene/SceneLoader.js +615 -0
  50. package/dist/scene/Waves.d.ts +85 -0
  51. package/dist/scene/Waves.js +365 -0
  52. package/dist/scene/autotile.d.ts +103 -0
  53. package/dist/scene/autotile.js +321 -0
  54. package/dist/scene/renderScripts.d.ts +53 -0
  55. package/dist/scene/renderScripts.js +67 -0
  56. package/dist/scene/spawnEntity.d.ts +201 -0
  57. package/dist/scene/spawnEntity.js +1326 -0
  58. package/dist/scene/types.d.ts +1166 -0
  59. package/dist/scene/types.js +34 -0
  60. package/dist/screenshot/ScreenshotManager.d.ts +14 -0
  61. package/dist/screenshot/ScreenshotManager.js +33 -0
  62. package/package.json +35 -0
package/SDK-GUIDE.md ADDED
@@ -0,0 +1,1726 @@
1
+ # @umicat/phaser-sdk — Agent Guide
2
+
3
+ Reference for AI agents building games on the Umicat platform. Tracks the **installed SDK version** — always matches the code in `node_modules/@umicat/phaser-sdk`. Read this before calling any platform API.
4
+
5
+ ## What the SDK provides
6
+
7
+ - `createUmicatGame(options)` — Phaser.Game factory with platform defaults (screenshot + recording wired up)
8
+ - `UmicatScene` — optional base scene with `createButton`, `shakeCamera`, `flashCamera` helpers
9
+ - `Umicat.init()` — platform services entry point
10
+ - `umicat.saves` — **per-user** key-value store (only the owner sees/writes their data)
11
+ - `umicat.gameData` — **per-game** key-value store (read by anyone; write requires auth)
12
+ - `umicat.rooms` — **multiplayer rooms** (server-authoritative state sync, requires sign-in and host support)
13
+
14
+ ## `createUmicatGame` options
15
+
16
+ | field | type | note |
17
+ |-------|------|------|
18
+ | `orientation` | `'portrait' \| 'landscape'` | mutually exclusive with `width`/`height`. Resolves to preset dims from `ORIENTATION_DIMENSIONS`. **Since 0.2.16.** |
19
+ | `width` | `number` | required if `orientation` is omitted |
20
+ | `height` | `number` | required if `orientation` is omitted |
21
+ | `scenes` | `Phaser.Scene class[]` | required |
22
+ | `backgroundColor` | `string` | defaults to `'#1a1a2e'` |
23
+ | `pixelArt` | `boolean` | defaults to `false` |
24
+ | `physics` | `Phaser.Types.Core.PhysicsConfig` | defaults to arcade physics, no gravity |
25
+ | `plugins` | `Phaser.Types.Core.PluginObject` | forwarded as-is into the Phaser `GameConfig`. **Since 0.2.9.** Earlier versions silently dropped this field. |
26
+
27
+ `orientation` and explicit `width`/`height` are a TypeScript union — pass one or the other, not both. The compiler rejects mixed usage.
28
+
29
+ Any field not listed is ignored. If you need something else (e.g. `callbacks`, custom `render` options), use plain `new Phaser.Game(config)` instead of `createUmicatGame`.
30
+
31
+ ### Orientation presets
32
+
33
+ ```ts
34
+ import { ORIENTATION_DIMENSIONS } from '@umicat/phaser-sdk';
35
+ // { landscape: { width: 1280, height: 720 }, portrait: { width: 720, height: 1280 } }
36
+ ```
37
+
38
+ The orientation a game uses is decided **once at game creation** and baked into the template's `src/config.ts`. There is no runtime API to flip orientation — switching mid-development would invalidate every screen layout.
39
+
40
+ ### Registering third-party Phaser plugins (e.g. rexUI)
41
+
42
+ Pass them via the `plugins` option — do NOT try to register them inside a scene's `preload`:
43
+
44
+ ```ts
45
+ import VirtualJoystickPlugin from 'phaser3-rex-plugins/plugins/virtualjoystick-plugin.js';
46
+
47
+ createUmicatGame({
48
+ // ...
49
+ plugins: {
50
+ global: [
51
+ { key: 'rexVirtualJoystick', plugin: VirtualJoystickPlugin, start: true },
52
+ ],
53
+ },
54
+ });
55
+ ```
56
+
57
+ Then in a scene: `this.plugins.get('rexVirtualJoystick').add(this, { ... })`.
58
+
59
+ ## Platform services
60
+
61
+ Initialize once at module load — resolves the host (home-ui, standalone, etc.) and returns a bound instance. Scenes read from the resulting promise.
62
+
63
+ ```ts
64
+ // src/main.ts
65
+ import { Umicat } from '@umicat/phaser-sdk';
66
+
67
+ export const umicatReady = Umicat.init({ standaloneGameId: 'my-game' })
68
+ .catch(() => null);
69
+ ```
70
+
71
+ `umicatReady` resolves to an `Umicat` instance, or `null` if init failed. Scenes should be resilient to `null` — they must still run for anonymous players.
72
+
73
+ ### Identity
74
+
75
+ ```ts
76
+ const umicat = await umicatReady;
77
+ if (umicat) {
78
+ umicat.user; // { id, name, avatar? } | null — null = anonymous
79
+ umicat.isAuthenticated; // boolean
80
+ umicat.gameId; // stable platform-issued game id
81
+ umicat.host; // 'umicat-home-ui' | 'standalone' | 'discord' (future)
82
+ }
83
+ ```
84
+
85
+ ### Save data — `umicat.saves`
86
+
87
+ Per-user key-value store scoped to `(gameId, userId)`. Backed by the Umicat backend when authenticated, by localStorage when standalone/anonymous — **the API is identical either way**. Games should never branch on auth state for save logic.
88
+
89
+ ```ts
90
+ // Read — returns null if the key doesn't exist
91
+ const highScore = await umicat.saves.get<number>('highScore');
92
+
93
+ // Write — returns the new version number
94
+ await umicat.saves.set('highScore', 12340);
95
+
96
+ // Optimistic concurrency — write only if stored version matches
97
+ try {
98
+ await umicat.saves.set('progress', { level: 3 }, { ifVersion: 7 });
99
+ } catch (err) {
100
+ // RpcError with code 'VERSION_MISMATCH' — someone else wrote first
101
+ }
102
+
103
+ // List keys (no values — cheap)
104
+ const keys = await umicat.saves.list();
105
+
106
+ // Delete
107
+ await umicat.saves.delete('progress');
108
+ ```
109
+
110
+ **Type parameter on `.get<T>()`** is only for TypeScript convenience — the SDK does not validate. Treat returned data as untrusted and defend against missing/malformed values.
111
+
112
+ ### Quotas (enforced at backend)
113
+
114
+ | Limit | Value |
115
+ |---|---|
116
+ | Per value | 100 KB |
117
+ | Per `(game, user)` total | 1 MB |
118
+ | Max keys per `(game, user)` | 64 |
119
+ | Key format | `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` |
120
+
121
+ Exceeding a quota throws `RpcError` with code `QUOTA_EXCEEDED`. Don't catch silently — surface a clear error.
122
+
123
+ ### When to use saves
124
+
125
+ **Persist (by convention, no need to ask):**
126
+ - High scores, personal bests
127
+ - Level / stage / chapter progression
128
+ - Unlockables (characters, skins, abilities)
129
+ - In-game currency, inventories
130
+ - Cosmetic selection (chosen character, color)
131
+ - User-configurable settings (volume, difficulty preference, control scheme)
132
+ - Tutorial-seen / onboarding-completed flags
133
+
134
+ **Do not persist (transient state):**
135
+ - Current-run position, velocity, HP
136
+ - Active enemies, projectiles, particles
137
+ - Scene camera position
138
+ - Animation frames, timers, cooldowns
139
+ - UI focus, current menu
140
+
141
+ **Rule of thumb:** if the user would be annoyed to lose it on refresh, persist. If they'd be confused to see it come back (e.g. "why is there an enemy here from my last game?"), don't.
142
+
143
+ ### Suggested key names
144
+
145
+ Use stable, descriptive key names so data carries across SDK versions:
146
+
147
+ | Concept | Key |
148
+ |---|---|
149
+ | Highest score ever | `highScore` |
150
+ | Current progression | `progress` |
151
+ | Unlocked items | `unlocks` |
152
+ | Player settings | `settings` |
153
+ | Cosmetic selection | `cosmetics` |
154
+ | Onboarding flag | `onboardedAt` |
155
+
156
+ ### Error handling patterns
157
+
158
+ ```ts
159
+ // Graceful degradation — save failures must never block gameplay
160
+ try {
161
+ await umicat.saves.set('highScore', score);
162
+ } catch (err) {
163
+ console.warn('[game] failed to save highScore', err);
164
+ // continue — game still works, just without persistence this turn
165
+ }
166
+ ```
167
+
168
+ ```ts
169
+ // Load at scene start — handle missing, malformed, and failure cases
170
+ const saved = await umicat.saves.get<number>('highScore').catch(() => null);
171
+ this.highScore = typeof saved === 'number' ? saved : 0;
172
+ ```
173
+
174
+ ### Game data — `umicat.gameData`
175
+
176
+ Same API shape as `saves`, but **game-scope**: one value per key, shared by all players of the game. Use it for things the whole playerbase sees — scoreboards, shared inventories, tournament state, level-of-the-day.
177
+
178
+ ```ts
179
+ // Read — public. Works for anonymous visitors.
180
+ type Entry = { name: string; score: number; at: number };
181
+ const board = await umicat.gameData.get<Entry[]>('leaderboard') ?? [];
182
+
183
+ // Write — requires an authenticated user. Throws RpcError('UNAUTHENTICATED') otherwise.
184
+ // Safe append: read + modify + write with ifVersion to avoid clobbering
185
+ // concurrent writers (two players finishing at the same time).
186
+ const { value: current, version } = await umicat.gameData
187
+ .get<Entry[]>('leaderboard')
188
+ .then((v) => ({ value: v ?? [], version: null as number | null })) // null means "not yet written"
189
+
190
+ async function submitScore(name: string, score: number) {
191
+ if (!umicat.isAuthenticated) return; // skip for anonymous
192
+ for (let attempt = 0; attempt < 3; attempt++) {
193
+ const raw = await umicat.gameData.get<Entry[]>('leaderboard');
194
+ const list = Array.isArray(raw) ? raw : [];
195
+ const next = [...list, { name, score, at: Date.now() }]
196
+ .sort((a, b) => b.score - a.score)
197
+ .slice(0, 100); // cap to top 100 so we don't blow the 100 KB limit
198
+ try {
199
+ await umicat.gameData.set('leaderboard', next);
200
+ return; // success
201
+ } catch (err: any) {
202
+ if (err?.code !== 'VERSION_MISMATCH') throw err;
203
+ // another writer landed first — re-read and retry
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
209
+ ### Saves vs. gameData — which one?
210
+
211
+ | Need | Use | Why |
212
+ |---|---|---|
213
+ | My personal high score | `saves` | Only I write it, only I read it. Anonymous-friendly (localStorage fallback). |
214
+ | Progress through levels | `saves` | Per-user private state. |
215
+ | Character / loadout choice | `saves` | Per-user. |
216
+ | Scoreboard visible to everyone | `gameData` | Multiple users write; anyone reads. |
217
+ | Today's puzzle / daily challenge | `gameData` | Single global value read by all players. |
218
+ | Shared tournament bracket | `gameData` | Shared mutable state across players. |
219
+ | Player name in a chat log | `gameData` | But consider size caps — chat is noisy. |
220
+ | Current frame's enemy positions | *neither* — transient, don't persist | Wastes quota and confuses state after refresh. |
221
+
222
+ Rule of thumb: if two different players would write to the same key, it's `gameData`. If a write from player A should never be visible to player B, it's `saves`.
223
+
224
+ ### Concurrency + size (gameData specifics)
225
+
226
+ - **List writes race.** If two players append to `leaderboard` simultaneously, one overwrites the other's work. Use `ifVersion` for read-modify-write. On `VERSION_MISMATCH`, re-read and retry.
227
+ - **Truncate.** A single key caps at 100 KB — roughly 500-1000 compact JSON entries. Keep lists bounded (top N, drop older), or shard across keys.
228
+ - **Reads are cheap and public.** No auth needed — even logged-out players see `gameData`. Don't put anything secret here.
229
+ - **Trust is thin.** The backend stores what you write, verbatim. A player with devtools could submit any JSON. For casual games this is fine; for competitive stakes, a future typed leaderboards API with server-side validation is the right fix.
230
+
231
+ ### Multiplayer rooms — `umicat.rooms`
232
+
233
+ Realtime rooms backed by umicat-realtime-service. The platform is **game-agnostic** — the server tracks who's in the room and relays data, but does not know what kind of game you're building. Two opaque primitives are your building blocks:
234
+
235
+ 1. **Delta-synced KV state** (`room.player.*`, `room.data.*`) — server-maintained maps. Values are JSON-serializable. Every client sees changes via `onStateChange`. State is preserved while the room exists; new joiners see the current snapshot.
236
+ 2. **Transient message relay** (`room.send` + `room.on`) — fire-and-forget. Payloads are stamped with `from: senderSessionId` and broadcast to every other client in the room. Not persisted.
237
+
238
+ #### Join a room
239
+
240
+ ```ts
241
+ // Join or create a room scoped to this game. The SDK fetches a short-lived
242
+ // token from the host and forwards it; the iframe never handles credentials.
243
+ const room = await umicat.rooms.joinOrCreate('lobby', {
244
+ displayName: umicat.user?.name ?? 'guest',
245
+ });
246
+
247
+ room.id; // room identifier
248
+ room.sessionId; // this connection's session id within the room
249
+ room.state; // proxy of the server-authoritative schema
250
+ ```
251
+
252
+ **Room type is always `'lobby'`.** This is the only room type the realtime server registers — there is no `'blokus'` / `'chess'` / `'mygame'` type. Passing any other name results in "no handler" and throws. To distinguish rooms (private matches, per-code lobbies, etc.) use the `roomCode` option, documented below.
253
+
254
+ #### Matchmaking — one game can have many rooms
255
+
256
+ By default (no `roomCode`), every player of the same game lands in **one shared room**. Fine for a single hangout or a "public lobby" — but not for games where users need to open *private* rooms (e.g. "play this match with my friend" / "create a room with code ABC123 and share it").
257
+
258
+ Use the `roomCode` option to bucket players. Two clients with the same `(gameId, roomCode)` join the same room; different codes create independent rooms.
259
+
260
+ ```ts
261
+ // --- Create a private room with a fresh code ---
262
+ const code = Math.random().toString(36).slice(2, 8).toUpperCase(); // e.g. 'AB3X7Y'
263
+ const hostRoom = await umicat.rooms.joinOrCreate('lobby', {
264
+ roomCode: code,
265
+ maxClients: 4, // cap to 4 players (server clamps to [1, 16])
266
+ displayName: umicat.user?.name ?? 'host',
267
+ });
268
+
269
+ // Show `code` in the UI; the host shares it with friends manually.
270
+
271
+ // --- Friend joins the same room with the shared code ---
272
+ const guestRoom = await umicat.rooms.joinOrCreate('lobby', {
273
+ roomCode: 'AB3X7Y', // whatever the host shared
274
+ displayName: umicat.user?.name ?? 'guest',
275
+ });
276
+ ```
277
+
278
+ **`roomCode` caveats:**
279
+ - Not a password. Anyone who knows the code can join. Use it for unguessable rather than *secret*.
280
+ - Defaults to empty string when omitted. Two clients that both omit `roomCode` share the same "default" room — useful for a single global lobby per game.
281
+ - Changing `roomCode` between joins does NOT transfer state; it's a different room. Leave the old room (`room.leave()`) before joining a new one.
282
+
283
+ **`maxClients` caveats:**
284
+ - Only honored by the client who CREATES the room (the first one in). Later joiners see the creator's choice.
285
+ - Server clamps to `[1, 16]`. Passing `maxClients: 1000` silently becomes 16.
286
+ - When full, `joinOrCreate` throws. Handle that in your UI (e.g. "Room is full. Ask your friend to create a smaller room or try a different code.").
287
+
288
+ **Quick match / public room pattern.** If you want a "click Play, get matched with someone" flow, omit `roomCode` — everyone lands in the same default room. Players are differentiated by their `sessionId`. Add your own ready / waiting logic in `room.data`.
289
+
290
+ ```ts
291
+ // Public quick match — no roomCode, everyone shares one room per game
292
+ const room = await umicat.rooms.joinOrCreate('lobby', {
293
+ maxClients: 2,
294
+ displayName: umicat.user?.name ?? 'guest',
295
+ });
296
+ ```
297
+
298
+ #### Browsing open rooms — `umicat.rooms.list()`
299
+
300
+ A room code is enough for "my friend and I agreed on code ABC123 out of band", but not for "show me a list of open games and let me click one to join." For that, ask the server what rooms are currently open for your game:
301
+
302
+ ```ts
303
+ interface RoomListEntry {
304
+ roomId: string; // opaque — pass to joinById
305
+ roomCode: string; // the code the host passed (empty for default rooms)
306
+ clients: number; // current occupants
307
+ maxClients: number; // cap the host set (server-clamped to [1, 16])
308
+ metadata?: unknown; // whatever the host set via room.setMetadata (optional)
309
+ createdAt?: number; // ms since epoch
310
+ }
311
+
312
+ const rooms = await umicat.rooms.list();
313
+ rooms
314
+ .filter(r => r.clients < r.maxClients) // hide full rooms
315
+ .forEach(renderLobbyCard);
316
+
317
+ // On click:
318
+ async function joinRoom(entry: RoomListEntry) {
319
+ const room = await umicat.rooms.joinById(entry.roomId, {
320
+ displayName: umicat.user?.name ?? 'guest',
321
+ });
322
+ // room state is populated after the first onStateChange — see
323
+ // "Connection lifecycle" below for the state-timing rule.
324
+ }
325
+ ```
326
+
327
+ **Scope.** `list()` only returns rooms for the caller's game. A player can't see rooms from other games (the server cross-checks the gameId claim on the token).
328
+
329
+ **Filter.** Pass `{ roomCode: 'ABC' }` to narrow to one code without paging through the whole list — useful for "is the room with code ABC up yet?" polling.
330
+
331
+ **Polling, not subscription.** There is no push-on-change API today. Poll on a timer (e.g. every 3-5 s) while the lobby is open, and refresh on the user's explicit click. Cancel the timer when the player leaves the lobby.
332
+
333
+ ```ts
334
+ // Simple lobby browser refresh loop
335
+ let stop = false;
336
+ async function refreshLoop() {
337
+ while (!stop) {
338
+ try {
339
+ const rooms = await umicat.rooms.list();
340
+ renderLobby(rooms);
341
+ } catch (e) {
342
+ // Network hiccup or REALTIME_UNAVAILABLE — surface in UI, keep looping.
343
+ }
344
+ await new Promise(r => setTimeout(r, 3000));
345
+ }
346
+ }
347
+ refreshLoop();
348
+ scene.events.once('shutdown', () => { stop = true; });
349
+ ```
350
+
351
+ **Unlisted rooms.** Every room created via `joinOrCreate` / `create` is currently listable by players of the same game. There is no "private room" flag yet — if you need that, tell your users to share the code out of band and don't render a lobby browser.
352
+
353
+ #### Delta-synced state — `room.player` and `room.data`
354
+
355
+ Use this for anything that represents the *current world*: positions, hp, score, turn state, scoreboard, etc. The server delta-syncs diffs to every client.
356
+
357
+ ```ts
358
+ // Per-player (only you can write your own entry)
359
+ room.player.set('pos', { x: 120, y: 340 });
360
+ room.player.set('hp', 80);
361
+ room.player.delete('pos');
362
+
363
+ // Read another player's data at any time
364
+ const otherPos = room.player.get<{x:number; y:number}>(otherSessionId, 'pos');
365
+
366
+ // Per-room (any client can write)
367
+ room.data.set('currentTurn', playerSessionId);
368
+ room.data.set('scoreboard', [{ sid: 's1', score: 12 }, ...]);
369
+ const board = room.data.get<Entry[]>('scoreboard') ?? [];
370
+
371
+ // React to any state change
372
+ const offState = room.onStateChange(() => {
373
+ room.state.players.forEach((p, sid) => {
374
+ const pos = room.player.get<{x:number;y:number}>(sid, 'pos');
375
+ // ... re-render
376
+ });
377
+ });
378
+ ```
379
+
380
+ Values are JSON-stringified under the hood. Use for anything JSON-serializable.
381
+
382
+ #### Transient events — `room.send` and `room.on`
383
+
384
+ Use this for ephemeral actions: fires, hits, emotes, explosions. Not persisted. (For **chat**, prefer the dedicated `room.chat` helper below — it adds local echo, length capping, and a stamped sender name on top of this same primitive.)
385
+
386
+ ```ts
387
+ // Sender
388
+ room.send('fire', { x: 100, y: 200, angle: 1.5 });
389
+
390
+ // Every other client (sender does NOT receive their own)
391
+ const offFire = room.on('fire', (msg: { from: string; x: number; y: number; angle: number }) => {
392
+ spawnProjectile(msg.x, msg.y, msg.angle);
393
+ });
394
+ ```
395
+
396
+ #### Chat — `room.chat.send` and `room.chat.onMessage`
397
+
398
+ A thin wrapper over the transient relay tuned for in-game chat. Use this instead of raw `room.send('chat', text)` — you get four things you'd otherwise re-implement in every game:
399
+
400
+ 1. **Local echo** — the sender's own message hits the same `onMessage` handler stream the moment it's sent, so your UI has one render path for both your messages and remote ones.
401
+ 2. **Stamped sender info** — every `ChatMessage` carries `kind`, `from` (sessionId), `displayName` (sender's human-readable name at send time), `text`, and `ts`. No need to look up the sender in `room.state.players`.
402
+ 3. **Trim + length cap** — text is `.trim()`ed, dropped if empty, and silently truncated to `MAX_CHAT_TEXT_LEN` (500) so a stray paste can't flood the relay.
403
+ 4. **Auto-emitted system messages** — when a remote player joins or leaves, the SDK dispatches a `kind: 'system.joined'` / `'system.left'` message into the same stream. No wire traffic; computed locally on each client from `room.state.players`. Skipped for the local player's own join.
404
+
405
+ ```ts
406
+ import { MAX_CHAT_TEXT_LEN } from '@umicat/phaser-sdk';
407
+ import type { ChatMessage } from '@umicat/phaser-sdk';
408
+
409
+ const log: ChatMessage[] = [];
410
+
411
+ const offChat = room.chat.onMessage((msg) => {
412
+ log.push(msg);
413
+ if (msg.kind === 'user') {
414
+ const who = msg.from === room.sessionId ? 'You' : (msg.displayName || 'Player');
415
+ appendChatLine(`${formatTime(msg.ts)} ${who}: ${msg.text}`);
416
+ } else {
417
+ // 'system.joined' or 'system.left' — render however you like.
418
+ // msg.text is already a sensible English fallback ("Alice joined").
419
+ appendSystemLine(`${formatTime(msg.ts)} — ${msg.text}`);
420
+ }
421
+ });
422
+
423
+ inputEl.maxLength = MAX_CHAT_TEXT_LEN; // mirror the cap on the input box
424
+ inputEl.addEventListener('keydown', async (e) => {
425
+ if (e.key !== 'Enter') return;
426
+ try {
427
+ await room.chat.send(inputEl.value); // trim + cap + local echo + wire send
428
+ inputEl.value = ''; // safe: echo already dispatched synchronously
429
+ } catch (err) {
430
+ // Local-side dispatch failed (e.g. connection dropped). Show a UI hint.
431
+ showChatError(err);
432
+ }
433
+ });
434
+
435
+ scene.events.once('shutdown', offChat);
436
+ ```
437
+
438
+ `ChatMessage` discriminates by `kind`:
439
+
440
+ ```ts
441
+ type ChatMessageKind = 'user' | 'system.joined' | 'system.left';
442
+ interface ChatMessage {
443
+ kind: ChatMessageKind;
444
+ from: string; // sessionId of the sender (or join/leave subject)
445
+ displayName: string; // their displayName at the time
446
+ text: string; // user-typed text, or English system fallback
447
+ ts: number; // Date.now() when emitted
448
+ }
449
+ ```
450
+
451
+ Notes:
452
+ - **No history.** A player who joins mid-room sees no prior `'user'` messages. If you need scrollback, write to `room.data.set('chatLog', [...])` yourself — the relay is fire-and-forget.
453
+ - **System messages are local-only.** Each client computes them from `room.state.players` add/remove events. They are NOT broadcast over the wire, and never echo from the wire even if a different client tries to send one.
454
+ - **`send` returns `Promise<void>`.** Resolves once dispatch is complete (echo + wire queued); rejects if the underlying connection blew up. It is **not** an ack — the relay itself is fire-and-forget; the Promise only catches local-side errors.
455
+ - **Order by arrival.** `ts` is `Date.now()`, advisory only. Skew between clients is small in practice but don't sort by `ts` across senders.
456
+ - **Trust model.** `text` and `displayName` come from the sender — no server-side validation. Same as every other transient relay payload.
457
+
458
+ #### Server-tracked membership
459
+
460
+ `room.state.players` is server-owned. Each entry exposes:
461
+ - `userId` — Umicat identity (from the JWT)
462
+ - `displayName` — set by the joining client
463
+ - `joinedAt` — ms timestamp
464
+ - `data` — the MapSchema<string> the player writes to via `room.player.set`
465
+
466
+ Games never mutate these directly; read them from `room.state.players`.
467
+
468
+ #### Connection lifecycle
469
+
470
+ ```ts
471
+ room.onLeave((code) => console.log('disconnected', code));
472
+ room.onError((code, message) => console.warn('room error', code, message));
473
+
474
+ // Clean up on scene shutdown
475
+ offState();
476
+ offFire();
477
+ await room.leave();
478
+ ```
479
+
480
+ #### Picking the right primitive
481
+
482
+ | Need | Use | Why |
483
+ |---|---|---|
484
+ | Smooth position sync of a player | `room.player.set('pos', {x,y})` at ~10-20 Hz + `onStateChange` | Delta-compressed, survives reconnect, server is source of truth. |
485
+ | Per-frame local movement feel | client-side prediction + interpolate remotes from state | State arrives at ~20 Hz; lerp toward it. |
486
+ | Scoreboard shared by all | `room.data.set('scoreboard', [...])` | One writer wins on conflict; use optimistic patterns or turn-based writes. |
487
+ | Projectile spawn / hit effect | `room.send('fire', {...})` + `room.on('fire', ...)` | Ephemeral, not needed on reconnect. |
488
+ | Chat message | `room.chat.send(text)` + `room.chat.onMessage(...)` | Helper over the relay: local echo, stamped sender, length cap. |
489
+ | Current turn in a turn-based game | `room.data.set('turn', sid)` | Authoritative shared state. |
490
+
491
+ Rules of thumb:
492
+ - **If someone joining mid-game should see it, it's state** (`room.player.*` or `room.data.*`).
493
+ - **If it's a one-shot action, it's a message** (`room.send` / `room.on`).
494
+ - **Do not implement position sync via messages.** You will reinvent delta compression badly and spend more bandwidth. Use `room.player.set('pos', {x,y})`.
495
+ - **Server is authoritative but permissive** — it doesn't validate what you put in `data`. Treat inbound values from other players as untrusted (don't eval, validate shape before reading).
496
+
497
+ #### Making remote motion feel smooth
498
+
499
+ State updates arrive at roughly 20 Hz (every ~50 ms). If you re-render remote entities by snapping directly to `room.player.get(sid, 'pos')` each frame, their movement will visibly step. Pick a smoothing technique that fits the game:
500
+
501
+ **Interpolation** — good for continuous motion (platformer, shooter, racer). Keep a `target` on each remote sprite, update it from state, lerp toward it each frame.
502
+
503
+ ```ts
504
+ // On state change, just update the target
505
+ room.onStateChange(() => {
506
+ room.state.players.forEach((_p, sid) => {
507
+ if (sid === room.sessionId) return;
508
+ const pos = room.player.get<{x:number;y:number}>(sid, 'pos');
509
+ if (pos) remoteSprites.get(sid)!.target = pos;
510
+ });
511
+ });
512
+
513
+ // In your scene's update() loop
514
+ remoteSprites.forEach((s) => {
515
+ s.sprite.x = Phaser.Math.Linear(s.sprite.x, s.target.x, 0.2);
516
+ s.sprite.y = Phaser.Math.Linear(s.sprite.y, s.target.y, 0.2);
517
+ });
518
+ ```
519
+
520
+ Pair this with **client-side prediction for the LOCAL player**: render your own sprite from a locally-predicted position (updated by input every frame) so input feels instant, not gated on the round trip. Publish the predicted position to state at ~10–20 Hz with `room.player.set('pos', predicted)`. You rarely need to reconcile in casual games; trust the local prediction.
521
+
522
+ **Extrapolation** — good for projectiles and other predictable trajectories. Send `{ x, y, vx, vy, t }` once on spawn, let each client simulate locally with `x += vx * dt`. Only re-sync on events that change the trajectory (hit, bounce, destroy). Uses far less bandwidth than per-frame position sync.
523
+
524
+ **Snap** — the right choice for:
525
+ - Infrequent updates: score, hp, current turn, chat messages, lobby ready flags.
526
+ - Turn-based games: pieces jumping to their next square shouldn't glide.
527
+ - Discrete grids: same reason.
528
+
529
+ Don't blindly apply interpolation to everything. It's wrong for a chess piece and wasteful on a hp bar.
530
+
531
+ #### Availability
532
+
533
+ - Requires sign-in. Anonymous players get `RpcError('UNAUTHENTICATED')`.
534
+ - Requires a host with multiplayer enabled. Standalone games (no host) get `RpcError('REALTIME_UNAVAILABLE')` — guard with `if (umicat.host === 'umicat-home-ui')` if your game can run both hosted and standalone.
535
+
536
+ #### Available room types
537
+
538
+ - `lobby` — the generic room described above. Use for any multiplayer gameplay.
539
+
540
+ **Rules of the road**
541
+ - The server is the source of truth. Never trust `send()` payloads coming from other clients — the server validates.
542
+ - Room state is *ephemeral*. When the last player leaves, it disposes. Persist anything you want to keep via `saves` or `gameData`.
543
+ - Keep messages small. State syncs are delta-compressed; raw `send()` payloads are not.
544
+ - Unsubscribe on scene shutdown. `onStateChange`, `on`, `onLeave`, `onError` all return an unsubscribe function — call them from `scene.events.once('shutdown', ...)`.
545
+ - One Colyseus client is kept internally; repeated `joinOrCreate` calls reuse it and mint fresh tokens each connection.
546
+
547
+ ## Scene-as-data (visual editor foundation, since 0.2.17)
548
+
549
+ Games that ship with `public/scenes/manifest.json` load layout from JSON instead of hardcoding `this.add.sprite(x, y, ...)` in scene classes. This split is what lets the visual editor edit a game without touching code.
550
+
551
+ **File layout:**
552
+ ```
553
+ src/scenes/
554
+ BootScene.ts — preloads manifest, draws loading bar, starts GameScene
555
+ GameScene.ts — generic loader: await loadWorldScene(this, sceneId)
556
+ public/scenes/
557
+ manifest.json — scenes list + asset table + initial scene id
558
+ world/<scene>.json — one world scene per file (entities, camera, world dims)
559
+ ```
560
+
561
+ **Manifest shape:**
562
+ ```json
563
+ {
564
+ "schemaVersion": 1,
565
+ "id": "my-game", "title": "My Game", "version": "1.0.0",
566
+ "initialScene": "main",
567
+ "scenes": [{ "id": "main", "type": "world", "file": "world/main.json", "hud": null }],
568
+ "huds": [],
569
+ "assets": [
570
+ { "id": "knight", "textureKey": "knight", "path": "uploaded/knight.png", "kind": "image" },
571
+ { "id": "tiles", "textureKey": "tiles", "path": "uploaded/tiles.png",
572
+ "kind": "spritesheet",
573
+ "spriteSheetConfig": { "frameWidth": 16, "frameHeight": 16 } }
574
+ ]
575
+ }
576
+ ```
577
+
578
+ The `assets[]` table is the single source of truth for asset loading in scene-as-data games. Replaces hand-rolled `BootScene.preload()` lines: `loadWorldScene` queues the right `this.load.*` call based on each asset's `kind` and config (lazy — only assets the active scene uses).
579
+
580
+ **Entity shape (world scenes — flat schema, SDK 0.3.0):**
581
+ ```json
582
+ {
583
+ "id": "player",
584
+ "kind": "sprite",
585
+ "role": "player",
586
+ "transform": { "x": 200, "y": 400, "rotation": 0, "scaleX": 1, "scaleY": 1 },
587
+ "assetId": "knight",
588
+ "properties": { "maxHp": 3, "speed": 200 }
589
+ }
590
+ ```
591
+
592
+ `kind` is the entity discriminator with seven variants: `sprite` / `rect` / `circle` / `code-rendered` / `group` / `tilemap` / `trigger`. Per-kind render fields live at the entity root (no nested `visual` sub-object):
593
+
594
+ - `sprite` → `assetId`, optional `frame` / `tint` / `alpha` / `flipX` / `flipY`
595
+ - `rect` → `width`, `height`, optional `fillColor` / `strokeColor` / `strokeWidth` / `alpha`
596
+ - `circle` → `radius`, optional `fillColor` / `strokeColor` / `strokeWidth` / `alpha`
597
+ - `code-rendered` → `script` (path to render script), optional `params` / `width` / `height`
598
+ - `tilemap` → `tileSize`, `size`, `layers[]`
599
+ - `trigger` → `shape`, optional `description` / `targets` / `behaviorScript`
600
+ - `group` → `children[]`
601
+
602
+ **Optional physics body (since 0.2.54).** Any renderable entity (`sprite` / `rect` / `circle` / `code-rendered`) may carry a `physics` block — the same shape prefabs use (see the Prefabs section's `physics` field reference). When present, `loadWorldScene` gives the entity an Arcade body, sized and offset per the block, so behavior code doesn't call `physics.add.existing` / `body.setSize` for it. A code-rendered platform, for example, can declare `"physics": { "bodyW": 200, "bodyH": 32, "immovable": true }` and be collidable the moment the scene loads.
603
+
604
+ **World gravity from scene data (since 0.2.55).** A world scene's `world.physics.gravity` (`{ x?, y? }`) — or, as a manifest-wide default, `manifest.globals.physics.gravity` — is applied to the scene's Arcade physics world by `loadWorldScene`. Scene-level wins over the manifest global. Example: a platformer's `world/main.json` carries `"world": { "width": 5120, "height": 720, "physics": { "gravity": { "x": 0, "y": 700 } } }` and the player just falls — no `this.physics.world.gravity` line in `GameScene.ts`. (Before 0.2.55 these two fields were typed but inert; only `game.json`'s `GameConfig.physics.gravity`, wired through `createUmicatGame` in `main.ts`, took effect.)
605
+
606
+ **API:**
607
+ ```ts
608
+ import { preloadManifest, getManifest, loadWorldScene, getEntityRegistry } from '@umicat/phaser-sdk';
609
+
610
+ class BootScene extends Phaser.Scene {
611
+ preload() { preloadManifest(this); /* + draw loading bar */ }
612
+ create() {
613
+ const manifest = getManifest(this);
614
+ this.scene.start('GameScene', { sceneId: manifest.initialScene });
615
+ }
616
+ }
617
+
618
+ class GameScene extends Phaser.Scene {
619
+ private sceneId!: string;
620
+ private player!: Phaser.Physics.Arcade.Sprite;
621
+ init(data: { sceneId: string }) { this.sceneId = data.sceneId; }
622
+ async create() {
623
+ await loadWorldScene(this, this.sceneId); // spawns entities, applies camera
624
+ const registry = getEntityRegistry(this)!;
625
+ this.player = registry.byRole('player')[0] as Phaser.Physics.Arcade.Sprite;
626
+ // ...wire physics + input on `this.player`...
627
+ }
628
+ update() {
629
+ // Safe: loadWorldScene suspends update() during its await, so any
630
+ // class field assigned after `await loadWorldScene(...)` is defined
631
+ // by the first time update() ticks. No `if (!this.player) return;`
632
+ // guard needed.
633
+ this.player.setVelocityX(0);
634
+ }
635
+ }
636
+ ```
637
+
638
+ **Async create() safety (since 0.2.46).** Phaser does not await `create()`'s returned promise — without help, `update()` starts ticking before `await loadWorldScene(...)` resolves, and any class field assigned after the await is still `undefined` on frame 1. `loadWorldScene` (and `loadHudScene`) suspends the scene's update/physics/tween processing for the duration of its await via the exported `suspendSceneUpdates(scene)` helper, then resumes — so the pattern above is safe by construction. If you have **additional** awaits in `create()` after `loadWorldScene` returns, wrap them yourself:
639
+
640
+ ```ts
641
+ async create() {
642
+ await loadWorldScene(this, this.sceneId);
643
+ const release = suspendSceneUpdates(this);
644
+ try {
645
+ await loadSomethingElse();
646
+ this.player = ...; // assigned here
647
+ } finally {
648
+ release();
649
+ }
650
+ }
651
+ ```
652
+
653
+ **Behavior conventions:**
654
+ - Layout (positions, asset references, world dims, camera bounds) lives in scene JSON.
655
+ - Behavior (physics setup, input handlers, update loop, tweens) lives in `GameScene.ts`.
656
+ - Look entities up by `role` (`registry.byRole('player')`) — portable across scenes. By id (`registry.byId('boss-1')`) when you need a specific one.
657
+ - Don't hand-write `this.load.image(...)` lines for game assets — declare them in `manifest.assets[]`.
658
+ - Don't call `this.add.sprite(...)` for game entities in code — declare them in scene JSON.
659
+
660
+ The `scene-data-architecture` agent skill has the full guidance. The `scene-data-migration` skill walks an existing scene-as-code game through the conversion.
661
+
662
+ **Edit mode (host-driven):** when the host (home-ui) sends `{ type: 'umicat:setEditMode', enabled: true }` via `postMessage`, the SDK pauses every active non-Boot Phaser scene — entities stay rendered but `update`/physics/tweens stop. Sending `enabled: false` resumes. Used by the visual editor's read-only viewer in slice 1.
663
+
664
+ ## Prefabs — entity types for runtime spawning (slice 11 Phase B.1, since 0.2.47)
665
+
666
+ Scene entities (above) cover *authored instances at design-time positions* — the player, a fixed boss, level decorations. They're the right pattern for layout-heavy games (top-down RPG, platformer, puzzle) where the user drags entities in the editor.
667
+
668
+ For **runtime-spawned types** — Galaga enemies in a wave, bullets, drops, particle effects — declare them as **prefabs** in `manifest.prefabs[]` instead. A prefab is a self-contained TYPE record: visual + physics + properties. Code spawns instances via `spawnPrefab(scene, prefabId, x, y)`. The visual editor surfaces prefabs in a separate Hierarchy tab so the user can tweak HP / speed / color / hitbox without touching code — the editor's value extends to genres where layout editing isn't useful.
669
+
670
+ ### Manifest reference
671
+
672
+ ```json
673
+ {
674
+ "schemaVersion": 1,
675
+ "scenes": [...],
676
+ "huds": [...],
677
+ "assets": [...],
678
+ "prefabs": [
679
+ {
680
+ "id": "enemy_grunt",
681
+ "kind": "code-rendered",
682
+ "role": "enemy",
683
+ "script": "src/visuals/enemy-grunt.ts",
684
+ "width": 40,
685
+ "height": 32,
686
+ "params": { "bodyColor": "#cc3344", "eyeColor": "#ffff66" },
687
+ "physics": {
688
+ "bodyW": 28,
689
+ "bodyH": 22,
690
+ "offsetX": 6,
691
+ "offsetY": 5,
692
+ "collideWorldBounds": false
693
+ },
694
+ "properties": {
695
+ "hp": 3,
696
+ "fireIntervalMs": 1800,
697
+ "scoreValue": 100
698
+ }
699
+ },
700
+ {
701
+ "id": "player_bullet",
702
+ "kind": "code-rendered",
703
+ "role": "bullet",
704
+ "script": "src/visuals/player-bullet.ts",
705
+ "width": 4,
706
+ "height": 14,
707
+ "physics": { "velocityY": -700 },
708
+ "properties": { "damage": 1 }
709
+ }
710
+ ]
711
+ }
712
+ ```
713
+
714
+ Prefabs use the same flat schema as scene entities (SDK 0.3.0). The four prefab kinds — `sprite` / `rect` / `circle` / `code-rendered` — each carry their render fields at the prefab root (no nested `visual` sub-object). The `physics` block carries the body sizing the SDK applies after `physics.add.existing`, so behavior code doesn't have to call `body.setSize` / `setOffset` manually. Since 0.2.54 the **same `physics` block also works on authored scene entities** (see the scene-as-data section) — prefabs and scene entities share one body-level path.
715
+
716
+ **Field reference for `physics`:**
717
+ - `bodyW` / `bodyH` — body dimensions. Default: the visual's drawn extent (sprite texture size, rect/circle dims, or code-rendered `width` / `height` — 64 if unset).
718
+ - `offsetX` / `offsetY` — body offset, measured from the visual's top-left. Default: centers the body inside the visual. (For `code-rendered` entities the SDK shifts the body frame so the offset is measured from the top-left the same way it is for sprites/rect/circle — a Phaser `Graphics` has no Origin component, so the body math needs the shift. Fixed in 0.2.54; before that, code-rendered bodies landed at the bottom-right of the visual.)
719
+ - `immovable`, `velocityX`, `velocityY`, `collideWorldBounds`, `bounceX`, `bounceY` — passed through to the Arcade body.
720
+
721
+ ### Code use
722
+
723
+ ```ts
724
+ import { spawnPrefab } from '@umicat/phaser-sdk';
725
+
726
+ // In GameScene.create() — replaces `this.physics.add.image(x, y, 'tex')`
727
+ class GameScene extends Phaser.Scene {
728
+ async create() {
729
+ await loadWorldScene(this, this.sceneId);
730
+
731
+ // Spawn a 3×6 grid of enemies. Each is a registered EntityRegistry
732
+ // instance; behavior code can find them all via `registry.byRole('enemy')`.
733
+ for (let row = 0; row < 3; row++) {
734
+ for (let col = 0; col < 6; col++) {
735
+ const enemy = spawnPrefab(this, 'enemy_grunt', col * 90 + 90, row * 72 + 100);
736
+ this.enemies.add(enemy);
737
+ }
738
+ }
739
+
740
+ // Per-instance overrides for a unique boss.
741
+ // `fields` carries flat render-field overrides (assetId / params /
742
+ // fillColor / etc.), deep-merged onto the prefab record.
743
+ const boss = spawnPrefab(this, 'enemy_grunt', 360, 200, {
744
+ properties: { hp: 30, scoreValue: 5000 },
745
+ fields: { params: { bodyColor: '#ff0066' } },
746
+ });
747
+ this.enemies.add(boss);
748
+ }
749
+
750
+ fireBullet(x: number, y: number) {
751
+ const bullet = spawnPrefab(this, 'player_bullet', x, y);
752
+ this.playerBullets.add(bullet);
753
+ }
754
+ }
755
+ ```
756
+
757
+ ### API
758
+
759
+ | Function | Purpose |
760
+ |---|---|
761
+ | `spawnPrefab(scene, prefabId, x, y, overrides?, options?)` | Create + register a runtime instance. Returns the GameObject. |
762
+ | `getPrefab(scene, prefabId)` | Read a prefab record from the manifest. Throws if missing. |
763
+ | `listPrefabs(scene, role?)` | Enumerate prefabs, optionally filtered by role. |
764
+ | `registry.byPrefabId(prefabId)` | List every live instance of a prefab. Used by editor live-edit + game-level operations ("kill all enemies"). |
765
+
766
+ `SpawnPrefabOverrides` is a deep-partial of the prefab record:
767
+
768
+ ```ts
769
+ interface SpawnPrefabOverrides {
770
+ role?: string;
771
+ visual?: DeepPartial<WorldVisual>;
772
+ physics?: DeepPartial<PrefabPhysics>;
773
+ properties?: Record<string, unknown>;
774
+ transform?: Partial<Omit<Transform, 'x' | 'y'>>; // rotation, scale, depth
775
+ }
776
+ ```
777
+
778
+ Each spawn gets a runtime-generated id of the form `<prefabId>#<n>` starting at 1. Use it (`go.getData('entityId')`) to disambiguate instances of the same prefab. The instance auto-unregisters on `destroy()` so dead enemies don't bloat the registry.
779
+
780
+ ### When prefab vs scene entity
781
+
782
+ | Scenario | Use |
783
+ |---|---|
784
+ | The thing is unique and stays at a fixed position the user might drag | scene entity in `world/*.json` |
785
+ | The thing is one of many at runtime-decided positions (waves, drops, projectiles) | prefab + `spawnPrefab` |
786
+ | The thing has a type the user might want to tweak (HP, speed, color, hitbox) without code | prefab |
787
+ | The thing is purely procedural — a transient particle effect, a debug overlay | inline code (`this.add.sprite`), no registry needed |
788
+
789
+ When in doubt: **anything you'd `this.physics.add.sprite` more than twice for the same type is a prefab.**
790
+
791
+ ### Anti-patterns
792
+
793
+ - ❌ Putting a prefab's per-spawn position in the prefab record. Position comes from `spawnPrefab(x, y)`; the prefab is *type-level*.
794
+ - ❌ Calling `body.setSize` / `setOffset` after `spawnPrefab`. The SDK applied them from the prefab's `physics` block. Re-applying breaks the editor's live-edit flow.
795
+ - ❌ Hardcoding tunable values (HP, fire rate, score) at the spawn site as overrides. They belong in the prefab record so the editor can edit them; overrides are for per-instance variation (a boss with 10× HP).
796
+
797
+ The `phaser-prefab-spawning` agent skill (Phase B.1) has the full guidance including a Galaga conversion walkthrough.
798
+
799
+ ## Game config — top-level settings as data (slice 11 Phase B.3, since 0.2.50)
800
+
801
+ `public/game.json` is a small JSON file carrying the **canvas + physics defaults + controls hints** that today live as TS `const` exports in `src/config.ts`. Once extracted to data the visual editor's eventual "Game settings" panel (Phase B.5) lets the user tweak canvas size / orientation / gravity / controls without code edits.
802
+
803
+ ### File reference
804
+
805
+ `public/game.json`:
806
+
807
+ ```json
808
+ {
809
+ "orientation": "portrait",
810
+ "pixelArt": true,
811
+ "physics": {
812
+ "gravity": { "x": 0, "y": 980 }
813
+ },
814
+ "controls": {
815
+ "primary": "wasd",
816
+ "fire": "space"
817
+ }
818
+ }
819
+ ```
820
+
821
+ All fields are optional. Use `orientation` *or* `width`+`height`, not both. `controls` is a hint table — the SDK doesn't bind keys from it; behavior code reads what it needs.
822
+
823
+ ### Code use
824
+
825
+ ```ts
826
+ // main.ts — requires top-level await (Vite default target supports it)
827
+ import { createUmicatGame, loadGameConfig } from '@umicat/phaser-sdk';
828
+ import { BootScene } from './scenes/BootScene';
829
+ import { GameScene } from './scenes/GameScene';
830
+
831
+ const cfg = await loadGameConfig();
832
+ createUmicatGame({
833
+ orientation: cfg?.orientation ?? 'portrait',
834
+ pixelArt: cfg?.pixelArt ?? false,
835
+ physics: cfg?.physics ? { default: 'arcade', arcade: { gravity: cfg.physics.gravity, debug: cfg.physics.debug } } : undefined,
836
+ scenes: [BootScene, GameScene],
837
+ });
838
+ ```
839
+
840
+ `loadGameConfig` resolves to `null` on missing file — callers fall back to in-code defaults so games without `public/game.json` keep working.
841
+
842
+ ### API
843
+
844
+ | Function | Purpose |
845
+ |---|---|
846
+ | `loadGameConfig(path?)` | Async fetch of `public/game.json` (or custom path). Returns `GameConfig | null`. |
847
+
848
+ ### When game.json vs rules.json
849
+
850
+ | Value | Goes in |
851
+ |---|---|
852
+ | Canvas dimensions, orientation, `pixelArt` flag | `game.json` |
853
+ | Default Arcade gravity at game level | `game.json` |
854
+ | Tunable game balance (lives, fire cooldown, win threshold) | `rules.json` |
855
+ | Per-entity-type properties (enemy HP, drop chance) | `prefab.properties` |
856
+
857
+ `game.json` is for **boot-time settings** read once when constructing Phaser. `rules.json` is for **gameplay tunables** read by behavior code with fallbacks and reactive subscriptions.
858
+
859
+ ## Rules — tunable game parameters as data (slice 11 Phase B.2, since 0.2.48)
860
+
861
+ `public/rules.json` is a free-form key-value tree where any value the user might want to tune lives — lives count, score thresholds, fire cooldowns, gravity, win conditions, controls. Replaces TS `const X = ...` declarations for these values. Behavior code reads via dot-notation paths; the visual editor's Rules mode (when it lands) lets the user tweak any field in a form.
862
+
863
+ ### File reference
864
+
865
+ `public/rules.json`:
866
+
867
+ ```json
868
+ {
869
+ "balance": {
870
+ "lives": 3,
871
+ "shootCooldownMs": 210,
872
+ "playerSpeed": 340,
873
+ "scoreThresholds": [1000, 2500, 5000, 10000]
874
+ },
875
+ "physics": {
876
+ "gravity": { "x": 0, "y": 980 }
877
+ },
878
+ "controls": {
879
+ "primary": "wasd",
880
+ "fire": "space"
881
+ },
882
+ "winCondition": {
883
+ "type": "score",
884
+ "target": 50000
885
+ }
886
+ }
887
+ ```
888
+
889
+ No required shape — group however makes sense (`balance`, `physics`, `controls`, `difficulty`, …). Optional `_meta_<key>` siblings carry editor-form hints (label, min, max, step, enum options); the editor's form-renderer uses them when present.
890
+
891
+ ### Code use
892
+
893
+ ```ts
894
+ import { preloadRules, getRule, onRuleChange } from '@umicat/phaser-sdk';
895
+
896
+ class BootScene extends Phaser.Scene {
897
+ preload() {
898
+ preloadManifest(this);
899
+ preloadRules(this); // tolerant of missing file
900
+ }
901
+ }
902
+
903
+ class GameScene extends Phaser.Scene {
904
+ async create() {
905
+ await loadWorldScene(this, this.sceneId);
906
+
907
+ // One-time reads
908
+ this.lives = getRule(this, 'balance.lives', 3);
909
+ this.shootCooldown = getRule(this, 'balance.shootCooldownMs', 200);
910
+
911
+ // Reactive: react to live editor edits (e.g. user lowers shootCooldownMs
912
+ // while playing → next shot uses the new value)
913
+ const unsub = onRuleChange(this, 'balance.shootCooldownMs', (v) => {
914
+ this.shootCooldown = v as number;
915
+ });
916
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, unsub);
917
+ }
918
+ }
919
+ ```
920
+
921
+ ### API
922
+
923
+ | Function | Purpose |
924
+ |---|---|
925
+ | `preloadRules(scene)` | Queue load of `public/rules.json` in BootScene preload. Missing file is fine. |
926
+ | `getRule(scene, path, fallback)` | Read a value by dot-path. Returns fallback when unset. |
927
+ | `getRules(scene)` | Read the full tree (rare; usually for debug HUDs). |
928
+ | `onRuleChange(scene, path, handler)` | Subscribe to live edits. Fires on the exact path AND any descendant. Returns an unsubscribe function. |
929
+ | `patchRule(scene, path, value)` | Internal — called by `EditorBridge` on `umicat:editor:patchRule`. Direct use from game code is unusual. |
930
+
931
+ ### When rule vs prefab.properties
932
+
933
+ | Scenario | Use |
934
+ |---|---|
935
+ | Value is per-instance of an entity type (this enemy's HP, that drop's chance) | `prefab.properties` |
936
+ | Value is global to the game (starting lives, fire cooldown, gravity, win threshold) | `rules.<group>.<key>` |
937
+ | Value is computed from gameplay state (current score, time elapsed) | code variable, not data |
938
+
939
+ A rule of thumb: if changing the value affects ONE entity type, it's a prefab property. If it affects the whole game or many entity types, it's a rule.
940
+
941
+ ### Anti-patterns
942
+
943
+ - ❌ **Putting algorithmic constants in rules** (e.g. `"physics.bulletSpeedFalloff": 0.7321`). Implementation detail. Stay in code.
944
+ - ❌ **Putting per-instance prefab properties in rules** (e.g. `rules.enemy.grunt.hp`). Belongs in the prefab's `properties.hp`.
945
+ - ❌ **Re-reading the same rule in a hot loop without subscribing.** `getRule` is cheap but not free. Cache the value in a class field; subscribe to changes if needed.
946
+ - ❌ **Deep paths used in `getRule` and `onRuleChange` that don't match the data shape.** No schema validation — fallbacks fire silently. Test by reading the rule once at scene start with a sentinel fallback (e.g. `getRule(this, 'balance.lives', -1)`); if you ever read `-1`, the path is wrong.
947
+
948
+ The `game-rules-extraction` agent skill teaches the agent to push tunables into `rules.json` automatically.
949
+
950
+ ## Waves — spawn schedules as data (slice 11 Phase B.4, since 0.2.49)
951
+
952
+ A **wave schedule** is a time-ordered sequence of spawn instructions in `public/waves/<id>.json` that references prefab ids. Models Galaga formations, infinite-runner difficulty curves, survivor-game wave timing — anything where the game spawns entities by a script.
953
+
954
+ The SDK provides `runWaveSchedule(scene, id, callbacks)` that drives the script and fires callbacks at each beat. Replaces hand-rolled wave loops (`time + interval` math, manual TimerEvent management, custom end conditions) in `GameScene.ts`.
955
+
956
+ ### File reference
957
+
958
+ `public/waves/stage-1.json`:
959
+
960
+ ```json
961
+ {
962
+ "schemaVersion": 1,
963
+ "id": "stage-1",
964
+ "name": "Stage 1",
965
+ "waves": [
966
+ {
967
+ "id": "wave-1",
968
+ "spawns": [
969
+ {
970
+ "kind": "formation",
971
+ "prefabId": "enemy_grunt",
972
+ "formation": { "shape": "grid", "rows": 3, "cols": 6, "spacing": { "x": 90, "y": 72 }, "origin": { "x": 90, "y": 120 } },
973
+ "startMs": 0,
974
+ "intervalMs": 100
975
+ }
976
+ ],
977
+ "endCondition": "allDead"
978
+ },
979
+ {
980
+ "id": "wave-2",
981
+ "delayMs": 1500,
982
+ "spawns": [
983
+ {
984
+ "kind": "formation",
985
+ "prefabId": "enemy_bomber",
986
+ "formation": { "shape": "v-shape", "cols": 5, "spacing": { "x": 60, "y": 40 }, "origin": { "x": 360, "y": 100 } },
987
+ "startMs": 0,
988
+ "intervalMs": 180
989
+ }
990
+ ],
991
+ "endCondition": "allDead"
992
+ },
993
+ {
994
+ "id": "boss",
995
+ "delayMs": 2500,
996
+ "spawns": [
997
+ { "kind": "point", "prefabId": "enemy_boss", "x": 360, "y": 200, "atMs": 0 }
998
+ ],
999
+ "endCondition": "allDead"
1000
+ }
1001
+ ]
1002
+ }
1003
+ ```
1004
+
1005
+ **Spawn instruction kinds (v1):**
1006
+ - `point` — single spawn at `{ x, y }` at `atMs` (relative to wave start).
1007
+ - `formation` — multiple spawns expanded from a `FormationRecord` (`shape: 'grid' | 'line' | 'v-shape' | 'arc'`), each at `startMs + i * intervalMs`.
1008
+
1009
+ **Wave end conditions:**
1010
+ - `"allDead"` (default) — wait until every spawned instance from this wave has destroyed.
1011
+ - `{ "type": "time", "ms": N }` — fixed duration from wave start.
1012
+ - `{ "type": "count", "killed": N }` — wait until N instances destroyed.
1013
+
1014
+ `loop: true` at the top of the schedule restarts wave 0 after the last one ends — for endless modes.
1015
+
1016
+ ### Code use
1017
+
1018
+ ```ts
1019
+ import { runWaveSchedule, spawnPrefab } from '@umicat/phaser-sdk';
1020
+
1021
+ class GameScene extends Phaser.Scene {
1022
+ async create() {
1023
+ await loadWorldScene(this, this.sceneId);
1024
+ this.enemies = this.physics.add.group();
1025
+ // ... wire colliders ...
1026
+
1027
+ runWaveSchedule(this, 'stage-1', {
1028
+ onSpawn: ({ prefabId, x, y, overrides }) => {
1029
+ const go = spawnPrefab(this, prefabId, x, y, overrides as Record<string, unknown>);
1030
+ this.enemies.add(go);
1031
+ return go; // returned so the controller can track allDead
1032
+ },
1033
+ onWaveStart: (i, wave) => {
1034
+ this.events.emit('hudWave', i + 1);
1035
+ },
1036
+ onWaveEnd: (i) => {
1037
+ // play a "wave cleared" SFX, brief tween
1038
+ },
1039
+ onScheduleEnd: () => {
1040
+ this.scene.start('VictoryScene');
1041
+ }
1042
+ });
1043
+ }
1044
+ }
1045
+ ```
1046
+
1047
+ ### API
1048
+
1049
+ | Function / type | Purpose |
1050
+ |---|---|
1051
+ | `loadWaveSchedule(scene, id)` | Async fetch via Phaser loader. Cached. Resolves to `WaveScheduleRecord`. |
1052
+ | `runWaveSchedule(scene, id, callbacks)` | Start a schedule. Returns a `WaveController` immediately; loads + starts asynchronously. |
1053
+ | `WaveController.pause()` / `.resume()` | Suspend / resume timers (e.g. pause menu). |
1054
+ | `WaveController.skipTo(index)` | Jump to a wave (debug / cheat). |
1055
+ | `WaveController.stop()` | Halt the schedule + fire `onScheduleEnd`. |
1056
+ | `WaveController.currentWaveIndex()` / `.isComplete()` | Status queries. |
1057
+
1058
+ **Always return the GameObject from `onSpawn`** when using `endCondition: 'allDead'` or `{ type: 'count' }` — the controller tracks `Phaser.GameObjects.Events.DESTROY` events on returned instances. Returning `void` short-circuits lifecycle tracking (acceptable for `{ type: 'time' }` end conditions where the schedule advances by clock, not kills).
1059
+
1060
+ ### When wave vs spawn-loop-in-code
1061
+
1062
+ | Scenario | Use |
1063
+ |---|---|
1064
+ | Time-ordered formations, staged waves, boss-on-wave-N | `waves/*.json` + `runWaveSchedule` |
1065
+ | Conditional spawns ("spawn wave 4 only if player has ≥ 5000 score") | Code, with `WaveController.skipTo` for advancing |
1066
+ | Spawn-on-trigger (enemy spawns when player enters a region) | Code calling `spawnPrefab` directly — not a wave |
1067
+ | Single boss with no minions | A single-wave schedule, OR a direct `spawnPrefab` if no formation |
1068
+ | Procedural generation (rogue-like room population) | Code reads `prefabs[]` + an RNG; not a wave schedule |
1069
+
1070
+ ### Anti-patterns
1071
+
1072
+ - ❌ **Manual `time + interval` loops with `scene.time.addEvent` for wave timing.** That's exactly what `runWaveSchedule` automates.
1073
+ - ❌ **Per-spawn hardcoded `(x, y)` in code when the agent could use a `formation: { shape: 'grid' }`** — the latter is editable in the waves timeline editor (Phase B.5).
1074
+ - ❌ **Spawning via `this.physics.add.sprite(...)` from inside `onSpawn`.** Use `spawnPrefab` so the entity goes through the prefab pipeline (physics, properties, registry tagging).
1075
+ - ❌ **Not returning the GameObject from `onSpawn`** when `endCondition: 'allDead'`. The wave never advances.
1076
+
1077
+ The `phaser-wave-schedule` agent skill teaches the wave authoring pattern with worked examples (Galaga, runner difficulty curve).
1078
+
1079
+ ## HUD scenes (visual editor slice 5+, since 0.2.29)
1080
+
1081
+ A HUD scene is a separate `Phaser.Scene` that runs in parallel with the active world scene and renders anchor-positioned UI on top of it. The visual editor's `HUD` mode edits these. Five widget kinds ship today: **text**, **image**, **icon-button**, **progress-bar**, **panel**.
1082
+
1083
+ ### Manifest reference
1084
+
1085
+ ```json
1086
+ {
1087
+ "scenes": [
1088
+ { "id": "main", "type": "world", "file": "world/main.json", "hud": "game-hud" }
1089
+ ],
1090
+ "huds": [
1091
+ { "id": "game-hud", "file": "hud/game-hud.json" }
1092
+ ]
1093
+ }
1094
+ ```
1095
+
1096
+ When `loadWorldScene(this, sceneId)` runs, it auto-launches the `UmicatHudScene` with the matching `hudId`. You don't call `loadHudScene` yourself — `loadWorldScene` does it for you. Multiple world scenes can reference the same HUD; editing `game-hud` updates every scene that points at it.
1097
+
1098
+ ### Scene file shape
1099
+
1100
+ `public/scenes/hud/game-hud.json`:
1101
+
1102
+ ```json
1103
+ {
1104
+ "schemaVersion": 1,
1105
+ "id": "game-hud",
1106
+ "name": "Game HUD",
1107
+ "type": "hud",
1108
+ "design": {
1109
+ "designAspectRatio": "16:9",
1110
+ "safeArea": { "top": 16, "right": 16, "bottom": 16, "left": 16 }
1111
+ },
1112
+ "entities": [ /* HudEntity[] */ ]
1113
+ }
1114
+ ```
1115
+
1116
+ ### Anchor + offset positioning (no transform)
1117
+
1118
+ HUD entities use a **9-grid anchor + numeric offset** instead of world coords:
1119
+
1120
+ ```json
1121
+ {
1122
+ "id": "score-text",
1123
+ "kind": "text",
1124
+ "anchor": { "side": "top-left", "offsetX": 16, "offsetY": 16 },
1125
+ "layer": "base",
1126
+ "source": { "mode": "static", "text": "Score: 0" }
1127
+ }
1128
+ ```
1129
+
1130
+ HUD entities use the same flat schema as world entities (SDK 0.3.0) — per-kind fields (`source` / `assetId` / `label` / `value` / etc.) live at the entity root, alongside `id` / `kind` / `anchor` / `layer` / `z`.
1131
+
1132
+ `side` is one of `top-left | top | top-right | left | center | right | bottom-left | bottom | bottom-right`. The widget is anchored to that corner/edge of the canvas, then offset inward (or outward) by `offsetX/Y`. Resizing the canvas (orientation change, device rotation) re-resolves anchors automatically.
1133
+
1134
+ `layer` is one of `base | overlay | modal` (default `base`) — controls render z-order; `modal` sits on top of `overlay` sits on top of `base`. `z` adds further ordering within a layer.
1135
+
1136
+ ### Widget reference
1137
+
1138
+ #### `text`
1139
+
1140
+ ```json
1141
+ {
1142
+ "kind": "text",
1143
+ "anchor": { "side": "top-left", "offsetX": 16, "offsetY": 16 },
1144
+ "source": { "mode": "static", "text": "Hello" },
1145
+ "fontSize": 24, "color": "#ffffff", "align": "left"
1146
+ }
1147
+ ```
1148
+
1149
+ `source` is either `{ mode: "static", text }` for a literal string OR `{ mode: "dynamic", binding, prefix?, suffix?, fallback? }` for a value read live from Phaser's game registry (see "Dynamic bindings" below).
1150
+
1151
+ #### `image`
1152
+
1153
+ ```json
1154
+ {
1155
+ "kind": "image",
1156
+ "anchor": { "side": "top-right", "offsetX": -16, "offsetY": 16 },
1157
+ "assetId": "coin-icon",
1158
+ "width": 32, "height": 32
1159
+ }
1160
+ ```
1161
+
1162
+ `assetId` resolves through `manifest.assets` exactly like world sprite entities. Optional `tint`, `alpha`, `frame` (atlas frame name or sprite-sheet index).
1163
+
1164
+ #### `icon-button`
1165
+
1166
+ ```json
1167
+ {
1168
+ "kind": "icon-button",
1169
+ "anchor": { "side": "bottom-right", "offsetX": -32, "offsetY": -32 },
1170
+ "layer": "overlay",
1171
+ "label": "Pause",
1172
+ "shape": "rounded-rect",
1173
+ "width": 96, "height": 48,
1174
+ "fillColor": "#3b82f6", "textColor": "#ffffff"
1175
+ }
1176
+ ```
1177
+
1178
+ Click handler is decoupled — the SDK emits a Phaser scene event `'hud:press'` with the entity id when the button is pressed. Subscribe in your gameplay code:
1179
+
1180
+ ```ts
1181
+ // In GameScene.ts (or any active scene)
1182
+ this.events.on('hud:press', (id: string) => {
1183
+ if (id === 'pause-button') { this.scene.pause(); /* etc. */ }
1184
+ });
1185
+ ```
1186
+
1187
+ For now there's no built-in action binding (no `behavior: { type: 'pause-game' }` etc.) — you wire each button by its id explicitly. That's coming in a later slice.
1188
+
1189
+ #### `progress-bar`
1190
+
1191
+ ```json
1192
+ {
1193
+ "kind": "progress-bar",
1194
+ "anchor": { "side": "top-left", "offsetX": 16, "offsetY": 50 },
1195
+ "value": { "mode": "dynamic", "binding": "playerHp", "fallback": 0 },
1196
+ "max": { "mode": "dynamic", "binding": "playerMaxHp", "fallback": 100 },
1197
+ "width": 200, "height": 16,
1198
+ "fillColor": "#22c55e", "backgroundColor": "#0f172a",
1199
+ "borderRadius": 4, "borderColor": "#ffffff", "borderWidth": 1
1200
+ }
1201
+ ```
1202
+
1203
+ `value` and `max` are independently bindable — either can be static (`{ mode: 'static', value: 100 }`) or dynamic. Bar fills from 0 → 1 as `value / max`. Subscribes to `changedata-<binding>` registry events and redraws on change.
1204
+
1205
+ #### `panel`
1206
+
1207
+ ```json
1208
+ {
1209
+ "kind": "panel",
1210
+ "anchor": { "side": "center" },
1211
+ "width": 320, "height": 200,
1212
+ "backgroundColor": "#000000", "backgroundAlpha": 0.5,
1213
+ "borderColor": "#ffffff", "borderWidth": 1, "borderRadius": 8
1214
+ }
1215
+ ```
1216
+
1217
+ A colored rectangle with optional border + rounded corners. Use as a visual frame behind groups of HUD widgets (no children layout in v1 — child widgets are sibling entities; you align them with anchor + offset).
1218
+
1219
+ ### Textured backgrounds for `icon-button` and `panel` (since 0.2.37)
1220
+
1221
+ Both `icon-button` and `panel` accept an asset image as the background instead of the default colored fill. Three flavors, each stretchable with crisp corners via the SDK's built-in 9-slice renderer:
1222
+
1223
+ **1. Single-image 9-slice** (since 0.2.37) — manifest asset is `kind: 'image'` with `ninePatch: { leftWidth, rightWidth, topHeight, bottomHeight }`. The whole texture slices into 9 regions; corners stay native, edges + middle stretch.
1224
+
1225
+ ```json
1226
+ {
1227
+ "kind": "icon-button",
1228
+ "label": "Start",
1229
+ "width": 240, "height": 64,
1230
+ "backgroundAssetId": "button-panel"
1231
+ }
1232
+ ```
1233
+
1234
+ **2. Per-frame on a uniform grid** (since 0.2.38) — manifest asset is `kind: 'spritesheet'` with `ninePatch: { perFrame: {...} }`. Used when every cell of a button-pack sheet is the same size and shares the same corner radius (typical itch.io packs). Pair with `backgroundFrame: <cell index>` to pick which cell.
1235
+
1236
+ ```json
1237
+ {
1238
+ "kind": "icon-button",
1239
+ "backgroundAssetId": "button-pack",
1240
+ "backgroundFrame": 3
1241
+ }
1242
+ ```
1243
+
1244
+ **3. Region atlas** (since 0.2.42) — manifest asset is `kind: 'atlas'` + `atlasFormat: 'json'` + `atlasPath: '...'`. The atlas JSON file carries named frame rects, each optionally with its own inline `ninePatch`. Pair with `backgroundRegion: '<frame name>'` to pick the region. Mutually exclusive with `backgroundFrame` — when both are set, `backgroundRegion` wins.
1245
+
1246
+ ```json
1247
+ {
1248
+ "kind": "icon-button",
1249
+ "backgroundAssetId": "ui-buttons",
1250
+ "backgroundRegion": "btn-primary-idle"
1251
+ }
1252
+ ```
1253
+
1254
+ Atlas JSON shape — Phaser-native JSON-Hash with an optional per-frame `ninePatch` field that Phaser's parser ignores but the SDK reads via a side-cache:
1255
+
1256
+ ```json
1257
+ {
1258
+ "frames": {
1259
+ "btn-primary-idle": {
1260
+ "frame": { "x": 7, "y": 9, "w": 18, "h": 30 },
1261
+ "sourceSize": { "w": 18, "h": 30 },
1262
+ "ninePatch": { "leftWidth": 4, "rightWidth": 4, "topHeight": 4, "bottomHeight": 4 }
1263
+ },
1264
+ "btn-primary-pressed": {
1265
+ "frame": { "x": 39, "y": 9, "w": 18, "h": 30 },
1266
+ "sourceSize": { "w": 18, "h": 30 }
1267
+ }
1268
+ },
1269
+ "meta": { "image": "ui-buttons.png", "size": { "w": 128, "h": 96 }, "scale": "1" }
1270
+ }
1271
+ ```
1272
+
1273
+ The Region Editor in the home-ui (visual editor slice 10) writes this shape for you — drag-tag named regions on a single PNG, optionally toggle 9-slice per region, save. Regions without `ninePatch` render as plain stretched images of that region; regions with `ninePatch` render via the 9-slice path.
1274
+
1275
+ ### Dynamic bindings — driving the HUD from gameplay
1276
+
1277
+ For values that change during play (score, HP, timer, lives), use **Phaser's game registry** as the bridge. The HUD widget references a `binding` key; gameplay code calls `this.registry.set(key, value)` and the HUD auto-refreshes.
1278
+
1279
+ ```ts
1280
+ // In GameScene.ts create():
1281
+ this.registry.set('score', 0);
1282
+ this.registry.set('playerHp', 100);
1283
+ this.registry.set('playerMaxHp', 100);
1284
+
1285
+ // Wherever score changes:
1286
+ this.registry.set('score', this.score + 10);
1287
+ ```
1288
+
1289
+ The HUD widget's text / progress fill updates synchronously on the next frame — no manual `setText` / re-render call needed. Registry is `game.registry` under the hood, shared across all scenes.
1290
+
1291
+ **Pick stable, lowercase keys**: `score`, `coins`, `playerHp`, `lives`, `currentLevel`, `xp`. The binding key goes into a `changedata-<key>` event name; spaces or special characters break it.
1292
+
1293
+ **No declare ceremony**: just `set` the key. The HUD picks it up. The `umicat.gameData` module is for *persistent* shared values (across sessions); for in-game reactive HUD, registry is the right tool.
1294
+
1295
+ ### Editor mode (for visual-editor users only)
1296
+
1297
+ The visual editor's `World/HUD` toggle pauses the active world scene and lets the user drag widgets / edit anchor + offset / live-tune visual fields. Edits flow back to `public/scenes/hud/<id>.json` on save (Cmd+S / Edit→Play / Publish). Auto-save during editing only persists JSON to disk — no rebuild — so the iframe doesn't reload mid-edit.
1298
+
1299
+ For game code, the editor is invisible — you write HUD scene JSON the same way regardless of whether the user uses the editor.
1300
+
1301
+ ## Collision + Y-sort (visual editor slice 8, since 0.2.44)
1302
+
1303
+ Two new fields on `AssetRecord` give per-asset collision shapes and footprint
1304
+ anchors for top-down draw-order sorting. The SDK consumes them automatically
1305
+ at sprite spawn time. `applyAssetHitbox` is also exported for behavior code
1306
+ that attaches a physics body after spawn.
1307
+
1308
+ ### `Asset.hitbox` — collision body shape
1309
+
1310
+ Per-asset rect (in v1) in source-pixel coordinates relative to the
1311
+ asset's frame top-left. Two shapes:
1312
+
1313
+ ```ts
1314
+ // Single — one body across all frames (trees, walls, idle characters)
1315
+ hitbox: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 }
1316
+
1317
+ // Per-frame — combat sheets where attack frames need a wider hitbox
1318
+ hitbox: {
1319
+ default: { kind: 'rect', x: 8, y: 24, w: 16, h: 8 },
1320
+ frames: {
1321
+ 8: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
1322
+ 9: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
1323
+ 10: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 },
1324
+ 11: { kind: 'rect', x: 0, y: 16, w: 32, h: 16 }
1325
+ }
1326
+ }
1327
+ ```
1328
+
1329
+ `kind: 'rect'` is the only valid value in v1; `'circle'` is reserved for v2
1330
+ and the union widens as a pure addition. The `isPerFrameHitbox(hitbox)` type
1331
+ guard narrows the union.
1332
+
1333
+ ### `Asset.depthAnchor` — Y-sort footprint pixel
1334
+
1335
+ ```ts
1336
+ depthAnchor: { x: 16, y: 28 } // pixel within frame 0 the SDK aligns sprite.y with
1337
+ ```
1338
+
1339
+ The SDK applies `sprite.setOrigin(x / frameW, y / frameH)` at spawn so the
1340
+ sprite's world `y` equals the world y of this pixel. Typical: feet for
1341
+ characters, trunk base for trees, foundation midpoint for buildings.
1342
+
1343
+ ### `applyAssetHitbox(sprite, asset)` — apply at runtime
1344
+
1345
+ Called automatically by `createSprite` at scene boot. **Also callable** by
1346
+ behavior code after attaching a body later:
1347
+
1348
+ ```ts
1349
+ import { applyAssetHitbox } from '@umicat/phaser-sdk';
1350
+
1351
+ const player = registry.getById('player');
1352
+ const asset = manifest.assets.find(a => a.id === player.getData('assetId'));
1353
+
1354
+ scene.physics.add.existing(player);
1355
+ applyAssetHitbox(player, asset); // reads asset.hitbox, applies to body
1356
+ ```
1357
+
1358
+ Semantics:
1359
+
1360
+ - **No-op (silent)** when `asset.hitbox` is unset. Safe to call defensively.
1361
+ - **Dev-warn (NOT throw)** when called on a sprite without a physics body.
1362
+ Most likely cause: call order — `scene.physics.add.existing(sprite)` must
1363
+ come first.
1364
+ - **For per-frame hitboxes**: installs an `ANIMATION_UPDATE` listener that
1365
+ swaps `body.setSize`/`setOffset` on every frame change during animation
1366
+ playback. Idempotent — re-applying tears down the old listener first.
1367
+
1368
+ **v1 caveat**: manual `sprite.setFrame(idx)` calls outside of animation
1369
+ playback do NOT swap the body. Combat sheets are animation-driven, so this
1370
+ rarely bites in practice.
1371
+
1372
+ ### Scene-level `ySort: true` — per-frame depth sort
1373
+
1374
+ ```json
1375
+ // public/scenes/world/main.json
1376
+ {
1377
+ "schemaVersion": 1,
1378
+ "id": "main",
1379
+ "type": "world",
1380
+ "ySort": true,
1381
+ "entities": [ ... ]
1382
+ }
1383
+ ```
1384
+
1385
+ When set, `loadWorldScene` installs a per-frame update hook that walks the
1386
+ entity registry and assigns `sprite.depth = sprite.y` to every entity.
1387
+ **Per-game cost**: O(n) per frame; 200 entities at 60 FPS = 12,000
1388
+ `setDepth` calls/sec — well within Phaser's render budget.
1389
+
1390
+ Two opt-out mechanisms (orthogonal, cover different intents):
1391
+
1392
+ ```ts
1393
+ // explicit constant depth → ySort skips the entity entirely
1394
+ { "transform": { "x": 100, "y": 100, "depth": 1000 } } // cloud always on top
1395
+
1396
+ // behavior-managed depth → ySort doesn't touch the entity
1397
+ { "transform": { "x": 100, "y": 100 }, "properties": { "skipYSort": true } }
1398
+ ```
1399
+
1400
+ ### The conditional rule (when to call `applyAssetHitbox`)
1401
+
1402
+ ```ts
1403
+ const asset = manifest.assets.find(a => a.id === sprite.getData('assetId'));
1404
+ scene.physics.add.existing(sprite);
1405
+
1406
+ if (asset?.hitbox) {
1407
+ applyAssetHitbox(sprite, asset); // metadata is source of truth
1408
+ } else {
1409
+ sprite.body.setSize(30, 20); // hardcode from gameplay intent
1410
+ sprite.body.setOffset(5, 5);
1411
+ }
1412
+ ```
1413
+
1414
+ Both paths are correct depending on context:
1415
+
1416
+ - **`applyAssetHitbox` path** — sprite uses an Asset that has `hitbox`
1417
+ metadata. Pack import sets it via vision; users refine via the Hitbox
1418
+ Editor. Hardcoding `setSize` afterward would override the user's
1419
+ authored values.
1420
+ - **Hardcode path** — primitive sprites (`scene.add.rectangle`), AI-gen
1421
+ images with no vision pass, chat-only games with no scene-as-data
1422
+ manifest. There's nothing to apply; hardcoding is the right answer.
1423
+
1424
+ The `asset-hitbox-physics` agent skill (in
1425
+ `plugins/unboxy-phaser/skills/asset-hitbox-physics/SKILL.md`) walks through
1426
+ this decision tree with worked examples.
1427
+
1428
+ ### Authoring metadata (the editor surface)
1429
+
1430
+ Users author `hitbox` + `depthAnchor` in the home-ui's Hitbox Editor modal —
1431
+ launched from the Assets panel's per-card hover button (green hitbox icon).
1432
+ Supports both Same-for-all-frames and Per-frame overrides modes, with a
1433
+ frame strip + override-dot indicators for the per-frame case.
1434
+
1435
+ Vision pre-fills the single-shape form at pack-import time for character /
1436
+ tree / building / rock / decoration subjects (narrow trunk for trees, foot
1437
+ footprint for characters, foundation for buildings).
1438
+
1439
+
1440
+ - Do **not** block scene creation on `umicatReady`. Games must cold-start immediately; hydrate when the promise resolves.
1441
+ - Do **not** save on every frame / every score tick. Debounce or only save on meaningful events (game over, level clear).
1442
+ - Do **not** store large binary blobs (images, audio) as save values — use a dedicated blob API when it ships.
1443
+ - Do **not** use `localStorage` directly for persistent data when `umicat.saves` is available — you'll lose cross-device sync when we ship provisional accounts.
1444
+ - Do **not** put secrets, private messages, or per-user data in `gameData` — it's public. That's `saves`.
1445
+ - Do **not** write to `gameData` on every score tick either — submit once at game-over or level-clear.
1446
+
1447
+ ## Tilemap per-tile metadata + collision (visual editor slice 6 Phase C, since 0.2.115)
1448
+
1449
+ Each tile in a tileset can carry per-tile metadata (collision flag, damage,
1450
+ terrain tag, movement modifier, etc.). The SDK reads it from
1451
+ `asset.tileset.tiles` and:
1452
+
1453
+ 1. **Auto-wires solid-tile collision** at scene load via Phaser's native
1454
+ `layer.setCollisionByProperty({ solid: true })`. Behavior code only
1455
+ needs `scene.physics.add.collider(player, layer)` — every tile flagged
1456
+ `solid: true` blocks the player.
1457
+ 2. **Exposes per-tile metadata** via the new `getTilemapAt(scene, entityId, x, y)`
1458
+ helper so game code can react to non-collision fields (damage, slope,
1459
+ movement, ground type).
1460
+
1461
+ ### Authoring
1462
+
1463
+ Users author metadata in the home-ui's Tile Metadata Editor — select a
1464
+ tile in the TilemapInspectorPanel's palette, then click the **⚙ Configure
1465
+ tile #N** button that appears below the palette. (Right-click also opens
1466
+ the modal as a power-user shortcut.) Eight fields per tile:
1467
+
1468
+ | Field | Type | Purpose |
1469
+ |-------|------|---------|
1470
+ | `solid` | boolean | Auto-armed via `setCollisionByProperty`. Standard collision. |
1471
+ | `oneWay` | boolean | Jump-through platform flag. v1 stores only; game code wires the check. |
1472
+ | `slope` | `'up-left'` \| `'up-right'` | Slope direction for diagonal walk-up. |
1473
+ | `damage` | number | HP/sec dealt when player stands on tile. Game code applies. |
1474
+ | `terrainTag` | string | Free-form (`grass`, `water`, `sand`) — used by Phase D autotile + behavior. |
1475
+ | `movement` | `'walk'` \| `'swim'` \| `'fly'` \| `'block'` | Character speed/passability modifier. |
1476
+ | `groundType` | string | Footstep-sound selection: `soft`, `stone`, `metal`. |
1477
+ | `customTag` | string | Game-specific (`checkpoint`, `spawn`, `exit`). |
1478
+
1479
+ Only non-default fields are persisted — sparse map keyed by tile index.
1480
+
1481
+ ### Collision — zero-config
1482
+
1483
+ If any painted tile has `solid: true`, the SDK auto-arms collision on the
1484
+ layer. Use the `addTilemapCollider` helper to wire it up — handles both
1485
+ the default cell-rect collision AND sub-tile collision rects (see next
1486
+ section) in one call:
1487
+
1488
+ ```ts
1489
+ import { addTilemapCollider } from '@umicat/phaser-sdk';
1490
+
1491
+ create() {
1492
+ // ... spawn player ...
1493
+ addTilemapCollider(this, 'world', this.player);
1494
+ }
1495
+ ```
1496
+
1497
+ No `setCollisionByIndex` or `setCollision` boilerplate — the SDK calls
1498
+ `setCollisionByProperty({ solid: true })` automatically at scene load.
1499
+
1500
+ ### Sub-tile collision shape (Godot/Tiled-style polygon-on-tile)
1501
+
1502
+ For tiles where only PART of the cell should block the player (e.g. a
1503
+ tile that's visually half wall + half floor, an L-shaped wall corner,
1504
+ fence posts, decorative overhangs), the Tile Metadata Editor's
1505
+ **Collision Shape** sub-panel lets the user draw **N axis-aligned
1506
+ rectangles** on the tile preview. The shape is **authored once per tile**
1507
+ — every painted instance of that tile across every level automatically
1508
+ gets the same collision shape, same as Godot's TileSet Physics editor.
1509
+
1510
+ Data shape (`TileMetadata.collisionRects`):
1511
+ ```ts
1512
+ Array<{ x: number, y: number, w: number, h: number }> // each rect in source-pixel coords, relative to tile top-left
1513
+ ```
1514
+
1515
+ Multi-rect supports L / U / frame-shaped collision (e.g. a wall corner
1516
+ is two rects forming an L; a frame border is four). For non-axis-aligned
1517
+ shapes (slopes), Phaser Arcade has no native support — that would require
1518
+ switching to Matter physics which has different semantics and breaks the
1519
+ project's Arcade-based skill library.
1520
+
1521
+ At runtime the SDK:
1522
+ 1. Disables Phaser's native cell-rect collision for tiles with custom
1523
+ `collisionRects` (so it doesn't double-collide).
1524
+ 2. Creates one invisible `Phaser.GameObjects.Rectangle` with an Arcade
1525
+ static body per rect at every painted instance of that tile, sized +
1526
+ positioned in world coords.
1527
+ 3. Bodies live in a `Phaser.Physics.Arcade.StaticGroup` stashed on the
1528
+ layer's data manager (`unboxySubTileStaticGroup`). Rebuilt on each
1529
+ painter op (paint / erase / fillRect / bucketFill) and on each
1530
+ `assetUpdate` (live save from the Tile Metadata Editor).
1531
+
1532
+ Behavior code doesn't need to know about any of this — `addTilemapCollider`
1533
+ wires up both the layer collider AND the sub-tile group at once. Cells
1534
+ without custom rects keep their default `solid` behavior; cells with a
1535
+ custom shape collide only on the sub-rects.
1536
+
1537
+ **Implementation note**: Phaser's TilemapLayer + Arcade physics has no
1538
+ native sub-tile collision. This is a known limitation — the Phaser
1539
+ community workaround is exactly this static-body pattern. Matter physics
1540
+ has per-tile polygon support but requires switching the whole game to a
1541
+ different physics model, which has different semantics than Arcade and
1542
+ breaks every existing behavior pattern. We deliberately keep Arcade and
1543
+ materialize sub-rects as static bodies.
1544
+
1545
+ ### `getTilemapAt(scene, entityId, worldX, worldY, options?)`
1546
+
1547
+ Read the painted tile at a world coord. Returns `null` when no tile is
1548
+ painted there (or coord is outside the layer); returns a `TilemapHit`
1549
+ otherwise:
1550
+
1551
+ ```ts
1552
+ interface TilemapHit {
1553
+ layerId: string; // which layer holds the tile
1554
+ tileIndex: number; // 0-based row-major index in the tileset image
1555
+ metadata: TileMetadata; // the eight-field object (empty when no metadata)
1556
+ tileX: number; // tile column within the layer
1557
+ tileY: number; // tile row within the layer
1558
+ }
1559
+ ```
1560
+
1561
+ ```ts
1562
+ import { getTilemapAt } from '@umicat/phaser-sdk';
1563
+
1564
+ update(_t: number, dt: number) {
1565
+ const hit = getTilemapAt(this, 'world', this.player.x, this.player.y);
1566
+ if (!hit) return;
1567
+ // Damage tile (lava, spikes)
1568
+ if (hit.metadata.damage) this.player.hp -= hit.metadata.damage * (dt / 1000);
1569
+ // Movement modifier (water slows the player to 50%)
1570
+ this.player.speed = hit.metadata.movement === 'swim' ? 0.5 : 1.0;
1571
+ // Footstep sound by ground type
1572
+ if (this.lastTile !== hit.tileIndex) {
1573
+ this.lastTile = hit.tileIndex;
1574
+ this.sound.play(`footstep-${hit.metadata.groundType ?? 'soft'}`);
1575
+ }
1576
+ }
1577
+ ```
1578
+
1579
+ Layer scan order is top-down by default (highest `z` wins, matching the
1580
+ visual stack). Pass `options.layerId` to scope to one layer:
1581
+
1582
+ ```ts
1583
+ // Read the ground layer specifically (ignore decoration overlays)
1584
+ const hit = getTilemapAt(this, 'world', x, y, { layerId: 'floor' });
1585
+ ```
1586
+
1587
+ ### Anti-patterns
1588
+
1589
+ - Do **not** call `setCollisionByIndex` / `setCollision` yourself — the
1590
+ SDK already arms `setCollisionByProperty({ solid: true })` after scene
1591
+ load. Calling both can produce conflicting state where some painted
1592
+ tiles are flagged but others aren't.
1593
+ - Do **not** read `layer.getTileAtWorldXY()` directly — it works but
1594
+ loses the `metadata` object, the cross-layer top-down walk, and the
1595
+ hidden-layer skip. Use `getTilemapAt`.
1596
+ - Do **not** rely on per-tile metadata for things that should be entity
1597
+ properties — boss patrol points, NPC spawn positions, conversation
1598
+ triggers. Those are entities (or triggers, slice 5), not tile flags.
1599
+
1600
+ ## Animated tiles (visual editor slice 6 Phase F, since 0.4.0)
1601
+
1602
+ Painted cells whose source tile index equals an animation's `rootTileIndex`
1603
+ cycle through `frames[*].tileIndex` synchronously at runtime — water flow,
1604
+ lava bubble, torch flicker, glowing crystals. All matching cells animate
1605
+ together (Sprout Lands water lake-cells all flow in lockstep).
1606
+
1607
+ ### Schema (tileset metadata extension)
1608
+
1609
+ ```json
1610
+ {
1611
+ "cellSize": { "width": 16, "height": 16 },
1612
+ "cols": 4, "rows": 1,
1613
+ "animations": [
1614
+ {
1615
+ "id": "water-flow",
1616
+ "rootTileIndex": 0,
1617
+ "frames": [
1618
+ { "tileIndex": 0, "duration": 250 },
1619
+ { "tileIndex": 1, "duration": 250 },
1620
+ { "tileIndex": 2, "duration": 250 },
1621
+ { "tileIndex": 3, "duration": 250 }
1622
+ ]
1623
+ }
1624
+ ]
1625
+ }
1626
+ ```
1627
+
1628
+ - **`rootTileIndex`** — the tile users paint with. The painter palette shows
1629
+ this tile with a 🎬 badge so users know which one to paint. Other frames
1630
+ (1, 2, 3) are "internal" — the SDK swaps cells to them per-tick.
1631
+ - **`frames`** — cycle order. Convention: `frames[0].tileIndex === rootTileIndex`
1632
+ so the static editor view (where animations don't run) matches frame 0.
1633
+ - **`duration`** — milliseconds per frame. Floor 16ms. Sprout Lands water
1634
+ uses 250ms (4fps) across all 4 frames.
1635
+
1636
+ ### Runtime behavior
1637
+
1638
+ `loadWorldScene` arms animations automatically — no code change needed in
1639
+ game scripts. Painted cells matching any `rootTileIndex` get swapped to the
1640
+ current frame every UPDATE tick. Cells painted with non-root indices stay
1641
+ static.
1642
+
1643
+ ### Edit mode vs Play mode
1644
+
1645
+ In Edit mode the scene is paused (`setActive(false)`), so the animation
1646
+ UPDATE handler doesn't fire. `enterEdit` resets every animated cell to its
1647
+ root index so the editor always shows the authored ("data") frame, not
1648
+ whatever was visible at the moment of toggle. `exitEdit` resumes — next
1649
+ UPDATE tick swaps each cell to its current animation frame via elapsed-
1650
+ time math.
1651
+
1652
+ ### Save-path safety
1653
+
1654
+ The iframe's per-frame `tile.index` mutation does NOT leak into the host's
1655
+ draft state. Home-ui keeps its own copy in `useEditorDraft`'s baseline +
1656
+ commands. Mid-animation Cmd+S saves root indices (the "data"), not the
1657
+ displayed frame.
1658
+
1659
+ ### Authoring UI
1660
+
1661
+ Open the asset → `TilesetInspectorModal` → "Animations" section →
1662
+ "+ Add animation…" or "Edit animations…". The editor lets you pick a root
1663
+ tile + frames + uniform duration. For typical N-cell water/lava/torch
1664
+ spritesheets there's a one-click "Animate all" shortcut (sets root=0,
1665
+ frames=[0..N-1], duration=250ms).
1666
+
1667
+ ### When NOT to use
1668
+
1669
+ - **Sprite animations** (player walk cycles, enemy attack frames) — those
1670
+ are Phaser sprite animations, registered via `scene.anims.create()` from
1671
+ `manifest.assets[].animations` (separate path, slice 7).
1672
+ - **One-shot tile state changes** (door opens, gate closes) — those are
1673
+ `layer.putTileAt(x, y, newIndex)` from behavior code, not data-driven
1674
+ cycling.
1675
+ - **Per-cell phase offset** (each water cell offset to look organic) — v2
1676
+ per design 06 §8.9. v1 ships synchronized cycling only.
1677
+
1678
+ ## Changelog
1679
+
1680
+ - **0.4.0** — visual editor slice 6 Phase F: **animated tiles** (2026-05-24). Closes the last open Slice 6 phase. New `TilesetAnimation` type + `TilesetMetadata.animations?: TilesetAnimation[]` schema (additive — minor bump). Painted cells with `tile.index === rootTileIndex` cycle through `frames[*].tileIndex` per-frame via a scene UPDATE listener installed by new `applyTilesetAnimations(layer, asset)` in `spawnEntity.ts`. All matching cells animate synchronously. Phaser 3 PARSES Tiled's `tile.animation` field into `tileset.tileData[id].animation` but its TilemapLayer renderer does NOT consume it (community `phaser-animated-tiles` plugin is bit-rotted on 3.80+); ~50-line custom impl avoids the dep. **Edit-mode behavior**: scenes pause via `setActive(false)` → handler dormant → cells reset to root via new `resetTilesetAnimationsToRoot(container)` so editor view always shows the authored frame. `exitEdit` resumes on next UPDATE tick. **Save-path safety**: per-frame `tile.index` mutation doesn't leak into the host's draft state. **Re-arm paths**: editor's `handleAssetUpdate` (Animation Editor save → live re-apply), `applyTilemapStructureOp addLayer` (new layer arm), every paint op's metadata re-application (newly-painted root tile starts animating immediately). Pairs with home-ui `TilesetAnimationsEditorModal` + "Animate all" quick-fill + 🎬 root-tile badge in the tilemap palette. SDK-GUIDE gains an "Animated tiles" chapter above the changelog covering schema, runtime, edit-mode behavior, when-not-to-use. Pack-import auto-detection of `water_0.png` / `water_1.png` filename sequences deferred to Phase F.2.
1681
+ - **0.2.121** — Phase C follow-up: **multi-rect sub-tile collision** (L / U / frame shapes). `TileMetadata.collisionRect` → `TileMetadata.collisionRects[]`. SDK `syncSubTileBodies` now creates N invisible static bodies per painted tile (one per rect). TileMetadataEditorModal's Collision Shape sub-panel rewritten as a multi-rect canvas: drag empty area = new rect (auto-selected), click existing rect = select it, drag selected = move, drag corner = resize, Delete key = remove selected, "Clear all" replaces "Clear". Selected rect = solid red border + corner handles; unselected rects = dashed translucent (clickable). Palette overlay draws N rects per cell. Backend POJO `TileMetadata.collisionRect` field replaced with `collisionRects: List<CollisionRect>`; parser validates each. Breaking change for in-flight test data but acceptable (one tester, no production data). Wall corners + complex collision shapes now expressible without splitting tiles.
1682
+ - **0.2.120** — fix: tilemap grid sketch (editor-only visualization showing tile boundaries on empty / sparsely-painted tilemaps) bled into Play mode. `createTilemap` now defaults the grid Graphics to `setVisible(false)` and tags it with `unboxyTilemapGrid` data flag. New `setTilemapGridsVisible(game, boolean)` helper walks every tilemap container's children, finds the tagged grid, and toggles. Called with `true` on `enterEdit` and `false` on `exitEdit`. Surfaced 2026-05-19 — in Play mode user saw the tilemap's grid lines overlaid on the actual tiles + extending past the world bounds.
1683
+ - **0.2.119** — fix: sub-tile collision bodies (0.2.116) landed at world origin instead of inside the tilemap's bounds — visible as static bodies stacked top-left of the canvas in `physics.world.createDebugGraphic()` while painted walls sat at the entity position. Root cause: `applyTilesetTileMetadata` was called inside `renderLayerInto` BEFORE the caller set `layerObj.x/y`, so `tile.getLeft()` returned layer-local coords (near 0,0) instead of world coords. `createTilemap` mirrors layer position from the container transform AFTER `renderLayerInto` returns, so painted tiles rendered correctly but sub-tile bodies were stranded. Fix: moved `applyTilesetTileMetadata` out of `renderLayerInto` to AFTER positioning in both `createTilemap` (initial spawn) and `applyTilemapStructureOp addLayer` (editor-time). `renderLayerInto` now does NOT call `applyTilesetTileMetadata` — comment notes the caller is responsible.
1684
+ - **0.2.118** — fix: deleting a tilemap entity in the editor left its TilemapLayers orphaned in scene root, still visible in the canvas until next save+reload. Same root cause as the 0.2.59-era "TilemapLayer can't be a Container child" architecture: layers live in scene root with a per-frame sync hook positioning them at `container.x + offset`. `deleteEntity` only destroyed the Container, so the orphaned layers had no parent to follow → frozen at their last sync position, visible until the next scene rebuild stripped them. Fix: `deleteEntity` now walks `container.getData('tilemapLayers')` and `.destroy()`s each layer before destroying the container. Also tears down the sub-tile static body group (`layer.getData('unboxySubTileStaticGroup')`) — those bodies are scene-root members too and would survive the container destroy.
1685
+ - **0.2.117** — fix: `createEntity` for tilemap entities didn't lazy-load tileset textures, leading to `tileset 'X' texture not loaded; skipping layer` warning + the painter falling back to drag-the-entity behavior right after CreateTilemapModal. The lazy-load gate was sprite-only (`entity.kind === 'sprite'`). Pre-existing bug, surfaced when the user deleted a tilemap and re-created one against a tileset that wasn't in the SDK's texture cache. Fix: generalize the lazy-load to walk `tilemap.layers[].tilesetIds[]`, resolve each via `manifestAsset` (preferred) or `getManifest(scene)`, and `loadAssetIntoScene` for any not in `scene.textures`. Also unconditionally `upsertCachedManifestAsset(scene, manifestAsset)` whenever the host passes one, mirroring the addLayer + assetUpdate paths so the manifest cache is consistent regardless of entity kind. Same fix pattern as 0.2.43's region-atlas live-edit gap. No new exports, no API changes.
1686
+ - **0.2.116** — visual editor slice 6 Phase C follow-up: **sub-tile collision rects** (Godot/Tiled-style polygon-on-tile, rect-only in v1). Solves the "tile is visually half wall + half floor — block only the wall half" case without splitting the asset. New `TileMetadata.collisionRect: { x, y, w, h }` (source-pixel coords relative to tile top-left). SDK runtime: `applyTilesetTileMetadata` now also calls new `syncSubTileBodies` — walks painted tiles with a `collisionRect`, disables Phaser's native cell-rect collision for them (`tile.setCollision(false, ...)`), and creates invisible `Phaser.GameObjects.Rectangle`s with Arcade static bodies sized to the sub-rect. Bodies live in a per-layer `StaticGroup` stashed on the layer's data manager (`unboxySubTileStaticGroup`). Rebuilt on every painter op (paint / erase / fillRect / bucketFill) — `handleEditTilemap` looks up the layer's tileset asset post-op and re-runs `applyTilesetTileMetadata`, which is cheap when no tile has a `collisionRect` (single map scan + early return). Also rebuilt on `assetUpdate` (Tile Metadata Editor save). **New SDK export `addTilemapCollider(scene, entityId, target, callback?)`** — single-call helper that wires both `physics.add.collider(target, layer)` AND `physics.add.collider(target, subTileGroup)` for every layer of a tilemap entity, so behavior code never has to know whether a tile has cell-rect or sub-rect collision. Returns the created `Collider[]` so callers can destroy on level transition. Pairs with home-ui's TileMetadataEditorModal which gains a "Collision shape" sub-panel — drag on a zoomed tile preview to define the rect, drag corner handles to resize, drag body to move, click-without-drag to clear; rect persists as `TileMetadata.collisionRect`. TilemapInspectorPanel's tile palette gains a red rect overlay on cells with a custom collision shape (additive to the existing metadata dot). Backend POJO + parser extended with new `CollisionRect` inner class (positive w/h, non-negative x/y validation). **Phaser community context**: Phaser's TilemapLayer + Arcade physics has no native sub-tile collision; the established community pattern is exactly this static-body workaround (Matter physics has per-tile polygon support but switching breaks every Arcade-based behavior pattern). We stay on Arcade and materialize per-painted-tile bodies. Pure forward-compat addition — existing tilesets without `collisionRect` keep their cell-rect-only behavior.
1687
+ - **0.2.115** — visual editor slice 6 Phase C: per-tile metadata + auto-collision + `getTilemapAt`. New `TilesetMetadata.tiles` field (sparse map keyed by tile index, eight-field per-tile metadata — solid / oneWay / slope / damage / terrainTag / movement / groundType / customTag). SDK consumer: new `applyTilesetTileMetadata(tileset, layer, asset)` is called automatically after `map.createLayer` in both initial spawn (`renderLayerInto` in spawnEntity.ts) AND editor-time `addLayer` (EditorBridge.applyTilemapStructureOp); it stamps `tileset.tileProperties` so newly-painted tiles inherit the lookup, walks already-painted tiles via `layer.forEachTile` to refresh `tile.properties`, and calls `layer.setCollisionByProperty({ solid: true })` so behavior code only needs `physics.add.collider(player, layer)` for free collision. Live edit: `handleAssetUpdate` (`umicat:editor:assetUpdate` message) now also walks every tilemap layer using the updated asset and re-applies metadata + collision wiring — Tile Metadata Editor save takes effect in the running iframe without a workspace rebuild / scene reload. New SDK export `getTilemapAt(scene, entityId, worldX, worldY, options?)` returns a `TilemapHit` (`{ layerId, tileIndex, metadata, tileX, tileY }`) or null; default layer scan is top-down (highest z wins, matches visual stack), `options.layerId` scopes to one layer. New exports: `applyTilesetTileMetadata`, `getTilemapAt`, types `TileMetadata`, `TilemapHit`. Asset record now carries `tileset.tiles?: { [tileIndex: number]: TileMetadata }`. Tilemap layer GO is tagged with `tilemapTilesetId` so the assetUpdate handler can find affected layers without a registry walk. Pairs with home-ui's TileMetadataEditorModal (right-click any tile in the TilemapInspectorPanel palette → modal with eight fields → PATCH `/games/{gameId}/assets/{assetId}/tileset` → updateAsset round-trip to iframe). No template PR needed — purely additive editor / runtime work. SDK-GUIDE gains a "Tilemap per-tile metadata + collision" chapter covering authoring, zero-config collision, `getTilemapAt` API + examples, and anti-patterns.
1688
+ - **0.2.55** — fix: gravity declared in scene data is now applied. `world.physics.gravity` (per world scene) and `manifest.globals.physics.gravity` (manifest-wide default) were typed-but-dead fields — `loadWorldScene` never read them, so a game that declared gravity in scene data ran at zero gravity (a platformer that "looks right but nothing falls"). `loadWorldScene` now applies them to `scene.physics.world.gravity`, scene-level winning over the manifest global, components applied individually (a partial `{ y }` leaves `x` intact). No schema change (both fields already existed in the `Manifest` / `WorldSceneConfig` types), no new exports. `game.json`'s `GameConfig.physics.gravity` is unchanged — it's a separate boot-time path wired through `createUmicatGame`. Behavior note: a game that *already* declared scene-data gravity now actually gets it — intended, since the field was inert before.
1689
+ - **0.2.54** — fix: code-rendered scene entities had no usable physics body, and the prefab physics path mis-anchored code-rendered bodies. Two parts: (1) **scene entities can now carry a `physics` block** (`sprite` / `primitive` / `code-rendered`, same `PrefabPhysics` shape prefabs use) — `spawnEntity` applies it via `physics.add.existing` + body size/offset/immovable/velocity/bounce. Before this, scene entities had no physics-application path at all, so a code-rendered platform got a 0×0 body and everything fell through it. (2) **code-rendered bodies are now correctly anchored.** A Phaser `Graphics` hardcodes `displayOrigin = (0,0)` (no Origin component) while render scripts draw centered on local (0,0); the Arcade body positions at `gameObject.x + offset.x`, so the prior default-offset body landed at the bottom-right of the visual ("player floats above the platform"). The fix shifts the *body frame* by `(-visualW/2, -visualH/2)` for code-rendered visuals — body-only, never touches the Graphics, so rendering is unaffected (this is the correct fix that 0.2.52's reverted `g.setOrigin` approach got wrong). Prefab and scene-entity physics now share one body-level path (`applyEntityPhysics` in `spawnEntity.ts`). Sprite / primitive physics behavior is unchanged. No new exports — `WorldEntityBase` gains an optional `physics` field; existing games are unaffected.
1690
+ - **0.2.45** — fix: `isPerFrameHitbox` type guard tightened to check `default != null` rather than `'default' in h`. Closes a wire-shape gap from slice 8 v1.1 — the backend now writes explicit null values on the alternate shape's fields when a Hitbox PATCH switches modes (Single ↔ Per-frame), because OpenSearch's `_update` API deep-merges nested objects and would otherwise leave stale per-frame fields after a Single-mode save. Without this guard fix, the SDK's `applyAssetHitbox` would have treated the explicit-null `default: null` as "per-frame mode" via the `'in'` check and crashed when calling `applyShape(body, null)`. Pure SDK change — no API additions; only the guard's branch condition.
1691
+ - **0.2.44** — visual editor slice 8: depth + asymmetric collision. New fields `AssetRecord.hitbox` (single rect or per-frame overrides) + `AssetRecord.depthAnchor` (footprint pixel for Y-sort). New scene flag `WorldScene.ySort: boolean` (per-frame `setDepth(y)` hook). New SDK export `applyAssetHitbox(sprite, asset)` — silent no-op without metadata, dev-warn on missing body, idempotent `ANIMATION_UPDATE` listener install for per-frame variants. New type guard `isPerFrameHitbox`. Two new editor protocol messages — `umicat:editor:assetUpdate { asset }` (broadcast hitbox/anchor edits to running iframe; re-applies to every spawned instance, no scene reload) + `umicat:editor:setDebugOverlay { showHitboxes }` (toggle the EditorOverlayScene's per-frame hitbox + anchor draw). See "Collision + Y-sort" chapter. Pairs with: backend `PATCH /games/{id}/assets/{aid}/hitbox`, vision detection in pack import (character/tree/building/rock/decoration subjects), Hitbox Editor modal in home-ui, `asset-hitbox-physics` agent skill teaching the conditional rule.
1692
+ - **0.2.33** — docs: HUD scenes chapter added to this guide (covers slice-5 widget kinds, anchor model, dynamic bindings via `scene.registry`, `hud:press` event). Existing workspaces pick this up via `npm update @umicat/phaser-sdk`.
1693
+ - **0.2.32** — fix: 9-grid anchor side change in the visual editor moved container-based widgets (icon-button, progress-bar, panel) far from the new corner. `applyHudPatch` now re-applies the origin shift that compensates for containers having no `setOrigin`.
1694
+ - **0.2.31** — fix: progress-bar and panel widgets drew their Graphics rect from `(0, 0)` instead of centered, so the visual was offset by `(w/2, h/2)` from the editor's selection rect. Both now draw centered.
1695
+ - **0.2.30** — added HUD `progress-bar` and `panel` widgets (visual editor slice 5.5). progress-bar supports independent static/dynamic `value` and `max` bindings; panel is a colored rectangle with optional border + rounded corners. New types: `HudProgressBarVisual`, `HudPanelVisual`, `HudNumberSource`. See "HUD scenes" chapter above.
1696
+ - **0.2.29** — added HUD scene runtime (visual editor slice 5). New module `src/scene/HudRuntime.ts` exporting `UmicatHudScene`, `loadHudScene`, `spawnHudEntity`, `resolveAnchor`. Three v1 widget kinds: `text` (static or dynamic), `image`, `icon-button`. Auto-launched by `loadWorldScene` when `manifest.scenes[].hud` is set. Dynamic bindings hook into Phaser's game registry. Editor protocol gains `umicat:editor:setEditMode { mode: 'world' \| 'hud' }`. See "HUD scenes" chapter above.
1697
+ - **0.2.28** — fix: `EditorBridge.computeScreenRect` was multiplying by `Phaser.Scale.ScaleManager.displayScale.x` which is `gameSize / canvasBoundsSize` (the inverse of what the variable name suggests), so coords ballooned out of the iframe. Now measures CSS-to-logical scale directly via `canvas.getBoundingClientRect()`.
1698
+ - **0.2.27** — added `EditorSelectionRectMessage` (visual editor slice 4 — inline AI popover). SDK posts `umicat:editor:selectionRect { entityId, rect }` on selection change / drag-end / pan-zoom; rect is in iframe-relative pixels. Used by home-ui to anchor a floating ✨ button next to the selected entity for entity-scoped AI prompts.
1699
+ - **0.2.26** — fix: tilemap entities couldn't be selected in the editor. `spawnEntity` for `tilemap` now sets explicit hit-test bounds (Phaser Graphics has no intrinsic size).
1700
+ - **0.2.25** — fix: Backspace / Delete in the editor didn't remove selected entities once focus was inside the iframe. The overlay scene now catches bare Backspace/Delete and posts `action: 'delete'` to the host.
1701
+ - **0.2.24** — fix: re-dragging an already-in-manifest asset spawned an unrendered entity. `createEntity` resolver now falls back to the cached `manifest.json` when the host omits `manifestAsset`.
1702
+ - **0.2.23** — fix: code-rendered entities couldn't be selected/dragged in the editor. `spawnEntity` now sets explicit Graphics size (Phaser Graphics returns 0×0 from `getBounds()` otherwise). Schema gains optional `visual.width` / `visual.height`.
1703
+ - **0.2.22** — render-script registry wired (visual editor slice 3.5). `createUmicatGame` accepts a new `renderScripts: Record<path, RenderScriptModule>` option; templates build it via `import.meta.glob('./visuals/*.ts', { eager: true })`. `loadWorldScene` falls back to that registry when no explicit `resolveRenderScript` is passed, so games don't have to plumb it through every scene call. Missing scripts no longer crash the scene boot — `spawnEntity` renders a clear orange-bordered "?" placeholder instead and tags the GameObject with `renderScriptMissing` for debug. `applyEdit` now handles `visual.params` patches on `code-rendered` entities by re-calling render with merged params (live editor preview as you tune in the Inspector). New exports: `RenderScriptModule`, `setRenderScriptRegistry`, `getRenderScriptRegistry`, `resolveRenderScript`. Pairs with the `phaser-render-script` agent skill which teaches the pure-function contract.
1704
+ - **0.2.21** — visual editor slice 3 (drag-to-place). Formalized trigger + tilemap entity data shapes (TriggerEntity now has `shape: rect|circle`, `description`, `targets[]`; TilemapEntity has `tileSize`, `size`, `layers[]`). spawnEntity renders both as placeholders (trigger = semi-transparent cyan rect/circle, tilemap = translucent grid sketch — full features ship in slices 5+6). New protocol: `umicat:editor:createEntity { entity, manifestAsset? }` (host-side drag-to-place spawns entity at world coord; lazy-loads asset texture if needed) + `umicat:editor:deleteEntity { entityId }` (Backspace + undo of create). `sceneLoaded` now also carries the manifest snapshot so home-ui can mutate the asset table when a new asset gets dropped on the canvas.
1705
+ - **0.2.20** — added editor shortcut forwarding (Cmd+Z/Y/S land in iframe when canvas focused; SDK posts back to host via `umicat:editor:shortcut`). Without this, native shortcuts didn't reach the host's keydown listener after the user clicked the canvas.
1706
+ - **0.2.19** — fix: editor `enter` arriving while BootScene is still preloading (the post-flush iframe-rebuild path) had nothing to pause, so the world scene resumed running freely once it started up. Now the bridge re-attempts the pause + scene snapshot for ~3 seconds after enter, catching the scene as it transitions from BootScene → GameScene. Snapshot is also re-posted once a scene file lands in cache. Surfaced when slice-2's auto-flush rebuilt the iframe and the user found the sprite started moving again post-save (2026-05-07).
1707
+ - **0.2.18** — added editor bridge (visual editor slice 2). Replaces the slice-1 `umicat:setEditMode` message with a full editor protocol: `umicat:editor:enter`/`:exit` (toggles edit mode + launches the overlay scene), `:applyEdit` (mutates a live entity from the host), `:setSelection` (host pushes selection), `:panZoom` (editor camera control), plus SDK→host `:sceneLoaded` (initial snapshot), `:pickEntity` (pointer-down selection), `:dragEnd` (entity dragged to new position). New module `src/editor/` with `EditorOverlayScene` (high-depth Phaser.Scene drawing selection rect + world bounds, capturing pointer events) and `EditorBridge` (postMessage handler + applyEdit logic). New exports: `setupEditorBridge`, `EditorOverlayScene`, `EDITOR_OVERLAY_KEY`, plus all editor protocol types. `setupEditorModeListener` is preserved as a thin wrapper that delegates to the bridge so existing `createUmicatGame` wiring continues to work. Pairs with home-ui's new Hierarchy + Inspector panels and `useEditorDraft` hook.
1708
+ - **0.2.17** — added scene-as-data foundation (visual editor slice 1). New exports: `loadWorldScene`, `preloadManifest`, `getManifest`, `preloadSceneAssets`, `spawnEntity`, `EntityRegistry`, `attachEntityRegistry`, `getEntityRegistry`, `setupEditorModeListener`, `isEditMode`, `parseColor`, `SCHEMA_VERSION`, plus all schema types (`Manifest`, `WorldScene`, `WorldEntity`, `Transform`, `AssetRecord`, etc.). New games that ship with `public/scenes/manifest.json` load entities + camera config from JSON; `GameScene.ts` becomes a generic loader that calls `await loadWorldScene(this, sceneId)`. `createUmicatGame` now also wires `setupEditorModeListener` so the host (home-ui) can pause active scenes via `postMessage({ type: 'umicat:setEditMode', enabled: boolean })`. Purely additive — existing scene-as-code games are unaffected. Migration to scene-as-data is opt-in via the `scene-data-migration` agent skill.
1709
+ - **0.2.16** — added orientation presets. `createUmicatGame` now accepts an `orientation: 'portrait' | 'landscape'` option as an alternative to explicit `width`/`height` (TS union — pass one or the other). New exports: `Orientation` type and `ORIENTATION_DIMENSIONS` map (`landscape: 1280×720`, `portrait: 720×1280`). Lets games declare orientation once and have a single source of truth for canvas dimensions.
1710
+ - **0.2.15** — fix: `ChatFacade.subscribeToPlayers` early-returned when `state.players` was `undefined` at construction time, which it always is on a fresh `joinOrCreate` (Colyseus delivers initial state as a separate message a few ms later). The early return meant `onStateChange` never subscribed and `system.joined` / `system.left` stayed silent in production despite the 0.2.14 callback-API fix. Now: always subscribe; treat the first state change as initial hydration (silent), subsequent changes do real diff. Three regression tests cover the deferred-hydration flow.
1711
+ - **0.2.14** — fix: `ChatFacade` `system.joined` / `system.left` events were silently no-op'ing in production. The lifecycle subscription used `state.players.onAdd` / `.onRemove` instance methods that Colyseus 0.16 removed in favour of `getStateCallbacks(room)`; the runtime check `typeof players.onAdd === 'function'` evaluated false, so neither system message fired. Now uses `room.onStateChange` + a known-names diff — robust to future Colyseus API changes. Tests updated to match. Patch-only; no API change for game code.
1712
+ - **0.2.13** — added `room.chat` helper for in-game chat. New API: `room.chat.send(text)` (returns `Promise<void>`) + `room.chat.onMessage(handler)`, plus exports `ChatMessage`, `ChatMessageKind`, and `MAX_CHAT_TEXT_LEN`. Wraps the existing `'chat'` wildcard relay with local echo (sender sees own message synchronously), stamped `displayName` on every payload, trim + 500-char silent truncate, and auto-emitted `kind: 'system.joined'` / `'system.left'` messages computed locally from `room.state.players` add/remove events (no wire traffic). Replaces the raw `room.send('chat', text)` pattern in the Multiplayer chapter — raw still works but loses local echo, length cap, and join/leave events.
1713
+ - **0.2.12** — added `umicat.rooms.list(options?)` — returns the open rooms for the caller's game right now, scoped server-side to the caller's gameId. Use this to build a lobby browser ("show me open rooms, click to join") without needing an out-of-band room code. SDK-GUIDE adds a "Browsing open rooms" section under Multiplayer covering the `RoomListEntry` shape, the poll-on-timer pattern, and the `{ roomCode }` filter for "is room X up yet?" probes.
1714
+ - **0.2.11** — added `roomCode` + `maxClients` to `JoinOptions`. SDK-GUIDE's Multiplayer chapter now documents matchmaking: room type is always `'lobby'`; use `roomCode` to bucket independent rooms per gameId (create-with-fresh-code, join-with-shared-code, quick-match with empty code). Fixes a real bug where agents were passing a gameId-derived string as the room type (e.g. `joinOrCreate('blokus-<code>', ...)`) and failing with "no handler" server-side.
1715
+ - **0.2.10** — docs: added a `createUmicatGame` options table (including the new `plugins` field) and a third-party Phaser plugin registration example. No code changes; version bumped so existing workspaces pick up the new guidance via `npm update @umicat/phaser-sdk`.
1716
+ - **0.2.9** — `createUmicatGame` now forwards the optional `plugins` field to Phaser's `GameConfig`. Previously the option was accepted at the call site but silently dropped — any `phaser3-rex-plugins` (or similar) registration never took effect, and `this.plugins.get(key)` returned undefined at runtime. Purely additive; no effect on games that don't pass `plugins`. Lets the `phaser-rex-touch` agent skill work end-to-end.
1717
+ - **0.2.8** — docs: added "Making remote motion feel smooth" section to the multiplayer chapter with concrete examples for interpolation (continuous motion), extrapolation (projectiles), and snap (discrete/turn-based/infrequent). No code changes — version bumped so existing workspaces pick up the new guidance via `npm update @umicat/phaser-sdk`.
1718
+ - **0.2.7** — fixed handshake race on slow-mounting hosts. `PostMessageTransport.connect` now retries `umicat:hello` every 200 ms until `umicat:init` arrives (previously one-shot → lost on Android/mobile where React mounted after the iframe first fired hello). Default handshake timeout bumped from 2 s → 5 s.
1719
+ - **0.2.6** — redesigned `umicat.rooms` around two generic primitives: delta-synced KV state (`room.player.set/get/delete`, `room.data.set/get/delete`) + transient relay (`room.send` / `room.on`). Server no longer bakes in game-specific fields like x/y/color/ready or a `move` handler — games define their own shapes via opaque JSON values, same contract as `gameData` / `saves`.
1720
+ - **0.2.5** — added `umicat.rooms` module (server-authoritative multiplayer rooms backed by Colyseus on umicat-realtime-service). Requires sign-in. Host must advertise `realtime` capability. Colyseus client loaded lazily — single-player games do not pay for the dependency.
1721
+ - **0.2.4** — added `umicat.gameData` module (game-scope key-value store for scoreboards, shared state)
1722
+ - **0.2.3** — anonymous users now use localStorage even inside a host (was throwing UNAUTHENTICATED when calling `saves` in home-ui without login)
1723
+ - **0.2.2** — added `SDK-GUIDE.md`
1724
+ - **0.2.1** — added `Umicat.init`, `umicat.user`, `umicat.saves`, Transport abstraction (PostMessage + LocalStorage backends)
1725
+ - **0.2.0** — added `UmicatScene`, recording support. Migrated to public npm.
1726
+ - **0.1.0** — initial release (CodeArtifact).