Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/arch-invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ jobs:
echo "::error::Storage access found in src/domain/ — use ISaveGamePort."
exit 1
fi
- name: no ignoreDestroy in runtime/
run: |
if grep -rnE "ignoreDestroy[[:space:]]*=[[:space:]]*true" src/runtime src/app 2>/dev/null; then
echo "::error::ignoreDestroy = true found — Scene/Group destroy will skip the object. (tsforge/no-ignore-destroy)"
exit 1
fi
- name: no runtime imports in domain/
run: |
if grep -rnE "from ['\"]@runtime|from ['\"]\.+/runtime" src/domain 2>/dev/null; then
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ Must pass. Fix root causes; do not skip hooks.

[tsforge](https://tsforge.dev) is the org TypeScript harness. Point it at a fork of this repo; the gate is `bun run check`.

A Phaser stack adapter (planner schema, conventions, greenfield clone, **Phaser rule pack**) is **planned in tsforge, not shipped**. That pack is what will make this template as enforcement-first as BoringStack: gate + rule-docs, not prose. Until it exists, treat this tree as generic TypeScript and trust `bun run check`. Do **not** add `.tsforge/scaffold-manifest.json` — that file is how tsforge detects the fullstack BoringStack template.
The **`phaser` rule pack** auto-applies when `phaser` is in package.json: scene SHUTDOWN ownership, no global emitter leaks, no Phaser factories in `update`/`tick`, branded scene/texture keys, no `ignoreDestroy`. This repo's `eslint.config.js` covers a syntactic subset of that pack so `bun run check` stays honest without depending on unpublished tsforge.

A Phaser **stack adapter** (planner schema, conventions, greenfield clone) is still planned, not shipped. Do **not** add `.tsforge/scaffold-manifest.json` — that file is how tsforge detects the fullstack BoringStack template.

## Deviations

Expand Down
4 changes: 2 additions & 2 deletions BUILD_THE_GAME.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,9 @@ Two faster inner loops:
tsforge
```

Point it at this tree. The gate it should run is `bun run check`.
Point it at this tree. The gate it should run is `bun run check`. The **`phaser` rule pack** auto-applies from the `phaser` dependency (scene shutdown, no factories in `update`, branded keys). This template's ESLint already covers a syntactic subset of that pack.

A dedicated Phaser adapter (greenfield clone, planner schema, Phaser conventions instead of React/Elysia) is planned in tsforge and **not shipped**. Until it lands, tsforge treats this repo as generic TypeScript. Do not add `.tsforge/scaffold-manifest.json` here — that file is how tsforge detects the fullstack BoringStack template.
A dedicated Phaser **adapter** (greenfield clone, planner schema, Phaser conventions instead of React/Elysia) is planned in tsforge and **not shipped**. Do not add `.tsforge/scaffold-manifest.json` here — that file is how tsforge detects the fullstack BoringStack template.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Arrow keys or WASD to move. Walk onto a yellow circle to score. Press **S** to s

[tsforge](https://tsforge.dev) is the BoringStack TypeScript build harness. Point it at a fork of this template; the gate is `bun run check`.

A dedicated Phaser stack adapter (planner schema, conventions, greenfield clone) is planned in tsforge and **not shipped yet**. Until it lands, tsforge treats this tree as generic TypeScript — it will not inject React/Elysia conventions if no BoringStack scaffold receipt is present. Do not add a `.tsforge/scaffold-manifest.json` here; that file is how tsforge detects the fullstack template.
The **`phaser` rule pack** auto-applies from the `phaser` dependency (scene shutdown, no factories in `update`, branded keys). A dedicated Phaser **stack adapter** (planner schema, conventions, greenfield clone) is planned and **not shipped yet**. Do not add a `.tsforge/scaffold-manifest.json` here; that file is how tsforge detects the fullstack template.

## Architecture in 30 seconds

Expand Down
4 changes: 4 additions & 0 deletions docs/ai/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Violations are lint errors (eslint-plugin-boundaries) and dep-cruiser errors in
- **Domain is pure.** No `Math.random`, no `Date.now`, no `window`, no `localStorage` — inject via ports.
- **Content is schema-validated at import time.** A malformed JSON file breaks the build.
- **Named exports only** (default exports allowed only in `main.ts` and config files).
- **Scene keys are branded constants** (`asSceneKey` in `src/runtime/phaser/scenes/sceneKeys.ts`), never string literals in `scene.start` / `super()`.
- **Scenes hook `Phaser.Scenes.Events.SHUTDOWN`** and dispose run-lifetime resources there. Do not set `ignoreDestroy`. Do not construct GameObjects in `update()`.

tsforge's `phaser` pack enforces the engine-API subset of these rules when pointed at a fork.

## When the rules feel wrong

Expand Down
2 changes: 1 addition & 1 deletion docs/ai/scene-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ bun run new:scene <Name>
└── index.ts
```

The `.ts` class holds only lifecycle hooks. The `.setup.ts` does the wiring and returns a runtime object the class calls in `update(time, delta)`.
The `.ts` class holds only lifecycle hooks. The `.setup.ts` does the wiring and returns a runtime object the class calls in `update(time, delta)`. Scene keys go through `asSceneKey` (never a string literal in `super()` / `scene.start`). Dispose run-lifetime resources on `Phaser.Scenes.Events.SHUTDOWN`. Do not construct GameObjects in `update()`.

## Example

Expand Down
28 changes: 28 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,38 @@ export default tseslint.config(
},

// Runtime and app: allowed to import phaser.
// Syntactic subset of the tsforge `phaser` pack. The full pack applies when
// tsforge runs against this tree (detected from the `phaser` dependency).
{
files: ['src/runtime/**/*.ts', 'src/app/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'no-restricted-syntax': [
'error',
{
selector: "AssignmentExpression[left.property.name='ignoreDestroy'][right.value=true]",
message:
'Do not set ignoreDestroy. Keep cross-scene objects in a game-lifetime service, or pool them. (tsforge/no-ignore-destroy)',
},
{
selector:
"CallExpression[callee.object.property.name='scene'][callee.property.name=/^(start|launch|stop|pause|resume|sleep|wake|switch|run|remove)$/][arguments.0.type='Literal']",
message:
'Pass a named scene-key constant, not a string literal. (tsforge/no-raw-scene-key-literal)',
},
{
selector:
"CallExpression[callee.object.name='window'][callee.property.name='addEventListener']",
message:
'Do not attach window listeners from a Scene; bind game-lifetime listeners in app bootstrap. (tsforge/no-unmanaged-global-listeners)',
},
{
selector:
'CallExpression[callee.name=/^(setInterval|setTimeout|requestAnimationFrame)$/]',
message:
'Do not use raw timers in Phaser runtime; scene.time is shutdown-owned. (tsforge/no-unmanaged-global-listeners)',
},
],
},
},

Expand Down
8 changes: 5 additions & 3 deletions scripts/new-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ const base = resolve(process.cwd(), 'src', 'runtime', 'phaser', 'scenes', Name);
const key = Name.replace(/Scene$/, '');

const files: Record<string, string> = {
[`${base}/${Name}.constants.ts`]: `export const ${key.toUpperCase()}_SCENE_KEY = '${key}';
[`${base}/${Name}.constants.ts`]: `import { asSceneKey, type SceneKey } from '../sceneKeys.js';

export const ${key.toUpperCase()}_SCENE_KEY: SceneKey = asSceneKey('${key}');
`,
[`${base}/${Name}.setup.ts`]: `import type Phaser from 'phaser';
[`${base}/${Name}.setup.ts`]: `import type * as Phaser from 'phaser';

export interface I${Name}Runtime {
update: (deltaMs: number) => void;
Expand All @@ -27,7 +29,7 @@ export const setup${Name} = (scene: Phaser.Scene): I${Name}Runtime => {
};
};
`,
[`${base}/${Name}.ts`]: `import Phaser from 'phaser';
[`${base}/${Name}.ts`]: `import * as Phaser from 'phaser';

import { ${key.toUpperCase()}_SCENE_KEY } from './${Name}.constants.js';
import { setup${Name}, type I${Name}Runtime } from './${Name}.setup.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const BOOT_SCENE_KEY = 'Boot';
import { asSceneKey, type SceneKey } from '../sceneKeys.js';

export const BOOT_SCENE_KEY: SceneKey = asSceneKey('Boot');
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export const WORLD_SCENE_KEY = 'World';
import { asSceneKey, type SceneKey } from '../sceneKeys.js';

export const WORLD_SCENE_KEY: SceneKey = asSceneKey('World');
8 changes: 6 additions & 2 deletions src/runtime/phaser/scenes/WorldScene/WorldScene.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ export const setupWorldScene = async (ctx: SetupContext): Promise<IWorldSceneRun

const keyboard = ctx.scene.input.keyboard;
// Keyboard is optional (touch-only / headless). Movement already no-ops via the input port.
keyboard?.addKey('S').on('down', () => events.emit('saveGame.requested', {}));
keyboard?.addKey('R').on('down', () => {
const saveKey = keyboard?.addKey('S');
const resetKey = keyboard?.addKey('R');
saveKey?.on('down', () => events.emit('saveGame.requested', {}));
resetKey?.on('down', () => {
state = ctx.deps.createState();
playerEntity.render(state.player);
wallLayer.redraw(state.grid);
Expand Down Expand Up @@ -124,6 +126,8 @@ export const setupWorldScene = async (ctx: SetupContext): Promise<IWorldSceneRun
hud.dispose();
save.dispose();
input.destroy();
keyboard?.removeKey('S');
keyboard?.removeKey('R');
events.clear();
wallLayer.destroy();
playerEntity.destroy();
Expand Down
5 changes: 5 additions & 0 deletions src/runtime/phaser/scenes/sceneKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { Brand } from '@shared/types';

export type SceneKey = Brand<string, 'SceneKey'>;

export const asSceneKey = (value: string): SceneKey => value as SceneKey;