name: game-generation-guidelines description: > 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".
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-controlsskill. - Every game MUST extend the shared engine. Do not reinvent rendering, input, or networking.
- Every game MUST use bitECS for entity management. See
bitecsskill. - 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, NOTenterQuery/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
keyin 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'sonUpdateMUST 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:
- Immediate, obvious feedback when you do something (hit an enemy, collect an item)
- Visual and audio feedback for all actions
- Clear scoreboard showing all players
- A "comeback mechanic" so losing players have a chance
- Escalating tension (game gets harder/faster over time)
- Clear winner announcement at end
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 --noEmiton 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
dtparameter - Resolution correct: No hardcoded sizes other than 1280x800
- Metadata complete: All fields in metadata.json are filled in