LeafCat79's picture
Update app.py
2810eef verified
"""
Text-to-Game Generator
- Code : Qwen/Qwen3-Coder-Next via HF router (novita provider)
- Images: black-forest-labs/FLUX.1-schnell via hf-inference (free)
Both run on HF servers. Zero local RAM, no GPU, no crashes.
Add HF_TOKEN as a Space secret: Settings > Variables and secrets > New secret.
"""
import os
import re
import io
import base64
import traceback
import gradio as gr
from openai import OpenAI
from huggingface_hub import InferenceClient
from PIL import Image
# ---------------------------------------------------------------------------
# Models and clients
# ---------------------------------------------------------------------------
CODE_MODEL = "Qwen/Qwen3-Coder-Next:novita" # via HF router
IMAGE_MODEL = "baidu/ERNIE-Image" # via fal-ai
HF_TOKEN = os.environ.get("HF_TOKEN", "")
def _check_token():
if not HF_TOKEN:
raise ValueError(
"HF_TOKEN is not set. Add it as a Space secret: "
"Settings > Variables and secrets > New secret > Name: HF_TOKEN"
)
def get_code_client():
_check_token()
return OpenAI(
base_url="https://router.huggingface.co/v1",
api_key=HF_TOKEN,
)
def get_image_client():
_check_token()
return InferenceClient(provider="fal-ai", api_key=HF_TOKEN)
# ---------------------------------------------------------------------------
# Game type configs
# ---------------------------------------------------------------------------
GAME_TYPES = {
"Platformer": {
"description": "Jump over enemies and obstacles to reach the goal.",
"prompt_template": (
"Create a complete, self-contained HTML5 platformer game with the theme: {theme}.\n"
"Requirements:\n"
"- Single HTML file with all CSS and JavaScript inline.\n"
"- Use an HTML5 canvas (id='gameCanvas') sized 800x450.\n"
"- Player moves left/right with arrow keys or WASD and jumps.\n"
"- At least 5 platforms, 2 moving enemies, and a goal to reach.\n"
"- Show score/lives on the canvas.\n"
"- Use requestAnimationFrame for the game loop.\n"
"- For every image asset use: const img = new Image(); img.src = 'sprite_NAME.png';\n"
" e.g. sprite_player.png, sprite_enemy.png, sprite_background.png, sprite_platform.png.\n"
"- No external libraries or CDN links.\n"
"- Theme all visuals to match: {theme}.\n"
"Output ONLY the raw HTML. No explanation, no markdown fences."
),
},
"Top-Down Shooter": {
"description": "Shoot waves of enemies before they reach you.",
"prompt_template": (
"Create a complete, self-contained HTML5 top-down shooter game with the theme: {theme}.\n"
"Requirements:\n"
"- Single HTML file with all CSS and JavaScript inline.\n"
"- Use an HTML5 canvas (id='gameCanvas') sized 800x450.\n"
"- Player moves with WASD/arrows; shoots with Space or click.\n"
"- Enemies spawn from edges in escalating waves.\n"
"- Display health, score, and wave on canvas.\n"
"- Game-over screen with score and restart button.\n"
"- Use requestAnimationFrame for the game loop.\n"
"- For every image asset: const img = new Image(); img.src = 'sprite_NAME.png';\n"
" e.g. sprite_player.png, sprite_enemy.png, sprite_bullet.png, sprite_background.png.\n"
"- No external libraries or CDN links.\n"
"- Theme everything to match: {theme}.\n"
"Output ONLY the raw HTML. No explanation, no markdown fences."
),
},
"Puzzle / Maze": {
"description": "Navigate a maze or solve a tile puzzle to escape.",
"prompt_template": (
"Create a complete, self-contained HTML5 maze/tile-puzzle game with the theme: {theme}.\n"
"Requirements:\n"
"- Single HTML file with all CSS and JavaScript inline.\n"
"- Use an HTML5 canvas (id='gameCanvas') sized 800x450.\n"
"- Player navigates with arrow keys or WASD.\n"
"- At least 15x10 tile grid, collectible keys, and a locked exit.\n"
"- Show a move counter or timer.\n"
"- Win screen when player collects all keys and exits.\n"
"- For every image asset: const img = new Image(); img.src = 'sprite_NAME.png';\n"
" e.g. sprite_player.png, sprite_wall.png, sprite_floor.png, sprite_key.png, sprite_exit.png.\n"
"- No external libraries or CDN links.\n"
"- Theme everything to match: {theme}.\n"
"Output ONLY the raw HTML. No explanation, no markdown fences."
),
},
"Arcade / Dodge": {
"description": "Dodge falling obstacles and survive as long as possible.",
"prompt_template": (
"Create a complete, self-contained HTML5 arcade dodge game with the theme: {theme}.\n"
"Requirements:\n"
"- Single HTML file with all CSS and JavaScript inline.\n"
"- Use an HTML5 canvas (id='gameCanvas') sized 800x450.\n"
"- Player moves with arrow keys or WASD; obstacles increase in speed over time.\n"
"- Collision ends the game; show time survived as score.\n"
"- High score stored in a JS variable; restart button on game-over screen.\n"
"- Use requestAnimationFrame for the game loop.\n"
"- For every image asset: const img = new Image(); img.src = 'sprite_NAME.png';\n"
" e.g. sprite_player.png, sprite_obstacle.png, sprite_background.png.\n"
"- No external libraries or CDN links.\n"
"- Theme everything to match: {theme}.\n"
"Output ONLY the raw HTML. No explanation, no markdown fences."
),
},
"Surprise Me!": {
"description": "Let the AI invent the genre - could be anything!",
"prompt_template": (
"Create a complete, self-contained HTML5 browser game with the theme: {theme}.\n"
"Pick any fun arcade genre: breakout, snake, flappy-style, space invaders, etc.\n"
"Requirements:\n"
"- Single HTML file with all CSS and JavaScript inline.\n"
"- Use an HTML5 canvas (id='gameCanvas') sized 800x450.\n"
"- Keyboard or mouse controlled.\n"
"- Clear win/lose conditions and a score display.\n"
"- Game-over / win screen with a restart button.\n"
"- Use requestAnimationFrame for the game loop.\n"
"- For every image asset: const img = new Image(); img.src = 'sprite_NAME.png';\n"
" e.g. sprite_player.png, sprite_background.png.\n"
"- No external libraries or CDN links.\n"
"- Theme everything vividly to match: {theme}.\n"
"Output ONLY the raw HTML. No explanation, no markdown fences."
),
},
}
GAME_TYPE_NAMES = list(GAME_TYPES.keys())
THEME_EXAMPLES = {
"Platformer": [["Jungle temple with ancient traps"], ["Neon cyberpunk rooftops"], ["Underwater pirate shipwreck"]],
"Top-Down Shooter": [["Alien desert invasion"], ["Viking village under siege"], ["Steampunk robot uprising"]],
"Puzzle / Maze": [["Haunted library with secret doors"], ["Ice cave with frozen keys"], ["Egyptian pyramid tiles"]],
"Arcade / Dodge": [["Asteroid field in a tiny rocket"], ["Chef dodging ingredients"], ["Time traveller avoiding paradox storms"]],
"Surprise Me!": [["A sentient library that rearranges itself"], ["Deep sea bioluminescent creatures"], ["Retro space diner on a comet"]],
}
# ---------------------------------------------------------------------------
# Image helpers
# ---------------------------------------------------------------------------
def _pil_to_data_uri(pil_image: Image.Image, size: int = 64) -> str:
img = pil_image.resize((size, size), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def _colored_placeholder(name: str) -> str:
colours = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6", "#1abc9c"]
colour = colours[abs(hash(name)) % len(colours)]
label = name.replace("sprite_", "")[:6]
svg = (
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">'
'<rect width="64" height="64" fill="' + colour + '" rx="8"/>'
'<text x="32" y="38" font-size="10" text-anchor="middle" fill="white">' + label + '</text>'
'</svg>'
)
return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode()
def _sprite_prompt(sprite_name: str, theme: str) -> str:
label = sprite_name.replace("sprite_", "").replace("_", " ")
return (
"Pixel-art game sprite: " + label + ". Theme: " + theme + ". "
"Vibrant colours, clear silhouette, plain dark background, 64x64 pixel style."
)
def _find_sprite_filenames(html_code: str) -> list:
pattern = r"""(?:src\s*=\s*['"]|\.src\s*=\s*['"])\s*(sprite_[a-zA-Z0-9_]+\.png)\s*['"]"""
return list(dict.fromkeys(re.findall(pattern, html_code)))
def _inject_sprites(html_code: str, sprite_map: dict) -> str:
for fname, data_uri in sprite_map.items():
html_code = html_code.replace('"' + fname + '"', '"' + data_uri + '"')
html_code = html_code.replace("'" + fname + "'", "'" + data_uri + "'")
return html_code
def generate_sprites(sprite_names: list, theme: str) -> dict:
if not sprite_names:
return {}
client = get_image_client()
mapping = {}
for fname in sprite_names:
name_no_ext = fname.replace(".png", "")
prompt = _sprite_prompt(name_no_ext, theme)
try:
pil_img = client.text_to_image(prompt, model=IMAGE_MODEL)
mapping[fname] = _pil_to_data_uri(pil_img, size=64)
except Exception as exc:
print("[FLUX] Failed '" + fname + "': " + str(exc))
mapping[fname] = _colored_placeholder(name_no_ext)
return mapping
# ---------------------------------------------------------------------------
# Code generation
# ---------------------------------------------------------------------------
def generate_game_code(game_type: str, theme: str, temperature: float, max_new_tokens: int):
if not theme.strip():
return "", "Please enter a theme first.", _placeholder_html("Enter a theme and generate a game.")
template = GAME_TYPES[game_type]["prompt_template"]
user_prompt = template.format(theme=theme.strip())
system_msg = (
"You are an expert HTML5 game developer. "
"Write a complete, working, single-file HTML5 game using canvas and vanilla JavaScript. "
"Use new Image() with src='sprite_NAME.png' for every visual asset. "
"Output ONLY the raw HTML - no markdown fences, no explanation."
)
try:
# Step 1: Generate HTML via Qwen3-Coder-Next (HF router -> novita)
client = get_code_client()
completion = client.chat.completions.create(
model=CODE_MODEL,
messages=[
{"role": "system", "content": system_msg},
{"role": "user", "content": user_prompt},
],
max_tokens=int(max_new_tokens),
temperature=float(temperature),
)
code = completion.choices[0].message.content.strip()
code = re.sub(r"^```[a-zA-Z]*\n?", "", code).strip()
code = re.sub(r"\n?```$", "", code).strip()
if "<html" not in code.lower() and "<!doctype" not in code.lower():
code = _wrap_in_html(code, theme)
# Step 2: Detect sprites
sprite_names = _find_sprite_filenames(code)
# Step 3: Generate sprites via FLUX.1-schnell (hf-inference)
sprite_map = generate_sprites(sprite_names, theme.strip())
# Step 4: Inject base64 images into HTML
final_code = _inject_sprites(code, sprite_map)
n_real = sum(1 for v in sprite_map.values() if "image/png" in v)
n_fallback = len(sprite_map) - n_real
status = (
"Done! " + str(len(sprite_names)) + " sprite(s) - "
+ str(n_real) + " by FLUX.1-schnell, "
+ str(n_fallback) + " fallback. Click Launch Game to play."
)
return final_code, status, _build_preview(final_code)
except Exception as exc:
traceback.print_exc()
err = str(exc)
if "401" in err or "unauthorized" in err.lower():
err = "Unauthorized - make sure your HF_TOKEN is added as a Space secret."
elif "402" in err or "payment" in err.lower():
err = "Free quota exceeded - upgrade to HF PRO or use your own HF_TOKEN."
elif "429" in err or "rate" in err.lower():
err = "Rate limited - wait a moment and try again."
elif "not supported" in err.lower():
err = "Model not supported by provider - check your HF_TOKEN."
else:
err = "Error: " + str(exc)
return "", err, _placeholder_html(err)
# ---------------------------------------------------------------------------
# HTML helpers
# ---------------------------------------------------------------------------
def _placeholder_html(message: str) -> str:
safe = message.replace("<", "&lt;").replace(">", "&gt;")
return (
'<div style="display:flex;align-items:center;justify-content:center;'
'width:100%;height:460px;background:#0d0d0d;border-radius:12px;'
'border:2px dashed #333;color:#555;font-family:monospace;font-size:14px;'
'text-align:center;padding:24px;box-sizing:border-box;">'
'<pre style="margin:0;white-space:pre-wrap;">' + safe + '</pre></div>'
)
def _wrap_in_html(snippet: str, theme: str) -> str:
return (
"<!DOCTYPE html>\n<html lang='en'>\n<head>\n"
"<meta charset='UTF-8'><title>" + theme + "</title>\n"
"<style>body{margin:0;background:#111;display:flex;justify-content:center;"
"align-items:center;height:100vh;}canvas{display:block;}</style>\n"
"</head>\n<body>\n" + snippet + "\n</body>\n</html>"
)
def _build_preview(html_code: str) -> str:
encoded = base64.b64encode(html_code.encode("utf-8")).decode("ascii")
return (
'<iframe src="data:text/html;base64,' + encoded + '" '
'style="width:100%;height:460px;border:none;border-radius:12px;background:#000;" '
'sandbox="allow-scripts" title="Game Preview"></iframe>'
)
def launch_game(code: str) -> str:
if not code or not code.strip():
return _placeholder_html("No game code yet - generate a game first.")
return _build_preview(code)
# ---------------------------------------------------------------------------
# UI helpers
# ---------------------------------------------------------------------------
def update_type_description(game_type: str) -> str:
return "_" + GAME_TYPES[game_type]["description"] + "_"
def get_first_theme(game_type: str) -> str:
return THEME_EXAMPLES[game_type][0][0]
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
def build_ui():
with gr.Blocks(title="Game Generator") as demo:
gr.Markdown(
"# Game Generator\n"
"Describe a theme, pick a genre - "
"**Qwen3-Coder-Next** (via Novita/HF router) writes the game and "
"**FLUX.1-schnell** (via HF Inference) generates every sprite. "
"Zero RAM used in your Space.\n\n"
"> Add your **HF_TOKEN** as a Space secret "
"(Settings > Variables and secrets > New secret > Name: HF_TOKEN)."
)
with gr.Row():
with gr.Column(scale=1, min_width=300):
gr.Markdown("## 1. Configure your game")
game_type_dropdown = gr.Dropdown(
choices=GAME_TYPE_NAMES, value="Platformer", label="Game genre",
)
type_description = gr.Markdown(
value="_" + GAME_TYPES["Platformer"]["description"] + "_",
)
theme_box = gr.Textbox(
label="Theme / setting",
placeholder="e.g. Neon cyberpunk rooftops",
lines=2,
value=THEME_EXAMPLES["Platformer"][0][0],
)
gr.Examples(
examples=THEME_EXAMPLES["Platformer"],
inputs=[theme_box],
label="Theme examples",
)
gr.Markdown("## 2. Generation settings")
temperature_slider = gr.Slider(
minimum=0.3, maximum=1.2, value=0.7, step=0.05,
label="Temperature - higher = more creative",
)
max_tokens_slider = gr.Slider(
minimum=2000, maximum=8000, value=6000, step=500,
label="Max tokens - more = longer game",
)
generate_btn = gr.Button("Generate Game + Sprites", variant="primary")
gen_status = gr.Markdown(value="_No game generated yet._")
with gr.Column(scale=2, min_width=500):
gr.Markdown("## 3. Generated code (editable)")
code_box = gr.Code(
label="HTML source (sprites embedded as base64)",
language="html",
lines=12,
interactive=True,
)
launch_btn = gr.Button("Launch Game", variant="secondary")
gr.Markdown("## 4. Live game window")
game_frame = gr.HTML(
value=_placeholder_html("Generate a game to see it here."),
)
game_type_dropdown.change(fn=update_type_description, inputs=[game_type_dropdown], outputs=[type_description])
game_type_dropdown.change(fn=get_first_theme, inputs=[game_type_dropdown], outputs=[theme_box])
generate_btn.click(
fn=generate_game_code,
inputs=[game_type_dropdown, theme_box, temperature_slider, max_tokens_slider],
outputs=[code_box, gen_status, game_frame],
)
launch_btn.click(fn=launch_game, inputs=[code_box], outputs=[game_frame])
gr.Markdown(
"---\n"
"Code: **Qwen3-Coder-Next** via HF router (novita). "
"Images: **ERNIE-Image** via fal-ai. "
"Edit the HTML source and click **Launch Game** to hot-reload."
)
return demo
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app = build_ui()
app.launch()