Game Generation Coding Guidelines
These are the rules Claude MUST follow when generating a new multiplayer game for the Steam Deck Randomizer system. Every generated game must compile, run, and be fun for 2-5 players on Steam Deck.
Golden Rules
- Every game MUST be multiplayer (2-5 players). No single-player games.
- Every game MUST work with gamepad AND keyboard. See
steamdeck-controls skill.
- Every game MUST extend the shared engine. Do not reinvent rendering, input, or networking.
- Every game MUST use bitECS for entity management. See
bitecs skill.
- Every game MUST fit in TWO files: one client scene file, one server room file.
- Every game MUST have clear win/lose conditions and 2-5 minute rounds.
- Every game MUST use only assets from the provided catalog. No external URLs.
- Every game MUST be frame-rate independent (use delta time, never frame counts).
- Every game MUST target 1280x800 resolution (Steam Deck native).
- Every game MUST handle player join/leave gracefully mid-game.
Architecture Overview
Generated Game
├── client/game.ts (extends BaseScene, uses bitECS + Phaser)
├── server/room.ts (Colyseus room logic, authoritative state)
├── assets.json (references to catalog assets)
└── metadata.json (title, description, controls, genre)
The engine (@sdr/engine) handles:
- Phaser initialization and lifecycle
- Gamepad + keyboard input reading
- Asset loading from manifest
- Colyseus client connection and state sync
- HUD (scores, timer, player list)
- Lobby (wait for players, ready up)
Claude generates ONLY gameplay logic on top of this.
Client-Side Game File Structure
Every client/game.ts MUST follow this structure:
import Phaser from "phaser";
import {
createWorld, addEntity, addComponent, removeEntity,
query, observe, onAdd, onRemove,
} from "bitecs";
import type { PlayerState, EntityDef, Vec2 } from "@sdr/shared";
import { BaseScene, InputManager } from "@sdr/engine";
import type { InputState } from "@sdr/engine";
// ============================================================
// 1. COMPONENTS (bitECS SoA format)
// ============================================================
const Position = { x: [] as number[], y: [] as number[] };
const Velocity = { dx: [] as number[], dy: [] as number[] };
const Health = { current: [] as number[], max: [] as number[] };
const PlayerControlled = { sessionId: [] as string[] };
// Phaser GameObjects are stored in a Map, NOT in ECS components:
const gameObjects = new Map<number, Phaser.GameObjects.Sprite | Phaser.GameObjects.Rectangle>();
// Add more components as needed for this game
// ============================================================
// 2. QUERIES (bitECS 0.4 uses query() directly, no defineQuery)
// ============================================================
// Queries are called inline: query(world, [Position, Velocity])
// There is NO defineQuery in bitECS 0.4.
// ============================================================
// 3. SYSTEMS (pure functions operating on the world)
// ============================================================
function movementSystem(world: ReturnType<typeof createWorld>, dt: number): void {
for (const eid of query(world, [Position, Velocity])) {
Position.x[eid] += Velocity.dx[eid] * dt;
Position.y[eid] += Velocity.dy[eid] * dt;
}
}
function inputSystem(
_world: ReturnType<typeof createWorld>,
input: InputState, // Use InputState, not a custom type
localPlayerEid: number,
speed: number,
): void {
// Apply deadzone and normalise diagonal movement
const DEADZONE = 0.15;
let dx = Math.abs(input.moveX) > DEADZONE ? input.moveX : 0;
let dy = Math.abs(input.moveY) > DEADZONE ? input.moveY : 0;
const mag = Math.hypot(dx, dy);
if (mag > 1) { dx /= mag; dy /= mag; }
Velocity.dx[localPlayerEid] = dx * speed;
Velocity.dy[localPlayerEid] = dy * speed;
}
function renderSystem(world: ReturnType<typeof createWorld>): void {
for (const eid of query(world, [Position])) {
const obj = gameObjects.get(eid);
if (obj) {
obj.x = Position.x[eid];
obj.y = Position.y[eid];
}
}
}
// ============================================================
// 4. SCENE (extends BaseScene)
// ============================================================
export default class TodaysGame extends BaseScene {
private world!: ReturnType<typeof createWorld>;
private inputManager!: InputManager;
private localPlayerEid = -1;
// REQUIRED: entity definitions for this game
entities: Record<string, EntityDef> = {
player: { sprite: "player_sprite", physics: "dynamic", speed: 200 },
// ... more entity types
};
create(): void {
this.world = createWorld();
// Set up observers for entity lifecycle BEFORE creating any entities.
// CRITICAL: add type-tag components BEFORE Position so observers fire correctly.
observe(this.world, onAdd(Position, Visual), (eid: number) => {
const sprite = scene.add.sprite(Position.x[eid], Position.y[eid], "player");
gameObjects.set(eid, sprite);
});
observe(this.world, onRemove(Position, Visual), (eid: number) => {
gameObjects.get(eid)?.destroy();
gameObjects.delete(eid);
});
// InputManager handles gamepad, keyboard, AND touch (virtual joystick on mobile)
this.inputManager = new InputManager(this);
this.inputManager.setup(); // No arguments needed
// Create entities, set up physics, load level
// ...
}
// REQUIRED: called every frame
onUpdate(dt: number, players: PlayerState[]): void {
const input = this.inputManager.getState();
// Axes (held): input.moveX / moveY / aimX / aimY (-1 to 1)
// Held buttons: input.action1 / action2 / action3 / action4 / pause
// Just-pressed: input.action1Pressed / action2Pressed / action3Pressed / action4Pressed
// ^^ use these for discrete actions (jump, fire) — true only on first frame of press
// Bumpers: input.bumperLeft / bumperRight / bumperLeftPressed / bumperRightPressed
// Triggers (analog): input.triggerLeft / triggerRight (0.0–1.0)
// lastDevice: "keyboard" | "gamepad" | "touch"
// CALL getState() EXACTLY ONCE PER FRAME — justPressed is relative to previous call
inputSystem(this.world, input, this.localPlayerEid, 200);
movementSystem(this.world, dt);
renderSystem(this.world);
// ... more systems
}
// REQUIRED: return winner's sessionId or null
checkWinCondition(players: PlayerState[]): string | null {
// Example: first to 10 points wins
const winner = players.find((p) => (p.score ?? 0) >= 10);
return winner?.sessionId ?? null;
}
}
Server-Side Room File Structure
The server uses a generic state container (GameState) with flexible custom data storage. Generated rooms do NOT define custom schema fields. Instead, use state.setCustom() / state.getCustom() for game-level data and state.setPlayerCustom() / state.getPlayerCustom() for per-player data.
Every server/room.ts MUST follow this structure:
import type { GeneratedRoomLogic } from "@sdr/server";
import type { GameState } from "@sdr/server";
const GAME_DURATION = 180; // seconds (3 minutes)
const roomLogic: GeneratedRoomLogic = {
onInit(state: GameState): void {
// Set up initial game state using custom data
state.setCustom("roundTimer", GAME_DURATION);
state.setCustom("items", []);
// Initialize per-player state
for (const player of state.getPlayers()) {
state.setPlayerCustom(player.sessionId, "score", 0);
state.setPlayerCustom(player.sessionId, "x", 640);
state.setPlayerCustom(player.sessionId, "y", 400);
}
},
onUpdate(dt: number, state: GameState): void {
const timer = state.getCustomOr("roundTimer", GAME_DURATION);
state.setCustom("roundTimer", timer - dt / 1000);
if (timer <= 0) {
state.phase = "finished";
}
// Authoritative game logic:
// - Validate player positions
// - Spawn items on timers
// - Check collisions server-side
// - Update scores via state.setPlayerCustom()
},
onPlayerInput(
sessionId: string,
input: { x: number; y: number; buttons: Record<string, boolean> },
state: GameState,
): void {
// Handle continuous input (movement, aim)
const x = state.getPlayerCustom<number>(sessionId, "x") ?? 0;
const y = state.getPlayerCustom<number>(sessionId, "y") ?? 0;
state.setPlayerCustom(sessionId, "x", x + input.x * 5);
state.setPlayerCustom(sessionId, "y", y + input.y * 5);
},
onPlayerAction(sessionId: string, action: string, data: unknown, state: GameState): void {
// Handle discrete player-initiated actions
// ALWAYS validate on server. Never trust client.
switch (action) {
case "use_item":
// Validate player has the item, apply effect
break;
case "attack":
// Validate range, cooldown, apply damage
break;
}
},
onPlayerJoin(sessionId: string, state: GameState): void {
// Initialize new player's custom data
state.setPlayerCustom(sessionId, "score", 0);
state.setPlayerCustom(sessionId, "x", 640);
state.setPlayerCustom(sessionId, "y", 400);
},
onPlayerLeave(sessionId: string, state: GameState): void {
// Clean up player-specific data if needed
},
checkWinCondition(state: GameState): string | null {
// Return sessionId of winner, or null if game continues
for (const player of state.getPlayers()) {
const score = state.getPlayerCustom<number>(player.sessionId, "score") ?? 0;
if (score >= 10) return player.sessionId;
}
return null;
},
};
export default roomLogic;
GameState API Reference
| Method |
Description |
state.setCustom(key, value) |
Store any JSON-serializable value as game-level state |
state.getCustom<T>(key) |
Retrieve a typed value (returns undefined if missing) |
state.getCustomOr<T>(key, default) |
Retrieve with fallback default value |
state.setPlayerCustom(sessionId, key, value) |
Store data on a specific player |
state.getPlayerCustom<T>(sessionId, key) |
Retrieve player-specific data |
state.getPlayers() |
Get all connected players |
state.phase |
Current phase: "lobby", "playing", "finished" |
state.timer |
Game timer (number) |
IMPORTANT: Do NOT assume x, y, or score exist on the player schema. Use setPlayerCustom / getPlayerCustom for ALL game-specific player data.
bitECS Patterns for Generated Games
addComponent Signature (CRITICAL)
bitECS 0.4 uses addComponent(world, eid, Component), NOT addComponent(world, Component, eid):
const eid = addEntity(world);
addComponent(world, eid, Position); // world, entity, component
addComponent(world, eid, Velocity);
Component Design Rules
Use SoA (Structure-of-Arrays) format for performance:
// GOOD: SoA - cache friendly
const Position = { x: [] as number[], y: [] as number[] };
// AVOID: AoS for hot data
const Position = [] as { x: number; y: number }[];
Keep components small and focused. One concern per component:
// GOOD: Separate concerns
const Position = { x: [] as number[], y: [] as number[] };
const Health = { current: [] as number[], max: [] as number[] };
// BAD: Kitchen sink component
const Entity = { x: [], y: [], health: [], name: [], score: [] };
Use tag components (empty objects) for flags:
const IsEnemy = {};
const IsCollectible = {};
const IsDead = {};
System Design Rules
Systems are pure functions. They take the world (and optional context) and mutate component data:
function gravitySystem(world: World, dt: number): void {
for (const eid of query(world, [Position, Velocity])) {
Velocity.dy[eid] += 9.8 * dt;
}
}
Run systems in a deterministic order in the scene's onUpdate:
onUpdate(dt: number, players: PlayerState[]): void {
inputSystem(this.world, input, this.localPlayerEid);
movementSystem(this.world, dt);
collisionSystem(this.world);
spawnSystem(this.world, dt);
scoreSystem(this.world, players);
cleanupSystem(this.world);
renderSystem(this.world, this);
}
Use observers for entity lifecycle (bitECS 0.4 uses observe + onAdd/onRemove, NOT enterQuery/exitQuery):
// Set up observers once (e.g., in scene create):
observe(world, onAdd(IsEnemy, Position), (eid: number) => {
// New enemy: create sprite
const sprite = scene.add.sprite(Position.x[eid], Position.y[eid], "enemy");
gameObjects.set(eid, sprite);
});
observe(world, onRemove(IsEnemy, Position), (eid: number) => {
// Enemy removed: destroy sprite
gameObjects.get(eid)?.destroy();
gameObjects.delete(eid);
});
CRITICAL: Store Phaser GameObjects in a Map<number, GameObject>, NOT in ECS components.
ECS components must contain only serializable data (numbers, strings).
Multiplayer State Sync Rules
Client-Server Authority Model
The server is AUTHORITATIVE for:
- Player positions (validated)
- Scores
- Game phase (lobby, playing, finished)
- Win/lose conditions
- Item spawns and pickups
- Damage and health
The client is responsible for:
- Reading local input
- Sending input to server
- Rendering interpolated state
- Playing sound effects
- Showing UI/HUD
- Client-side prediction (optional, for responsiveness)
Network Message Types
Generated games communicate via these Colyseus message types:
// Client -> Server
"input" // { x, y, buttons } - every frame
"action" // { action: string, data: unknown } - discrete events
"ready" // { ready: boolean } - lobby ready state
// Server -> Client (via state sync)
// Colyseus automatically syncs GameState schema changes
// Use broadcast for game events:
"game:start" // Game begins
"game:event" // Custom game events (item spawned, explosion, etc.)
"game:win" // { winnerId: string } - game over
Keep Network Traffic Minimal
- Send input every frame (it's small: x, y, buttons)
- Send actions only on discrete events (button press, not hold)
- Do NOT send full entity state from client (server is authoritative)
- Use Colyseus schema for automatic delta compression
Asset Usage Rules
Using the Asset Catalog
Games MUST only reference assets from packages/generator/src/assets/catalog.json. The asset catalog contains pre-curated, pre-licensed assets from opengameart.org.
// In assets.json for a generated game:
{
"sprites": [
{ "id": "player_knight", "key": "player", "url": "sprites/knight_idle.png" },
{ "id": "enemy_slime", "key": "enemy", "url": "sprites/slime.png" }
],
"audio": [
{ "id": "sfx_hit", "key": "hit", "url": "audio/hit.wav" }
],
"music": [
{ "id": "bgm_battle", "key": "bgm", "url": "music/battle_loop.ogg" }
]
}
Asset Rules
- Never use external URLs. All assets must be from the catalog.
- Reference assets by their
key in Phaser (e.g., this.add.sprite(x, y, "player")).
- Use placeholder rectangles if an asset is missing. Never crash due to a missing asset.
- Keep total assets per game under 20 (sprites + audio + music combined).
Game Design Constraints
Pacing & Win Conditions (CRITICAL)
- Rounds: 60-120 seconds. Err on the side of shorter and more intense.
- Include a visible countdown timer via HUD.
- The game MUST end. When the timer expires or a score target is reached, the game MUST stop gameplay and show a clear winner screen.
checkWinCondition() alone is NOT enough. The scene's onUpdate MUST check it and act on it by showing a game-over overlay and freezing gameplay.
- After the win screen (5s), restart the round automatically (reset timer, scores, and entities).
- Escalate tension: make freeze intervals shorter, spawns faster, or hazards more frequent as the timer runs down.
- Score targets should be achievable in 60-90 seconds of active play. If the score target is too high, the timer will end the round instead.
Player Count
- Minimum: 2 players
- Maximum: 5 players
- Game must be fun at ANY player count in that range
- If a player disconnects, the game continues (don't end on disconnect)
Game Topics (Provided by Randomizer)
Each game receives three topic words from the randomizer: a setting (where it takes place), an activity (what players do), and a twist (what makes it weird). For example: "underwater basketball with magnets" or "haunted mansion dodgeball on ice". Design the game to incorporate all three topics into a fun 2D multiplayer experience.
Difficulty
- Simple rules that can be understood in 10 seconds
- Show a brief "How to Play" overlay before starting (5 seconds)
- No complex tutorials or progression systems
Fun Factor Checklist
Every generated game should aim for:
File Naming and Metadata
metadata.json
{
"id": "2026-02-15",
"date": "2026-02-15",
"title": "pirate arena with shrinking platforms",
"description": "A 2D multiplayer game: pirate arena with shrinking platforms",
"playerCount": { "min": 2, "max": 5 },
"controls": "Left stick to move, A to attack, B to dodge",
"howToPlay": "Battle other pirates on shrinking platforms. Last pirate standing wins!",
"seed": "2026-02-15-0",
"topics": {
"seed": "2026-02-15-0",
"setting": "pirate ship",
"activity": "arena battle",
"twist": "with shrinking platforms"
},
"assets": {
"sprites": [],
"audio": [],
"music": []
}
}
Validation Checklist (Post-Generation)
Before a game is deployed, it must pass ALL of these checks:
- TypeScript compilation:
tsc --noEmit on both client and server files
- Imports valid: Only imports from
@sdr/shared, @sdr/engine, phaser, bitecs, colyseus
- Extends BaseScene: Client file exports a default class extending BaseScene
- Required methods implemented:
entities, onUpdate, checkWinCondition
- No external URLs: No fetch() calls, no external image/audio URLs
- Uses InputManager: Input read through the unified input system, not raw Phaser input
- Uses bitECS 0.4: Entities managed through createWorld/addEntity/query/observe pattern (NOT defineQuery/enterQuery/exitQuery)
- Frame-rate independent: All movement uses
dt parameter
- Resolution correct: No hardcoded sizes other than 1280x800
- Metadata complete: All fields in metadata.json are filled in
1---2name: game-generation-guidelines3description: Coding guidelines and constraints for Claude when generating nightly multiplayer games. Covers the engine API surface, ECS patterns with bitECS, multiplayer state sync with Colyseus, asset usage, and required game structure. This is the primary reference for the generation script. Trigger: "generate game", "game generation", "nightly game", "game coding guidelines".4---56# Game Generation Coding Guidelines78These are the rules Claude MUST follow when generating a new multiplayer game for the Steam Deck Randomizer system. Every generated game must compile, run, and be fun for 2-5 players on Steam Deck.910---1112## Golden Rules13141. **Every game MUST be multiplayer** (2-5 players). No single-player games.152. **Every game MUST work with gamepad AND keyboard**. See `steamdeck-controls` skill.163. **Every game MUST extend the shared engine**. Do not reinvent rendering, input, or networking.174. **Every game MUST use bitECS** for entity management. See `bitecs` skill.185. **Every game MUST fit in TWO files**: one client scene file, one server room file.196. **Every game MUST have clear win/lose conditions** and 2-5 minute rounds.207. **Every game MUST use only assets from the provided catalog**. No external URLs.218. **Every game MUST be frame-rate independent** (use delta time, never frame counts).229. **Every game MUST target 1280x800** resolution (Steam Deck native).2310. **Every game MUST handle player join/leave gracefully** mid-game.2425---2627## Architecture Overview2829```30Generated Game31 ├── client/game.ts (extends BaseScene, uses bitECS + Phaser)32 ├── server/room.ts (Colyseus room logic, authoritative state)33 ├── assets.json (references to catalog assets)34 └── metadata.json (title, description, controls, genre)35```3637The engine (`@sdr/engine`) handles:38- Phaser initialization and lifecycle39- Gamepad + keyboard input reading40- Asset loading from manifest41- Colyseus client connection and state sync42- HUD (scores, timer, player list)43- Lobby (wait for players, ready up)4445Claude generates ONLY gameplay logic on top of this.4647---4849## Client-Side Game File Structure5051Every `client/game.ts` MUST follow this structure:5253```typescript54import Phaser from "phaser";55import {56 createWorld, addEntity, addComponent, removeEntity,57 query, observe, onAdd, onRemove,58} from "bitecs";59import type { PlayerState, EntityDef, Vec2 } from "@sdr/shared";60import { BaseScene, InputManager } from "@sdr/engine";61import type { InputState } from "@sdr/engine";6263// ============================================================64// 1. COMPONENTS (bitECS SoA format)65// ============================================================66const Position = { x: [] as number[], y: [] as number[] };67const Velocity = { dx: [] as number[], dy: [] as number[] };68const Health = { current: [] as number[], max: [] as number[] };69const PlayerControlled = { sessionId: [] as string[] };70// Phaser GameObjects are stored in a Map, NOT in ECS components:71const gameObjects = new Map<number, Phaser.GameObjects.Sprite | Phaser.GameObjects.Rectangle>();72// Add more components as needed for this game7374// ============================================================75// 2. QUERIES (bitECS 0.4 uses query() directly, no defineQuery)76// ============================================================77// Queries are called inline: query(world, [Position, Velocity])78// There is NO defineQuery in bitECS 0.4.7980// ============================================================81// 3. SYSTEMS (pure functions operating on the world)82// ============================================================83function movementSystem(world: ReturnType<typeof createWorld>, dt: number): void {84 for (const eid of query(world, [Position, Velocity])) {85 Position.x[eid] += Velocity.dx[eid] * dt;86 Position.y[eid] += Velocity.dy[eid] * dt;87 }88}8990function inputSystem(91 _world: ReturnType<typeof createWorld>,92 input: InputState, // Use InputState, not a custom type93 localPlayerEid: number,94 speed: number,95): void {96 // Apply deadzone and normalise diagonal movement97 const DEADZONE = 0.15;98 let dx = Math.abs(input.moveX) > DEADZONE ? input.moveX : 0;99 let dy = Math.abs(input.moveY) > DEADZONE ? input.moveY : 0;100 const mag = Math.hypot(dx, dy);101 if (mag > 1) { dx /= mag; dy /= mag; }102103 Velocity.dx[localPlayerEid] = dx * speed;104 Velocity.dy[localPlayerEid] = dy * speed;105}106107function renderSystem(world: ReturnType<typeof createWorld>): void {108 for (const eid of query(world, [Position])) {109 const obj = gameObjects.get(eid);110 if (obj) {111 obj.x = Position.x[eid];112 obj.y = Position.y[eid];113 }114 }115}116117// ============================================================118// 4. SCENE (extends BaseScene)119// ============================================================120export default class TodaysGame extends BaseScene {121 private world!: ReturnType<typeof createWorld>;122 private inputManager!: InputManager;123 private localPlayerEid = -1;124125 // REQUIRED: entity definitions for this game126 entities: Record<string, EntityDef> = {127 player: { sprite: "player_sprite", physics: "dynamic", speed: 200 },128 // ... more entity types129 };130131 create(): void {132 this.world = createWorld();133134 // Set up observers for entity lifecycle BEFORE creating any entities.135 // CRITICAL: add type-tag components BEFORE Position so observers fire correctly.136 observe(this.world, onAdd(Position, Visual), (eid: number) => {137 const sprite = scene.add.sprite(Position.x[eid], Position.y[eid], "player");138 gameObjects.set(eid, sprite);139 });140 observe(this.world, onRemove(Position, Visual), (eid: number) => {141 gameObjects.get(eid)?.destroy();142 gameObjects.delete(eid);143 });144145 // InputManager handles gamepad, keyboard, AND touch (virtual joystick on mobile)146 this.inputManager = new InputManager(this);147 this.inputManager.setup(); // No arguments needed148149 // Create entities, set up physics, load level150 // ...151 }152153 // REQUIRED: called every frame154 onUpdate(dt: number, players: PlayerState[]): void {155 const input = this.inputManager.getState();156 // Axes (held): input.moveX / moveY / aimX / aimY (-1 to 1)157 // Held buttons: input.action1 / action2 / action3 / action4 / pause158 // Just-pressed: input.action1Pressed / action2Pressed / action3Pressed / action4Pressed159 // ^^ use these for discrete actions (jump, fire) — true only on first frame of press160 // Bumpers: input.bumperLeft / bumperRight / bumperLeftPressed / bumperRightPressed161 // Triggers (analog): input.triggerLeft / triggerRight (0.0–1.0)162 // lastDevice: "keyboard" | "gamepad" | "touch"163164 // CALL getState() EXACTLY ONCE PER FRAME — justPressed is relative to previous call165166 inputSystem(this.world, input, this.localPlayerEid, 200);167 movementSystem(this.world, dt);168 renderSystem(this.world);169 // ... more systems170 }171172 // REQUIRED: return winner's sessionId or null173 checkWinCondition(players: PlayerState[]): string | null {174 // Example: first to 10 points wins175 const winner = players.find((p) => (p.score ?? 0) >= 10);176 return winner?.sessionId ?? null;177 }178}179```180181---182183## Server-Side Room File Structure184185The server uses a **generic state container** (GameState) with flexible custom data storage. Generated rooms do NOT define custom schema fields. Instead, use `state.setCustom()` / `state.getCustom()` for game-level data and `state.setPlayerCustom()` / `state.getPlayerCustom()` for per-player data.186187Every `server/room.ts` MUST follow this structure:188189```typescript190import type { GeneratedRoomLogic } from "@sdr/server";191import type { GameState } from "@sdr/server";192193const GAME_DURATION = 180; // seconds (3 minutes)194195const roomLogic: GeneratedRoomLogic = {196 onInit(state: GameState): void {197 // Set up initial game state using custom data198 state.setCustom("roundTimer", GAME_DURATION);199 state.setCustom("items", []);200201 // Initialize per-player state202 for (const player of state.getPlayers()) {203 state.setPlayerCustom(player.sessionId, "score", 0);204 state.setPlayerCustom(player.sessionId, "x", 640);205 state.setPlayerCustom(player.sessionId, "y", 400);206 }207 },208209 onUpdate(dt: number, state: GameState): void {210 const timer = state.getCustomOr("roundTimer", GAME_DURATION);211 state.setCustom("roundTimer", timer - dt / 1000);212213 if (timer <= 0) {214 state.phase = "finished";215 }216217 // Authoritative game logic:218 // - Validate player positions219 // - Spawn items on timers220 // - Check collisions server-side221 // - Update scores via state.setPlayerCustom()222 },223224 onPlayerInput(225 sessionId: string,226 input: { x: number; y: number; buttons: Record<string, boolean> },227 state: GameState,228 ): void {229 // Handle continuous input (movement, aim)230 const x = state.getPlayerCustom<number>(sessionId, "x") ?? 0;231 const y = state.getPlayerCustom<number>(sessionId, "y") ?? 0;232 state.setPlayerCustom(sessionId, "x", x + input.x * 5);233 state.setPlayerCustom(sessionId, "y", y + input.y * 5);234 },235236 onPlayerAction(sessionId: string, action: string, data: unknown, state: GameState): void {237 // Handle discrete player-initiated actions238 // ALWAYS validate on server. Never trust client.239 switch (action) {240 case "use_item":241 // Validate player has the item, apply effect242 break;243 case "attack":244 // Validate range, cooldown, apply damage245 break;246 }247 },248249 onPlayerJoin(sessionId: string, state: GameState): void {250 // Initialize new player's custom data251 state.setPlayerCustom(sessionId, "score", 0);252 state.setPlayerCustom(sessionId, "x", 640);253 state.setPlayerCustom(sessionId, "y", 400);254 },255256 onPlayerLeave(sessionId: string, state: GameState): void {257 // Clean up player-specific data if needed258 },259260 checkWinCondition(state: GameState): string | null {261 // Return sessionId of winner, or null if game continues262 for (const player of state.getPlayers()) {263 const score = state.getPlayerCustom<number>(player.sessionId, "score") ?? 0;264 if (score >= 10) return player.sessionId;265 }266 return null;267 },268};269270export default roomLogic;271```272273### GameState API Reference274275| Method | Description |276|--------|-------------|277| `state.setCustom(key, value)` | Store any JSON-serializable value as game-level state |278| `state.getCustom<T>(key)` | Retrieve a typed value (returns `undefined` if missing) |279| `state.getCustomOr<T>(key, default)` | Retrieve with fallback default value |280| `state.setPlayerCustom(sessionId, key, value)` | Store data on a specific player |281| `state.getPlayerCustom<T>(sessionId, key)` | Retrieve player-specific data |282| `state.getPlayers()` | Get all connected players |283| `state.phase` | Current phase: "lobby", "playing", "finished" |284| `state.timer` | Game timer (number) |285286**IMPORTANT**: Do NOT assume `x`, `y`, or `score` exist on the player schema. Use `setPlayerCustom` / `getPlayerCustom` for ALL game-specific player data.287288---289290## bitECS Patterns for Generated Games291292### addComponent Signature (CRITICAL)293294bitECS 0.4 uses `addComponent(world, eid, Component)`, NOT `addComponent(world, Component, eid)`:295296```typescript297const eid = addEntity(world);298addComponent(world, eid, Position); // world, entity, component299addComponent(world, eid, Velocity);300```301302### Component Design Rules3033041. **Use SoA (Structure-of-Arrays) format** for performance:305 ```typescript306 // GOOD: SoA - cache friendly307 const Position = { x: [] as number[], y: [] as number[] };308309 // AVOID: AoS for hot data310 const Position = [] as { x: number; y: number }[];311 ```3123132. **Keep components small and focused**. One concern per component:314 ```typescript315 // GOOD: Separate concerns316 const Position = { x: [] as number[], y: [] as number[] };317 const Health = { current: [] as number[], max: [] as number[] };318319 // BAD: Kitchen sink component320 const Entity = { x: [], y: [], health: [], name: [], score: [] };321 ```3223233. **Use tag components** (empty objects) for flags:324 ```typescript325 const IsEnemy = {};326 const IsCollectible = {};327 const IsDead = {};328 ```329330### System Design Rules3313321. **Systems are pure functions**. They take the world (and optional context) and mutate component data:333 ```typescript334 function gravitySystem(world: World, dt: number): void {335 for (const eid of query(world, [Position, Velocity])) {336 Velocity.dy[eid] += 9.8 * dt;337 }338 }339 ```3403412. **Run systems in a deterministic order** in the scene's `onUpdate`:342 ```typescript343 onUpdate(dt: number, players: PlayerState[]): void {344 inputSystem(this.world, input, this.localPlayerEid);345 movementSystem(this.world, dt);346 collisionSystem(this.world);347 spawnSystem(this.world, dt);348 scoreSystem(this.world, players);349 cleanupSystem(this.world);350 renderSystem(this.world, this);351 }352 ```3533543. **Use observers** for entity lifecycle (bitECS 0.4 uses `observe` + `onAdd`/`onRemove`, NOT `enterQuery`/`exitQuery`):355 ```typescript356 // Set up observers once (e.g., in scene create):357 observe(world, onAdd(IsEnemy, Position), (eid: number) => {358 // New enemy: create sprite359 const sprite = scene.add.sprite(Position.x[eid], Position.y[eid], "enemy");360 gameObjects.set(eid, sprite);361 });362363 observe(world, onRemove(IsEnemy, Position), (eid: number) => {364 // Enemy removed: destroy sprite365 gameObjects.get(eid)?.destroy();366 gameObjects.delete(eid);367 });368 ```369370 **CRITICAL**: Store Phaser GameObjects in a `Map<number, GameObject>`, NOT in ECS components.371 ECS components must contain only serializable data (numbers, strings).372373---374375## Multiplayer State Sync Rules376377### Client-Server Authority Model378379The server is AUTHORITATIVE for:380- Player positions (validated)381- Scores382- Game phase (lobby, playing, finished)383- Win/lose conditions384- Item spawns and pickups385- Damage and health386387The client is responsible for:388- Reading local input389- Sending input to server390- Rendering interpolated state391- Playing sound effects392- Showing UI/HUD393- Client-side prediction (optional, for responsiveness)394395### Network Message Types396397Generated games communicate via these Colyseus message types:398399```typescript400// Client -> Server401"input" // { x, y, buttons } - every frame402"action" // { action: string, data: unknown } - discrete events403"ready" // { ready: boolean } - lobby ready state404405// Server -> Client (via state sync)406// Colyseus automatically syncs GameState schema changes407// Use broadcast for game events:408"game:start" // Game begins409"game:event" // Custom game events (item spawned, explosion, etc.)410"game:win" // { winnerId: string } - game over411```412413### Keep Network Traffic Minimal4144151. Send input every frame (it's small: x, y, buttons)4162. Send actions only on discrete events (button press, not hold)4173. Do NOT send full entity state from client (server is authoritative)4184. Use Colyseus schema for automatic delta compression419420---421422## Asset Usage Rules423424### Using the Asset Catalog425426Games MUST only reference assets from `packages/generator/src/assets/catalog.json`. The asset catalog contains pre-curated, pre-licensed assets from opengameart.org.427428```typescript429// In assets.json for a generated game:430{431 "sprites": [432 { "id": "player_knight", "key": "player", "url": "sprites/knight_idle.png" },433 { "id": "enemy_slime", "key": "enemy", "url": "sprites/slime.png" }434 ],435 "audio": [436 { "id": "sfx_hit", "key": "hit", "url": "audio/hit.wav" }437 ],438 "music": [439 { "id": "bgm_battle", "key": "bgm", "url": "music/battle_loop.ogg" }440 ]441}442```443444### Asset Rules4454461. **Never use external URLs**. All assets must be from the catalog.4472. **Reference assets by their `key`** in Phaser (e.g., `this.add.sprite(x, y, "player")`).4483. **Use placeholder rectangles** if an asset is missing. Never crash due to a missing asset.4494. **Keep total assets per game under 20** (sprites + audio + music combined).450451---452453## Game Design Constraints454455### Pacing & Win Conditions (CRITICAL)456- Rounds: 60-120 seconds. Err on the side of shorter and more intense.457- Include a visible countdown timer via HUD.458- **The game MUST end**. When the timer expires or a score target is reached, the game MUST stop gameplay and show a clear winner screen.459- `checkWinCondition()` alone is NOT enough. The scene's `onUpdate` MUST check it and act on it by showing a game-over overlay and freezing gameplay.460- After the win screen (5s), restart the round automatically (reset timer, scores, and entities).461- Escalate tension: make freeze intervals shorter, spawns faster, or hazards more frequent as the timer runs down.462- Score targets should be achievable in 60-90 seconds of active play. If the score target is too high, the timer will end the round instead.463464### Player Count465- Minimum: 2 players466- Maximum: 5 players467- Game must be fun at ANY player count in that range468- If a player disconnects, the game continues (don't end on disconnect)469470### Game Topics (Provided by Randomizer)471472Each game receives three topic words from the randomizer: a **setting** (where it takes place), an **activity** (what players do), and a **twist** (what makes it weird). For example: "underwater basketball with magnets" or "haunted mansion dodgeball on ice". Design the game to incorporate all three topics into a fun 2D multiplayer experience.473474### Difficulty475- Simple rules that can be understood in 10 seconds476- Show a brief "How to Play" overlay before starting (5 seconds)477- No complex tutorials or progression systems478479### Fun Factor Checklist480Every generated game should aim for:481- [ ] Immediate, obvious feedback when you do something (hit an enemy, collect an item)482- [ ] Visual and audio feedback for all actions483- [ ] Clear scoreboard showing all players484- [ ] A "comeback mechanic" so losing players have a chance485- [ ] Escalating tension (game gets harder/faster over time)486- [ ] Clear winner announcement at end487488---489490## File Naming and Metadata491492### metadata.json493494```json495{496 "id": "2026-02-15",497 "date": "2026-02-15",498 "title": "pirate arena with shrinking platforms",499 "description": "A 2D multiplayer game: pirate arena with shrinking platforms",500 "playerCount": { "min": 2, "max": 5 },501 "controls": "Left stick to move, A to attack, B to dodge",502 "howToPlay": "Battle other pirates on shrinking platforms. Last pirate standing wins!",503 "seed": "2026-02-15-0",504 "topics": {505 "seed": "2026-02-15-0",506 "setting": "pirate ship",507 "activity": "arena battle",508 "twist": "with shrinking platforms"509 },510 "assets": {511 "sprites": [],512 "audio": [],513 "music": []514 }515}516```517518---519520## Validation Checklist (Post-Generation)521522Before a game is deployed, it must pass ALL of these checks:5235241. **TypeScript compilation**: `tsc --noEmit` on both client and server files5252. **Imports valid**: Only imports from `@sdr/shared`, `@sdr/engine`, `phaser`, `bitecs`, `colyseus`5263. **Extends BaseScene**: Client file exports a default class extending BaseScene5274. **Required methods implemented**: `entities`, `onUpdate`, `checkWinCondition`5285. **No external URLs**: No fetch() calls, no external image/audio URLs5296. **Uses InputManager**: Input read through the unified input system, not raw Phaser input5307. **Uses bitECS 0.4**: Entities managed through createWorld/addEntity/query/observe pattern (NOT defineQuery/enterQuery/exitQuery)5318. **Frame-rate independent**: All movement uses `dt` parameter5329. **Resolution correct**: No hardcoded sizes other than 1280x80053310. **Metadata complete**: All fields in metadata.json are filled in