codesmith
Skill4 starsChanged 7 months agoMITView on GitHub
Writes the gameplay code for a Phaser 3 game (src/scenes/Game.js plus optional helpers). Reads the GDD, levels, and sprite/tile manifest; produces ES module code that consumes assets by name (never magic numbers). Use after sprite-artist and tile-artist have produced a complete manifest.
In this repository22 other files it gives its agents.
- action-platformerSkill
- audio-composerSkill
- beat-em-upSkill
- bg-artistSkill
- game-designerSkill
- gameforgeSkill
- gap-checkerSkill
- iso-city-builderSkill
- iso-living-worldSkill
- manifest-auditorSkill
- mobile-compat-checkerSkill
- multiplayerSkill
- palette-enforcerSkill
- parallel-qa-orchestraSkill
- physics-debug-validatorSkill
- pixel-action-adventureSkill
- playtesterSkill
- refinerSkill
- rpg-overworldSkill
- sprite-artistSkill
- tile-artistSkill
- world-architectSkill
The file
We split this skill into the rules it gives an agent
10 rules. About 0 are common, 0 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈0
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 246
Before writing Game.js, read the top entries from the persistent debug library and treat them as hard constraints:
- Very specific: line 256
If state.audio is set in game-state.json, add these to Game.js:
- Very specific: line 290
Animation keys: ALWAYS <ENTITY_ID>-<state-lowercased>.
- Very specific: line 291
Sprite frames: ALWAYS frame = rowIdx * cols + colIdx.
- Very specific: line 299
Determinism: don't use Math.random() or Date.now() directly — use Phaser.Math.RND (seeded via game config).
- Very specific: line 301
window.__gameState: MUST update every frame with at least phase, playerX, playerY plus any field referenced by winCondition / … - Very specific: line 302
this.events.emit('scene-ready'): MUST fire at end of create(). - Very specific: line 303
Win/lose: when reached, set this.gameOver = true, emit 'game-won' or 'game-lost', render an overlay text.
- Very specific: line 307
Read GDD + levels + manifest from game-state.json.
- Very specific: line 310
Run scripts/write_files.mjs <project-dir> — refuses paths outside src/.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Codesmith
Phaser 3 game programmer. Produces ONE required file (src/scenes/Game.js) and optional helpers under src/entities/ or src/lib/.
When to use
Final LLM stage of the generation pipeline, after design + level + asset stages have completed.
Output contract
Output ONLY a JSON object:
{
"files": [
{ "path": "src/scenes/Game.js", "content": "<full file contents>" },
{ "path": "src/entities/Enemy.js", "content": "..." } // optional
]
}src/scenes/Game.js is required. May also write under src/entities/ or src/lib/. MUST NOT write outside src/.
Runtime environment (already provided by template)
Phaserglobal (3.85+) imported asimport Phaser from 'phaser'.- Pre-loaded textures via Boot/Preload:
entities-N(one per sprite sheet, N = 1, 2, ...)tiles
- Pre-built animations:
<ENTITY_ID>-<state>(lowercase state). E.g.KNIGHT-walk,SLIME-hurt. this.registry.get('levels')→ levels arraythis.registry.get('manifest')→ full manifestinit({ levelIndex })data on scene start
GameScene contract (REQUIRED)
import Phaser from 'phaser';
export default class Game extends Phaser.Scene {
constructor() { super({ key: 'Game' }); }
init(data) { this.levelIndex = data?.levelIndex ?? 0; }
create() {
const levels = this.registry.get('levels');
const manifest = this.registry.get('manifest');
const level = levels[this.levelIndex];
const tileSize = manifest.tiles.tileSize;
// 1. Build tilemap
const map = this.make.tilemap({ data: level.tiles, tileWidth: tileSize, tileHeight: tileSize });
const tileset = map.addTilesetImage('tiles', 'tiles', tileSize, tileSize, 0, 0);
const layer = map.createLayer(0, tileset, 0, 0);
layer.setCollision(manifest.tiles.passable.map((p, i) => p ? -1 : i).filter(i => i >= 0));
// 2. (For platformer only) set per-world gravity
// this.physics.world.gravity.set(0, 600);
// 3. Spawn entities — look up sheet by name
const findSheet = (id) => {
for (const s of manifest.sprites) {
const r = s.rows.indexOf(id);
if (r >= 0) return { tex: s.textureKey, rowIdx: r, cols: s.cols.length };
}
return null;
};
// ... loop level.spawns, create sprites with frame = sheet.rowIdx * sheet.cols
// play '<ENTITY_ID>-idle' on each
// 4. Wire physics colliders / overlaps
// 5. Wire input: cursors + WASD + SPACE
// 6. Update window.__gameState every frame
// 7. At end of create():
this.events.emit('scene-ready');
}
update(time, delta) {
// movement, animation switching, AI, win/lose checks
this.updateState();
}
updateState() {
window.__gameState = {
phase: this.gameOver ? (this.won ? 'won' : 'lost') : 'playing',
playerX: this.player ? this.player.x : 0,
playerY: this.player ? this.player.y : 0,
// ... game-specific fields referenced by gdd.winCondition / loseCondition
};
}
}NES-quality visual patterns (use these — not plain text HUD)
Generated games must use real visual effects, not just text labels. These patterns are copy-pasteable and tested across all 4 example games.
HUD — NES-style segmented HP bar + portrait box
_buildHud() {
const W = this.scale.width;
// Portrait box (top-left)
this.add.rectangle(4, 4, 36, 36, 0x000000).setOrigin(0).setScrollFactor(0).setDepth(300);
this.add.rectangle(5, 5, 34, 34, 0x111111).setOrigin(0).setScrollFactor(0).setDepth(301);
const portrait = this.add.sprite(22, 22, texMap.PLAYER_ENTITY).setScrollFactor(0).setDepth(302);
portrait.setDisplaySize(28, 28);
// Segmented HP bar (10 segments)
this._hpSegments = [];
for (let i = 0; i < MAX_HP; i++) {
const seg = this.add.rectangle(46 + i * 12, 8, 10, 8, 0xdd2222).setOrigin(0).setScrollFactor(0).setDepth(300);
this._hpSegments.push(seg);
}
// Lives counter
this._livesTxt = this.add.text(4, 44, '×3', { fontSize: '11px', fill: '#fff', stroke: '#000', strokeThickness: 2 }).setScrollFactor(0).setDepth(300);
// Score (top-right, yellow)
this._scoreTxt = this.add.text(W - 8, 4, 'SCORE\n000000', { fontSize: '11px', fill: '#ffdd00', align: 'right' }).setOrigin(1, 0).setScrollFactor(0).setDepth(300);
}
_refreshHud() {
this._hpSegments.forEach((seg, i) => seg.setVisible(i < this.playerHp));
this._scoreTxt.setText('SCORE\n' + String(this.score).padStart(6, '0'));
}Hit particles
_emitHitParticles(x, y, color = 0xffffff) {
for (let i = 0; i < 5; i++) {
const g = this.add.graphics();
g.fillStyle(color, 1);
g.fillRect(0, 0, 4, 4);
g.setPosition(x, y).setDepth(50);
const angle = (Math.PI * 2 * i) / 5;
this.tweens.add({
targets: g, x: x + Math.cos(angle) * 28, y: y + Math.sin(angle) * 28,
alpha: 0, duration: 240, onComplete: () => g.destroy(),
});
}
}Pickup sparkle
_pickupSparkle(x, y) {
const c = this.add.circle(x, y, 1, 0xffffff, 0.9).setDepth(60);
this.tweens.add({ targets: c, scaleX: 18, scaleY: 18, alpha: 0, duration: 220, onComplete: () => c.destroy() });
}Enemy death animation
_killEnemy(e) {
let flashes = 0;
const flash = () => {
if (flashes++ >= 3 || !e.active) return;
e.setTint(0xffffff);
this.time.delayedCall(60, () => { if (e.active) { e.clearTint(); this.time.delayedCall(60, flash); } });
};
flash();
if (e.body) e.body.setAllowGravity(true);
this.tweens.add({ targets: e, alpha: 0, y: e.y + 30, duration: 380, onComplete: () => e.destroy() });
}Screen flash (on player hurt or big event)
_screenFlash() {
const { width: W, height: H } = this.scale;
const fl = this.add.rectangle(W / 2, H / 2, W, H, 0xffffff, 0.4).setScrollFactor(0).setDepth(500);
this.tweens.add({ targets: fl, alpha: 0, duration: 180, onComplete: () => fl.destroy() });
}3-layer parallax background (action-platformer / platformer)
// In create(), after checking manifest.bg:
if (manifest.bg) {
this.add.image(0, 0, 'bg').setOrigin(0).setScrollFactor(0.05).setDepth(-300).setDisplaySize(worldW, worldH);
this.add.image(0, 0, 'bg').setOrigin(0).setScrollFactor(0.15).setDepth(-200).setDisplaySize(worldW, worldH).setAlpha(0.6);
this.add.image(0, 0, 'bg').setOrigin(0).setScrollFactor(0.35).setDepth(-100).setDisplaySize(worldW, worldH).setAlpha(0.35);
}Beat-em-up combo counter
// In _hitEnemy():
this._comboCount = (this._comboCount ?? 0) + 1;
clearTimeout(this._comboTimer);
this._comboTimer = setTimeout(() => { this._comboCount = 0; }, 1500);
if (this._comboCount >= 2) {
const txt = this.add.text(e.x, e.y - 20, `×${this._comboCount}`, {
fontSize: '14px', fill: '#ffdd00', stroke: '#000', strokeThickness: 3,
}).setDepth(80);
this.tweens.add({ targets: txt, y: txt.y - 28, alpha: 0, duration: 600, onComplete: () => txt.destroy() });
}Coyote-time jump (action-platformer)
const COYOTE_MS = 80;
// In update():
if (this.player.body.blocked.down) { this._coyote = COYOTE_MS; this._isJumping = false; }
else { this._coyote = Math.max(0, this._coyote - delta); }
if (Phaser.Input.Keyboard.JustDown(jumpKey) && this._coyote > 0 && !this._isJumping) {
this.player.body.setVelocityY(JUMP_VY); // e.g. -380
this._coyote = 0;
this._isJumping = true;
}
// Variable jump height (release early = shorter jump):
if (!jumpKey.isDown && this.player.body.velocity.y < -80) {
this.player.body.setVelocityY(this.player.body.velocity.y * 0.88);
}Platform-edge-aware enemy patrol
_patrolEnemy(e, delta) {
const ts = manifest.tiles.tileSize;
const dir = e.getData('dir') ?? 1;
const nextX = e.x + dir * (ts * 0.6);
const tileAhead = this._tileLayer.getTileAtWorldXY(nextX, e.y);
const tileBelow = this._tileLayer.getTileAtWorldXY(nextX, e.y + ts * 0.6);
if (!tileBelow || tileAhead) e.setData('dir', -dir); // flip at edge or wall
e.body.setVelocityX(e.getData('dir') * e.getData('speed'));
e.setFlipX(e.getData('dir') < 0);
}Known-pitfalls injection (from debug library)
Before writing Game.js, read the top entries from the persistent debug library and treat them as hard constraints:
node scripts/debug_library.mjs --listEach entry is a symptom → fix pair accumulated across past refiner runs. If the library is non-empty, prepend its entries to your mental model as "DO NOT DO X because it causes Y." This prevents codesmith from shipping bugs the refiner already solved.
Audio integration
If state.audio is set in game-state.json, add these to Game.js:
import AudioManager from '../audio/AudioManager.js';
// In create(), after all entity setup:
AudioManager.init(this);
// In pickup overlap callbacks:
AudioManager.play('pickup');
// In damage callbacks:
AudioManager.play('hit');
// In player death handler:
AudioManager.play('death');
AudioManager.stopMusic();
// In jump input handler (platformer):
AudioManager.play('jump');
// In shoot/fire handler:
AudioManager.play('shoot');
// In win-condition handler:
AudioManager.play('win');
AudioManager.stopMusic();AudioManager handles Safari AudioContext unlock automatically via the first user gesture. Never call it before AudioManager.init(this) runs.
Hard rules
- ES module syntax —
import Phaser from 'phaser',export default class. No CJS, no TypeScript. - Animation keys: ALWAYS
<ENTITY_ID>-<state-lowercased>. NEVER reference an animation key not present in the manifest. - Sprite frames: ALWAYS
frame = rowIdx * cols + colIdx. Look up viamanifest.sprites[i]. - Movement:
- Held inputs →
key.isDown. - One-shot actions (jump, attack, fire) →
Phaser.Input.Keyboard.JustDown(key). THIS IS NON-NEGOTIABLE — single keypresses can be sub-frame andisDownpolling will miss them.
- Held inputs →
- Top-down physics:
gravity 0, normalize diagonal velocity (b.velocity.normalize().scale(speed)). - Platformer physics:
this.physics.world.gravity.set(0, 600)increate(). Jump onJustDown(SPACE) && body.blocked.down. Pickup/enemy groups:this.physics.add.group({ allowGravity: false }). - Body sizing:
setDisplaySize(tileSize, tileSize)thenbody.setSize(tileSize * 0.6-0.7, tileSize * 0.6-0.85)so hitbox matches visible pixels. - Camera:
cameras.main.startFollow(player, true, 0.1, 0.1)for scrolling games;roundPixels = true. - Determinism: don't use
Math.random()orDate.now()directly — usePhaser.Math.RND(seeded via game config). - Comments: minimal. Identifiers carry meaning. No file-header docblocks, no decorative comments.
window.__gameState: MUST update every frame with at leastphase,playerX,playerYplus any field referenced bywinCondition/loseCondition. The playtester reads this.this.events.emit('scene-ready'): MUST fire at end ofcreate(). The harness blocks on this.- Win/lose: when reached, set
this.gameOver = true, emit'game-won'or'game-lost', render an overlay text. Stop processing input/physics inupdate()after gameOver.
Process
- Read GDD + levels + manifest from
game-state.json. - Build the file using the contract above.
- Validate with
scripts/validate_code.mjs(basic AST + animation-key reference check). - Run
scripts/write_files.mjs <project-dir>— refuses paths outsidesrc/.
References
references/phaser-recipes.md— copy-pasteable patterns: tilemap setup, 4-direction top-down, platformer jump arc, projectile pooling, simple enemy AI.references/scene-contract.md— the full GameScene contract (verbose with annotations).references/animation-keys.md— how the manifest → Phaser animation mapping works.references/common-pitfalls.md— top 10 things that bite first-time Phaser devs (texture filtering, body offset, scale mode, depth sorting, input focus, etc.).
Scripts
scripts/validate_code.mjs <file-path> <manifest-path>— checks: parses as JS, referenced animation keys exist in manifest, no use ofMath.random(), GameScene exports default class withkey: 'Game'.scripts/write_files.mjs <project-dir> <files-json>— safety-checked file writer. Rejects paths outsidesrc/.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

