Spaces:
Running
Running
| """ | |
| 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("<", "<").replace(">", ">") | |
| 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() |