| """Gradio UI for the AI Game Builder — Build Small Hackathon Track 2.""" |
| from __future__ import annotations |
|
|
| import html |
| import os |
| import random |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
|
|
| from game_agent import GameOrchestrator |
|
|
| load_dotenv() |
|
|
| MODEL_PROVIDER = os.getenv("MODEL_PROVIDER", "hf") |
| OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434") |
| MODEL_ID = os.getenv("HF_MODEL") or os.getenv("OLLAMA_MODEL", "Qwen/Qwen2.5-Coder-32B-Instruct") |
|
|
| orchestrator = GameOrchestrator( |
| model=MODEL_ID, |
| ollama_host=OLLAMA_HOST, |
| provider=MODEL_PROVIDER, |
| ) |
|
|
| _PLACEHOLDER = """ |
| <div class="cabinet-screen empty-screen"> |
| <div class="empty-copy"> |
| <span class="coin-slot">INSERT IDEA</span> |
| <strong>Describe a tiny arcade game</strong> |
| <small>Try “a frog dodging moon rain” or click a cartridge prompt.</small> |
| </div> |
| </div> |
| """ |
|
|
| _CSS = """ |
| :root { |
| --ink: #f7f0d6; |
| --muted: #9fb8a8; |
| --panel: #141319; |
| --panel-2: #1e1a22; |
| --line: #3d332e; |
| --mint: #5df2c2; |
| --amber: #ffb13b; |
| --red: #ff5e58; |
| --blue: #6fb8ff; |
| } |
| .gradio-container { |
| background: |
| radial-gradient(circle at 20% 0%, rgba(255,177,59,.16), transparent 28%), |
| linear-gradient(135deg, #171116 0%, #0a1014 48%, #12140d 100%) !important; |
| color: var(--ink) !important; |
| } |
| footer { display: none !important; } |
| #title-bar { |
| margin: 14px 14px 8px; |
| padding: 14px 18px; |
| background: linear-gradient(90deg, #261818, #101b1b); |
| border: 2px solid var(--line); |
| border-radius: 8px; |
| box-shadow: 0 14px 40px rgba(0,0,0,.34); |
| display: flex; |
| justify-content: space-between; |
| gap: 16px; |
| align-items: center; |
| } |
| #app-name { |
| color: var(--ink); |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 27px; |
| font-weight: 900; |
| letter-spacing: 0; |
| } |
| #app-sub { |
| color: var(--mint); |
| font-family: "Courier New", monospace; |
| font-size: 12px; |
| text-align: right; |
| } |
| .panel-heading { |
| color: var(--amber) !important; |
| font-family: Georgia, "Times New Roman", serif !important; |
| } |
| .quick-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 8px; |
| } |
| .quick-grid button, .control-row button { |
| border-radius: 7px !important; |
| } |
| .cabinet-shell { |
| padding: 14px; |
| background: linear-gradient(160deg, #3b231e, #161114 62%, #0b0f10); |
| border: 3px solid #55372d; |
| border-radius: 8px; |
| box-shadow: inset 0 0 0 2px rgba(255,255,255,.05), 0 20px 60px rgba(0,0,0,.42); |
| } |
| .cabinet-top { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 10px; |
| font-family: "Courier New", monospace; |
| color: var(--muted); |
| font-size: 12px; |
| } |
| .lamp { |
| display: inline-block; |
| width: 9px; |
| height: 9px; |
| margin-left: 7px; |
| border-radius: 999px; |
| background: var(--red); |
| box-shadow: 0 0 12px var(--red); |
| } |
| .lamp:nth-child(2) { |
| background: var(--amber); |
| box-shadow: 0 0 12px var(--amber); |
| } |
| .lamp:nth-child(3) { |
| background: var(--mint); |
| box-shadow: 0 0 12px var(--mint); |
| } |
| .cabinet-screen { |
| min-height: 560px; |
| border: 2px solid #080808; |
| border-radius: 6px; |
| overflow: hidden; |
| background: #050505; |
| box-shadow: inset 0 0 38px rgba(0,0,0,.88); |
| } |
| .empty-screen { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| } |
| .empty-copy { |
| max-width: 340px; |
| text-align: center; |
| font-family: "Courier New", monospace; |
| color: var(--ink); |
| } |
| .empty-copy strong { |
| display: block; |
| margin: 12px 0 8px; |
| font-family: Georgia, "Times New Roman", serif; |
| font-size: 26px; |
| } |
| .empty-copy small { |
| color: var(--muted); |
| } |
| .coin-slot { |
| display: inline-block; |
| padding: 6px 10px; |
| border: 1px solid var(--amber); |
| color: var(--amber); |
| border-radius: 6px; |
| } |
| .arcade-frame iframe { |
| display: block; |
| width: 100%; |
| height: 560px; |
| border: 0; |
| background: #050505; |
| outline: 2px solid transparent; |
| } |
| .arcade-frame:focus-within iframe { |
| outline-color: var(--mint); |
| } |
| .focus-hint { |
| display: flex; |
| justify-content: space-between; |
| gap: 12px; |
| padding: 8px 10px; |
| background: #111315; |
| border-bottom: 1px solid #303039; |
| color: var(--muted); |
| font: 12px "Courier New", monospace; |
| } |
| .focus-hint strong { |
| color: var(--mint); |
| font-weight: 700; |
| } |
| .gradio-container button.primary { |
| background: var(--amber) !important; |
| color: #20130d !important; |
| } |
| """ |
|
|
| _TITLE_BAR = """ |
| <div id="title-bar"> |
| <span id="app-name">Backyard Arcade</span> |
| <span id="app-sub">Tiny games grown from strange prompts | Build Small Track 2</span> |
| </div> |
| """ |
|
|
| _WELCOME = ( |
| "Welcome to Backyard Arcade: tiny games grown from strange prompts.\n\n" |
| "Tell me a game idea. I’ll design a safe arcade cartridge using one of four " |
| "render tags: `dodge`, `collector`, `jumper`, or `snake`. Ask for something " |
| "strange and I can blend two tags together.\n\n" |
| "Try: *a snake game where I eat glowing orbs*, *a frog dodging moon rain*, " |
| "or *jump between falling blocks and reach the last one*." |
| ) |
|
|
| _STRANGE_REMIX_INSTRUCTIONS = ( |
| ( |
| "Strange remix seed `{seed}`. Blend the current game with one different " |
| "render tag from dodge, collector, jumper, or snake. Add one rule inversion " |
| "that changes how the player thinks, but keep the controls simple and the " |
| "game immediately playable." |
| ), |
| ( |
| "Strange remix seed `{seed}`. Make a genre collision: keep the current game " |
| "recognizable, then mix in one different render tag and one surprising win " |
| "condition. Use weirdness 8-10, but avoid confusing controls or invisible " |
| "goals." |
| ), |
| ) |
|
|
|
|
| _GAME_FOCUS_SCRIPT = """ |
| <script> |
| (function () { |
| const controls = [ |
| ["up", "▲", "ArrowUp", "ArrowUp", 38], |
| ["left", "◀", "ArrowLeft", "ArrowLeft", 37], |
| ["down", "▼", "ArrowDown", "ArrowDown", 40], |
| ["right", "▶", "ArrowRight", "ArrowRight", 39], |
| ]; |
| const controlKeys = new Set(controls.map((item) => item[2]).concat([" ", "Spacebar"])); |
| |
| function focusGame() { |
| document.body.tabIndex = 0; |
| document.body.focus({ preventScroll: true }); |
| const canvas = document.querySelector("canvas"); |
| if (canvas) { |
| canvas.tabIndex = 0; |
| canvas.focus({ preventScroll: true }); |
| } |
| } |
| |
| function makeKeyEvent(type, key, code, keyCode) { |
| const event = new KeyboardEvent(type, { |
| key, |
| code, |
| bubbles: true, |
| cancelable: true, |
| }); |
| Object.defineProperty(event, "keyCode", { get: () => keyCode }); |
| Object.defineProperty(event, "which", { get: () => keyCode }); |
| return event; |
| } |
| |
| function sendKey(type, key, code, keyCode) { |
| focusGame(); |
| const event = makeKeyEvent(type, key, code, keyCode); |
| window.dispatchEvent(event); |
| document.dispatchEvent(event); |
| } |
| |
| function installDpad() { |
| if (document.getElementById("gbai-dpad")) return; |
| |
| const style = document.createElement("style"); |
| style.textContent = ` |
| #gbai-dpad { |
| position: fixed; |
| right: 10px; |
| bottom: 10px; |
| z-index: 99999; |
| display: grid; |
| grid-template-columns: repeat(3, 38px); |
| grid-template-rows: repeat(3, 38px); |
| gap: 5px; |
| opacity: .78; |
| user-select: none; |
| touch-action: none; |
| } |
| #gbai-dpad button { |
| border: 1px solid rgba(255,255,255,.35); |
| border-radius: 6px; |
| background: rgba(5,8,10,.82); |
| color: #dfffea; |
| font: 700 17px monospace; |
| box-shadow: 0 0 10px rgba(0,0,0,.35); |
| } |
| #gbai-dpad button:active { |
| background: rgba(93,242,194,.35); |
| } |
| `; |
| document.head.appendChild(style); |
| |
| const dpad = document.createElement("div"); |
| dpad.id = "gbai-dpad"; |
| dpad.innerHTML = ` |
| <span></span><button data-dir="up" aria-label="Up">▲</button><span></span> |
| <button data-dir="left" aria-label="Left">◀</button><span></span><button data-dir="right" aria-label="Right">▶</button> |
| <span></span><button data-dir="down" aria-label="Down">▼</button><span></span> |
| `; |
| document.body.appendChild(dpad); |
| |
| for (const [dir, , key, code, keyCode] of controls) { |
| const button = dpad.querySelector(`[data-dir="${dir}"]`); |
| button.addEventListener("pointerdown", (event) => { |
| event.preventDefault(); |
| sendKey("keydown", key, code, keyCode); |
| }); |
| button.addEventListener("pointerup", (event) => { |
| event.preventDefault(); |
| sendKey("keyup", key, code, keyCode); |
| }); |
| button.addEventListener("pointerleave", () => sendKey("keyup", key, code, keyCode)); |
| } |
| } |
| |
| window.addEventListener("load", () => setTimeout(focusGame, 100)); |
| window.addEventListener("load", () => setTimeout(installDpad, 150)); |
| window.addEventListener("pointerdown", focusGame, true); |
| window.addEventListener("keydown", (event) => { |
| if (controlKeys.has(event.key)) event.preventDefault(); |
| }, true); |
| })(); |
| </script> |
| """ |
|
|
|
|
| def _inject_game_focus_script(raw_html: str) -> str: |
| """Make generated games easier to control inside Gradio's iframe.""" |
| if "function focusGame()" in raw_html: |
| return raw_html |
| if "</body>" in raw_html: |
| return raw_html.replace("</body>", f"{_GAME_FOCUS_SCRIPT}</body>", 1) |
| return raw_html + _GAME_FOCUS_SCRIPT |
|
|
|
|
| def _wrap_game_html(raw_html: str) -> str: |
| """Wrap game HTML in an iframe for safe Gradio embedding.""" |
| embedded_html = _inject_game_focus_script(raw_html) |
| escaped = html.escape(embedded_html) |
| return ( |
| '<div class="arcade-frame" onclick="this.querySelector(\'iframe\').focus()">' |
| '<div class="focus-hint">' |
| '<span><strong>Click the game first</strong>, then use arrows or WASD.</span>' |
| '<span>Keyboard focus stays inside the cabinet.</span>' |
| '</div>' |
| f'<iframe tabindex="0" srcdoc="{escaped}" sandbox="allow-scripts"></iframe>' |
| '</div>' |
| ) |
|
|
|
|
| def chat_handler(message: str, history: list, current_html: str, raw_html: str | None): |
| if not message.strip(): |
| return "", history, current_html, raw_html |
|
|
| history = history + [{"role": "user", "content": message}] |
|
|
| reply, game_html = orchestrator.chat(message) |
|
|
| history = history + [{"role": "assistant", "content": reply}] |
|
|
| if game_html is not None: |
| raw_html = game_html |
| current_html = _wrap_game_html(game_html) |
|
|
| return "", history, current_html, raw_html |
|
|
|
|
| def new_game_handler(history: list): |
| orchestrator.new_game() |
| history = history + [{ |
| "role": "assistant", |
| "content": "Fresh cartridge loaded. What tiny arcade game should we build?", |
| }] |
| return history, _PLACEHOLDER, None |
|
|
|
|
| def restart_game_handler(history: list, raw_html: str | None): |
| if not raw_html: |
| history = history + [{ |
| "role": "assistant", |
| "content": "No game is loaded yet. Describe one first.", |
| }] |
| return history, _PLACEHOLDER, raw_html |
|
|
| history = history + [{ |
| "role": "assistant", |
| "content": "Restarted the current game without spending another model call.", |
| }] |
| return history, _wrap_game_html(raw_html), raw_html |
|
|
|
|
| def quick_prompt_handler(prompt: str, history: list, current_html: str, raw_html: str | None): |
| return chat_handler(prompt, history, current_html, raw_html) |
|
|
|
|
| def orb_snake_handler(history: list, current_html: str, raw_html: str | None): |
| prompt = ( |
| "Make a direct-control snake game where I eat glowing orbs and grow longer. " |
| "The snake must not move by itself. It should move one grid cell only when " |
| "I press an arrow key or WASD. Make it easy to understand and immediately " |
| "playable." |
| ) |
| return chat_handler(prompt, history, current_html, raw_html) |
|
|
|
|
|
|
| def strange_mix_handler(history: list, current_html: str, raw_html: str | None): |
| seed = random.randint(1000, 9999) |
| prompt = random.choice(_STRANGE_REMIX_INSTRUCTIONS).format(seed=seed) |
| return quick_prompt_handler(prompt, history, current_html, raw_html) |
|
|
| def tuning_handler( |
| control_feel: int, |
| game_pace: int, |
| chaos: int, |
| history: list, |
| current_html: str, |
| raw_html: str | None, |
| ): |
| if not raw_html: |
| history = history + [{ |
| "role": "assistant", |
| "content": "Build a game first, then I can tune the controls.", |
| }] |
| return history, current_html, raw_html |
|
|
| prompt = ( |
| "Modify the current game using these tuning values. " |
| f"Player control sensitivity: {control_feel}/10, where 1 is slow and floaty " |
| f"and 10 is fast and crisp. Overall game pace: {game_pace}/10. " |
| f"Game-specific chaos or object density: {chaos}/10. " |
| "Keep the same core game, but adjust movement speed, enemy/item rates, " |
| "spawn density, timers, or scoring so the game feels tuned to those values." |
| ) |
| _, next_history, next_html, next_raw = chat_handler( |
| prompt, history, current_html, raw_html |
| ) |
| return next_history, next_html, next_raw |
|
|
|
|
|
|
| with gr.Blocks(title="Backyard Arcade") as demo: |
| gr.HTML(_TITLE_BAR) |
| raw_html_state = gr.State(value=None) |
|
|
| with gr.Row(equal_height=False): |
| with gr.Column(scale=1, min_width=300): |
| gr.Markdown("### Chat", elem_classes=["panel-heading"]) |
| chatbot = gr.Chatbot( |
| height=390, |
| show_label=False, |
| value=[{"role": "assistant", "content": _WELCOME}], |
| layout="bubble", |
| render_markdown=True, |
| ) |
| with gr.Row(): |
| msg_input = gr.Textbox( |
| scale=5, |
| placeholder="Describe a game or a change...", |
| show_label=False, |
| lines=1, |
| max_lines=3, |
| ) |
| send_btn = gr.Button("Send", scale=1, variant="primary", min_width=56) |
|
|
| gr.Markdown("### Cartridges", elem_classes=["panel-heading"]) |
| with gr.Row(elem_classes=["quick-grid"]): |
| frog_btn = gr.Button("Frog Rain", size="sm") |
| ghost_btn = gr.Button("Ghost Coins", size="sm") |
| snake_btn = gr.Button("Orb Snake", size="sm") |
| boss_btn = gr.Button("Tiny Boss", size="sm") |
|
|
|
|
| gr.Markdown("### Ideas", elem_classes=["panel-heading"]) |
| starfish_btn = gr.Button("Sleepy Starfish", size="sm") |
| ribbon_btn = gr.Button("Bakery Ribbon", size="sm") |
| frog_coin_btn = gr.Button("Frog Coinstorm", size="sm") |
|
|
| gr.Markdown("### Controls", elem_classes=["panel-heading"]) |
| with gr.Row(elem_classes=["control-row"]): |
| new_btn = gr.Button("New", variant="secondary", size="sm") |
| restart_btn = gr.Button("Restart", variant="secondary", size="sm") |
| with gr.Row(elem_classes=["control-row"]): |
| remix_btn = gr.Button("Remix", size="sm") |
| strange_btn = gr.Button("Strange Mix", size="sm") |
| power_btn = gr.Button("Add Powerup", size="sm") |
| fix_btn = gr.Button("Fix Game", size="sm") |
|
|
| gr.Markdown("### Tuning", elem_classes=["panel-heading"]) |
| control_slider = gr.Slider( |
| 1, 10, value=5, step=1, label="Control sensitivity" |
| ) |
| pace_slider = gr.Slider(1, 10, value=5, step=1, label="Game pace") |
| chaos_slider = gr.Slider( |
| 1, 10, value=5, step=1, label="Game-specific chaos" |
| ) |
| tune_btn = gr.Button("Apply Tuning", variant="secondary", size="sm") |
|
|
| with gr.Column(scale=3): |
| with gr.Tabs(): |
| with gr.Tab("Game"): |
| with gr.Group(elem_classes=["cabinet-shell"]): |
| gr.HTML( |
| """ |
| <div class="cabinet-top"> |
| <span>ARCADE CABINET</span> |
| <span><i class="lamp"></i><i class="lamp"></i><i class="lamp"></i></span> |
| </div> |
| """ |
| ) |
| game_display = gr.HTML(value=_PLACEHOLDER) |
|
|
| _chat_inputs = [msg_input, chatbot, game_display, raw_html_state] |
| _chat_outputs = [msg_input, chatbot, game_display, raw_html_state] |
|
|
| msg_input.submit(chat_handler, _chat_inputs, _chat_outputs) |
| send_btn.click( chat_handler, _chat_inputs, _chat_outputs) |
|
|
| new_btn.click( |
| new_game_handler, |
| inputs=[chatbot], |
| outputs=[chatbot, game_display, raw_html_state], |
| ) |
| restart_btn.click( |
| restart_game_handler, |
| inputs=[chatbot, raw_html_state], |
| outputs=[chatbot, game_display, raw_html_state], |
| ) |
|
|
| frog_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a frog dodge game where moon rain falls from the sky. " |
| "Fill in the rules and make it playable right away.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| ghost_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a coin collecting game with friendly ghosts as hazards. " |
| "Invent the scoring, movement, and lose condition.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| snake_btn.click( |
| orb_snake_handler, |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| boss_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a tiny boss fight with bouncing spells. Keep the controls simple " |
| "and make the boss pattern readable.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
|
|
| starfish_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a strange arcade game about a sleepy starfish collecting lost " |
| "alarm clocks while dodging moon crumbs. It should feel silly, " |
| "readable, and playable right away.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| ribbon_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a snake-style game where I guide a glowing ribbon through a " |
| "midnight bakery, eating floating jam orbs and growing longer. The " |
| "snake should only move when I press a key.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| frog_coin_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Make a weird mix game where a frog platformer gets invaded by " |
| "falling coins. I need to jump, collect, and survive until the game " |
| "decides I am rich enough.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| remix_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Remix the current game with one surprising new mechanic. " |
| "Keep it playable and keep the controls simple.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| strange_btn.click( |
| strange_mix_handler, |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| power_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Add one useful powerup to the current game. Make the powerup visible, " |
| "collectible, and explain its effect in the game UI.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| fix_btn.click( |
| lambda history, display, raw: quick_prompt_handler( |
| "Fix the current game so it is playable: make sure the player is visible, " |
| "keyboard controls work, on-screen directional controls work, the score " |
| "is visible, and game over or restart instructions are clear. If this is " |
| "a snake game, make it direct-control: it should not move by itself.", |
| history, |
| display, |
| raw, |
| ), |
| inputs=[chatbot, game_display, raw_html_state], |
| outputs=_chat_outputs, |
| ) |
| tune_btn.click( |
| tuning_handler, |
| inputs=[ |
| control_slider, |
| pace_slider, |
| chaos_slider, |
| chatbot, |
| game_display, |
| raw_html_state, |
| ], |
| outputs=[chatbot, game_display, raw_html_state], |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(css=_CSS) |
|
|