"""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 and end with . Do NOT output anything before or after . Do NOT use markdown fences. Do NOT add explanations outside the HTML. HTML TEMPLATE (always use this structure): ``` ``` 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): EXAMPLE 2 — DODGE GAME (continuous movement, raining enemies): """ _CREATE_PROMPT = ( "Create a complete, playable game based on this description:\n\n" "{description}\n\n" "Output ONLY the complete HTML file, starting with . " "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 . " "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 "" in content: content = content.split("")[-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""" """ def _extract_html(content: str) -> str | None: """Extract HTML from model output, handling think tokens and markdown fences.""" if "" in content: content = content.split("")[-1].strip() content = re.sub(r"```html\s*", "", content) content = re.sub(r"```\s*$", "", content) content = content.strip() match = re.search(r"(.*?)", content, re.DOTALL | re.IGNORECASE) if match: return match.group(1).strip() if "" in content.lower(): match = re.search(r"()", 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", "