| """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 |
|
|