| 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-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";
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[] };
const gameObjects = new Map<number, Phaser.GameObjects.Sprite | Phaser.GameObjects.Rectangle>();
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,
localPlayerEid: number,
speed: number,
): void {
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];
}
}
}
export default class TodaysGame extends BaseScene {
private world!: ReturnType<typeof createWorld>;
private inputManager!: InputManager;
private localPlayerEid = -1;
entities: Record<string, EntityDef> = {
player: { sprite: "player_sprite", physics: "dynamic", speed: 200 },
};
create(): void {
this.world = createWorld();
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);
});
this.inputManager = new InputManager(this);
this.inputManager.setup();
}
onUpdate(dt: number, players: PlayerState[]): void {
const input = this.inputManager.getState();
inputSystem(this.world, input, this.localPlayerEid, 200);
movementSystem(this.world, dt);
renderSystem(this.world);
}
checkWinCondition(players: PlayerState[]): string | null {
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;
const roomLogic: GeneratedRoomLogic = {
onInit(state: GameState): void {
state.setCustom("roundTimer", GAME_DURATION);
state.setCustom("items", []);
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";
}
},
onPlayerInput(
sessionId: string,
input: { x: number; y: number; buttons: Record<string, boolean> },
state: GameState,
): void {
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 {
switch (action) {
case "use_item":
break;
case "attack":
break;
}
},
onPlayerJoin(sessionId: string, state: GameState): void {
state.setPlayerCustom(sessionId, "score", 0);
state.setPlayerCustom(sessionId, "x", 640);
state.setPlayerCustom(sessionId, "y", 400);
},
onPlayerLeave(sessionId: string, state: GameState): void {
},
checkWinCondition(state: GameState): string | null {
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);
addComponent(world, eid, Velocity);
Component Design Rules
-
Use SoA (Structure-of-Arrays) format for performance:
const Position = { x: [] as number[], y: [] as number[] };
const Position = [] as { x: number; y: number }[];
-
Keep components small and focused. One concern per component:
const Position = { x: [] as number[], y: [] as number[] };
const Health = { current: [] as number[], max: [] as number[] };
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):
observe(world, onAdd(IsEnemy, Position), (eid: number) => {
const sprite = scene.add.sprite(Position.x[eid], Position.y[eid], "enemy");
gameObjects.set(eid, sprite);
});
observe(world, onRemove(IsEnemy, Position), (eid: number) => {
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:
"input"
"action"
"ready"
"game:start"
"game:event"
"game:win"
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.
{
"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