SKILL.md
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:
```typescript // 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:
```typescript // 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:
``typescript const IsEnemy = {}; const IsCollectible = {}; const IsDead = {}; ``
System Design Rules
- Systems are pure functions. They take the world (and optional context) and mutate component data:
``typescript 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:
``typescript 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):
```typescript // 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