[game-studio] add spec-driven prototype loop

This commit is contained in:
Brian Brunner
2026-07-14 17:40:38 +00:00
parent 11c74d6ba2
commit 2264dac251
30 changed files with 1198 additions and 204 deletions
@@ -1,7 +1,7 @@
{
"name": "game-studio",
"version": "0.1.2",
"description": "Design, prototype, and ship browser games with guided 2D and 3D workflows, asset pipelines, and playtesting support.",
"version": "0.2.0",
"description": "Build browser-game vertical slices through a frozen spec, stable starter, deterministic evaluation, refinement, and publishing handoff.",
"author": {
"name": "OpenAI",
"email": "support@openai.com",
@@ -19,13 +19,15 @@
"rapier",
"webgl",
"sprites",
"playtest"
"playtest",
"playwright",
"game-spec"
],
"skills": "./skills/",
"interface": {
"displayName": "Game Studio",
"shortDescription": "Design, prototype, and ship browser games",
"longDescription": "Plan, prototype, and build browser games with guided workflows for gameplay systems, UI, asset pipelines, and playtesting across 2D and 3D projects.",
"shortDescription": "Build and evaluate playable browser-game slices",
"longDescription": "Move from a browser-game brief to a frozen GameSpec, stable Phaser starter, real UI playtest, deterministic screenshot and state evaluation, refinement, and Sites publishing handoff.",
"developerName": "OpenAI",
"category": "Developer Tools",
"capabilities": [
@@ -36,7 +38,7 @@
"privacyPolicyURL": "https://openai.com/policies/privacy-policy/",
"termsOfServiceURL": "https://openai.com/policies/terms-of-use/",
"defaultPrompt": [
"Design a browser game and plan the core loop"
"Turn my browser-game idea into a tested playable vertical slice"
],
"brandColor": "#0F766E",
"composerIcon": "./assets/game-studio.svg",
@@ -0,0 +1,3 @@
# Prompt
Build a one-minute browser game where a courier moves around a neon grid and routes three signals. Keep it keyboard-first, make the current objective obvious, and stop at one polished vertical slice that can be evaluated deterministically.
@@ -0,0 +1,28 @@
# Neon Relay: prompt to playable slice
This worked example is implemented by the copyable starter at `../../skills/phaser-2d-game/assets/phaser-2d-starter/`.
## 1. Brief
The prompt in `prompt.md` becomes one 60-second loop: locate a signal beacon, move onto it, route it, and repeat until three signals are complete. Levels, enemies, audio, persistence, and production art are excluded.
## 2. Frozen contract
The starter's `game-spec.json` freezes seed `5601`, six semantic action IDs, the `ready` / `playing` / `won` states, and three required screenshots. Runtime code and `eval/scenarios.json` consume those IDs unchanged.
## 3. Stable implementation
The starter keeps rules in `src/state.ts`, rendering in `src/game.ts`, physical controls in `src/actions.ts`, and the DOM HUD plus bridge wiring in `src/main.ts`. Both keyboard input and Playwright dispatch the same actions. The visible state badge and JSON snapshot update from the same state object.
## 4. Play and evaluate
The real-input path is: move with WASD or arrows, stand on the gold beacon, press Space, and repeat. The starter's `eval/scenarios.json` encodes the same seeded route without arbitrary sleeps. The game-eval runner captures:
- `route-three-signals--ready.png` and `.json`;
- `route-three-signals--playing.png` and `.json`;
- `route-three-signals--won.png` and `.json`;
- console, page, failed-request, and HTTP error evidence.
## 5. Refine and publish handoff
Review the three screenshot/state pairs against the rubric, fix the smallest visible issue set, and replay all required states. A passing production build can then be packaged by `game-publish-sites`; the workflow stops at a Sites handoff unless the user explicitly authorizes publishing.
@@ -0,0 +1,52 @@
---
name: game-eval
description: Evaluate a browser-game vertical slice through deterministic Playwright scenarios, named screenshot states, console and network error capture, machine-readable state snapshots, and a concise playability rubric. Use when implementing or refining a browser game, checking a frozen GameSpec, or producing repeatable playtest evidence.
---
# Game Eval
Exercise the game through semantic actions and observable state. Treat screenshots and browser state as complementary evidence: neither alone proves a playable loop.
## Preconditions
- Read the frozen `game-spec.json` and its required screenshot state IDs.
- Confirm the game exposes the named bridge from GameSpec. The default is `window.__GAME_STUDIO__` with `ready`, `reset(seed)`, `dispatch(action)`, `step(frames)`, and `getState()`.
- Serve a production-like build at a stable local URL.
- Read `references/scenario-contract.md` before authoring or changing scenarios.
## Deterministic Run
1. Copy `assets/playwright/game.e2e.ts` and `assets/playwright/scenarios.json` into the project test surface.
2. Map each scenario to GameSpec action IDs and reset it with the frozen seed.
3. Register console, page, failed-request, and HTTP error listeners before navigation.
4. Wait for `bridge.ready === true`; do not replace readiness with arbitrary sleeps.
5. Dispatch semantic actions through the bridge. Use `step(frames)` when time affects simulation.
6. At each checkpoint, assert the state marker, attach `getState()` as JSON, and capture a screenshot named exactly after the GameSpec state.
7. Fail the scenario on unexpected console, page, or network errors.
8. Run the concise rubric below and report evidence paths with findings.
When a real pointer or keyboard behavior is part of the contract, add one scenario that uses the actual browser input in addition to bridge-driven deterministic coverage.
## Rubric
Score each item `pass`, `partial`, or `fail` with one sentence of evidence:
- **Boot:** reaches the first actionable state without browser errors.
- **Control:** documented inputs produce immediate, legible feedback.
- **Loop:** the core action can progress to the stated success or failure state.
- **Clarity:** objective, current state, and outcome are readable without obscuring play.
- **Determinism:** the same seed and actions reproduce state snapshots and named screenshots.
- **Resilience:** restart and viewport resize preserve a playable state.
A vertical slice is ready to publish only when every item passes and all required GameSpec screenshots exist.
## Refine Loop
Lead with player-visible findings. For each issue, give the state, action sequence, screenshot or snapshot path, expected GameSpec behavior, and smallest likely owning subsystem. Fix one coherent issue set, then re-run the affected scenario plus the complete required-state set.
## Resources
- Scenario data contract: `references/scenario-contract.md`
- Machine-readable scenario schema: `references/scenario.schema.json`
- Copyable Playwright runner: `assets/playwright/game.e2e.ts`
- Example scenario file: `assets/playwright/scenarios.json`
@@ -0,0 +1,4 @@
interface:
display_name: "Game Eval"
short_description: "Playtest deterministic browser-game states"
default_prompt: "Use $game-eval to run deterministic browser scenarios, capture named screenshots and state, and score the vertical slice."
@@ -0,0 +1,101 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import scenarios from "./scenarios.json";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type ScenarioStep = {
action?: string;
count?: number;
frames?: number;
expect?: Record<string, JsonValue>;
screenshot?: string;
};
type GameStudioBridge = {
ready: boolean;
reset(seed?: number): void;
dispatch(action: string): void;
step(frames?: number): void;
getState(): JsonValue;
};
type GameStudioWindow = Window & { __GAME_STUDIO__?: GameStudioBridge };
function safeUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") return `${url.protocol}<redacted>`;
return `${url.origin}${url.pathname}`;
} catch {
return "<unparseable-url>";
}
}
function captureBrowserErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() !== "error") return;
const location = message.location();
const source = location.url ? safeUrl(location.url) : "<unknown-source>";
errors.push(`console: ${source}:${location.lineNumber}:${location.columnNumber}`);
});
page.on("pageerror", () => errors.push("page: uncaught exception"));
page.on("requestfailed", (request) => {
errors.push(`request: ${safeUrl(request.url())} (failed)`);
});
page.on("response", (response) => {
if (response.status() >= 400) errors.push(`response: ${response.status()} ${safeUrl(response.url())}`);
});
return errors;
}
async function state(page: Page): Promise<JsonValue> {
return page.evaluate(() => (window as GameStudioWindow).__GAME_STUDIO__!.getState());
}
async function expectSubset(page: Page, subset: Record<string, JsonValue>): Promise<void> {
const current = await state(page);
expect(current).toMatchObject(subset);
}
async function checkpoint(page: Page, testInfo: TestInfo, scenarioId: string, stateId: string): Promise<void> {
await expect(page.locator("[data-game-state]")).toHaveAttribute("data-game-state", stateId);
await testInfo.attach(`${scenarioId}--${stateId}.json`, {
body: JSON.stringify(await state(page), null, 2),
contentType: "application/json"
});
await page.screenshot({ path: testInfo.outputPath(`${scenarioId}--${stateId}.png`), fullPage: true });
}
for (const scenario of scenarios.scenarios) {
test(scenario.id, async ({ page }, testInfo) => {
const browserErrors = captureBrowserErrors(page);
try {
await page.goto(scenarios.baseURL);
await page.waitForFunction(() => (window as GameStudioWindow).__GAME_STUDIO__?.ready === true);
await page.evaluate((seed) => (window as GameStudioWindow).__GAME_STUDIO__!.reset(seed), scenario.seed);
for (const step of scenario.steps as ScenarioStep[]) {
if (step.action) {
for (let index = 0; index < (step.count ?? 1); index += 1) {
await page.evaluate((action) => (window as GameStudioWindow).__GAME_STUDIO__!.dispatch(action), step.action);
}
}
if (step.frames !== undefined) {
await page.evaluate((frames) => (window as GameStudioWindow).__GAME_STUDIO__!.step(frames), step.frames);
}
if (step.expect) await expectSubset(page, step.expect);
if (step.screenshot) await checkpoint(page, testInfo, scenario.id, step.screenshot);
}
expect(browserErrors, "unexpected browser errors").toEqual([]);
} finally {
if (browserErrors.length > 0) {
await testInfo.attach(`${scenario.id}--browser-errors.json`, {
body: JSON.stringify(browserErrors, null, 2),
contentType: "application/json"
});
}
}
});
}
@@ -0,0 +1,24 @@
{
"schemaVersion": "0.1",
"baseURL": "http://127.0.0.1:4173",
"scenarios": [
{
"id": "route-three-signals",
"seed": 5601,
"steps": [
{ "expect": { "status": "ready", "score": 0 }, "screenshot": "ready" },
{ "action": "move-left", "count": 6 },
{ "expect": { "status": "playing" }, "screenshot": "playing" },
{ "action": "move-up", "count": 3 },
{ "action": "collect" },
{ "action": "move-right", "count": 7 },
{ "action": "move-down", "count": 5 },
{ "action": "collect" },
{ "action": "move-left", "count": 7 },
{ "action": "move-up", "count": 2 },
{ "action": "collect" },
{ "expect": { "status": "won", "score": 3 }, "screenshot": "won" }
]
}
]
}
@@ -0,0 +1,48 @@
# Deterministic browser scenario contract
## Bridge
Expose one test-only observation boundary without coupling scenarios to Phaser objects:
```ts
type GameStudioBridge = {
ready: boolean;
reset(seed?: number): void;
dispatch(action: string): void;
step(frames?: number): void;
getState(): unknown;
};
```
Production input and the bridge must dispatch the same semantic action IDs. `getState()` must return JSON-safe data and exclude renderer objects, timestamps, and nondeterministic IDs.
## State marker
Keep one DOM element with `data-game-state="<state-id>"`. It may be a compact visible badge or a screenshot-safe test marker, but it must not depend on canvas pixel inspection. Update it in the same render pass as the player-visible state.
## Scenario file
Validate `scenarios.json` with `scenario.schema.json`. Each scenario:
- starts from an explicit seed;
- uses stable action IDs from GameSpec;
- advances time explicitly when required;
- names checkpoints after GameSpec state IDs;
- asserts a small state subset rather than serializing framework internals.
Do not use arbitrary delays. Wait for bridge readiness or a named state transition.
## Error evidence
Register all listeners before navigation and persist only these categorical records:
- console errors: sanitized source location plus line and column;
- uncaught `pageerror` events: an `uncaught exception` category;
- `requestfailed` events: sanitized request source plus a `failed` category;
- responses with status 400 or above: status plus sanitized response source.
For HTTP(S), a sanitized source contains origin and pathname only. For every other URI scheme, retain the scheme and replace its payload with `<redacted>`. Never persist raw console text, exception text, request-failure reasons, URL user-info, query strings, fragments, local paths, or inline URI data. Treat every captured browser error as a failure in this prototype.
## Evidence names
Use `<scenario-id>--<state-id>.png` for screenshots and `<scenario-id>--<state-id>.json` for state attachments. Keep names stable across runs so diffs stay understandable.
@@ -0,0 +1,49 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openai.com/game-studio/scenario.schema.json",
"title": "Game Studio browser scenarios",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "baseURL", "scenarios"],
"properties": {
"schemaVersion": { "const": "0.1" },
"baseURL": { "type": "string", "format": "uri" },
"scenarios": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "seed", "steps"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"seed": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"steps": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"action": { "$ref": "#/$defs/id" },
"count": { "type": "integer", "minimum": 1 },
"frames": { "type": "integer", "minimum": 0 },
"expect": { "type": "object" },
"screenshot": { "$ref": "#/$defs/id" }
},
"anyOf": [
{ "required": ["action"] },
{ "required": ["frames"] },
{ "required": ["expect"] },
{ "required": ["screenshot"] }
]
}
}
}
}
}
},
"$defs": {
"id": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
}
}
@@ -1,76 +1,49 @@
---
name: game-playtest
description: Run browser-game playtests and frontend QA. Use when the user asks for smoke tests, screenshot-based verification, browser automation, HUD or overlay review, or structured issue-finding in a browser game.
description: Play browser games through their real UI and report player-visible QA findings across controls, transitions, HUD, canvas, responsive layout, and visual states. Use for exploratory playtesting, manual browser QA, screenshot review, or real-input verification; route repeatable GameSpec scenarios to game-eval.
---
# Game Playtest
## Overview
Play the slice the way a player does, through the visible UI and documented controls. Use `../game-eval/SKILL.md` alongside this skill when the project has a frozen GameSpec or deterministic bridge.
Use this skill to test browser games the way players experience them: through boot, input, scene transitions, HUD readability, and visual state changes. Prefer browser automation and screenshot review when the project supports it.
## Real UI Pass
## Preferred Workflow
1. Boot the normal development or packaged URL and reach the first actionable screen.
2. Use actual keyboard, pointer, touch, pause, and restart controls named by the game.
3. Complete the core loop to a visible success or failure state.
4. Capture screenshots at the GameSpec state names; add exploratory screenshots only when they explain a finding.
5. Check the DOM HUD separately from the canvas, then inspect how they compose at desktop and one narrow viewport.
6. Record console, page, request, and HTTP errors.
7. Report findings in severity order with exact input sequences and owning surface.
1. Boot the game and confirm the first actionable screen.
2. Exercise the main verbs.
3. Capture screenshots from representative states.
4. Check the UI layer independently from the render layer.
5. Report findings in severity order with reproduction steps.
Do not use test-bridge dispatch as the only playtest. It proves deterministic rules, not physical input wiring or player legibility.
## Tooling Guidance
## Deterministic Companion Pass
- Prefer Playwright or equivalent browser automation already available in the repo.
- When the game is canvas or WebGL heavy, screenshots are mandatory because DOM assertions alone miss visual regressions.
- Use screenshots to judge playfield obstruction and HUD weight, not just correctness of text or layout.
- When deterministic automation is not practical, do a structured manual pass and capture evidence.
- For 3D rendering bugs or unexplained frame cost, use SpectorJS and browser performance tooling rather than guessing from code alone.
Route scenario contracts, seeded reset, state snapshots, named screenshot assertions, and rubric scoring to `../game-eval/SKILL.md`. A release candidate needs both:
- one real-input route through the core loop;
- deterministic evidence for every required GameSpec state.
## Common Checks
### 2D checks
- first-load readiness and objective clarity;
- input feedback, focus handling, and pause/restart behavior;
- sprite or geometry alignment and outcome readability;
- HUD overlap, menu transitions, and playfield visibility;
- desktop/narrow viewport sanity and safe areas;
- reduced-motion behavior when applicable;
- visual state matching the attached JSON snapshot;
- sprite alignment and baseline consistency
- hit or hurt animation readability
- HUD overlap with the playfield
- command menu state changes
- tile or platform readability
- input-state feedback and turn-state clarity
For explicit 3D work, also check camera reset, pointer-lock transitions, depth readability, asset stalls, collision proxies, and WebGL performance cliffs.
### 3D checks
## Reporting
- first-load playability versus dashboard-like chrome
- persistent overlay weight versus playfield visibility
- camera control and camera reset behavior
- pointer-lock or drag-look transitions when menus and overlays open
- depth readability and silhouette clarity
- secondary panels collapsed or dismissible during normal play
- resize behavior
- WebGL context loss or renderer fallback behavior
- material or lighting regressions
- GLB or texture streaming stalls
- collision proxy or physics mismatch
- performance cliffs tied to post-processing or asset load
## Responsive and Browser Checks
- desktop and mobile viewport sanity
- safe-area and notch issues where relevant
- reduced-motion behavior for UI transitions
- keyboard, pointer, and pause-state handling
- React state and scene state synchronization when the project uses React Three Fiber
## Reporting Standard
Lead with findings. Keep each finding concrete:
- what the user sees
- how to reproduce it
- why it matters
- what likely subsystem owns it
For each finding include what the player sees, the exact controls and named state, screenshot/snapshot evidence, why it matters, and the likely owning subsystem. End with the real-input path completed and any GameSpec state that remains unproven.
## References
- Shared architecture: `../web-game-foundations/SKILL.md`
- Deterministic evaluation: `../game-eval/SKILL.md`
- Frontend review cues: `../game-ui-frontend/SKILL.md`
- 3D debugging notes: `../../references/webgl-debugging-and-performance.md`
- Full checklist: `../../references/playtest-checklist.md`
- Broader checklist: `../../references/playtest-checklist.md`
@@ -0,0 +1,39 @@
---
name: game-publish-sites
description: Final-check and package a browser-self-contained game, then hand the verified static output to Sites publishing when that capability is available. Use when a browser-game vertical slice has passed evaluation and the user asks to prepare, preview, or publish it; never deploy without explicit user authorization.
---
# Publish Game to Sites
Prepare a static game artifact that can run from its packaged directory without development servers, localhost calls, or remote runtime dependencies. Publishing is a separate, explicit handoff.
## Package
1. Read the frozen `game-spec.json`, its required screenshots, and the latest `game-eval` rubric.
2. Stop if required states are missing, any rubric item is not `pass`, or browser errors remain.
3. Run the project's production build. Preserve the repository's package manager and existing build command.
4. Inspect the output using `references/final-check.md`. All scripts, styles, fonts, audio, images, and data needed at runtime must resolve from the packaged directory.
5. Serve that directory with a plain static server and re-run the boot, core-loop, restart, and required-screenshot scenarios against it.
6. Produce a handoff containing the output directory, title, suggested slug, GameSpec path, eval evidence paths, and build command.
Do not rewrite the game into a new framework or introduce a deployment SDK merely to package it.
## Sites Handoff
If a connected Sites publishing capability is available and the user explicitly asks to publish, pass the verified output directory and handoff metadata to it. Preserve its preview/review step before any public publish. If Sites is unavailable, return the package path and the missing capability; do not substitute another host without the user's direction.
For preparation-only requests, stop after the handoff. Do not deploy, create a public URL, or modify an existing Site.
## Completion Report
Return:
- packaged directory and build command;
- production-like URL used for the final check;
- GameSpec revision and scenario/evidence paths;
- browser error result and rubric summary;
- Sites handoff status: `ready`, `unavailable`, or `published` with user authorization.
## Reference
- Final package checklist: `references/final-check.md`
@@ -0,0 +1,4 @@
interface:
display_name: "Publish Game to Sites"
short_description: "Package and hand off a browser game"
default_prompt: "Use $game-publish-sites to final-check and package this browser game, then hand it off to Sites publishing when available."
@@ -0,0 +1,29 @@
# Final browser-game package check
## Artifact
- Production build exits successfully and produces one static output directory.
- Entry HTML loads with a relative or deploy-safe base path.
- Runtime code contains no localhost, development-server, or filesystem references.
- Required scripts, styles, images, audio, fonts, and game data live in the output directory.
- Source maps, debug menus, and test bridge exposure follow the project's shipping policy. The bridge may remain read-only when evals depend on it; mutation hooks should be gated when the game is public.
## Static-server replay
- First actionable state appears without console, page, request, or HTTP errors.
- Documented keyboard and pointer input reaches the same semantic actions used in evals.
- Core loop reaches the GameSpec success or failure state.
- Restart reproduces the seeded initial state.
- Every required named screenshot is recaptured from the packaged build.
- Desktop viewport and one narrow viewport remain playable.
## Sites metadata
Prepare, but do not publish without explicit authorization:
- title and suggested slug;
- static output directory;
- GameSpec revision;
- final scenario and screenshot evidence paths;
- concise game description and controls;
- any intentional external network dependency.
@@ -0,0 +1,39 @@
---
name: game-spec
description: Turn a browser-game brief into a frozen, machine-readable GameSpec for a playable vertical slice. Use when starting or revising a game prototype, before implementation begins, or when gameplay, controls, named states, deterministic hooks, evaluation scenarios, and out-of-scope boundaries need one explicit contract.
---
# Game Spec
Convert the user's idea into a small contract that implementation and evaluation can share. Optimize for one playable vertical slice, not a complete design document.
## Workflow
1. Write a one-paragraph brief covering the fantasy, player verbs, session length, and visible win or fail condition.
2. Copy `assets/game-spec.template.json` into the game project as `game-spec.json`.
3. Fill every required field using `references/game-spec.schema.json`. Read `references/game-spec.md` for field decisions and freeze rules.
4. Use stable kebab-case IDs for actions and observable states. Bind evaluation to those IDs rather than implementation details.
5. Confirm the spec has exactly one vertical-slice objective, deterministic reset behavior, required screenshot states, and explicit exclusions.
6. Freeze the file before implementation. If intent changes, update the spec first and record the revision in `changes`.
## Required Contract
Keep these surfaces aligned:
- `actions`: semantic inputs such as `move-left` or `restart`, with human bindings.
- `states`: player-visible checkpoints such as `ready`, `playing`, `won`, or `failed`.
- `determinism`: seed, reset action, fixed-step policy, and browser bridge name.
- `evaluation`: scenario file and the named screenshot states that prove the slice.
- `outOfScope`: features deliberately excluded from this iteration.
Do not prescribe scene classes, component trees, or art production details unless they affect the observable contract.
## Handoff
Return the brief, the validated `game-spec.json` path, and a short list of frozen action/state IDs. Route implementation to `../phaser-2d-game/SKILL.md` by default, then evaluation to `../game-eval/SKILL.md`.
## Resources
- Field and freeze guidance: `references/game-spec.md`
- Machine-readable schema: `references/game-spec.schema.json`
- Copyable template: `assets/game-spec.template.json`
@@ -0,0 +1,4 @@
interface:
display_name: "Game Spec"
short_description: "Freeze a machine-readable vertical-slice contract"
default_prompt: "Use $game-spec to turn this browser-game brief into a frozen, machine-readable GameSpec."
@@ -0,0 +1,34 @@
{
"schemaVersion": "0.1",
"revision": 1,
"id": "game-id",
"title": "Game title",
"brief": "One paragraph describing the fantasy, player verbs, and visible stakes.",
"sessionLengthSeconds": 90,
"verbs": ["move", "collect"],
"coreLoop": ["Read the next target", "Act", "See immediate feedback", "Repeat under increasing pressure"],
"objective": "Complete one measurable objective.",
"success": "Describe the visible win condition.",
"failure": ["Describe each visible fail condition."],
"actions": [
{ "id": "move-left", "bindings": ["ArrowLeft", "KeyA"], "effect": "Move one step left." },
{ "id": "restart", "bindings": ["KeyR"], "effect": "Reset to the seeded start state." }
],
"states": [
{ "id": "ready", "observableWhen": "The game accepts player input." },
{ "id": "won", "observableWhen": "The success condition is visibly complete." }
],
"determinism": {
"seed": 5601,
"rng": "seeded",
"fixedStepMs": null,
"resetAction": "restart",
"bridge": "window.__GAME_STUDIO__"
},
"evaluation": {
"scenarioFile": "eval/scenarios.json",
"requiredScreenshots": ["ready", "won"]
},
"outOfScope": ["Additional levels", "Persistent progression", "Production asset pipeline"],
"changes": []
}
@@ -0,0 +1,37 @@
# GameSpec guidance
## Contract boundary
GameSpec describes what a player and evaluator can observe in one vertical slice. It is not a backlog, narrative bible, balance sheet, or framework architecture.
## Field decisions
- Keep `brief` to one paragraph and `sessionLengthSeconds` to a realistic prototype session.
- Make `verbs` player language: move, dodge, collect, aim, choose.
- Give every `action` a stable ID and at least one binding. Code and Playwright scenarios use the same IDs.
- Give every `state` an observable condition. Each required screenshot name must match a state ID.
- Use one nonnegative 32-bit integer `seed`. Resetting with the same seed must reproduce initial gameplay state.
- Use a fixed simulation step when time affects gameplay. Discrete or turn-based games may set `fixedStepMs` to `null`.
- Keep `outOfScope` concrete so implementation does not grow sideways.
## Freeze rule
Treat the first validated file as frozen. Implementation may add internal detail without changing it. When player-visible intent changes:
1. revise the GameSpec first;
2. append a terse entry to `changes` with the new revision and reason;
3. update affected eval scenarios;
4. re-run every required screenshot state.
Do not silently make tests match accidental implementation behavior.
## Acceptance check
A GameSpec is ready when another agent can answer all of these without guessing:
- What does the player do in the first ten seconds?
- How does the slice end successfully or unsuccessfully?
- Which semantic inputs drive it?
- How is the same start state reproduced through `window.__GAME_STUDIO__`?
- Which named screenshots prove the important states?
- What will not be built in this slice?
@@ -0,0 +1,132 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openai.com/game-studio/game-spec.schema.json",
"title": "GameSpec",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"revision",
"id",
"title",
"brief",
"sessionLengthSeconds",
"verbs",
"coreLoop",
"objective",
"success",
"failure",
"actions",
"states",
"determinism",
"evaluation",
"outOfScope",
"changes"
],
"properties": {
"schemaVersion": { "const": "0.1" },
"revision": { "type": "integer", "minimum": 1 },
"id": { "$ref": "#/$defs/id" },
"title": { "type": "string", "minLength": 1 },
"brief": { "type": "string", "minLength": 1 },
"sessionLengthSeconds": { "type": "integer", "minimum": 10, "maximum": 1800 },
"verbs": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/id" }
},
"coreLoop": {
"type": "array",
"minItems": 2,
"items": { "type": "string", "minLength": 1 }
},
"objective": { "type": "string", "minLength": 1 },
"success": { "type": "string", "minLength": 1 },
"failure": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"actions": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "bindings", "effect"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"bindings": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"effect": { "type": "string", "minLength": 1 }
}
}
},
"states": {
"type": "array",
"minItems": 2,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "observableWhen"],
"properties": {
"id": { "$ref": "#/$defs/id" },
"observableWhen": { "type": "string", "minLength": 1 }
}
}
},
"determinism": {
"type": "object",
"additionalProperties": false,
"required": ["seed", "rng", "fixedStepMs", "resetAction", "bridge"],
"properties": {
"seed": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
"rng": { "type": "string", "minLength": 1 },
"fixedStepMs": { "type": ["number", "null"], "exclusiveMinimum": 0 },
"resetAction": { "$ref": "#/$defs/id" },
"bridge": { "const": "window.__GAME_STUDIO__" }
}
},
"evaluation": {
"type": "object",
"additionalProperties": false,
"required": ["scenarioFile", "requiredScreenshots"],
"properties": {
"scenarioFile": { "type": "string", "minLength": 1 },
"requiredScreenshots": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/$defs/id" }
}
}
},
"outOfScope": {
"type": "array",
"minItems": 1,
"items": { "type": "string", "minLength": 1 }
},
"changes": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["revision", "reason"],
"properties": {
"revision": { "type": "integer", "minimum": 2 },
"reason": { "type": "string", "minLength": 1 }
}
}
}
},
"$defs": {
"id": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
}
}
}
+37 -76
View File
@@ -1,94 +1,55 @@
---
name: game-studio
description: Route early browser-game work. Use when the user needs stack selection and workflow planning across design, implementation, assets, and playtesting before moving to a specialist skill.
description: Run the end-to-end browser-game studio loop from brief and frozen GameSpec through a stable starter, implementation, real UI playtest, screenshot/state review, refinement, and publish handoff. Use when a user asks to make, prototype, iterate on, or ship a browser game and needs one executable workflow across the specialist skills.
---
# Game Studio
## Overview
Drive toward one playable vertical slice. Default to the small Phaser 2D path unless the user explicitly requests an existing 3D stack.
Use this skill as the umbrella entrypoint for browser-game work. Default to a 2D Phaser path unless the user explicitly asks for 3D, Three.js, React Three Fiber, shader-heavy rendering, or another WebGL-first direction.
## Executable Loop
This plugin is intentionally asymmetric:
Keep one visible artifact trail through every stage:
- 2D is the strongest execution path in v1.
- 3D has one opinionated default ecosystem: vanilla Three.js for plain TypeScript or Vite apps, React Three Fiber for React-hosted 3D apps, and GLB or glTF 2.0 as the default shipping asset format.
- Shared architecture, UI, and playtest practices apply to both.
1. **Brief:** Reduce the request to fantasy, player verbs, session length, objective, success/failure, and exclusions.
2. **Freeze:** Use `../game-spec/SKILL.md` to create and validate `game-spec.json`. Freeze action IDs, observable state IDs, seed, and required screenshots before coding.
3. **Start stable:** For 2D work, copy `../phaser-2d-game/assets/phaser-2d-starter/` instead of inventing new plumbing. Preserve its input map, DOM HUD boundary, deterministic state, bridge, and state marker.
4. **Implement:** Build only the smallest loop that reaches a visible success or failure state. Keep rules outside renderer objects and keep GameSpec IDs stable.
5. **Run:** Start the repository's normal development command and reach the first actionable state.
6. **Play the real UI:** Use real keyboard or pointer input for the main player path. Confirm the bridge is an evaluation boundary, not a replacement for playable controls.
7. **Review evidence:** Use `../game-eval/SKILL.md` for deterministic scenarios, named screenshots, console/network capture, state snapshots, and the concise rubric.
8. **Refine:** Fix the smallest player-visible issue set, then replay affected scenarios and every required screenshot state. Revise GameSpec first if intent changes.
9. **Package and publish:** Use `../game-publish-sites/SKILL.md` to produce a browser-self-contained build and Sites handoff. Publish only when the user explicitly authorizes it and Sites is available.
## Use This Skill When
Do not call a slice complete because it builds. Completion requires a real-input playthrough plus deterministic evidence for every required state.
- the user is still choosing a stack
- the request spans multiple domains such as runtime, UI, asset pipeline, and QA
- the user says "help me build a game" without naming the implementation path
## Routing
## Do Not Stay Here When
- Frozen vertical-slice contract: `../game-spec/SKILL.md`
- Default 2D implementation and starter: `../phaser-2d-game/SKILL.md`
- Deterministic browser evaluation: `../game-eval/SKILL.md`
- Manual or exploratory browser QA: `../game-playtest/SKILL.md`
- Static packaging and Sites handoff: `../game-publish-sites/SKILL.md`
- HUD and menu direction: `../game-ui-frontend/SKILL.md`
- 2D sprite work when the slice needs it: `../sprite-pipeline/SKILL.md`
- Explicit vanilla Three.js requests: `../three-webgl-game/SKILL.md`
- Explicit React Three Fiber requests: `../react-three-fiber-game/SKILL.md`
- Explicit 3D asset shipping work: `../web-3d-asset-pipeline/SKILL.md`
- the runtime is clearly plain Three.js
- the runtime is clearly React Three Fiber
- the task is clearly a shipped-asset problem
- the task is clearly frontend-only or QA-only
The same brief → freeze → implement → playtest → review → refine → publish loop applies to explicit 3D work, but this prototype adds no new 3D starter or pipeline.
Once the intent is clear, route to the most specific specialist skill and continue from there.
## Required Handoff
## Routing Rules
For an implemented slice, return:
1. Classify the request before designing or coding:
- `2D default`: Phaser, sprites, tilemaps, top-down, side-view, grid tactics, action platformers.
- `3D + plain TS/Vite`: imperative scene control, engine-like loops, non-React apps, direct Three.js work.
- `3D + React`: React-hosted product surfaces, declarative scene composition, shared React state, UI-heavy 3D apps.
- `3D asset pipeline`: GLB, glTF, texture packaging, compression, LOD, runtime asset size.
- `Alternative engine`: Babylon.js or PlayCanvas requests, usually as comparison or ecosystem fit questions.
- `Shared`: core loop design, frontend direction, save/debug/perf boundaries, browser QA.
2. Route to the specialist skills immediately after classification:
- Shared architecture and engine choice: `../web-game-foundations/SKILL.md`
- Deep 2D implementation: `../phaser-2d-game/SKILL.md`
- Vanilla Three.js implementation: `../three-webgl-game/SKILL.md`
- React-hosted 3D implementation: `../react-three-fiber-game/SKILL.md`
- 3D asset shipping and optimization: `../web-3d-asset-pipeline/SKILL.md`
- HUD and menu direction: `../game-ui-frontend/SKILL.md`
- 2D sprite generation and normalization: `../sprite-pipeline/SKILL.md`
- Browser QA and visual review: `../game-playtest/SKILL.md`
3. Keep one coherent plan across the routed skills. Do not let engine, UI, asset, and QA decisions drift apart.
- frozen GameSpec revision and path;
- starter or stack used and development command;
- real-input playtest path;
- required screenshot and state-snapshot paths;
- console/network error result and rubric summary;
- unresolved GameSpec gaps;
- packaged output or next refinement action.
## Default Workflow
## Worked Example
1. Lock the game fantasy and player verbs.
2. Define the core loop, failure states, progression, and target play session length.
3. Choose the implementation track:
- Default to Phaser for 2D browser games.
- Choose vanilla Three.js when the project is explicitly 3D and wants direct render-loop control in a plain TypeScript or Vite app.
- Choose React Three Fiber when the project already lives in React or wants declarative scene composition with shared React state.
- Choose raw WebGL only when the user explicitly wants a custom renderer or shader-first surface.
4. Define the UI surface early. Browser games usually need a DOM HUD and menu layer even when the playfield is canvas or WebGL.
- For 3D starter scaffolds, default to a low-chrome HUD that preserves the playfield and keeps secondary panels collapsed.
5. Decide the asset workflow:
- 2D characters and effects: use `sprite-pipeline`.
- 3D models, textures, and shipping format: use `web-3d-asset-pipeline`.
6. Close with a playtest loop before calling the work production-ready.
## Output Expectations
- For planning requests, return a game-specific plan with stack choice, gameplay loop, UI surface, asset workflow, and test approach.
- For implementation requests, keep the chosen stack obvious in the file structure and code boundaries.
- For mixed requests, preserve the plugin default: 2D Phaser first unless the user asks for something else.
- When the user asks about Babylon.js or PlayCanvas, compare them honestly but keep Three.js and R3F as the primary code-generation defaults unless the user explicitly chooses another engine.
## References
- Engine selection: `../../references/engine-selection.md`
- Three.js stack: `../../references/threejs-stack.md`
- React Three Fiber stack: `../../references/react-three-fiber-stack.md`
- 3D asset pipeline: `../../references/web-3d-asset-pipeline.md`
- Vanilla Three.js starter: `../../references/threejs-vanilla-starter.md`
- React Three Fiber starter: `../../references/react-three-fiber-starter.md`
- Frontend prompting patterns: `../../references/frontend-prompts.md`
- Playtest checklist: `../../references/playtest-checklist.md`
## Examples
- "Help me prototype a browser tactics game."
- "I need a Phaser-based action game loop with a HUD and menus."
- "I want a Three.js exploration demo with WebGL lighting and browser-safe UI."
- "I want a React-based 3D configurator with React Three Fiber."
- "Optimize my GLB assets for the web and keep the file sizes under control."
- "Set up the asset workflow for consistent 2D sprite animations."
Read `../../examples/neon-relay/workflow.md` for one prompt-to-playable example backed by a complete GameSpec and deterministic scenario contract.
@@ -1,88 +1,56 @@
---
name: phaser-2d-game
description: Implement 2D browser games with Phaser. Use when the user wants a Phaser, TypeScript, and Vite stack for scenes, gameplay systems, cameras, sprite animation, and DOM-overlay HUD patterns.
description: Implement deterministic 2D browser-game vertical slices with Phaser, TypeScript, Vite, a DOM HUD, semantic input actions, and a browser test bridge. Use for Phaser implementation, scene/gameplay architecture, or when copying and adapting the Game Studio 2D starter.
---
# Phaser 2D Game
## Overview
Use this as the default implementation path after `game-spec` freezes the vertical-slice contract.
Use this skill for the main execution path in this plugin. Phaser is the default stack for 2D browser games here because it handles rendering, timing, sprites, cameras, and scene orchestration well without forcing gameplay rules into the framework.
## Start From the Stable Template
Preferred stack:
Copy `assets/phaser-2d-starter/` into the target project. It contains a small playable loop with:
- Phaser
- TypeScript
- Vite
- DOM-based HUD or menus layered over the game canvas
- Phaser + TypeScript + Vite;
- deterministic seed-derived state;
- one explicit keyboard-to-action map;
- a DOM HUD shell around the canvas;
- `window.__GAME_STUDIO__` for reset, semantic actions, explicit stepping, and JSON-safe state;
- `data-game-state` markers for named screenshot checkpoints;
- no remote assets or runtime services.
Replace the example rules and presentation while preserving these boundaries. Keep GameSpec action and state IDs synchronized with `src/actions.ts`, the HUD marker, and eval scenarios.
## Architecture
1. Keep gameplay state outside Phaser scenes.
- Systems own rules, turn order, movement, combat, inventory, objectives, and progression.
- Phaser scenes adapt system state into sprites, camera motion, animation playback, and effects.
2. Make scenes thin.
- Boot and asset preload
- Menu or shell scene
- Gameplay scene
- Optional overlay or debug scene
3. Keep renderer-facing objects disposable.
- Sprite containers, emitters, tweens, and camera rigs are view state, not source of truth.
4. Favor stable asset manifest keys over direct file-path references throughout gameplay code.
1. Keep simulation state and rules outside Phaser display objects.
2. Let the scene adapt state into sprites, shapes, cameras, animation, and effects.
3. Send production keyboard/pointer input and evaluation input through the same semantic action dispatcher.
4. Return JSON-safe state from the bridge; never expose Phaser objects or timestamps.
5. Use seeded randomness and explicit fixed stepping when gameplay depends on time.
6. Keep dense text, status, menus, and settings in the DOM HUD unless an in-world presentation is essential.
## Implementation Guidance
## Vertical-Slice Order
- Use one integration boundary where the scene reads simulation state and emits input actions back.
- Prefer deterministic system updates over scene-local mutation.
- Treat HUD and menus as DOM when text, status density, or responsiveness matter.
- Keep animation state derived from gameplay state rather than ad hoc sprite flags.
## 2D Modes Covered Well
- Turn-based grids and tactics
- Top-down exploration
- Side-view action platformers
- Character-action combat with sprite animation
- Lightweight management or deck-driven battle scenes
## Camera and Presentation
- Choose the camera model early: locked, follow, room-based, or tactical-pan.
- Keep camera logic separate from game rules.
- Use restrained screen shake, hit-stop, and parallax. Effects should improve readability, not obscure it.
## UI Integration
- Use DOM overlays for HUD, command menus, settings, and narrative panels.
- Keep the canvas responsible for the world, combat readability, and motion.
- Avoid shoving dense text or complex settings UIs into Phaser unless the project explicitly needs an in-canvas presentation.
## Asset Organization
- `characters/`
- `environment/`
- `ui/`
- `fx/`
- `audio/`
- `data/`
Keep manifest keys human-readable and stable.
## Default Directory Shape
See `../../references/phaser-architecture.md` for a concrete module split.
1. Copy the starter and replace its bundled `game-spec.json` with the frozen project spec.
2. Implement the objective and visible success/failure state before adding content breadth.
3. Make each named state observable in both the HUD/canvas and `data-game-state`.
4. Run the game through real controls.
5. Route deterministic review to `../game-eval/SKILL.md`.
## Anti-Patterns
- Game rules inside `update()` loops without a system boundary
- Scene-to-scene state passed through mutable global objects
- HUD text rendered in the game canvas just because it is convenient
- Asset paths embedded everywhere instead of a manifest layer
- Overusing generic React dashboard patterns for game UI
- Game rules hidden in an `update()` loop.
- Separate control paths for humans and evals.
- Unseeded randomness, wall-clock IDs, or arbitrary automation sleeps.
- Mutable globals passed between scenes.
- HUD text embedded in canvas solely for convenience.
- Expanding assets, levels, or systems beyond the frozen slice.
## References
- Shared architecture: `../web-game-foundations/SKILL.md`
- Frozen contract: `../game-spec/SKILL.md`
- Deterministic evaluation: `../game-eval/SKILL.md`
- Deeper module structure: `../../references/phaser-architecture.md`
- Frontend direction: `../game-ui-frontend/SKILL.md`
- Sprite workflow: `../sprite-pipeline/SKILL.md`
- Phaser structure: `../../references/phaser-architecture.md`
- Sprite workflow when required: `../sprite-pipeline/SKILL.md`
@@ -0,0 +1,24 @@
{
"schemaVersion": "0.1",
"baseURL": "http://127.0.0.1:4173",
"scenarios": [
{
"id": "route-three-signals",
"seed": 5601,
"steps": [
{ "expect": { "status": "ready", "score": 0 }, "screenshot": "ready" },
{ "action": "move-left", "count": 6 },
{ "expect": { "status": "playing" }, "screenshot": "playing" },
{ "action": "move-up", "count": 3 },
{ "action": "collect" },
{ "action": "move-right", "count": 7 },
{ "action": "move-down", "count": 5 },
{ "action": "collect" },
{ "action": "move-left", "count": 7 },
{ "action": "move-up", "count": 2 },
{ "action": "collect" },
{ "expect": { "status": "won", "score": 3 }, "screenshot": "won" }
]
}
]
}
@@ -0,0 +1,39 @@
{
"schemaVersion": "0.1",
"revision": 1,
"id": "neon-relay",
"title": "Neon Relay",
"brief": "Route three signals across a neon grid by moving a courier to each beacon and confirming the connection, with immediate HUD and playfield feedback.",
"sessionLengthSeconds": 60,
"verbs": ["move", "collect", "restart"],
"coreLoop": ["Locate the beacon", "Move onto it", "Route the signal", "Repeat until three signals are connected"],
"objective": "Route three signals.",
"success": "The HUD reads 3 / 3 and the game enters the won state.",
"failure": [],
"actions": [
{ "id": "move-left", "bindings": ["ArrowLeft", "KeyA"], "effect": "Move one grid cell left." },
{ "id": "move-right", "bindings": ["ArrowRight", "KeyD"], "effect": "Move one grid cell right." },
{ "id": "move-up", "bindings": ["ArrowUp", "KeyW"], "effect": "Move one grid cell up." },
{ "id": "move-down", "bindings": ["ArrowDown", "KeyS"], "effect": "Move one grid cell down." },
{ "id": "collect", "bindings": ["Space"], "effect": "Route a signal while standing on its beacon." },
{ "id": "restart", "bindings": ["KeyR"], "effect": "Reset to the seeded start state." }
],
"states": [
{ "id": "ready", "observableWhen": "The seeded board is visible and accepts input." },
{ "id": "playing", "observableWhen": "The player has taken an action and fewer than three signals are routed." },
{ "id": "won", "observableWhen": "Three signals are routed." }
],
"determinism": {
"seed": 5601,
"rng": "seed-derived beacon sequence",
"fixedStepMs": null,
"resetAction": "restart",
"bridge": "window.__GAME_STUDIO__"
},
"evaluation": {
"scenarioFile": "eval/scenarios.json",
"requiredScreenshots": ["ready", "playing", "won"]
},
"outOfScope": ["Additional levels", "Enemies", "Audio", "Persistent progression", "Production art"],
"changes": []
}
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#08111f" />
<title>Neon Relay</title>
</head>
<body>
<main id="game-shell">
<header id="hud" aria-label="Game status">
<div>
<p class="eyebrow">NEON RELAY // TRAINING SLICE</p>
<h1>Route three signals</h1>
</div>
<dl>
<div><dt>Signals</dt><dd id="score">0 / 3</dd></div>
<div><dt>State</dt><dd id="status">Ready</dd></div>
</dl>
<button id="restart" type="button">Restart <kbd>R</kbd></button>
</header>
<section id="game" aria-label="Neon Relay playfield"></section>
<footer>
<span>Move <kbd>WASD</kbd> or <kbd>Arrows</kbd></span>
<span>Route signal <kbd>Space</kbd></span>
</footer>
<output id="game-state-marker" data-game-state="boot" aria-live="polite">STATE: BOOT</output>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,18 @@
{
"name": "game-studio-phaser-starter",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"phaser": "^3.90.0"
},
"devDependencies": {
"typescript": "^5.8.3",
"vite": "^7.0.0"
}
}
@@ -0,0 +1,29 @@
export const ACTIONS = {
moveLeft: "move-left",
moveRight: "move-right",
moveUp: "move-up",
moveDown: "move-down",
collect: "collect",
restart: "restart"
} as const;
export type ActionId = (typeof ACTIONS)[keyof typeof ACTIONS];
const ACTION_IDS = new Set<string>(Object.values(ACTIONS));
export function isActionId(action: string): action is ActionId {
return ACTION_IDS.has(action);
}
export const INPUT_ACTION_MAP: Readonly<Record<string, ActionId>> = {
ArrowLeft: ACTIONS.moveLeft,
KeyA: ACTIONS.moveLeft,
ArrowRight: ACTIONS.moveRight,
KeyD: ACTIONS.moveRight,
ArrowUp: ACTIONS.moveUp,
KeyW: ACTIONS.moveUp,
ArrowDown: ACTIONS.moveDown,
KeyS: ACTIONS.moveDown,
Space: ACTIONS.collect,
KeyR: ACTIONS.restart
};
@@ -0,0 +1,87 @@
import Phaser from "phaser";
import { ACTIONS, type ActionId } from "./actions";
import { createInitialState, reduceAction, type GameState } from "./state";
const CELL = 56;
const GRID_X = 32;
const GRID_Y = 18;
export class GameRuntime {
private state: GameState;
private graphics?: Phaser.GameObjects.Graphics;
private readonly onState: (state: GameState) => void;
private readonly onReady: () => void;
constructor(parent: HTMLElement, seed: number, onState: (state: GameState) => void, onReady: () => void) {
this.state = createInitialState(seed);
this.onState = onState;
this.onReady = onReady;
const runtime = this;
new Phaser.Game({
type: Phaser.AUTO,
parent,
width: 960,
height: 540,
backgroundColor: "#08111f",
render: { antialias: true, pixelArt: false },
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH },
scene: {
create(this: Phaser.Scene) {
runtime.graphics = this.add.graphics();
runtime.render();
runtime.onReady();
}
}
});
}
dispatch(action: ActionId): void {
if (action === ACTIONS.restart) {
this.reset(this.state.seed);
return;
}
this.state = reduceAction(this.state, action);
this.render();
}
reset(seed = this.state.seed): void {
this.state = createInitialState(seed);
this.render();
}
step(frames = 1): void {
this.state = { ...this.state, tick: this.state.tick + Math.max(0, frames) };
this.render();
}
getState(): GameState {
return structuredClone(this.state);
}
private render(): void {
if (!this.graphics) return;
const graphics = this.graphics;
graphics.clear();
graphics.lineStyle(1, 0x17304d, 0.8);
for (let x = 0; x <= 16; x += 1) graphics.lineBetween(GRID_X + x * CELL, GRID_Y, GRID_X + x * CELL, GRID_Y + 9 * CELL);
for (let y = 0; y <= 9; y += 1) graphics.lineBetween(GRID_X, GRID_Y + y * CELL, GRID_X + 16 * CELL, GRID_Y + y * CELL);
const beaconX = GRID_X + this.state.beacon.x * CELL + CELL / 2;
const beaconY = GRID_Y + this.state.beacon.y * CELL + CELL / 2;
graphics.fillStyle(0xffca5c, 0.2).fillCircle(beaconX, beaconY, 23);
graphics.lineStyle(3, 0xffca5c, 1).strokeCircle(beaconX, beaconY, 15);
const playerX = GRID_X + this.state.player.x * CELL + 10;
const playerY = GRID_Y + this.state.player.y * CELL + 10;
graphics.fillStyle(this.state.status === "won" ? 0x59f6b1 : 0x56d6ff, 1).fillRoundedRect(playerX, playerY, 36, 36, 9);
if (this.state.status === "won") {
graphics.fillStyle(0x07101c, 0.74).fillRoundedRect(280, 190, 400, 150, 18);
graphics.lineStyle(2, 0x59f6b1, 1).strokeRoundedRect(280, 190, 400, 150, 18);
}
this.onState(this.getState());
}
}
@@ -0,0 +1,63 @@
import "./style.css";
import gameSpec from "../game-spec.json";
import { ACTIONS, INPUT_ACTION_MAP, isActionId } from "./actions";
import { GameRuntime } from "./game";
import type { GameState } from "./state";
declare global {
interface Window {
__GAME_STUDIO__?: {
ready: boolean;
reset(seed?: number): void;
dispatch(action: string): void;
step(frames?: number): void;
getState(): GameState;
};
}
}
const DEFAULT_SEED = gameSpec.determinism.seed;
const gameRoot = document.querySelector<HTMLElement>("#game")!;
const score = document.querySelector<HTMLElement>("#score")!;
const status = document.querySelector<HTMLElement>("#status")!;
const marker = document.querySelector<HTMLOutputElement>("#game-state-marker")!;
const restart = document.querySelector<HTMLButtonElement>("#restart")!;
function renderHud(state: GameState): void {
score.textContent = `${state.score} / ${state.targetScore}`;
status.textContent = state.status[0].toUpperCase() + state.status.slice(1);
marker.dataset.gameState = state.status;
marker.textContent = `STATE: ${state.status.toUpperCase()}`;
}
const bridge = {
ready: false,
reset(seed = DEFAULT_SEED) {
runtime.reset(seed);
},
dispatch(action: string) {
if (!isActionId(action)) throw new TypeError(`Unknown game action: ${action}`);
runtime.dispatch(action);
},
step(frames = 1) {
runtime.step(frames);
},
getState() {
return runtime.getState();
}
};
const runtime = new GameRuntime(gameRoot, DEFAULT_SEED, renderHud, () => {
bridge.ready = true;
});
window.__GAME_STUDIO__ = bridge;
window.addEventListener("keydown", (event) => {
const action = INPUT_ACTION_MAP[event.code];
if (!action) return;
event.preventDefault();
bridge.dispatch(action);
});
restart.addEventListener("click", () => bridge.dispatch(ACTIONS.restart));
@@ -0,0 +1,70 @@
import { ACTIONS, type ActionId } from "./actions";
export type GameStatus = "ready" | "playing" | "won";
export type GameState = {
seed: number;
tick: number;
status: GameStatus;
score: number;
targetScore: number;
player: { x: number; y: number };
beacon: { x: number; y: number };
};
const GRID_WIDTH = 16;
const GRID_HEIGHT = 9;
function beaconFor(seed: number, score: number): { x: number; y: number } {
return {
x: ((seed + score * 7) % (GRID_WIDTH - 2)) + 1,
y: ((Math.floor(seed / 10) + score * 5) % (GRID_HEIGHT - 2)) + 1
};
}
export function createInitialState(seed: number): GameState {
if (!Number.isInteger(seed) || seed < 0 || seed > 2147483647) {
throw new RangeError("seed must be a nonnegative 32-bit integer");
}
return {
seed,
tick: 0,
status: "ready",
score: 0,
targetScore: 3,
player: { x: 8, y: 4 },
beacon: beaconFor(seed, 0)
};
}
function clamp(value: number, max: number): number {
return Math.max(0, Math.min(max, value));
}
export function reduceAction(state: GameState, action: ActionId): GameState {
if (state.status === "won") return state;
const next: GameState = {
...state,
tick: state.tick + 1,
status: "playing",
player: { ...state.player },
beacon: { ...state.beacon }
};
if (action === ACTIONS.moveLeft) next.player.x = clamp(next.player.x - 1, GRID_WIDTH - 1);
if (action === ACTIONS.moveRight) next.player.x = clamp(next.player.x + 1, GRID_WIDTH - 1);
if (action === ACTIONS.moveUp) next.player.y = clamp(next.player.y - 1, GRID_HEIGHT - 1);
if (action === ACTIONS.moveDown) next.player.y = clamp(next.player.y + 1, GRID_HEIGHT - 1);
if (action === ACTIONS.collect && next.player.x === next.beacon.x && next.player.y === next.beacon.y) {
next.score += 1;
if (next.score >= next.targetScore) {
next.status = "won";
} else {
next.beacon = beaconFor(next.seed, next.score);
}
}
return next;
}
@@ -0,0 +1,88 @@
:root {
color: #e8f4ff;
background: #030811;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background: radial-gradient(circle at 50% 0%, #10233e 0%, #030811 60%);
}
button, kbd { font: inherit; }
#game-shell {
position: relative;
width: min(1120px, calc(100vw - 32px));
overflow: hidden;
border: 1px solid #284565;
border-radius: 24px;
background: #08111f;
box-shadow: 0 30px 90px rgb(0 0 0 / 45%);
}
#hud {
min-height: 104px;
display: flex;
align-items: center;
gap: 32px;
padding: 18px 24px;
border-bottom: 1px solid #17304d;
background: rgb(5 14 27 / 92%);
}
.eyebrow { margin: 0 0 4px; color: #56d6ff; font-size: 11px; letter-spacing: 0.18em; }
h1 { margin: 0; font-size: clamp(18px, 3vw, 28px); }
dl { display: flex; gap: 28px; margin: 0 0 0 auto; }
dl div { min-width: 74px; }
dt { color: #7894b3; font-size: 11px; text-transform: uppercase; letter-spacing: 0.12em; }
dd { margin: 4px 0 0; font-weight: 700; }
button {
padding: 10px 14px;
color: #e8f4ff;
border: 1px solid #365d85;
border-radius: 10px;
background: #10233e;
cursor: pointer;
}
#game { width: 100%; aspect-ratio: 16 / 9; }
#game canvas { display: block; width: 100% !important; height: 100% !important; }
footer {
display: flex;
gap: 24px;
padding: 12px 24px;
color: #9bb2c9;
border-top: 1px solid #17304d;
font-size: 13px;
}
kbd { padding: 2px 6px; border: 1px solid #365d85; border-radius: 5px; background: #10233e; }
#game-state-marker {
position: absolute;
right: 16px;
bottom: 14px;
padding: 5px 8px;
color: #08111f;
border-radius: 999px;
background: #59f6b1;
font: 700 10px/1 ui-monospace, monospace;
letter-spacing: 0.08em;
}
@media (max-width: 720px) {
#hud { align-items: flex-start; flex-wrap: wrap; gap: 12px; }
dl { order: 3; width: 100%; margin: 0; }
button { margin-left: auto; }
footer { flex-direction: column; gap: 6px; }
}
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}