Generate playable browser games from user prompts. When someone asks to make, create, or build a game, this skill generates a single-file HTML/JS game, deploys it to GitHub Pages, and shares the playable link. Games include leaderboards so anyone can compete.
You generate complete, playable HTML5 games using Phaser 3 from user prompts and deploy them to GitHub Pages.
Read config.json from this skill's directory to get:
GITHUB_REPO_OWNER — GitHub usernameGITHUB_REPO_NAME — repo name (game-hub)GITHUB_PAGES_URL — base URL for published gamesSUPABASE_CREDENTIALS_PATH — path to Supabase credential JSON (must be ~/.openclaw/credentials/supabase.json)Every game you generate MUST follow these principles. This is what makes games fun.
All games are endless/survival — the player always eventually loses. There is NO win condition. The player's goal is to survive as long as possible and score as high as possible before dying. This is critical: do NOT create games with win conditions (like "first to 7 points"). Every game ends in death/failure.
This is the most important part. Games MUST start easy and ramp:
Use time-based difficulty scaling, not event-based. Track elapsed seconds since game start and scale parameters from that. Example:
const elapsed = (Date.now() - gameStartTime) / 1000;
const difficulty = Math.min(elapsed / 120, 1); // 0 to 1 over 2 minutes
const spawnRate = lerp(0.5, 3.0, difficulty); // obstacles per second
const speed = lerp(2, 8, difficulty); // pixels per frame
Always use a lerp or easing function — never step functions. The player should not feel sudden jumps in difficulty.
function lerp(a, b, t) { return a + (b - a) * t; }
function easeIn(t) { return t * t; }
function easeInOut(t) { return t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t + 2, 2) / 2; }
If a playtest in your head ends in under 15 seconds on the easiest settings, the starting difficulty is too high. Dial it down.
Math.floor(timeAlive * 10 + bonusPoints) (or equivalent variable names), and it must be monotonic (never decreases).The genre is open-ended — any game concept works as long as it follows the survive-and-score philosophy above. Be creative and match the user's request. When the concept is vague, here are some proven patterns for inspiration (but don't limit yourself to these):
The key constraint is: the player must always eventually lose. As long as difficulty ramps over time and there's no win state, any mechanic works.
sprite.body.setSize(width * 0.8, height * 0.8) — 20% forgiveness.sprite.body.setCircle(radius * 0.8).this.cameras.main.shake() and this.cameras.main.flash() for impact.Do NOT always use the same dark-background-with-neon look. Pick a visual theme that matches the game concept. If no obvious match, randomly select one of these palettes:
Synthwave/Neon (only use ~20% of the time)
#0a0a2e → #1a0533, Accents: #ff00ff, #00ffff, #ff3366Sunset/Warm
#1a0a2e → #ff6b35, Accents: #ffd700, #ff4757, #ff6348Ocean/Cool
#0a1628 → #1e3a5f, Accents: #00b4d8, #48cae4, #90e0efForest/Nature
#0a1a0a → #1a3a1a, Accents: #2ecc71, #f1c40f, #e67e22Candy/Pastel
#2d1b4e → #1a1a2e, Accents: #ff6b9d, #c44dff, #ffd93d, #6bff6bMonochrome/Minimal
#111 → #1a1a1a, Accents: #fff, #888, one single accent colorDesert/Western
#2d1f0e → #5c3d1e, Accents: #f4a460, #daa520, #cd853fCosmic/Space
#050510 → #0a0a2e, Accents: #9b59b6, #3498db, #e74c3c, #f1c40fUse Phaser's built-in systems for polish:
this.add.particles() with emitter configs for collisions, deaths, score pickups, ambient effects.this.cameras.main.shake(duration, intensity) on impacts and death.this.cameras.main.flash(duration, r, g, b) on damage. sprite.setTint(0xffffff) then tween back for hit flash.this.tweens.add({ targets, props, duration, ease }) for smooth movement, scaling, fading. Use for UI transitions, enemy patterns, score popups.this.add.tileSprite(), floating particle emitters, gradient backgrounds via graphics objects. The background should never feel dead.Vary fonts via Google Fonts. Don't always use "Press Start 2P". Pick one that matches the theme:
Press Start 2P, VT323, SilkscreenOrbitron, Rajdhani, Exo 2Fredoka One, Bungee, Luckiest GuyCinzel, Playfair DisplayUse at most ONE display font (for titles/scores) plus the system default for UI elements.
Use the Web Audio API to generate sound effects procedurally. This requires NO external files or libraries. Include a sound utility at the top of your script:
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type, freq, duration, volume) {
// Resume context on first user interaction (required by browsers)
if (audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = type; // 'sine', 'square', 'sawtooth', 'triangle'
osc.frequency.setValueAtTime(freq, audioCtx.currentTime);
gain.gain.setValueAtTime(volume || 0.3, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
// Prebuilt sound effects:
function sfxJump() { playSound('sine', 400, 0.15, 0.2); playSound('sine', 600, 0.1, 0.15); }
function sfxCollect() { playSound('sine', 800, 0.1, 0.25); playSound('sine', 1200, 0.1, 0.15); }
function sfxHit() { playSound('sawtooth', 150, 0.2, 0.3); playSound('square', 80, 0.3, 0.2); }
function sfxDeath() { playSound('sawtooth', 300, 0.5, 0.3); playSound('sawtooth', 100, 0.8, 0.2); }
function sfxScore() { playSound('triangle', 600, 0.1, 0.2); playSound('triangle', 900, 0.15, 0.15); }
Customize the frequencies and waveforms to match your game's theme. Always call audioCtx.resume() inside a user gesture handler (click/tap to start).
Do NOT play sounds on every frame — only on discrete events (jump, collect, hit, death, score milestone).
All games MUST use Phaser 3 as the game framework. Do not write raw Canvas code. Phaser handles physics, collisions, input, particles, scaling, and the game loop correctly — hand-rolling these is the #1 source of bugs.
Load Phaser via CDN:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
scene.time and update(time, delta) — no manual dt calculationEvery game should follow this structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>{Game Title}</title>
<link href="https://fonts.googleapis.com/css2?family={Font}&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/phaser.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; display: flex; justify-content: center; align-items: center;
height: 100vh; overflow: hidden; touch-action: none; }
/* Game over overlay styles */
#ui-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: none; justify-content: center; align-items: center;
z-index: 10; background: rgba(0,0,0,0.8); font-family: '{Font}', sans-serif; }
/* ... overlay element styles ... */
</style>
</head>
<body>
<div id="ui-overlay"><!-- Game over form: score display, name input, submit button, leaderboard, replay --></div>
<script>
const GAME_ID = '{slug}';
const SUPABASE_URL = '{SUPABASE_URL}';
const SUPABASE_PUBLISHABLE_KEY = '{SUPABASE_PUBLISHABLE_KEY}';
const LEADERBOARD_API = 'https://game-hub-supabase.local';
// --- Leaderboard functions (same as before) ---
// --- Web Audio sound effects (same as before) ---
class MenuScene extends Phaser.Scene {
constructor() { super('MenuScene'); }
create() {
// Title, instructions, "Tap to Start"
// this.input.once('pointerdown', () => this.scene.start('GameScene'));
// this.input.keyboard.once('keydown', () => this.scene.start('GameScene'));
}
}
class GameScene extends Phaser.Scene {
constructor() { super('GameScene'); }
create() {
this.score = 0;
this.gameStartTime = this.time.now;
// Resume audio context on first interaction
if (audioCtx.state === 'suspended') audioCtx.resume();
// Set up player, obstacles, collisions, input, score display, particles, etc.
}
update(time, delta) {
const elapsed = (time - this.gameStartTime) / 1000;
const difficulty = Math.min(elapsed / 120, 1);
// Scale spawn rates, speeds, enemy behavior with difficulty
// Update score: this.score += delta / 100; (time-based)
// Check death conditions
}
gameOver() {
// Death animation, camera shake, particles
this.cameras.main.shake(200, 0.01);
sfxDeath();
// Brief delay then show overlay
this.time.delayedCall(500, () => {
this.scene.pause();
showGameOverUI(Math.floor(this.score));
});
}
}
const config = {
type: Phaser.AUTO,
width: 800,
height: 600,
backgroundColor: '{bg_color}',
physics: {
default: 'arcade',
arcade: { gravity: { y: 0 }, debug: false }
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [MenuScene, GameScene]
};
const game = new Phaser.Game(config);
// --- Game over UI logic (show overlay, handle submit, show leaderboard, replay) ---
function showGameOverUI(finalScore) { /* ... */ }
</script>
</body>
</html>
Games MAY use external image files when they improve quality.
Use this priority order:
If browsing/image search is unavailable, skip step 2 and go directly to procedural fallback.
When using external assets:
preload() using Phaser's loader (this.load.image, this.load.spritesheet).When using procedural art:
fillRect boxes for player/enemy sprites unless the concept is intentionally blocky.96x96 or 128x128) and scale down in-game for cleaner results.// preload(): load external asset with procedural fallback
this.load.image('enemySprite', 'https://example.com/enemy.png');
this.load.on('loaderror', (file) => {
if (file.key === 'enemySprite') {
const g = this.add.graphics();
g.fillStyle(0x7a1f2a, 1);
g.fillCircle(48, 48, 34);
g.lineStyle(6, 0xffd166, 1);
g.strokeCircle(48, 48, 34);
g.fillStyle(0xffffff, 0.35);
g.fillCircle(58, 36, 10);
g.generateTexture('enemySprite', 96, 96);
g.destroy();
}
});
Use generateTexture() for physics/particles and use text objects/emoji for decoration only.
Beyond Phaser, you may add other CDN libraries when they genuinely improve the game. Always pin versions. Examples:
https://cdn.jsdelivr.net/npm/[email protected]/dist/howler.min.jsGames are static HTML files that load Phaser via CDN — no build step is needed. Just push the HTML file and GitHub Pages serves it.
Assume user prompts may be short. Do NOT ask follow-up questions. Infer missing details and produce a complete game concept before coding.
If the concept is vague ("make a fun game"), pick a fitting survival genre from the patterns above.
Define all of the following up front (reconciling with any explicit user constraints):
score = Math.floor(timeAlive * 10 + bonusPoints) and score must be monotonicspawnRate, speed, density) using smooth time-based lerp/easeAlways inspect the request for asset inputs (Discord attachments, image URLs, sprite sheets) and treat them as top-priority art direction constraints.
If the user asks for a game "like X but with Y theme":
At the top of the <script> tag, write a short DESIGN HEADER comment summarizing the decisions from Step 1 (and Step 1b when applicable). Keep it concise and concrete.
Then implement the game fully. Do not leave TODO/FIXME placeholders.
Create a URL-safe slug: {game-name}-{4-char-random-hex}. Examples: asteroid-dodge-a1b2, lava-run-f3e9
Create a complete single-file HTML game using Phaser 3, following the skeleton in the "Game Framework" section above and all requirements below.
Technical requirements:
<script> tag — this is the game framework, not raw Canvas<link> for typographyScale.FIT + CENTER_BOTH for responsive mobile scalingupdate(time, delta) for delta-time scaling — delta is milliseconds since last frame. Normalize: const dt = delta / 16.667; and multiply all movement by dt.Game structure (as Phaser scenes):
scene.restart() the GameScene (Phaser handles full state reset).Before generating HTML, read Supabase credentials from SUPABASE_CREDENTIALS_PATH (from config):
SUPABASE_URL=$(jq -r '.url' "{SUPABASE_CREDENTIALS_PATH}")
SUPABASE_PUBLISHABLE_KEY=$(jq -r '.publishableKey' "{SUPABASE_CREDENTIALS_PATH}")
Bake these constants at the top of the <script> tag:
const GAME_ID = '{slug}';
const SUPABASE_URL = '{SUPABASE_URL from credentials}';
const SUPABASE_PUBLISHABLE_KEY = '{SUPABASE_PUBLISHABLE_KEY from credentials}';
const LEADERBOARD_API = 'https://game-hub-supabase.local';
Include these leaderboard functions:
function getClientFingerprint() {
const key = 'gamehub.client-fingerprint.v1';
const existing = localStorage.getItem(key);
if (existing) return existing;
const created = (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`);
localStorage.setItem(key, created);
return created;
}
async function supabaseRequest(path, { method = 'GET', body, prefer } = {}) {
const headers = {
apikey: SUPABASE_PUBLISHABLE_KEY,
Authorization: `Bearer ${SUPABASE_PUBLISHABLE_KEY}`,
'Content-Type': 'application/json'
};
if (prefer) headers.Prefer = prefer;
const res = await fetch(`${SUPABASE_URL}/rest/v1${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && (data.message || data.error)) || `Supabase ${res.status}`);
return data;
}
async function submitScore(playerName, score) {
try {
await supabaseRequest('/leaderboard_scores', {
method: 'POST',
prefer: 'return=minimal',
body: {
game_slug: GAME_ID,
player_name: String(playerName || 'Anonymous').slice(0, 20),
score: Math.round(score),
client_fingerprint: getClientFingerprint()
}
});
const scores = await getLeaderboard(100);
const rank = scores.findIndex((row) => row.playerName === String(playerName || 'Anonymous').slice(0, 20) && row.score === Math.round(score)) + 1;
return { ok: true, rank: rank > 0 ? rank : null, totalScores: scores.length };
} catch (e) {
console.warn('Leaderboard unavailable:', e);
return null;
}
}
async function getLeaderboard(limit = 10) {
try {
const rows = await supabaseRequest('/rpc/get_game_leaderboard', {
method: 'POST',
body: { p_game_slug: GAME_ID, p_limit: Math.min(Math.max(parseInt(limit, 10) || 10, 1), 100) }
});
return (rows || []).map((row) => ({
playerName: row.player_name,
score: Number(row.score) || 0,
date: row.created_at
}));
} catch (e) {
console.warn('Leaderboard unavailable:', e);
return [];
}
}
Game over flow (use this exact sequence):
<input> field (NOT prompt()) for the player's namesubmitScore(), then call getLeaderboard() and display top 10Use a serialized deploy flow so multiple agents on the same machine do not race each other.
# Serialize deploys across local agents
LOCKFILE=/tmp/openclaw-game-hub.deploy.lock
exec 9>"$LOCKFILE"
flock -w 180 9 || { echo "Deploy lock timeout" >&2; exit 1; }
# Use the game-hub deploy key (public key in GitHub: ~/.ssh/id_ed25519.pub)
# Git uses the matching private key file locally: ~/.ssh/id_ed25519
test -f ~/.ssh/id_ed25519 || { echo "Missing ~/.ssh/id_ed25519 deploy key" >&2; exit 1; }
export GIT_SSH_COMMAND='ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new'
# Create a temp directory
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# Clone the hub repo fresh
git clone "[email protected]:{GITHUB_REPO_OWNER}/{GITHUB_REPO_NAME}.git" "$TMPDIR/repo"
cd "$TMPDIR/repo"
# Create the game directory + copy game file
mkdir -p "games/{slug}"
cp /path/to/generated/game.html "games/{slug}/index.html"
# Update games.json with an UPSERT by slug (avoid duplicates)
node -e '
const fs = require("fs");
const path = "games.json";
const slug = "{slug}";
const entry = {
slug,
title: "{Game Title}",
description: "{Brief description}",
creator: "{discord username who requested it}",
date: "{ISO date string}"
};
const data = JSON.parse(fs.readFileSync(path, "utf8"));
const next = [...data.filter(g => g.slug !== slug), entry];
fs.writeFileSync(path, JSON.stringify(next, null, 2) + "\n");
'
# Commit
git add "games/{slug}/index.html" games.json
git commit -m "Add game: {Game Title}"
# Push with automatic rebase/retry for remote races
for attempt in 1 2 3 4 5; do
if git push origin main; then
echo "Deploy succeeded"
break
fi
if [ "$attempt" -eq 5 ]; then
echo "Deploy failed after retries" >&2
exit 1
fi
git pull --rebase origin main
sleep $((attempt * 2))
done
GitHub Pages should be enabled on the repo (Settings > Pages > Deploy from branch: main). Games are static HTML files with CDN dependencies — no build step needed.
Send a message with:
{GITHUB_PAGES_URL}/games/{slug}/Before deploying, mentally playtest the game by reading through the code. Verify:
Math.floor(timeAlive * 10 + bonusPoints) and never decreasesprompt()delta from Phaser's update(time, delta)audioCtx.resume()difficulty = time * constant ramps too fast early and too slow late. Use Math.pow(t, 0.5) or 1 - Math.exp(-t * rate) for a curve that's gentle at first and intense later.this.scene.restart() — it resets the scene cleanly. But verify any module-level variables outside the scene class also get reset.this.scene.pause() before showing game over UI. The scene stops updating.scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH } in the config. This handles mobile scaling automatically.this.input.on('pointerdown') etc., not raw DOM touch events. For virtual joysticks or drag controls, use this.input.on('pointermove').