game-builder-ai / app.py
AnshulRaj's picture
Rename app to Backyard Arcade
22984d2 verified
Raw
History Blame Contribute Delete
21.2 kB
"""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)