@codyswann/lisa 2.178.5 → 2.179.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 (142) hide show
  1. package/dist/configs/eslint/phaser.d.ts +2 -8
  2. package/dist/configs/eslint/phaser.d.ts.map +1 -1
  3. package/dist/configs/eslint/phaser.js +143 -17
  4. package/dist/configs/eslint/phaser.js.map +1 -1
  5. package/eslint-plugin-phaser/README.md +19 -0
  6. package/eslint-plugin-phaser/index.js +35 -0
  7. package/eslint-plugin-phaser/package.json +10 -0
  8. package/eslint-plugin-phaser/rules/no-allocation-in-update.js +189 -0
  9. package/eslint-plugin-phaser/rules/no-create-in-update.js +200 -0
  10. package/eslint-plugin-phaser/rules/require-shutdown-cleanup.js +191 -0
  11. package/package.json +5 -3
  12. package/phaser/package-lisa/package.lisa.json +11 -3
  13. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  15. package/plugins/lisa/skills/analyze-claude-remote/SKILL.md +72 -2
  16. package/plugins/lisa/skills/generate-claude-remote-build-script/SKILL.md +30 -1
  17. package/plugins/lisa-agy/plugin.json +1 -1
  18. package/plugins/lisa-agy/skills/analyze-claude-remote/SKILL.md +72 -2
  19. package/plugins/lisa-agy/skills/generate-claude-remote-build-script/SKILL.md +30 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-copilot/skills/analyze-claude-remote/SKILL.md +72 -2
  27. package/plugins/lisa-copilot/skills/generate-claude-remote-build-script/SKILL.md +30 -1
  28. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-cursor/skills/analyze-claude-remote/SKILL.md +72 -2
  30. package/plugins/lisa-cursor/skills/generate-claude-remote-build-script/SKILL.md +30 -1
  31. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-expo/agents/ops-specialist.md +2 -2
  34. package/plugins/lisa-expo/skills/ops-check-logs/SKILL.md +8 -2
  35. package/plugins/lisa-expo/skills/ops-db-ops/SKILL.md +7 -3
  36. package/plugins/lisa-expo/skills/ops-deploy/SKILL.md +6 -2
  37. package/plugins/lisa-expo/skills/ops-run-local/SKILL.md +5 -2
  38. package/plugins/lisa-expo-agy/agents/ops-specialist.md +2 -2
  39. package/plugins/lisa-expo-agy/plugin.json +1 -1
  40. package/plugins/lisa-expo-agy/skills/ops-check-logs/SKILL.md +8 -2
  41. package/plugins/lisa-expo-agy/skills/ops-db-ops/SKILL.md +7 -3
  42. package/plugins/lisa-expo-agy/skills/ops-deploy/SKILL.md +6 -2
  43. package/plugins/lisa-expo-agy/skills/ops-run-local/SKILL.md +5 -2
  44. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-expo-copilot/agents/ops-specialist.agent.md +2 -2
  46. package/plugins/lisa-expo-copilot/skills/ops-check-logs/SKILL.md +8 -2
  47. package/plugins/lisa-expo-copilot/skills/ops-db-ops/SKILL.md +7 -3
  48. package/plugins/lisa-expo-copilot/skills/ops-deploy/SKILL.md +6 -2
  49. package/plugins/lisa-expo-copilot/skills/ops-run-local/SKILL.md +5 -2
  50. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-expo-cursor/agents/ops-specialist.md +2 -2
  52. package/plugins/lisa-expo-cursor/skills/ops-check-logs/SKILL.md +8 -2
  53. package/plugins/lisa-expo-cursor/skills/ops-db-ops/SKILL.md +7 -3
  54. package/plugins/lisa-expo-cursor/skills/ops-deploy/SKILL.md +6 -2
  55. package/plugins/lisa-expo-cursor/skills/ops-run-local/SKILL.md +5 -2
  56. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  59. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  63. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  64. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  67. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  68. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  69. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  72. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  73. package/plugins/lisa-phaser/rules/phaser.md +111 -44
  74. package/plugins/lisa-phaser/skills/phaser-accessibility/SKILL.md +135 -0
  75. package/plugins/lisa-phaser/skills/phaser-accessibility/agents/openai.yaml +4 -0
  76. package/plugins/lisa-phaser/skills/phaser-asset-pipeline/SKILL.md +148 -0
  77. package/plugins/lisa-phaser/skills/phaser-asset-pipeline/agents/openai.yaml +4 -0
  78. package/plugins/lisa-phaser/skills/phaser-build-deploy/SKILL.md +148 -0
  79. package/plugins/lisa-phaser/skills/phaser-build-deploy/agents/openai.yaml +4 -0
  80. package/plugins/lisa-phaser/skills/phaser-i18n/SKILL.md +122 -0
  81. package/plugins/lisa-phaser/skills/phaser-i18n/agents/openai.yaml +4 -0
  82. package/plugins/lisa-phaser/skills/phaser-services/SKILL.md +208 -0
  83. package/plugins/lisa-phaser/skills/phaser-services/agents/openai.yaml +4 -0
  84. package/plugins/lisa-phaser/skills/phaser-testing/SKILL.md +103 -10
  85. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  86. package/plugins/lisa-phaser-agy/skills/phaser-accessibility/SKILL.md +135 -0
  87. package/plugins/lisa-phaser-agy/skills/phaser-asset-pipeline/SKILL.md +148 -0
  88. package/plugins/lisa-phaser-agy/skills/phaser-build-deploy/SKILL.md +148 -0
  89. package/plugins/lisa-phaser-agy/skills/phaser-i18n/SKILL.md +122 -0
  90. package/plugins/lisa-phaser-agy/skills/phaser-services/SKILL.md +208 -0
  91. package/plugins/lisa-phaser-agy/skills/phaser-testing/SKILL.md +103 -10
  92. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-phaser-copilot/rules/phaser.md +111 -44
  94. package/plugins/lisa-phaser-copilot/skills/phaser-accessibility/SKILL.md +135 -0
  95. package/plugins/lisa-phaser-copilot/skills/phaser-asset-pipeline/SKILL.md +148 -0
  96. package/plugins/lisa-phaser-copilot/skills/phaser-build-deploy/SKILL.md +148 -0
  97. package/plugins/lisa-phaser-copilot/skills/phaser-i18n/SKILL.md +122 -0
  98. package/plugins/lisa-phaser-copilot/skills/phaser-services/SKILL.md +208 -0
  99. package/plugins/lisa-phaser-copilot/skills/phaser-testing/SKILL.md +103 -10
  100. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  101. package/plugins/lisa-phaser-cursor/rules/phaser.mdc +111 -44
  102. package/plugins/lisa-phaser-cursor/skills/phaser-accessibility/SKILL.md +135 -0
  103. package/plugins/lisa-phaser-cursor/skills/phaser-asset-pipeline/SKILL.md +148 -0
  104. package/plugins/lisa-phaser-cursor/skills/phaser-build-deploy/SKILL.md +148 -0
  105. package/plugins/lisa-phaser-cursor/skills/phaser-i18n/SKILL.md +122 -0
  106. package/plugins/lisa-phaser-cursor/skills/phaser-services/SKILL.md +208 -0
  107. package/plugins/lisa-phaser-cursor/skills/phaser-testing/SKILL.md +103 -10
  108. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-rails/agents/ops-specialist.md +1 -1
  111. package/plugins/lisa-rails-agy/agents/ops-specialist.md +1 -1
  112. package/plugins/lisa-rails-agy/plugin.json +1 -1
  113. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-rails-copilot/agents/ops-specialist.agent.md +1 -1
  115. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  116. package/plugins/lisa-rails-cursor/agents/ops-specialist.md +1 -1
  117. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  119. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  120. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  121. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  123. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  124. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  125. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  126. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  127. package/plugins/src/base/skills/analyze-claude-remote/SKILL.md +72 -2
  128. package/plugins/src/base/skills/generate-claude-remote-build-script/SKILL.md +30 -1
  129. package/plugins/src/expo/agents/ops-specialist.md +2 -2
  130. package/plugins/src/expo/skills/ops-check-logs/SKILL.md +8 -2
  131. package/plugins/src/expo/skills/ops-db-ops/SKILL.md +7 -3
  132. package/plugins/src/expo/skills/ops-deploy/SKILL.md +6 -2
  133. package/plugins/src/expo/skills/ops-run-local/SKILL.md +5 -2
  134. package/plugins/src/phaser/rules/phaser.md +111 -44
  135. package/plugins/src/phaser/skills/phaser-accessibility/SKILL.md +135 -0
  136. package/plugins/src/phaser/skills/phaser-asset-pipeline/SKILL.md +148 -0
  137. package/plugins/src/phaser/skills/phaser-build-deploy/SKILL.md +148 -0
  138. package/plugins/src/phaser/skills/phaser-i18n/SKILL.md +122 -0
  139. package/plugins/src/phaser/skills/phaser-services/SKILL.md +208 -0
  140. package/plugins/src/phaser/skills/phaser-testing/SKILL.md +103 -10
  141. package/plugins/src/rails/agents/ops-specialist.md +1 -1
  142. package/tsconfig/phaser.json +8 -1
@@ -0,0 +1,122 @@
1
+ ---
2
+ name: phaser-i18n
3
+ description: This skill should be used when localizing a Phaser 4 game — a typed string catalog so no user-facing text is hardcoded, runtime locale switching that re-renders open text, interpolation/pluralization, and the BitmapText vs Text trade-offs (glyph coverage, RTL, CJK) localization forces. Use it when adding any player-facing string, building a language selector, or fixing missing-glyph/hardcoded-text issues. Pairs with phaser-services, phaser-accessibility, and phaser-asset-pipeline.
4
+ ---
5
+
6
+ # Phaser 4 Internationalization
7
+
8
+ ## Overview
9
+
10
+ No user-facing string is hardcoded in a scene. All player-visible text comes from
11
+ a **typed string catalog** keyed by typed constants, so a missing or misspelled
12
+ key is a compile error and every string has a home for translation. The catalog
13
+ is a small typed wrapper (no heavy dependency required); locale switching
14
+ re-renders any open text. Announced strings ([[phaser-accessibility]]) and
15
+ service messages ([[phaser-services]]) draw from the same catalog.
16
+
17
+ ## The typed catalog
18
+
19
+ One module owns the locales and the lookup. The key type is derived from the
20
+ default locale so every locale must cover the same keys:
21
+
22
+ ```ts
23
+ // src/i18n/catalog.ts
24
+ const en = {
25
+ "menu.play": "Play",
26
+ "menu.settings": "Settings",
27
+ "hud.score": "Score: {score}",
28
+ "result.cleared": "Level {level} cleared!",
29
+ "lives": "{n} life|{n} lives", // singular|plural
30
+ } as const;
31
+
32
+ const es: Record<keyof typeof en, string> = {
33
+ "menu.play": "Jugar", "menu.settings": "Ajustes",
34
+ "hud.score": "Puntos: {score}", "result.cleared": "¡Nivel {level} superado!",
35
+ "lives": "{n} vida|{n} vidas",
36
+ };
37
+
38
+ export type StringKey = keyof typeof en;
39
+ const locales = { en, es } as const;
40
+ export type Locale = keyof typeof locales;
41
+ ```
42
+
43
+ ## The `t()` function: interpolation + pluralization
44
+
45
+ ```ts
46
+ // src/i18n/i18n.ts
47
+ let current: Locale = "en";
48
+ export function setLocale(l: Locale) { current = l; EventCenter.emit(GameEvent.LocaleChanged); }
49
+ export function getLocale() { return current; }
50
+
51
+ export function t(key: StringKey, params?: Record<string, string | number>): string {
52
+ let s = (locales[current][key] ?? locales.en[key]) as string; // fall back to en, never crash
53
+ if (s.includes("|") && params && "n" in params) // pick plural form
54
+ s = (Number(params.n) === 1 ? s.split("|")[0] : s.split("|")[1]);
55
+ return s.replace(/\{(\w+)\}/g, (_, k) => String(params?.[k] ?? `{${k}}`));
56
+ }
57
+ ```
58
+
59
+ Usage is always `t(...)` with a typed key — never a raw string in a scene:
60
+
61
+ ```ts
62
+ this.add.bitmapText(x, y, Font.UI, t("hud.score", { score: 0 }));
63
+ this.announce(t("result.cleared", { level })); // [[phaser-accessibility]] live region
64
+ ```
65
+
66
+ The plural/interpolation rules are pure functions — put them in `src/logic/**`
67
+ so Vitest covers them ([[phaser-testing]]).
68
+
69
+ ## Locale switching at runtime
70
+
71
+ Changing language must update text that is already on screen. Emit a
72
+ `LocaleChanged` event on the EventsCenter ([[phaser-services]]); each scene with
73
+ visible text subscribes and re-applies `t()` to its labels, then removes the
74
+ listener in `shutdown` (the on/off discipline).
75
+
76
+ ```ts
77
+ create() {
78
+ const refresh = () => this.scoreText.setText(t("hud.score", { score: this.score }));
79
+ EventCenter.on(GameEvent.LocaleChanged, refresh);
80
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => EventCenter.off(GameEvent.LocaleChanged, refresh));
81
+ }
82
+ ```
83
+
84
+ Persist the chosen locale via SaveService and apply it on boot; default from
85
+ `navigator.language` when there is no saved choice.
86
+
87
+ ## BitmapText vs Text — the localization trade-off
88
+
89
+ The performance advice "use BitmapText for hot text" ([[phaser-gameobjects]])
90
+ collides with i18n: a bitmap font only contains the glyphs it was generated with.
91
+
92
+ - **BitmapText** — fastest, but the BMFont must include every glyph the locale
93
+ needs. Fine for digits/Latin HUD; generate per-script font pages
94
+ ([[phaser-asset-pipeline]]) if you ship CJK/Cyrillic/etc. via BitmapText.
95
+ - **Text** (canvas) — renders any glyph the loaded web font supports, handles
96
+ diacritics and combining marks, and is the safe choice for arbitrary
97
+ translated body text and user-generated content. Cost is rasterization per
98
+ change, so it is for static/low-churn strings.
99
+
100
+ Rule of thumb: BitmapText for high-churn numeric/short HUD with a covered glyph
101
+ set; Text for translated prose and any locale whose script the bitmap font does
102
+ not include. For RTL locales (Arabic/Hebrew), use canvas `Text` with
103
+ `rtl: true`/right alignment and lay out mirrored — BitmapText does not shape RTL.
104
+
105
+ ## Project conventions
106
+
107
+ - Every player-facing string is a `t(StringKey, …)` call — no inline literals in
108
+ scenes/entities (this mirrors the no-raw-string-keys discipline).
109
+ - The default locale defines the key type; other locales must satisfy it
110
+ (compile error on a missing key).
111
+ - Interpolation/plural logic lives in `src/logic/**`; the catalog and `t()` live
112
+ in `src/i18n/**`.
113
+ - Locale persists via SaveService and is announced through the live region for
114
+ screen-reader users ([[phaser-accessibility]]).
115
+
116
+ ## Verification
117
+
118
+ Verified by switching locale at runtime and confirming on-screen text updates
119
+ live (no reload), a missing key fails `bun run typecheck`, and pluralization unit
120
+ tests pass for n=0/1/many. For non-Latin locales, confirm glyphs render (no tofu
121
+ boxes) — that is the signal you need a per-script BMFont page or canvas Text.
122
+ </content>
@@ -0,0 +1,208 @@
1
+ ---
2
+ name: phaser-services
3
+ description: This skill should be used when wiring cross-cutting services and shared state in a Phaser 4 game — the typed registry wrapper for global state, a single dedicated EventsCenter bus (never game.events), the .on()/.off() listener discipline enforced in scene shutdown, SoundService (mobile audio unlock on first gesture), InputService (semantic actions instead of raw keys), and SaveService (versioned localStorage with a migration chain). Use it when adding global state, an event bus, audio/input/save plumbing, or fixing listener leaks and lost saves. Pairs with phaser-scenes, phaser-testing, and phaser-i18n.
4
+ ---
5
+
6
+ # Phaser 4 Services and Shared State
7
+
8
+ ## Overview
9
+
10
+ Cross-cutting concerns — global state, app events, sound, input, save — live in
11
+ `src/services/**` as thin, typed singletons, **not** scattered through scenes.
12
+ Two rules anchor everything: state flows through a typed registry wrapper, and
13
+ app messaging flows through **one dedicated EventsCenter** that is never
14
+ `game.events`. Direct `localStorage` access outside `src/services/**` is
15
+ lint-banned — persistence goes through SaveService.
16
+
17
+ ## Global state: a typed wrapper over the registry
18
+
19
+ `this.registry` is a game-wide key/value `DataManager` shared across scenes. Raw
20
+ access is stringly-typed and untestable, so wrap it once:
21
+
22
+ ```ts
23
+ // src/services/state.ts
24
+ interface GameState { coins: number; level: number; muted: boolean; seed: number; }
25
+ const DEFAULTS: GameState = { coins: 0, level: 1, muted: false, seed: 0 };
26
+
27
+ export class GameStore {
28
+ constructor(private reg: Phaser.Data.DataManager) {
29
+ for (const k in DEFAULTS) if (!reg.has(k)) reg.set(k, (DEFAULTS as any)[k]);
30
+ }
31
+ get<K extends keyof GameState>(k: K): GameState[K] { return this.reg.get(k); }
32
+ set<K extends keyof GameState>(k: K, v: GameState[K]) { this.reg.set(k, v); }
33
+ onChange<K extends keyof GameState>(k: K, fn: (v: GameState[K]) => void) {
34
+ const h = (_p: unknown, key: string, v: GameState[K]) => { if (key === k) fn(v); };
35
+ this.reg.events.on(Phaser.Data.Events.CHANGE_DATA, h);
36
+ return () => this.reg.events.off(Phaser.Data.Events.CHANGE_DATA, h); // caller .off()s in shutdown
37
+ }
38
+ }
39
+ ```
40
+
41
+ Construct it once (in Boot) and read it via the registry. Keys are typed by
42
+ `GameState`, so a typo is a compile error. The registry holds *small* shared
43
+ state (settings, run seed, currency) — not GameObjects and not per-frame data.
44
+
45
+ ## The EventsCenter bus (never `game.events`)
46
+
47
+ App-level messaging ("enemy-died", "score-changed", "level-cleared") uses a
48
+ single dedicated emitter. Reusing `game.events` (the engine's own bus) is
49
+ lint-banned — it mixes your events with engine lifecycle events and is
50
+ impossible to fully tear down.
51
+
52
+ ```ts
53
+ // src/services/event-center.ts
54
+ export const EventCenter = new Phaser.Events.EventEmitter();
55
+
56
+ // typed key constants come from src/assets.ts (generated) — no raw strings
57
+ import { GameEvent } from "../assets";
58
+ EventCenter.emit(GameEvent.ScoreChanged, score);
59
+ ```
60
+
61
+ ## Listener discipline: every `.on()` has a matching `.off()`
62
+
63
+ This is the leak rule the `require-shutdown-cleanup` lint rule and the testing
64
+ leak gate ([[phaser-testing]]) enforce. **Any** external listener — `EventCenter`,
65
+ `this.input`, `this.scale`, `this.time`, `window`, the registry — must be removed
66
+ in the scene's `shutdown`. Listeners on the scene's own display objects die with
67
+ the scene; external ones do not, and they fire again (doubled) after a restart.
68
+
69
+ ```ts
70
+ export class Game extends Phaser.Scene {
71
+ private cleanups: Array<() => void> = [];
72
+
73
+ create() {
74
+ const onResize = (s: Phaser.Structs.Size) => this.layout(s.width, s.height);
75
+ this.scale.on(Phaser.Scale.Events.RESIZE, onResize);
76
+ this.cleanups.push(() => this.scale.off(Phaser.Scale.Events.RESIZE, onResize));
77
+
78
+ const onScore = (n: number) => this.hud.setScore(n);
79
+ EventCenter.on(GameEvent.ScoreChanged, onScore);
80
+ this.cleanups.push(() => EventCenter.off(GameEvent.ScoreChanged, onScore));
81
+
82
+ this.events.once(Phaser.Scenes.Events.SHUTDOWN, this.shutdown, this);
83
+ }
84
+
85
+ shutdown() { this.cleanups.forEach(fn => fn()); this.cleanups.length = 0; }
86
+ }
87
+ ```
88
+
89
+ Keeping the matching `.off()` next to each `.on()` (a `cleanups` array, or named
90
+ handler methods you remove in `shutdown`) is the pattern that survives the leak
91
+ gate. Prefer `.once()` where a listener should fire only once.
92
+
93
+ ## SoundService — audio unlock on first gesture
94
+
95
+ Web Audio starts **suspended** until a user gesture; sound played before then is
96
+ silently dropped ("works on my machine, silent on the phone"). Centralize the
97
+ unlock and all playback:
98
+
99
+ ```ts
100
+ // src/services/sound-service.ts
101
+ export class SoundService {
102
+ private unlocked = false;
103
+ constructor(private sound: Phaser.Sound.BaseSoundManager, private store: GameStore) {}
104
+
105
+ attachUnlock(scene: Phaser.Scene) {
106
+ if (this.unlocked) return;
107
+ scene.input.once(Phaser.Input.Events.POINTER_DOWN, () => this.resume());
108
+ // Phaser also fires its own unlock; resume() is idempotent
109
+ this.sound.once(Phaser.Sound.Events.UNLOCKED, () => this.resume());
110
+ }
111
+ private resume() { if ((this.sound as any).context?.state === "suspended") (this.sound as any).context.resume(); this.unlocked = true; }
112
+ play(key: string, cfg?: Phaser.Types.Sound.SoundConfig) { if (!this.store.get("muted")) this.sound.play(key, cfg); }
113
+ }
114
+ ```
115
+
116
+ Scenes call `soundService.play(Sfx.Jump)` — never `this.sound.play("jump")`.
117
+ Sound keys are generated constants ([[phaser-asset-pipeline]]).
118
+
119
+ ## InputService — semantic actions, not raw keys
120
+
121
+ Scenes should ask "is *jump* down?", not "is the spacebar down?". A semantic
122
+ layer makes rebinding, gamepads, and touch swap in without touching game code,
123
+ and keeps replays deterministic (record actions, not hardware).
124
+
125
+ ```ts
126
+ // src/services/input-service.ts
127
+ export type Action = "left" | "right" | "jump" | "fire" | "pause";
128
+
129
+ export class InputService {
130
+ private down = new Set<Action>();
131
+ private bindings: Record<number, Action> = {};
132
+ constructor(kb: Phaser.Input.Keyboard.KeyboardPlugin) {
133
+ this.bindings[Phaser.Input.Keyboard.KeyCodes.LEFT] = "left";
134
+ this.bindings[Phaser.Input.Keyboard.KeyCodes.SPACE] = "jump";
135
+ kb.on("keydown", (e: KeyboardEvent) => { const a = this.bindings[e.keyCode]; if (a) this.down.add(a); });
136
+ kb.on("keyup", (e: KeyboardEvent) => { const a = this.bindings[e.keyCode]; if (a) this.down.delete(a); });
137
+ }
138
+ isDown(a: Action) { return this.down.has(a); }
139
+ snapshot(): readonly Action[] { return [...this.down]; } // for replay capture
140
+ }
141
+ ```
142
+
143
+ `update()` reads `input.isDown("jump")` and forwards the action set to pure logic
144
+ in `src/logic/**`. (Register/remove the keyboard listeners with the same on/off
145
+ discipline above.)
146
+
147
+ ## SaveService — versioned localStorage with a migration chain
148
+
149
+ Raw `localStorage` outside `src/services/**` is lint-banned. Saves carry a schema
150
+ `VERSION`; on load, unknown-but-older saves run forward through a migration chain,
151
+ so an old player's data is never silently lost or crash-loaded.
152
+
153
+ ```ts
154
+ // src/services/save-service.ts
155
+ const KEY = "save:v"; const VERSION = 3;
156
+ interface SaveV3 { version: 3; coins: number; unlocked: string[]; settings: { muted: boolean } }
157
+
158
+ const migrations: Record<number, (s: any) => any> = {
159
+ 1: s => ({ ...s, unlocked: [], version: 2 }), // v1 -> v2
160
+ 2: s => ({ ...s, settings: { muted: false }, version: 3 }), // v2 -> v3
161
+ };
162
+
163
+ export class SaveService {
164
+ load(): SaveV3 {
165
+ const raw = localStorage.getItem(KEY); // only legal localStorage access lives here
166
+ if (!raw) return this.fresh();
167
+ try {
168
+ let s = JSON.parse(raw);
169
+ while (s.version < VERSION) s = migrations[s.version](s);
170
+ return s as SaveV3;
171
+ } catch { return this.fresh(); } // corrupt save -> fresh, never crash
172
+ }
173
+ save(s: SaveV3) { localStorage.setItem(KEY, JSON.stringify({ ...s, version: VERSION })); }
174
+ private fresh(): SaveV3 { return { version: VERSION, coins: 0, unlocked: [], settings: { muted: false } }; }
175
+ }
176
+ ```
177
+
178
+ Bump `VERSION` and add the `n -> n+1` migration whenever the shape changes; never
179
+ mutate the read path to "just handle" an old shape inline.
180
+
181
+ ## Errors and telemetry
182
+
183
+ A vendor-neutral telemetry/analytics abstraction (`src/services/telemetry.ts`)
184
+ exposes `track(event, props)` and `captureError(err, ctx)` behind a stable
185
+ interface so the concrete sink (PostHog, Sentry, none) swaps without touching
186
+ game code. Wire Phaser's error surfaces to it — `window.onerror`, the loader's
187
+ `FILE_LOAD_ERROR`, and `try/catch` around scene transitions all route to
188
+ `captureError`. Strings shown to players come from the i18n catalog
189
+ ([[phaser-i18n]]), never hardcoded.
190
+
191
+ ## Project conventions
192
+
193
+ - Services live in `src/services/**`; that directory is the **only** place raw
194
+ `localStorage` is permitted.
195
+ - One EventsCenter instance, exported once; never `game.events` for app events.
196
+ - Every external `.on()` is paired with an `.off()` removed in `shutdown` (lint:
197
+ `require-shutdown-cleanup`; verified by the leak gate in [[phaser-testing]]).
198
+ - Service logic that is pure (migrations, action mapping, mute gating) belongs in
199
+ or beside `src/logic/**` so Vitest covers it.
200
+
201
+ ## Verification
202
+
203
+ Services are verified by the runtime gates: the leak gate ([[phaser-testing]])
204
+ proves listeners are removed (start/stop a scene N times, counts return to
205
+ baseline); a SaveService unit test feeds each old version through the migration
206
+ chain and asserts the current shape; and on a real phone, the first tap unlocks
207
+ audio (sound plays) — confirm before committing.
208
+ </content>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: phaser-testing
3
- description: This skill should be used when writing or designing tests for a Phaser 4 game — unit-testing pure game logic with Vitest, keeping logic Phaser-free so it tests without a browser, the Phaser.HEADLESS renderer for logic-only boots, asset-manifest coverage tests, and Playwright smoke tests that prove the game actually boots and renders. Use it when adding tests, setting up CI verification, or deciding what is testable at which level. Pairs with phaser-project-structure, phaser-assets, and phaser-physics.
3
+ description: This skill should be used when writing or designing tests for a Phaser 4 game — unit-testing pure game logic with Vitest, keeping logic Phaser-free so it tests without a browser, the Phaser.HEADLESS renderer for logic-only boots, asset-manifest coverage tests, and the CI runtime gates (boot smoke, allocation/perf budget, leak gate, determinism gate, deterministic Playwright visual regression, bundle-size budget) that verify scenes and entities that are excluded from unit coverage. Use it when adding tests, setting up CI verification, or deciding what is testable at which level. Pairs with phaser-project-structure, phaser-assets, and phaser-services.
4
4
  ---
5
5
 
6
6
  # Phaser 4 Testing
@@ -13,10 +13,17 @@ pyramid, bottom-up:
13
13
  1. **Vitest unit tests** over `src/logic/**` — the bulk of coverage.
14
14
  2. **Manifest/contract tests** — cheap structural checks (asset packs, scene
15
15
  keys, anim definitions).
16
- 3. **Playwright smoke test** — the game boots in a real browser, renders past
17
- the Preloader, no console errors.
18
-
19
- There is no official Phaser testing harness — this layering IS the strategy.
16
+ 3. **Headless integration** (sparingly) `Phaser.HEADLESS` for things that
17
+ genuinely need the engine loop.
18
+ 4. **Playwright smoke** — the game boots in a real browser, renders past the
19
+ Preloader, no console errors.
20
+ 5. **CI runtime gates** — scenes/entities are excluded from unit coverage and
21
+ instead verified by deterministic runtime gates: boot smoke, allocation/perf
22
+ budget, leak gate, determinism gate, visual regression, bundle-size budget.
23
+
24
+ There is no official Phaser testing harness — this layering IS the strategy. The
25
+ dividing line: pure logic is unit-tested; everything that needs the engine is a
26
+ runtime gate, never a brittle unit test that mocks the world.
20
27
 
21
28
  ## Layer 1: pure logic under Vitest (the rule that makes it possible)
22
29
 
@@ -85,15 +92,101 @@ Convention: scenes set `window.__sceneReady = key` in `create()` (dev/test
85
92
  builds) so tests await real readiness instead of sleeping. Run against
86
93
  `bun run dev` (or `vite preview` in CI).
87
94
 
95
+ ## Layer 5: CI runtime gates (how scenes get verified)
96
+
97
+ Scenes and entities are deliberately **excluded from unit coverage** — mocking
98
+ the engine to "unit test" a scene tests the mock. Instead, CI runs a set of
99
+ deterministic gates against a real (or headless) game. Each gate fails the build
100
+ on regression; together they are the contract that the engine-coupled layer
101
+ keeps working.
102
+
103
+ **Boot smoke** — the Playwright test above, hardened: page error listener, wait
104
+ for `__sceneReady`, assert zero console errors. This is the floor.
105
+
106
+ **Allocation / perf budget** — run the game for N frames and assert the frame
107
+ budget and heap growth. Per-frame allocation is what the `no-allocation-in-update`
108
+ and `no-create-in-update` lint rules forbid statically; this gate catches what
109
+ slips through dynamically.
110
+
111
+ ```ts
112
+ test("steady-state frames stay within budget", async ({ page }) => {
113
+ await page.goto("/"); await bootTo(page, "Game");
114
+ const stats = await page.evaluate(async () => {
115
+ const g = (window as any).__game as Phaser.Game;
116
+ const frames: number[] = []; let last = performance.now();
117
+ for (let i = 0; i < 600; i++) { await new Promise(r => g.events.once("postrender", r)); const n = performance.now(); frames.push(n - last); last = n; }
118
+ return { p95: frames.sort((a,b)=>a-b)[Math.floor(frames.length*0.95)], heap: (performance as any).memory?.usedJSHeapSize };
119
+ });
120
+ expect(stats.p95).toBeLessThan(20); // ~50fps floor on CI hardware
121
+ });
122
+ ```
123
+
124
+ **Leak gate** — start and stop a scene N times and assert that texture count,
125
+ event-listener count, active tweens, and timers all return to baseline. This is
126
+ the runtime enforcement of the `require-shutdown-cleanup` rule and the
127
+ on/off-discipline in [[phaser-services]].
128
+
129
+ ```ts
130
+ test("scene start/stop leaves no leaks", async ({ page }) => {
131
+ await page.goto("/"); await bootTo(page, "MainMenu");
132
+ const before = await page.evaluate(() => (window as any).__game.textures.getTextureKeys().length);
133
+ for (let i = 0; i < 20; i++) await page.evaluate(async () => {
134
+ const g = (window as any).__game as Phaser.Game; g.scene.start("Game");
135
+ await new Promise(r => setTimeout(r, 50)); g.scene.stop("Game");
136
+ await new Promise(r => setTimeout(r, 50));
137
+ });
138
+ const after = await page.evaluate(() => (window as any).__game.textures.getTextureKeys().length);
139
+ expect(after).toBeLessThanOrEqual(before); // no per-cycle texture growth
140
+ });
141
+ ```
142
+
143
+ **Determinism gate** — boot twice with the same seed, drive the same inputs, and
144
+ assert an identical state hash. Logic exposes a serializable snapshot; the gate
145
+ hashes it. Same seed → same hash, always. This is the runtime backstop for the
146
+ no-`Math.random()`/`Date.now()`/`performance.now()` rules ([[phaser-services]]).
147
+
148
+ ```ts
149
+ const run = (seed: number) => page.evaluate(s => (window as any).__sim(s, REPLAY), seed);
150
+ expect(hash(await run(1234))).toBe(hash(await run(1234)));
151
+ ```
152
+
153
+ **Deterministic visual regression** — `toHaveScreenshot` under **software GL**
154
+ (`--use-gl=swiftshader`), a **frozen frame** (pause the loop / step a fixed
155
+ number of ticks at a fixed delta), and **masked dynamic regions** (timers,
156
+ particles). Without all three, screenshots flake. Pin Playwright's browser and
157
+ OS in CI so the baseline is stable.
158
+
159
+ ```ts
160
+ await page.evaluate(() => { const g=(window as any).__game; g.loop.sleep(); g.step(0,16.6); });
161
+ await expect(page).toHaveScreenshot("game.png", { mask: [page.locator("#timer")], maxDiffPixelRatio: 0.01 });
162
+ ```
163
+
164
+ **Bundle-size budget** — assert the built bundle stays under a byte budget so a
165
+ stray dependency or an un-split `phaser` chunk fails the PR. Wire it to the prod
166
+ build's `manualChunks` split ([[phaser-build-deploy]]).
167
+
168
+ ```jsonc
169
+ // size-limit / custom check after `bun run build`
170
+ [{ "path": "dist/assets/index-*.js", "limit": "60 kB" },
171
+ { "path": "dist/assets/phaser-*.js", "limit": "400 kB" }]
172
+ ```
173
+
88
174
  ## Project conventions
89
175
 
90
- - `bun run test` = Vitest (layers 1–2, coverage-gated). Playwright smoke runs as
91
- its own script/CI job against a built preview.
176
+ - `bun run test` = Vitest (layers 1–2, coverage-gated **over `src/logic/**`
177
+ only** scenes/entities are out of scope here). The layer-5 gates run as their
178
+ own CI jobs against a built `vite preview` (and a headless boot for the
179
+ determinism gate).
180
+ - Build pins are part of the contract: `phaser ^4.2.0`, Vite, TypeScript 6.
92
181
  - Tests never assert on private scene fields; they assert on logic outputs
93
- (layer 1) or observable behavior (layer 4).
182
+ (layer 1), observable behavior (layer 4), or the runtime invariants the gates
183
+ measure (layer 5).
94
184
 
95
185
  ## Verification
96
186
 
97
187
  The testing setup itself is verified when `bun run test` passes with coverage
98
- over `src/logic/**`, and the Playwright smoke fails when you deliberately break
99
- boot (rename a pack file) a smoke test that can't fail is decoration.
188
+ over `src/logic/**`, the Playwright smoke fails when you deliberately break boot
189
+ (rename a pack file), and each layer-5 gate fails on its targeted regression —
190
+ leak gate when a listener loses its `.off()`, determinism gate when a
191
+ `Math.random()` sneaks in, visual gate when the frame changes. A gate that
192
+ can't fail is decoration.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "2.178.5",
3
+ "version": "2.179.0",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -5,60 +5,127 @@ alwaysApply: true
5
5
 
6
6
  # Phaser 4 Project Rules
7
7
 
8
- This is a **Phaser 4** (v4.1+ "Salusa") TypeScript game project. Phaser 4 is the
9
- only supported target never introduce Phaser 3 idioms. The lint config enforces
10
- the bans below; do not disable those rules, fix the code.
11
-
12
- ## Phaser 4 Only — Banned v3 Idioms
13
-
14
- - **No pipelines.** `setPipeline` / `setPostPipeline` / `resetPipeline` are gone.
15
- Custom rendering is a **RenderNode** (registered via `render.renderNodes` in the
16
- game config); post-processing is a **Filter** (`gameObject.filters` /
17
- `camera.filters`). See the `phaser-rendering` skill.
18
- - **No preFX/postFX.** The v3 FX controllers were replaced by the unified Filter
19
- system. `BitmapMask` `Mask` filter.
20
- - **No `setTintFill`/`tintFill`.** Use `setTint()` + `setTintMode(Phaser.TintModes.FILL)`.
21
- - **No `Phaser.Geom.Point`** (removed use `Phaser.Math.Vector2`) and **no
22
- `Phaser.Struct.Set/Map`** (removed use native `Set`/`Map`).
23
- - **No raw WebGL calls.** External GL work goes through the `Extern` game object.
24
- - **No `setPipeline('Light2D')`.** Lighting is `gameObject.setLighting(true)`.
8
+ This is a **Phaser 4** (v4.2+, npm `phaser@^4.2.0`) TypeScript game project built
9
+ on the official `template-vite-ts` layout (Vite + TypeScript 6). Phaser 4 is the
10
+ only supported target never introduce Phaser 3 idioms. These decisions are
11
+ **locked** and most are **lint-enforced**; do not disable a rule, fix the code.
12
+
13
+ ## Locked architecture decisions
14
+
15
+ 1. **Thin scenes over a pure-logic core.** Scenes and GameObjects are thin
16
+ adapters that wire pure logic to the engine. All game rules live in
17
+ `src/logic/**` as plain TypeScript with **zero `phaser` imports** (data in,
18
+ data out). This is what makes the game unit-testable. ECS (bitECS / Miniplex)
19
+ is an opt-in option, not the default — reach for it only at high entity
20
+ counts. See `phaser-project-structure` and `phaser-testing`.
21
+ 2. **Canvas-only, framework-agnostic UI.** UI is plain TS + DOM/canvas (the
22
+ `template-vite-ts` baseline). A React/Vue EventBus bridge is documented only
23
+ as an upgrade path it is not the default and is not added unless required.
24
+ 3. **Arcade physics by default; Matter is opt-in.** Matter is heavier and does
25
+ not interop with Arcade. Switching is a one-line config swap. See
26
+ `phaser-physics`.
27
+ 4. **Determinism.** No `Math.random()`, `Date.now()`, or `performance.now()` in
28
+ game code. Use the seeded `Phaser.Math.RandomDataGenerator` (`Phaser.Math.RND`
29
+ or a local instance seeded from one place) so replays and tests reproduce.
30
+ 5. **State and events.** Global state goes through a **typed wrapper over the
31
+ registry**; cross-cutting events go through a **single dedicated
32
+ EventsCenter** (`new Phaser.Events.EventEmitter()`) — **never reuse
33
+ `game.events`**. Every external `.on()` (`this.input` / `scale` / `time` /
34
+ `window` / the custom bus) must have a matching `.off()` in the scene's
35
+ `shutdown`. See `phaser-services`.
36
+ 6. **Performance.** Pool transient objects via `Group`s; make **no allocations
37
+ and create no objects/tweens/timers inside `update()`**; atlas everything; use
38
+ `BitmapText` for high-churn text; use `SpriteGPULayer` / `TilemapGPULayer` for
39
+ mass rendering. See `phaser-gameobjects`.
40
+ 7. **Baked-in asset pipeline.** Raw art in `assets/src` is packed at build time
41
+ (free-tex-packer-core atlases, audiosprite, BMFont) into `public/assets`, and
42
+ a codegen step emits typed keys to `src/assets.ts` so a missing or renamed key
43
+ is a **compile error**. No raw string asset / scene / event keys anywhere.
44
+ See `phaser-asset-pipeline`.
45
+ 8. **PWA on by default** via `vite-plugin-pwa` (`registerType: "autoUpdate"`,
46
+ `globPatterns` extended to cover game assets). See `phaser-build-deploy`.
47
+ 9. **Cross-cutting services are scaffolded** in `src/services/**`: SoundService
48
+ (mobile audio unlock on first gesture), InputService (semantic actions, not
49
+ raw keys), SaveService (typed `localStorage` with a schema `VERSION` +
50
+ migration chain — never raw `localStorage`), a typed i18n string catalog (no
51
+ hardcoded user-facing strings), accessibility (prefers-reduced-motion,
52
+ pause-on-blur, keyboard-navigable menus, screen-reader live region), and a
53
+ vendor-neutral telemetry/analytics abstraction. In-game errors are wired to
54
+ the observability sink. See `phaser-services`, `phaser-i18n`,
55
+ `phaser-accessibility`.
56
+ 10. **Testing is a pyramid.** Pure logic is unit-tested with Vitest;
57
+ scenes/entities are excluded from unit coverage and verified by **runtime
58
+ gates** in CI (boot smoke, allocation/perf budget, leak gate, determinism
59
+ gate, deterministic visual regression, bundle-size budget). See
60
+ `phaser-testing`. Build pins: `phaser ^4.2.0`, Vite, TypeScript 6.
61
+
62
+ ## Enforced lint rules (prose must match machine enforcement)
63
+
64
+ The lint config enforces the rules below. Each exists because the prose above
65
+ relies on it — keep them in sync.
66
+
67
+ **`no-restricted-imports`**
68
+
69
+ - Importing `phaser` anywhere under `src/logic/**` is banned. The logic core
70
+ stays engine-free so Vitest runs it without a browser.
71
+
72
+ **`no-restricted-syntax`** bans:
73
+
74
+ - Removed v3 idioms: `setPipeline` / `setPostPipeline` / `resetPipeline`,
75
+ `setTintFill`, `preFX` / `postFX`, `Phaser.Geom.Point`, `Phaser.Struct`,
76
+ `BitmapMask`, `Mesh`, `Plane`, `Camera3D`. Use the v4 replacements
77
+ (RenderNodes, Filters, `Phaser.Math.Vector2`, native `Set`/`Map`, GPU layers).
78
+ - `Math.random()` / `Date.now()` / `performance.now()` in `src` game code — use
79
+ the seeded RND and the scene clock (`this.time.now`).
80
+ - Reusing `game.events` for app messaging — use the dedicated EventsCenter.
81
+ - `debug: true` inside a physics/game config object literal — debug stays off in
82
+ committed code (toggle locally, never commit it on).
83
+
84
+ **Direct-`localStorage` ban**
85
+
86
+ - Direct `localStorage` access outside `src/services/**` is banned. Persist
87
+ through SaveService (versioned + migrated).
88
+
89
+ **Custom `eslint-plugin-phaser` rules**
90
+
91
+ - `no-create-in-update` — no `this.add.*`, `tweens.add`, `time.addEvent`,
92
+ `physics.add.*`, or `new Phaser.*` inside an `update()` method. Create in
93
+ `create()`; in the loop, reuse and pool.
94
+ - `no-allocation-in-update` — no object/array literals, closures, or
95
+ `.map`/`.filter`/`.reduce` chains inside `update()`. Hoist scratch objects.
96
+ - `require-shutdown-cleanup` — a `Scene` that registers persistent external
97
+ listeners must define a `shutdown` handler that removes them.
25
98
 
26
99
  ## Architecture
27
100
 
28
101
  - **One scene class per file** under `src/scenes/`, named after the scene key.
29
102
  The scene flow is `Boot → Preloader → MainMenu → Game` (plus overlays). Boot
30
103
  loads only what the Preloader needs; the Preloader loads everything else.
31
- - **Pure game logic lives outside scenes** in `src/logic/` (or `src/core/`) with
32
- **no `phaser` imports** plain TypeScript functions and classes that take and
33
- return data. Scenes are thin orchestrators that wire logic to GameObjects.
34
- This is what makes the game unit-testable; see the `phaser-testing` skill.
35
- - **Asset keys are typed constants** in `src/assets.ts` never inline magic
36
- strings for texture/audio/animation keys. Load via asset-pack manifests
37
- (`this.load.pack`), not ad-hoc `load.image` lists scattered across scenes.
38
-
39
- ## Determinism
40
-
41
- - **No `Math.random()` in game code.** Use the scene-seeded
42
- `Phaser.Math.RandomDataGenerator` (`Phaser.Math.RND` or a local instance with
43
- an explicit seed) so replays and tests are reproducible.
44
- - Arcade physics keeps its v4 default `fixedStep: true`. Do not switch physics to
45
- variable step to "fix" a tunneling bug — fix the body/velocity configuration.
104
+ - **Pure game logic lives in `src/logic/**`** with no `phaser` imports — plain
105
+ TypeScript that takes and returns data. Scenes/entities are thin orchestrators
106
+ that wire that logic to GameObjects. See `phaser-testing`.
107
+ - **Keys are typed constants** generated into `src/assets.ts` — never inline
108
+ magic strings for texture/audio/animation/scene/event keys. Load via asset-pack
109
+ manifests (`this.load.pack`), not ad-hoc `load.image` lists.
46
110
 
47
111
  ## Performance
48
112
 
49
- - **No allocations in `update()`.** No `new`, array literals, closures, or
50
- `.filter/.map` chains in per-frame paths — hoist scratch objects, reuse vectors.
113
+ - **No allocations in `update()`** and **no object/tween/timer creation in
114
+ `update()`** (both lint-enforced) — hoist scratch objects, reuse vectors.
51
115
  - **Pool, don't churn.** Bullets, enemies, particles, and pickups come from
52
- `Group` pools (`get`/`killAndHide`), never `new`/`destroy` per spawn.
53
- - **Mass rendering uses the GPU layers.** Large static/animated sprite fields →
54
- `SpriteGPULayer`; large tile maps → `TilemapGPULayer`. Texture atlases (PCT
55
- where possible) over loose images — see the `phaser-assets` skill.
56
- - Target WebGL; the Canvas renderer is deprecated in v4 and only acceptable as an
57
- explicit, documented fallback decision.
116
+ `Group` pools (`get` / `killAndHide`), never `new` / `destroy` per spawn.
117
+ - **Mass rendering uses the GPU layers.** Large sprite fields → `SpriteGPULayer`;
118
+ large tile maps → `TilemapGPULayer`. Atlas everything (see
119
+ `phaser-asset-pipeline`); `BitmapText` for high-churn text.
120
+ - Target WebGL; the Canvas renderer is deprecated in v4 and acceptable only as an
121
+ explicit, documented fallback.
58
122
 
59
123
  ## Verification
60
124
 
61
- Before reporting any change complete: run `bun run typecheck`, `bun run test`,
62
- and `bun run build`. For changes that affect rendering or input, verify in the
63
- real browser — `bun run dev` plus a Playwright check (the game boots, the canvas
64
- renders, no console errors). A green typecheck alone is not proof a game works.
125
+ Before reporting any change complete: run `bun run typecheck`, `bun run lint`,
126
+ `bun run test`, and `bun run build`. For changes that affect rendering or input,
127
+ verify in the real browser — `bun run dev` plus a Playwright check (the game
128
+ boots, the canvas renders, no console errors). CI additionally runs the runtime
129
+ gates (`phaser-testing`). A green typecheck alone is not proof a game works.
130
+ </content>
131
+ </invoke>