game-builder-ai / game_engine.py
AnshulRaj's picture
Sync game_engine.py from GitHub project
b0ccbac verified
Raw
History Blame Contribute Delete
39.8 kB
"""p5.js game generation engine — Tier 2 of the two-tier architecture.
Calls Ollama directly with a p5.js instruction guide as system prompt.
The instruction guide teaches the model how to write complete games
using p5.js, with two reference examples (snake + dodge).
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
import ollama
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError, field_validator
load_dotenv(dotenv_path=Path(__file__).with_name(".env"))
class GameSpec(BaseModel):
title: str = "Tiny Arcade"
archetype: str = Field(default="dodge", description="dodge, collector, jumper, or snake")
secondary_archetype: str | None = Field(
default=None, description="optional second archetype for strange remix mode"
)
weirdness: int = Field(default=5, ge=1, le=10)
tags: list[str] = Field(default_factory=lambda: ["arcade", "local-ai"])
theme: str = "neon arcade"
palette: str = "mint amber red"
player_name: str = "player"
goal: str = "survive and score points"
controls: str = "Arrow keys or WASD"
difficulty: int = Field(default=5, ge=1, le=10)
player_speed: int = Field(default=5, ge=1, le=10)
jump_strength: int = Field(default=6, ge=1, le=10)
hazard_count: int = Field(default=7, ge=0, le=20)
hazard_speed: int = Field(default=5, ge=1, le=10)
collectible_count: int = Field(default=5, ge=0, le=20)
goal_score: int = Field(default=10, ge=1, le=100)
direct_control: bool = True
twist: str = "glowing collectibles and a playful arcade mood"
@field_validator("archetype")
@classmethod
def normalize_archetype(cls, value: str) -> str:
value = (value or "dodge").lower().strip()
aliases = {
"platformer": "jumper",
"jump": "jumper",
"platform": "jumper",
"avoid": "dodge",
"survival": "dodge",
"collect": "collector",
"maze": "collector",
"grid": "snake",
}
value = aliases.get(value, value)
if value not in {"dodge", "collector", "jumper", "snake"}:
return "dodge"
return value
@field_validator("secondary_archetype")
@classmethod
def normalize_secondary_archetype(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
normalized = cls.normalize_archetype(value)
return normalized
_P5_CDN = "https://cdn.jsdelivr.net/npm/p5@1.9.4/lib/p5.min.js"
_INSTRUCTION_GUIDE = f"""/no_think
You are a game developer that writes complete, playable browser games using p5.js.
OUTPUT FORMAT:
Return a COMPLETE HTML file. Start with <!DOCTYPE html> and end with </html>.
Do NOT output anything before <!DOCTYPE html> or after </html>.
Do NOT use markdown fences. Do NOT add explanations outside the HTML.
HTML TEMPLATE (always use this structure):
```
<!DOCTYPE html>
<html>
<head>
<script src="{_P5_CDN}"></script>
<style>body {{ margin:0; overflow:hidden; background:#111; }} canvas {{ display:block; }}</style>
</head>
<body>
<script>
// Your p5.js game code here
</script>
</body>
</html>
```
p5.js API REFERENCE:
Setup & Loop:
setup() — called once; create canvas and initialize state
draw() — called ~60x/sec; your main game loop
createCanvas(w, h) — create the drawing canvas
frameRate(fps) — set frames per second (use 10 for grid/snake games)
noLoop() — stop draw loop
loop() — restart draw loop
Drawing:
background(r,g,b) or background(gray) or background('#hex') — fill canvas
fill(r,g,b) or fill('#hex') — set shape fill color
noFill() — no fill
stroke(r,g,b) or stroke('#hex') — set outline color
noStroke() — no outline
strokeWeight(w) — outline thickness
rect(x, y, w, h) — draw rectangle
ellipse(cx, cy, w, h) — draw ellipse
triangle(x1,y1, x2,y2, x3,y3) — draw triangle
line(x1,y1, x2,y2) — draw line
Text:
text(string, x, y) — draw text
textSize(size) — set font size
textAlign(LEFT|CENTER|RIGHT, TOP|CENTER|BOTTOM)
textFont('monospace')
Input:
keyPressed() — called once per key press
keyCode — LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW
key — character key pressed (e.g., ' ' for space)
keyIsDown(LEFT_ARROW) — true while key held; use in draw() for smooth movement
mouseX, mouseY — current mouse position
mousePressed() — called on mouse click
Math:
random(max) or random(min, max) — random float
floor(n) — round down
ceil(n) — round up
constrain(val, lo, hi) — clamp value to range
dist(x1,y1, x2,y2) — distance between points
map(val, lo1,hi1, lo2,hi2) — remap value from one range to another
abs(n) — absolute value
min(a,b) / max(a,b)
Canvas:
width, height — canvas dimensions (set by createCanvas)
GAME DESIGN RULES:
1. All game state in global variables (let, not const for mutables)
2. Initialize every array/object before any helper function reads it. Example:
set enemies = [] before calling spawnFood() if spawnFood checks enemies.
3. Use a gameOver boolean — when true, show "GAME OVER" + score, then return early from draw()
4. Always show score on screen using text()
5. Use bright/neon colors on dark backgrounds for arcade feel
6. Handle keyboard in keyPressed() for discrete actions (direction changes)
and keyIsDown() in draw() for continuous movement
7. Keep canvas size between 400x400 and 600x500
8. Enemies/items: use arrays, respawn when off-screen
9. Collision: for rectangles use AABB overlap, for circles use dist()
10. Prevent default browser scroll: add return false in keyPressed() for arrow keys
11. Do NOT use localStorage, sessionStorage, indexedDB, fetch, external assets,
images, audio, modules, or browser APIs that require same-origin permissions.
The game runs inside a sandboxed iframe with scripts only.
12. Draw the player, score, and at least one visible item/enemy on the first frame.
13. Prefer direct player control over automatic movement. If the user asks for
snake, make it a direct-control snake by default: the snake moves one grid
cell per arrow/WASD key press and does not move by itself in draw().
14. For grid movement, keep direction values as -1, 0, or 1, then multiply by
grid exactly once when computing the new position. Never set dx = grid and
then calculate x + dx * grid.
EXAMPLE 1 — SNAKE GAME (grid-based, discrete movement):
<!DOCTYPE html>
<html>
<head>
<script src="{_P5_CDN}"></script>
<style>body {{ margin:0; overflow:hidden; background:#111; }} canvas {{ display:block; }}</style>
</head>
<body>
<script>
let snake, food, grid = 20, score = 0, gameOver = false;
function setup() {{
createCanvas(400, 400);
snake = [{{x: 200, y: 200}}];
spawnFood();
}}
function spawnFood() {{
food = {{
x: floor(random(width / grid)) * grid,
y: floor(random(height / grid)) * grid
}};
}}
function draw() {{
background(20);
if (gameOver) {{
fill(255, 60, 80);
textSize(36); textAlign(CENTER, CENTER);
text("GAME OVER", width/2, height/2);
textSize(18);
text("Score: " + score, width/2, height/2 + 45);
textSize(14);
text("Refresh to restart", width/2, height/2 + 75);
return;
}}
fill(255, 50, 80); noStroke();
rect(food.x, food.y, grid - 2, grid - 2);
fill(0, 255, 120);
for (let s of snake) rect(s.x, s.y, grid - 2, grid - 2);
fill(255); textSize(16); textAlign(LEFT, TOP);
text("Score: " + score, 10, 10);
}}
function moveSnake(dx, dy) {{
if (gameOver) return;
let head = {{x: snake[0].x + dx * grid, y: snake[0].y + dy * grid}};
head.x = (head.x + width) % width;
head.y = (head.y + height) % height;
for (let s of snake) {{
if (s.x === head.x && s.y === head.y) {{ gameOver = true; return; }}
}}
snake.unshift(head);
if (head.x === food.x && head.y === food.y) {{
score++;
spawnFood();
}} else {{
snake.pop();
}}
}}
function keyPressed() {{
let handled = true;
if (keyCode === UP_ARROW || key === 'w' || key === 'W') moveSnake(0, -1);
else if (keyCode === DOWN_ARROW || key === 's' || key === 'S') moveSnake(0, 1);
else if (keyCode === LEFT_ARROW || key === 'a' || key === 'A') moveSnake(-1, 0);
else if (keyCode === RIGHT_ARROW || key === 'd' || key === 'D') moveSnake(1, 0);
else handled = false;
if (handled) return false;
return false;
}}
</script>
</body>
</html>
EXAMPLE 2 — DODGE GAME (continuous movement, raining enemies):
<!DOCTYPE html>
<html>
<head>
<script src="{_P5_CDN}"></script>
<style>body {{ margin:0; overflow:hidden; background:#111; }} canvas {{ display:block; }}</style>
</head>
<body>
<script>
let player, enemies = [], score = 0, gameOver = false;
function setup() {{
createCanvas(500, 500);
player = {{x: 250, y: 450, w: 30, h: 30}};
for (let i = 0; i < 8; i++) {{
enemies.push({{
x: random(width), y: random(-400, -20),
w: 22, h: 22, speed: random(2, 5)
}});
}}
}}
function draw() {{
background(10, 10, 35);
if (gameOver) {{
fill(255, 0, 100); textSize(36); textAlign(CENTER, CENTER);
text("GAME OVER", width/2, height/2);
textSize(18);
text("Score: " + score, width/2, height/2 + 45);
return;
}}
if (keyIsDown(LEFT_ARROW)) player.x -= 5;
if (keyIsDown(RIGHT_ARROW)) player.x += 5;
player.x = constrain(player.x, 0, width - player.w);
fill(0, 255, 204); noStroke();
rect(player.x, player.y, player.w, player.h);
for (let e of enemies) {{
e.y += e.speed;
if (e.y > height) {{
e.y = random(-100, -20);
e.x = random(width - e.w);
score++;
}}
if (player.x < e.x + e.w && player.x + player.w > e.x &&
player.y < e.y + e.h && player.y + player.h > e.y) {{
gameOver = true;
}}
fill(255, 60, 60);
rect(e.x, e.y, e.w, e.h);
}}
fill(255); textSize(16); textAlign(LEFT, TOP);
text("Score: " + score, 10, 10);
}}
function keyPressed() {{
return false;
}}
</script>
</body>
</html>
"""
_CREATE_PROMPT = (
"Create a complete, playable game based on this description:\n\n"
"{description}\n\n"
"Output ONLY the complete HTML file, starting with <!DOCTYPE html>. "
"Make it fun, colorful, and arcade-style. "
"Include score tracking and game over logic. "
"Make controls respond immediately to arrow keys and WASD where appropriate. "
"In keyPressed(), update movement before returning false; never return before "
"the arrow/WASD movement code runs. "
"For snake games, default to direct-control movement: one grid cell per "
"arrow/WASD press, with no automatic movement unless the user explicitly "
"asks for classic auto-moving snake."
)
_MODIFY_PROMPT = (
"Here is the current game HTML:\n\n{current_html}\n\n"
"Modify it according to this request:\n\n{modification}\n\n"
"Output the COMPLETE updated HTML file, starting with <!DOCTYPE html>. "
"Keep everything that wasn't asked to change. "
"Make controls respond immediately to arrow keys and WASD where appropriate. "
"In keyPressed(), update movement before returning false; never return before "
"the arrow/WASD movement code runs. "
"For snake games, default to direct-control movement: one grid cell per "
"arrow/WASD press, with no automatic movement unless the user explicitly "
"asks for classic auto-moving snake."
)
_SPEC_SYSTEM_PROMPT = """/no_think
You are an arcade game designer for a local game builder.
Return ONLY valid JSON. Do not include markdown.
Choose one archetype:
- dodge: move around, avoid hazards, collect score
- collector: collect items while avoiding enemies
- jumper: platform jumping with gravity, reachable platforms, win platform
- snake: grid movement, eat items, grow
For strange mode, set weirdness 7-10 and choose a secondary_archetype. The
renderer will blend a safe second mechanic into the base game.
Your job is to convert the user's messy idea into a playable arcade design.
Be whimsical, but keep mechanics simple and reliable.
All numeric values must fit the schema ranges.
"""
_SPEC_CREATE_PROMPT = """Create a game design JSON for this user request:
{description}
Schema:
{{
"title": "short game title",
"archetype": "dodge|collector|jumper|snake",
"secondary_archetype": "dodge|collector|jumper|snake|null",
"weirdness": 1-10,
"tags": ["2-5 short tags, include the render type"],
"theme": "short visual theme",
"palette": "3-5 color words",
"player_name": "short noun",
"goal": "short playable objective",
"controls": "short controls text",
"difficulty": 1-10,
"player_speed": 1-10,
"jump_strength": 1-10,
"hazard_count": 0-20,
"hazard_speed": 1-10,
"collectible_count": 0-20,
"goal_score": 1-100,
"direct_control": true,
"twist": "one whimsical mechanic or flavor detail"
}}
"""
_SPEC_MODIFY_PROMPT = """Current game design JSON:
{spec_json}
User wants this change:
{modification}
Return the complete updated JSON using the same schema. Keep the archetype unless
the user clearly asks for a different kind of game.
"""
def _extract_json_object(content: str) -> dict | None:
import json
if "</think>" in content:
content = content.split("</think>")[-1].strip()
content = re.sub(r"```json\s*", "", content)
content = re.sub(r"```\s*$", "", content).strip()
try:
parsed = json.loads(content)
return parsed if isinstance(parsed, dict) else None
except Exception:
pass
match = re.search(r"({.*})", content, re.DOTALL)
if not match:
return None
try:
parsed = json.loads(match.group(1))
return parsed if isinstance(parsed, dict) else None
except Exception:
return None
def _heuristic_spec(description: str) -> GameSpec:
lower = description.lower()
if any(word in lower for word in ("jump", "platform", "block", "falling means", "fall")):
archetype = "jumper"
elif "snake" in lower:
archetype = "snake"
elif any(word in lower for word in ("collect", "coin", "orb", "star")):
archetype = "collector"
else:
archetype = "dodge"
title_by_type = {
"dodge": "Moon Rain Dash",
"collector": "Ghostlight Collector",
"jumper": "Falling Block Pilgrim",
"snake": "Orb Trail",
}
return GameSpec(
title=title_by_type[archetype],
archetype=archetype,
theme="neon arcade",
player_name="runner",
goal="follow the objective and survive",
twist="the local model picked a safe arcade pattern from your idea",
)
def _coerce_spec(data: dict | None, fallback_text: str) -> GameSpec:
if data is None:
return _heuristic_spec(fallback_text)
try:
return GameSpec.model_validate(data)
except ValidationError:
merged = _heuristic_spec(fallback_text).model_dump()
cleaned = {k: v for k, v in data.items() if k in merged}
if isinstance(cleaned.get("palette"), list):
cleaned["palette"] = ", ".join(str(item) for item in cleaned["palette"][:6])
if isinstance(cleaned.get("controls"), list):
cleaned["controls"] = ", ".join(str(item) for item in cleaned["controls"][:4])
if isinstance(cleaned.get("tags"), str):
cleaned["tags"] = [cleaned["tags"]]
merged.update(cleaned)
for key in list(merged):
candidate = dict(merged)
try:
return GameSpec.model_validate(candidate)
except ValidationError as e:
bad_fields = {err["loc"][0] for err in e.errors() if err.get("loc")}
for bad_field in bad_fields:
if bad_field in cleaned:
merged[bad_field] = _heuristic_spec(fallback_text).model_dump()[bad_field]
if not bad_fields:
break
return GameSpec.model_validate(merged)
def _html_escape_json(spec: GameSpec) -> str:
import json
return json.dumps(spec.model_dump(), ensure_ascii=False)
def render_spec_html(spec: GameSpec) -> str:
spec_json = _html_escape_json(spec)
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script src="{_P5_CDN}"></script>
<style>
html, body {{ margin:0; overflow:hidden; background:#111; }}
canvas {{ display:block; }}
</style>
</head>
<body>
<script>
const SPEC = {spec_json};
let player, hazards = [], items = [], platforms = [], snake = [];
let score = 0, gameOver = false, won = false, frameTick = 0;
let keys = {{}}, grid = 24, food = null;
const STRANGE = SPEC.weirdness >= 7 && SPEC.secondary_archetype;
const COLORS = {{
bg: [17, 18, 28], grid: [44, 46, 62], player: [93, 242, 194],
hazard: [255, 94, 88], item: [255, 177, 59], goal: [111, 184, 255],
text: [247, 240, 214]
}};
function setup() {{
createCanvas(560, 500);
textFont('monospace');
resetGame();
}}
function resetGame() {{
score = 0; gameOver = false; won = false; frameTick = 0;
hazards = []; items = []; platforms = []; snake = [];
if (SPEC.archetype === 'jumper') setupJumper();
else if (SPEC.archetype === 'snake') setupSnake();
else setupFreeMove();
setupStrangeMode();
}}
function setupFreeMove() {{
player = {{x: width/2, y: height - 70, w: 28, h: 28, vx: 0, vy: 0}};
for (let i = 0; i < SPEC.hazard_count; i++) {{
hazards.push({{x: random(width-24), y: random(-height, -20), w: 24, h: 24, vy: map(SPEC.hazard_speed,1,10,1.2,5.5), phase: random(100)}});
}}
for (let i = 0; i < max(1, SPEC.collectible_count); i++) spawnItem();
}}
function setupJumper() {{
player = {{x: 40, y: height - 70, w: 24, h: 28, vx: 0, vy: 0, onGround: false}};
let count = constrain(SPEC.collectible_count + 5, 7, 15);
let x = 40, y = height - 35;
platforms.push({{x: 20, y: height - 25, w: 95, h: 14, goal: false}});
for (let i = 1; i < count; i++) {{
x = constrain(x + random(55, 105), 20, width - 120);
if (x > width - 130) x = random(20, 120);
y = height - 40 - i * ((height - 95) / count);
platforms.push({{x, y, w: random(76, 120), h: 14, goal: i === count - 1}});
}}
items.push(...platforms.slice(1, -1).map(p => ({{x: p.x + p.w/2, y: p.y - 22, r: 8}})));
}}
function setupSnake() {{
snake = [{{x: 240, y: 240}}, {{x: 216, y: 240}}, {{x: 192, y: 240}}];
spawnFood();
}}
function setupStrangeMode() {{
if (!STRANGE) return;
if (SPEC.archetype !== 'dodge' && SPEC.secondary_archetype === 'dodge') {{
for (let i = 0; i < max(3, floor(SPEC.weirdness / 2)); i++) {{
hazards.push({{x: random(width-22), y: random(-height, -20), w: 22, h: 22, vy: map(SPEC.hazard_speed,1,10,1,4), phase: random(100)}});
}}
}}
if (SPEC.archetype !== 'collector' && SPEC.secondary_archetype === 'collector') {{
for (let i = 0; i < max(3, floor(SPEC.weirdness / 2)); i++) spawnItem();
}}
}}
function spawnFood() {{
food = {{x: floor(random(width/grid))*grid, y: floor(random(height/grid))*grid}};
}}
function spawnItem() {{
items.push({{x: random(30,width-30), y: random(45,height-70), r: 9, pulse: random(100)}});
}}
function draw() {{
background(...COLORS.bg);
drawBackdrop();
if (SPEC.archetype === 'jumper') updateJumper();
else if (SPEC.archetype === 'snake') updateSnake();
else updateFreeMove();
updateStrangeMode();
drawHud();
if (gameOver || won) drawEnd();
}}
function drawBackdrop() {{
stroke(...COLORS.grid); strokeWeight(1);
for (let x = 0; x < width; x += 24) line(x, 0, x, height);
for (let y = 0; y < height; y += 24) line(0, y, width, y);
}}
function updateFreeMove() {{
if (!gameOver && !won) {{
let speed = map(SPEC.player_speed,1,10,2,7);
if (keys.ArrowLeft || keys.a) player.x -= speed;
if (keys.ArrowRight || keys.d) player.x += speed;
if (keys.ArrowUp || keys.w) player.y -= speed;
if (keys.ArrowDown || keys.s) player.y += speed;
player.x = constrain(player.x, 0, width-player.w);
player.y = constrain(player.y, 35, height-player.h);
}}
drawItems();
for (let h of hazards) {{
if (!gameOver && !won) {{
h.y += h.vy;
h.x += sin((frameCount + h.phase) * 0.035) * 0.8;
if (h.y > height + 30) {{ h.y = random(-160, -20); h.x = random(width-h.w); score++; }}
if (hitRect(player, h)) gameOver = true;
}}
fill(...COLORS.hazard); noStroke(); rect(h.x, h.y, h.w, h.h, 4);
}}
if (SPEC.archetype === 'collector' && score >= SPEC.goal_score) won = true;
drawPlayer();
}}
function updateJumper() {{
if (!gameOver && !won) {{
let speed = map(SPEC.player_speed,1,10,2,6);
let gravity = 0.55;
if (keys.ArrowLeft || keys.a) player.vx = -speed;
else if (keys.ArrowRight || keys.d) player.vx = speed;
else player.vx *= 0.75;
player.vy += gravity;
player.x += player.vx; player.y += player.vy;
player.x = constrain(player.x, 0, width-player.w);
player.onGround = false;
for (let p of platforms) {{
fill(p.goal ? COLORS.goal[0] : 90, p.goal ? COLORS.goal[1] : 220, p.goal ? COLORS.goal[2] : 150);
rect(p.x, p.y, p.w, p.h, 4);
if (player.vy >= 0 && player.x + player.w > p.x && player.x < p.x+p.w &&
player.y + player.h >= p.y && player.y + player.h <= p.y + p.h + 12) {{
player.y = p.y - player.h; player.vy = 0; player.onGround = true;
if (p.goal) won = true;
}}
}}
for (let it of items) {{
fill(...COLORS.item); ellipse(it.x, it.y, it.r*2);
if (dist(player.x+player.w/2, player.y+player.h/2, it.x, it.y) < 22) {{ score += 5; it.x = -999; }}
}}
if (player.y > height + 80) gameOver = true;
}} else {{
for (let p of platforms) {{ fill(p.goal ? COLORS.goal : [90,220,150]); rect(p.x,p.y,p.w,p.h,4); }}
}}
drawPlayer();
}}
function updateSnake() {{
fill(...COLORS.item); ellipse(food.x+grid/2, food.y+grid/2, 18, 18);
for (let i = snake.length-1; i >= 0; i--) {{
fill(i === 0 ? COLORS.player : [30, 190, 110]);
rect(snake[i].x+1, snake[i].y+1, grid-2, grid-2, 4);
}}
if (score >= SPEC.goal_score) won = true;
}}
function updateStrangeMode() {{
if (!STRANGE || gameOver || won) return;
if (SPEC.archetype !== 'dodge' && SPEC.secondary_archetype === 'dodge' && player) {{
for (let h of hazards) {{
h.y += h.vy;
h.x += sin((frameCount + h.phase) * 0.05) * 1.2;
if (h.y > height + 30) {{ h.y = random(-180, -20); h.x = random(width-h.w); }}
fill(255, 94, 88, 210); noStroke(); rect(h.x, h.y, h.w, h.h, 5);
if (hitRect(player, h)) gameOver = true;
}}
}}
if (SPEC.archetype !== 'collector' && SPEC.secondary_archetype === 'collector' && player) {{
drawItems();
}}
}}
function moveSnake(dx, dy) {{
if (SPEC.archetype !== 'snake' || gameOver || won) return;
let head = {{x: (snake[0].x + dx*grid + width) % width, y: (snake[0].y + dy*grid + height) % height}};
for (let s of snake) if (s.x === head.x && s.y === head.y) {{ gameOver = true; return; }}
snake.unshift(head);
if (head.x === food.x && head.y === food.y) {{ score += 10; spawnFood(); }}
else snake.pop();
}}
function drawItems() {{
for (let i = items.length-1; i >= 0; i--) {{
let it = items[i];
fill(...COLORS.item); ellipse(it.x, it.y, it.r*2 + sin(frameCount*.08 + it.pulse)*3);
if (!gameOver && dist(player.x+player.w/2, player.y+player.h/2, it.x, it.y) < player.w/2 + it.r) {{
score += 5; items.splice(i,1); spawnItem();
}}
}}
}}
function drawPlayer() {{ fill(...COLORS.player); stroke(210); strokeWeight(2); rect(player.x, player.y, player.w, player.h, 5); }}
function hitRect(a,b) {{ return a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y; }}
function drawHud() {{
noStroke(); fill(...COLORS.text); textSize(16); textAlign(LEFT, TOP);
text(SPEC.title + " Score: " + score, 10, 10);
textSize(12); textAlign(RIGHT, TOP); text(SPEC.controls, width-10, 12);
if (STRANGE) {{
fill(255, 177, 59); textAlign(LEFT, TOP);
text("strange mix: " + SPEC.archetype + " + " + SPEC.secondary_archetype, 10, 31);
}}
}}
function drawEnd() {{
fill(0,0,0,180); rect(0,0,width,height);
textAlign(CENTER,CENTER); textSize(38); fill(won ? COLORS.goal : COLORS.hazard);
text(won ? "YOU WIN" : "GAME OVER", width/2, height/2 - 35);
fill(...COLORS.text); textSize(16);
text(SPEC.goal, width/2, height/2 + 4);
text("Press R or Space to restart", width/2, height/2 + 36);
}}
function keyPressed() {{
keys[key] = true; keys[key.toLowerCase()] = true;
if (keyCode === LEFT_ARROW) {{ keys.ArrowLeft = true; moveSnake(-1,0); }}
if (keyCode === RIGHT_ARROW) {{ keys.ArrowRight = true; moveSnake(1,0); }}
if (keyCode === UP_ARROW) {{ keys.ArrowUp = true; moveSnake(0,-1); }}
if (keyCode === DOWN_ARROW) {{ keys.ArrowDown = true; moveSnake(0,1); }}
if ((key === 'w' || key === 'W') && SPEC.archetype === 'snake') moveSnake(0,-1);
if ((key === 's' || key === 'S') && SPEC.archetype === 'snake') moveSnake(0,1);
if ((key === 'a' || key === 'A') && SPEC.archetype === 'snake') moveSnake(-1,0);
if ((key === 'd' || key === 'D') && SPEC.archetype === 'snake') moveSnake(1,0);
if ((key === ' ' || key === 'w' || key === 'W' || keyCode === UP_ARROW) && SPEC.archetype === 'jumper' && player.onGround) {{
player.vy = -map(SPEC.jump_strength,1,10,8,15);
}}
if (key === 'r' || key === 'R' || key === ' ') {{
if (gameOver || won) resetGame();
}}
return false;
}}
function keyReleased() {{
keys[key] = false; keys[key.toLowerCase()] = false;
if (keyCode === LEFT_ARROW) keys.ArrowLeft = false;
if (keyCode === RIGHT_ARROW) keys.ArrowRight = false;
if (keyCode === UP_ARROW) keys.ArrowUp = false;
if (keyCode === DOWN_ARROW) keys.ArrowDown = false;
return false;
}}
</script>
</body>
</html>"""
def _extract_html(content: str) -> str | None:
"""Extract HTML from model output, handling think tokens and markdown fences."""
if "</think>" in content:
content = content.split("</think>")[-1].strip()
content = re.sub(r"```html\s*", "", content)
content = re.sub(r"```\s*$", "", content)
content = content.strip()
match = re.search(r"(<!DOCTYPE html>.*?</html>)", content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
if "<html" in content.lower() and "</html>" in content.lower():
match = re.search(r"(<html.*?</html>)", content, re.DOTALL | re.IGNORECASE)
if match:
return match.group(1).strip()
return None
def _validate_html(html: str) -> bool:
"""Check that the HTML contains the minimum p5.js game structure."""
lower = html.lower()
banned = (
"localstorage",
"sessionstorage",
"indexeddb",
"xmlhttprequest",
"fetch(",
"keyisdown(key.",
"dx = grid",
"dx=grid",
"dx = -grid",
"dx=-grid",
"dy = grid",
"dy=grid",
"dy = -grid",
"dy=-grid",
"navigator.serviceworker",
"<script type=\"module\"",
"<script type='module'",
)
return (
"p5" in lower
and "function setup" in lower
and "function draw" in lower
and "createcanvas" in lower
and "<script" in lower
and not any(term in lower for term in banned)
and not _has_early_keypressed_return(html)
)
def _extract_js_function_body(source: str, function_name: str) -> str | None:
match = re.search(
rf"function\s+{re.escape(function_name)}\s*\([^)]*\)\s*{{",
source,
re.IGNORECASE,
)
if not match:
return None
start = match.end()
depth = 1
for index in range(start, len(source)):
char = source[index]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[start:index]
return None
def _has_early_keypressed_return(html: str) -> bool:
"""Detect p5 keyPressed handlers that return before processing movement."""
body = _extract_js_function_body(html, "keyPressed")
if not body:
return False
lower = body.lower()
if not any(term in lower for term in ("left_arrow", "right_arrow", "up_arrow", "down_arrow")):
return False
first_return = lower.find("return false")
if first_return == -1:
return False
movement_terms = (
"movesnake(",
"moveplayer(",
"dir =",
"nextdir =",
"direction =",
"nextdirection =",
".x +=",
".x -=",
".y +=",
".y -=",
"snake.unshift",
)
movement_positions = [
lower.find(term) for term in movement_terms if lower.find(term) != -1
]
if not movement_positions:
return False
return first_return < min(movement_positions)
def _find_chrome() -> str | None:
for name in ("google-chrome", "chromium", "chromium-browser"):
path = shutil.which(name)
if path:
return path
return None
def _browser_smoke_error(html: str) -> str | None:
"""Return a runtime/rendering error if generated HTML fails in a sandboxed iframe.
This is intentionally best-effort: if Chrome/Pillow are unavailable, generation
still works. Local hackathon demos get the stronger check automatically.
"""
if os.getenv("GAME_BUILDER_SKIP_BROWSER_SMOKE") == "1":
return None
chrome = _find_chrome()
if chrome is None:
return None
try:
from PIL import Image
except Exception:
Image = None
try:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
game_file = tmp_path / "game.html"
screenshot = tmp_path / "screenshot.png"
game_file.write_text(html, encoding="utf-8")
cmd = [
chrome,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--enable-logging=stderr",
"--v=0",
f"--screenshot={screenshot}",
"--window-size=640,620",
"--virtual-time-budget=4000",
game_file.as_uri(),
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=12)
combined = f"{proc.stdout}\n{proc.stderr}"
console_lines = [
line for line in combined.splitlines() if "CONSOLE:" in line
]
bad_terms = (
"Uncaught",
"ReferenceError",
"TypeError",
"SyntaxError",
"SecurityError",
"is not defined",
"is not iterable",
)
for line in console_lines:
if any(term in line for term in bad_terms):
return line.strip()[:500]
if Image is not None and screenshot.exists():
img = Image.open(screenshot).convert("RGB")
colors = img.getcolors(maxcolors=1_000_000) or []
visible_pixels = 0
for count, (r, g, b) in colors:
if r > 35 or g > 35 or b > 35:
visible_pixels += count
if visible_pixels < 1000:
return "Browser smoke test rendered a blank or near-blank game."
except Exception:
return None
return None
class GameEngine:
def __init__(
self,
model: str = "Qwen/Qwen2.5-Coder-32B-Instruct",
ollama_host: str = "http://127.0.0.1:11434",
provider: str | None = None,
) -> None:
self.model = model
self.provider = (provider or os.getenv("MODEL_PROVIDER", "hf")).lower()
self.client = ollama.Client(host=ollama_host)
self.current_html: str | None = None
self.current_spec: GameSpec | None = None
def generate(self, description: str, max_retries: int = 2) -> str:
"""Generate a new game spec from text and render it to playable HTML."""
spec = self._design_spec(description)
self.current_spec = spec
self.current_html = render_spec_html(spec)
return self.current_html
def modify(self, modification: str, max_retries: int = 2) -> str:
"""Modify the current game spec. Returns updated HTML string."""
if self.current_spec is None:
raise ValueError("No active game to modify. Generate a game first.")
spec = self._design_spec(
modification,
current_spec=self.current_spec,
)
self.current_spec = spec
self.current_html = render_spec_html(spec)
return self.current_html
def _design_spec(
self,
description: str,
current_spec: GameSpec | None = None,
) -> GameSpec:
messages = [{"role": "system", "content": _SPEC_SYSTEM_PROMPT}]
if current_spec is None:
messages.append({
"role": "user",
"content": _SPEC_CREATE_PROMPT.format(description=description),
})
else:
messages.append({
"role": "user",
"content": _SPEC_MODIFY_PROMPT.format(
spec_json=_html_escape_json(current_spec),
modification=description,
),
})
data = None
try:
content = self._call_spec_llm(messages)
data = _extract_json_object(content)
except Exception:
data = None
fallback_text = description if current_spec is None else current_spec.model_dump_json()
spec = _coerce_spec(data, fallback_text)
if current_spec is not None and data is None:
update = _heuristic_spec(description)
merged = current_spec.model_dump()
for key in (
"difficulty",
"player_speed",
"jump_strength",
"hazard_count",
"hazard_speed",
"collectible_count",
"goal_score",
"weirdness",
):
merged[key] = getattr(update, key)
if any(word in description.lower() for word in ("weird", "strange", "mix", "remix")):
merged["weirdness"] = 9
merged["secondary_archetype"] = "dodge" if current_spec.archetype != "dodge" else "collector"
spec = GameSpec.model_validate(merged)
return spec
def _call_spec_llm(self, messages: list[dict]) -> str:
if self.provider == "ollama":
response = self.client.chat(
model=self.model,
messages=messages,
format="json",
options={"temperature": 0.8, "num_predict": 900, "num_ctx": 4096},
)
return response.message.content
from huggingface_hub import InferenceClient
token = (
os.getenv("HF_TOKEN")
or os.getenv("HUGGINGFACEHUB_API_TOKEN")
or os.getenv("HUGGING_TOKEN")
)
client = InferenceClient(model=self.model, token=token, timeout=45)
response = client.chat.completions.create(
messages=messages,
max_tokens=900,
temperature=0.8,
)
return response.choices[0].message.content
def _call(self, messages: list[dict], max_retries: int) -> str:
last_error: Exception | None = None
for attempt in range(max_retries + 1):
response = self.client.chat(
model=self.model,
messages=messages,
options={
"temperature": 0.7 + attempt * 0.05,
"num_predict": 4000,
"num_ctx": 8192,
},
)
content: str = response.message.content
html = _extract_html(content)
if html is None:
last_error = ValueError(
f"No HTML found in response (attempt {attempt + 1}): "
f"{content[:200]}"
)
messages = messages + [
{"role": "assistant", "content": content},
{
"role": "user",
"content": (
"Your response did not contain valid HTML. "
"Please output a COMPLETE HTML file starting with "
"<!DOCTYPE html> and ending with </html>."
),
},
]
continue
if not _validate_html(html):
last_error = ValueError(
f"HTML missing p5.js structure (attempt {attempt + 1})"
)
messages = messages + [
{"role": "assistant", "content": content},
{
"role": "user",
"content": (
"The HTML is not safe or complete enough for the "
"sandboxed iframe. Output a complete p5.js HTML file "
"with function setup(), function draw(), createCanvas(), "
"no localStorage/sessionStorage/indexedDB/fetch/modules, "
"visible player/score/items on the first frame, and "
"keyPressed() must update movement before any return false. "
"For grid games, use direction values -1/0/1 and multiply "
"by grid exactly once."
),
},
]
continue
runtime_error = _browser_smoke_error(html)
if runtime_error is not None:
last_error = ValueError(
f"Browser smoke test failed (attempt {attempt + 1}): "
f"{runtime_error}"
)
messages = messages + [
{"role": "assistant", "content": content},
{
"role": "user",
"content": (
"The game failed when run inside a sandboxed iframe. "
f"Browser error: {runtime_error}\n\n"
"Repair the game and output the COMPLETE updated HTML. "
"Initialize every array/object before helper functions "
"read it, avoid localStorage/sessionStorage/indexedDB/"
"fetch/modules, and make sure the first frame visibly "
"draws the player, score, and at least one item/enemy. "
"If using keyPressed(), update movement before any "
"return false statement."
),
},
]
continue
self.current_html = html
return html
raise ValueError(
f"Failed to generate valid game HTML after {max_retries + 1} attempts. "
f"Last error: {last_error}"
)
def reset(self) -> None:
self.current_html = None
self.current_spec = None