"""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 = """
INSERT IDEA Describe a tiny arcade game Try “a frog dodging moon rain” or click a cartridge prompt.
""" _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 = """
Backyard Arcade Tiny games grown from strange prompts | Build Small Track 2
""" _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 = """ """ 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 "" in raw_html: return raw_html.replace("", f"{_GAME_FOCUS_SCRIPT}", 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 ( '
' '
' 'Click the game first, then use arrows or WASD.' 'Keyboard focus stays inside the cabinet.' '
' f'' '
' ) 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( """
ARCADE CABINET
""" ) 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)