File size: 30,999 Bytes
76b9939
84ab022
 
 
 
d455ec5
84ab022
a22d8dd
 
df65baa
d455ec5
76b9939
 
675f75f
76b9939
4900774
76b9939
 
ff680de
76b9939
675f75f
507bbf7
 
d455ec5
4900774
76b9939
 
a22d8dd
76b9939
 
da2ca08
d455ec5
76b9939
d455ec5
 
 
507bbf7
da2ca08
ff680de
 
 
 
ba60839
84ab022
b93c43a
675f75f
84ab022
 
4824a4a
84ab022
 
 
 
7f2f0ed
 
84ab022
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4900774
ff680de
76b9939
de7a564
76b9939
 
ff680de
84ab022
 
ff680de
 
 
 
 
 
 
 
 
 
df65baa
 
ff680de
 
df65baa
ff680de
 
 
 
 
df65baa
ff680de
 
 
 
 
 
 
 
 
84ab022
 
76b9939
 
 
df65baa
84ab022
 
df65baa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84ab022
 
76b9939
 
 
 
 
 
 
df65baa
ff680de
76b9939
 
4900774
84ab022
a22d8dd
 
84ab022
 
e61e8fe
4769793
 
 
 
 
 
bec5b87
e61e8fe
4769793
 
 
 
 
 
bec5b87
e61e8fe
84ab022
e61e8fe
4769793
 
 
e61e8fe
 
4769793
 
bec5b87
e61e8fe
 
4769793
 
 
e61e8fe
84ab022
ff680de
e61e8fe
4769793
 
 
e61e8fe
 
4769793
 
 
e61e8fe
84ab022
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ff680de
84ab022
a22d8dd
 
d455ec5
4900774
 
bec5b87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84ab022
ff680de
4900774
 
 
 
 
 
 
df65baa
 
4900774
 
 
 
 
 
 
 
 
507bbf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16640e1
84ab022
df65baa
d455ec5
507bbf7
 
 
d455ec5
507bbf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d455ec5
bec5b87
 
 
84ab022
507bbf7
 
84ab022
a505043
d455ec5
507bbf7
16640e1
507bbf7
16640e1
4900774
a22d8dd
84ab022
a22d8dd
4900774
84ab022
4900774
0aa39d5
 
84ab022
4900774
76b9939
84ab022
76b9939
de7a564
84ab022
 
 
f1221c3
 
f9787b6
 
df65baa
c437906
 
df65baa
0ba8dab
 
f9787b6
f1221c3
f9787b6
f1221c3
c437906
df65baa
 
f1221c3
4769793
0ba8dab
 
 
 
f1221c3
 
 
c437906
f9787b6
 
 
 
f1221c3
 
 
 
 
 
 
f9787b6
f1221c3
c437906
f9787b6
 
f1221c3
 
 
 
 
 
 
 
 
 
 
c437906
 
 
 
 
f9787b6
 
 
0ba8dab
84ab022
 
 
76b9939
84ab022
76b9939
84ab022
df65baa
ff680de
a22d8dd
ff680de
84ab022
 
 
 
 
 
 
 
 
 
 
df65baa
84ab022
 
 
ff680de
84ab022
 
ff680de
16640e1
84ab022
ff680de
84ab022
 
 
 
16640e1
 
ff680de
16640e1
d455ec5
4900774
ff680de
84ab022
76b9939
 
 
84ab022
 
 
 
 
 
 
 
675f75f
76b9939
 
 
 
 
 
 
 
ff680de
 
 
 
 
76b9939
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c437906
ff680de
c437906
 
 
76b9939
 
 
 
 
 
 
 
675f75f
76b9939
 
 
 
84ab022
76b9939
 
 
 
 
 
675f75f
76b9939
 
 
 
 
a22d8dd
76b9939
 
84ab022
 
d455ec5
ff680de
76b9939
 
 
 
df65baa
76b9939
 
84ab022
76b9939
84ab022
76b9939
 
84ab022
 
76b9939
 
84ab022
a22d8dd
 
76b9939
 
 
 
 
 
 
 
84ab022
76b9939
84ab022
76b9939
84ab022
76b9939
84ab022
675f75f
84ab022
76b9939
 
84ab022
df65baa
de7a564
df65baa
84ab022
 
ff680de
 
 
 
 
 
 
 
 
76b9939
ff680de
 
 
 
 
76b9939
ff680de
76b9939
 
 
 
 
a22d8dd
 
84ab022
 
 
 
 
 
 
 
 
 
76b9939
 
84ab022
 
 
76b9939
 
 
 
 
 
84ab022
d455ec5
 
df65baa
76b9939
 
 
 
675f75f
76b9939
 
 
 
 
 
d8b0471
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
"""
Text-to-Game Generator
Pipeline:
  Theme --> [Groq Llama] --> HTML5 game code (with sprite_NAME.png refs)
  Theme --> [Groq Llama acting as Z-Image-Engineer] --> cinematic image prompts
         --> [FLUX.1-dev via fal-ai] --> sprite images
         --> injected as base64 into game HTML

Secrets needed:
  GROQ_API_KEY  - console.groq.com (free, no credit card)
  HF_TOKEN      - huggingface.co/settings/tokens (for FLUX.1-dev via fal-ai)
"""

import os
import re
import io
import base64
import traceback
import time
import gradio as gr
from openai import OpenAI
import requests
from urllib.parse import quote
from huggingface_hub import InferenceClient
from PIL import Image

# ---------------------------------------------------------------------------
# Clients
# ---------------------------------------------------------------------------

GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
HF_TOKEN     = os.environ.get("HF_TOKEN", "")

CODE_MODEL   = "llama-3.1-8b-instant"          # Groq β€” game code
PROMPT_MODEL = "llama-3.3-70b-versatile"       # Groq β€” image prompt enhancement
IMAGE_MODEL  = "black-forest-labs/FLUX.1-dev"  # fal-ai β€” image generation
POLLINATIONS_URL = "https://image.pollinations.ai/prompt/{prompt}?width={w}&height={h}&model=flux&nologo=true&seed={seed}"

# Canvas dimensions β€” iframe is fixed to these so game fits perfectly
CANVAS_W = 800
CANVAS_H = 450


def get_groq_client():
    if not GROQ_API_KEY:
        raise ValueError(
            "GROQ_API_KEY not set. "
            "Get a free key at console.groq.com and add it as a Space secret."
        )
    return OpenAI(
        base_url="https://api.groq.com/openai/v1",
        api_key=GROQ_API_KEY,
    )


# ---------------------------------------------------------------------------
# Z-Image-Engineer system prompt (from BennyDaBall/Qwen3-4b-Z-Image-Engineer-V4)
# ---------------------------------------------------------------------------

Z_ENGINEER_SYSTEM = (
    "Interpret the user seed as production intent, then build a definitive 200-250 word "
    "single-paragraph image prompt that preserves every explicit constraint while intelligently "
    "expanding missing details. First infer the core subject, action, setting, and emotional tone; "
    "treat these as non-negotiable anchors. Then enhance with precise visual staging "
    "(explicit foreground, midground, background), clear visual hierarchy and eye path, "
    "physically plausible lighting (source, direction, softness, color temperature), and optical "
    "strategy (if lens/aperture are provided, preserve exactly; if absent, choose fitting lens and "
    "aperture and imply their depth-of-field effect). Integrate organic, manufactured, and "
    "environmental textures with realistic material behavior, add motion/atmospheric cues only "
    "when they support the scene, and apply a coherent color grade consistent with mood and "
    "environment. Output ONLY the image prompt paragraph. No explanation, no preamble."
)

# ---------------------------------------------------------------------------
# Game type configs β€” Platformer and Top-Down Shooter only
# ---------------------------------------------------------------------------

GAME_TYPES = {
    "Platformer": {
        "description": "Reach the goal platform while avoiding wandering monsters.",
        "prompt_template": (
            "Create a complete, self-contained HTML5 platformer game with the theme: {theme}.\n"
            "EXACT GAME RULES β€” implement every one precisely:\n"
            "- Canvas: id='gameCanvas', size 800x450.\n"
            "- PLATFORMS: at least 6 platforms drawn with ctx.drawImage(platformImg, x, y, w, h). "
            "  Include one full-width ground platform at y=420 height=20. "
            "  Place 5 elevated platforms at varied x/y positions. "
            "  Use new Image() with src='sprite_platform.png' for ALL platforms.\n"
            "- TARGET/GOAL: a glowing star or chest sprite at the top-right platform. "
            "  Use new Image() with src='sprite_goal.png'. "
            "  When player bounding box overlaps goal: show WIN screen with score and Restart button.\n"
            "- PLAYER: starts bottom-left. Size 40x40. "
            "  Move left/right with A/D or ArrowLeft/ArrowRight (speed 4). "
            "  Jump with W, ArrowUp, or Space (velY = -12, only when grounded). "
            "  Gravity: velY += 0.5 every frame. "
            "  Platform collision: set grounded=false BEFORE loop; inside loop if player feet hit platform top set grounded=true velY=0. "
            "  Keep player inside canvas horizontally.\n"
            "- MONSTERS: 3 monsters, each patrolling back-and-forth on its own platform. "
            "  Size 32x32. Speed 1.5 px/frame. Reverse direction at platform edges. "
            "  Use new Image() with src='sprite_enemy.png'. "
            "  If monster bounding box overlaps player: player lives -= 1, respawn player at start. "
            "  0 lives = GAME OVER screen with Restart button.\n"
            "- HUD: lives top-left, score top-right.\n"
            "- IMAGES: declare all at top of script before anything else: "
            "  const playerImg = new Image(); playerImg.src = 'sprite_player.png'; "
            "  const bgImg = new Image(); bgImg.src = 'sprite_background.png'; "
            "  const enemyImg = new Image(); enemyImg.src = 'sprite_enemy.png'; "
            "  const platformImg = new Image(); platformImg.src = 'sprite_platform.png'; "
            "  const goalImg = new Image(); goalImg.src = 'sprite_goal.png'; "
            "  Use Promise.all([loadImg(playerImg),loadImg(bgImg),loadImg(enemyImg),loadImg(platformImg),loadImg(goalImg)]).then(startGame).\n"
            "- Define ALL functions at TOP LEVEL, not inside Promise.then or startGame.\n"
            "- Add window keydown/keyup listeners to a keys Set inside startGame().\n"
            "- NO external libraries, NO CDN links.\n"
            "Output ONLY the raw HTML. No explanation, no markdown fences."
        ),
    },
    "Top-Down Shooter": {
        "description": "Shoot monsters coming from the top before they reach you.",
        "prompt_template": (
            "Create a complete, self-contained HTML5 top-down shooter game with the theme: {theme}.\n"
            "EXACT GAME RULES β€” implement every one precisely:\n"
            "- Canvas: id='gameCanvas', size 800x450.\n"
            "- MONSTERS spawn at random x positions along the TOP edge (y=0) and move DOWNWARD only (y += speed each frame).\n"
            "- Monster size: 32x32. Monster speed: 1-2 px/frame (slower than player speed of 4).\n"
            "- PLAYER starts at bottom-center, moves left/right only with A/D or ArrowLeft/ArrowRight keys.\n"
            "- Player size: 48x48. Player speed: 4 px/frame. Keep player inside canvas bounds.\n"
            "- LEFT MOUSE CLICK fires one bullet from player position toward the click point.\n"
            "  Bullet speed: 10 px/frame. Remove bullets that leave canvas.\n"
            "- BULLET HIT: if a bullet rect overlaps a monster rect, remove BOTH the bullet and the monster. Score += 10.\n"
            "- MONSTER COLLISION: if a monster rect overlaps the player rect, remove the monster. Player health -= 1.\n"
            "- MONSTER ESCAPED: if a monster reaches y > canvas.height remove it (no health loss).\n"
            "- New monsters spawn every 90 frames. Spawn rate increases every 500 points.\n"
            "- HUD: draw score top-left, health top-right (show as hearts or number).\n"
            "- GAME OVER when health reaches 0: show score and a Restart button.\n"
            "- NO gravity, NO velY += 0.5, NO grounded, NO jumping.\n"
            "- Use new Image() with src='sprite_player.png' for the player (48x48).\n"
            "- Use new Image() with src='sprite_background.png' for the background (full canvas).\n"
            "- Use new Image() with src='sprite_enemy.png' for monsters (32x32).\n"
            "- Declare all images at top of script, use Promise.all to wait before starting gameLoop.\n"
            "- Define all functions at TOP LEVEL, not inside Promise.then or startGame.\n"
            "- Add window keydown/keyup listeners to a keys Set inside startGame().\n"
            "- NO external libraries, NO CDN links.\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": [["Ancient Egyptian tomb raid with cursed mummies"], ["Alien desert invasion"], ["Viking village under siege"]],
}

# ---------------------------------------------------------------------------
# Step 1: Generate image prompts via Z-Image-Engineer (Groq)
# ---------------------------------------------------------------------------

def generate_image_prompts(theme: str, game_type: str) -> dict:
    client = get_groq_client()
    if game_type == "Top-Down Shooter":
        bg_style   = "bird's eye overhead view, top-down 2D game background, viewed from directly above, like a map"
        char_style = (
            "top-down overhead game sprite, viewed from directly above, "
            "character body seen from above like looking straight down, "
            "head at top shoulders below, like Metal Slug or GTA 2 sprite angle, "
            "pure black background, character only"
        )
    else:
        bg_style   = "2D side-scrolling platformer background, horizontal landscape viewed from the side"
        char_style = (
            "2D side-view platformer game sprite, character facing right, "
            "full body visible from the side like Super Mario or Mega Man, "
            "classic side-scrolling game art style, "
            "pure black background, character only"
        )

    seeds = {
        "sprite_player.png": (
            f"{char_style}, {theme} theme, "
            f"vibrant colors, strong clear silhouette, 64x64 pixel style, "
            f"single character centered, no scenery no ground no environment"
        ),
        "sprite_background.png": (
            f"{bg_style}, {theme} theme, "
            f"wide atmospheric scene, game art style, 800x450, "
            f"no characters no sprites, environment only"
        ),
        "sprite_enemy.png": (
            f"{char_style}, {theme} theme, enemy monster villain, "
            f"menacing threatening design, strong clear silhouette, 64x64 pixel style, "
            f"single enemy centered, no scenery no ground no environment"
        ),
    }
    if game_type == "Platformer":
        seeds["sprite_platform.png"] = (
            f"2D side-view pixel-art platform tile, {theme} theme, "
            f"rectangular solid surface like a game platform, stone or wood texture, "
            f"pure black background, platform only, 128x24 pixel style"
        )
        seeds["sprite_goal.png"] = (
            f"2D side-view pixel-art treasure chest or glowing star goal, {theme} theme, "
            f"glowing clearly visible reward item, "
            f"pure black background, item only, 40x40 pixel style"
        )
    prompts = {}
    for sprite_name, seed in seeds.items():
        try:
            response = client.chat.completions.create(
                model=PROMPT_MODEL,
                messages=[
                    {"role": "system", "content": Z_ENGINEER_SYSTEM},
                    {"role": "user",   "content": seed},
                ],
                max_tokens=400,
                temperature=0.8,
            )
            prompts[sprite_name] = response.choices[0].message.content.strip()
        except Exception as exc:
            print(f"[Z-Engineer] Failed {sprite_name}: {exc}")
            prompts[sprite_name] = seed
    return prompts

# ---------------------------------------------------------------------------
# Step 2: Generate images via FLUX.1-dev (fal-ai)
# ---------------------------------------------------------------------------

def _remove_background(pil_image: Image.Image) -> Image.Image:
    """Remove background from sprite using rembg library."""
    try:
        from rembg import remove
        buf_in = io.BytesIO()
        pil_image.save(buf_in, format="PNG")
        buf_in.seek(0)
        buf_out = io.BytesIO()
        remove(buf_in.read(), output=buf_out)
        buf_out.seek(0)
        return Image.open(buf_out).convert("RGBA")
    except Exception as exc:
        print(f"[rembg] Background removal failed: {exc}")
        return pil_image


def _pil_to_data_uri(pil_image: Image.Image, size: tuple = None) -> str:
    img = pil_image.resize(size, Image.LANCZOS) if size else pil_image
    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 _generate_via_hf(prompt: str) -> Image.Image:
    """Try FLUX.1-dev via HF auto-provider selection.
    HF automatically picks the fastest available provider β€” if one is
    quota-depleted it routes to the next available one automatically."""
    client = InferenceClient(api_key=HF_TOKEN)
    return client.text_to_image(prompt, model=IMAGE_MODEL)


def _generate_via_pollinations(prompt: str, sprite_name: str, is_bg: bool) -> Image.Image:
    """Fallback: Pollinations.AI, free, no key needed."""
    w, h = (CANVAS_W, CANVAS_H) if is_bg else (64, 64)
    seed = abs(hash(sprite_name)) % 99999
    url  = POLLINATIONS_URL.format(prompt=quote(prompt), w=w, h=h, seed=seed)
    for attempt in range(3):
        try:
            resp = requests.get(url, timeout=120)
            resp.raise_for_status()
            return Image.open(io.BytesIO(resp.content))
        except Exception as exc:
            if attempt < 2:
                time.sleep(20)
            else:
                raise exc


def generate_sprites(image_prompts: dict) -> tuple:
    sprite_map = {}
    errors     = []

    for i, (sprite_name, prompt) in enumerate(image_prompts.items()):
        try:
            is_bg = "background" in sprite_name

            # Try FLUX.1-dev via fal-ai if HF_TOKEN is set
            if HF_TOKEN:
                try:
                    pil_img = _generate_via_hf(prompt)
                    provider = "FLUX.1-dev/auto"
                except Exception as fal_exc:
                    fal_err = str(fal_exc)
                    print(f"[fal-ai] Failed {sprite_name}: {fal_err} β€” falling back to Pollinations")
                    errors.append(f"{sprite_name} (fal-ai failed, used Pollinations): {fal_err[:60]}")
                    if i > 0:
                        time.sleep(20)
                    pil_img  = _generate_via_pollinations(prompt, sprite_name, is_bg)
                    provider = "Pollinations"
            else:
                # No HF_TOKEN β€” go straight to Pollinations
                if i > 0:
                    time.sleep(20)
                pil_img  = _generate_via_pollinations(prompt, sprite_name, is_bg)
                provider = "Pollinations"

            if not is_bg:
                pil_img = _remove_background(pil_img)
            size = None if is_bg else (64, 64)
            sprite_map[sprite_name] = _pil_to_data_uri(pil_img, size=size)
            print(f"[{provider}] OK: {sprite_name}")

        except Exception as exc:
            error_msg = str(exc)
            errors.append(f"{sprite_name}: {error_msg[:80]}")
            print(f"[Image] FAILED {sprite_name}: {error_msg}")
            sprite_map[sprite_name] = _colored_placeholder(sprite_name.replace(".png", ""))

    return sprite_map, errors

# ---------------------------------------------------------------------------
# Step 3: Inject sprites into HTML
# ---------------------------------------------------------------------------

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

# ---------------------------------------------------------------------------
# Step 4: Generate game code via Groq Llama
# ---------------------------------------------------------------------------

CODE_SYSTEM = (
    "You are an expert HTML5 game developer. "
    "Write a complete, working, single-file HTML5 game using canvas and vanilla JavaScript. "
    "CRITICAL RULES - copy these patterns EXACTLY as written, do not deviate: "
    "1. The VERY FIRST lines of the script must be exactly: "
    "   const canvas = document.getElementById('gameCanvas'); "
    "   const ctx = canvas.getContext('2d'); "
    "   const playerImg = new Image(); playerImg.src = 'sprite_player.png'; "
    "   const bgImg = new Image();     bgImg.src = 'sprite_background.png'; "
    "   const enemyImg = new Image();  enemyImg.src = 'sprite_enemy.png'; "
    "   const keys = new Set(); "
    "   const bullets = []; "
    "   const enemies = []; "
    "   let score = 0; let health = 3; let frameCount = 0; let gameOver = false; "
    "2. FOR TOP-DOWN SHOOTER declare player at CENTER - copy EXACTLY: "
    "   let player = {x: canvas.width/2-24, y: canvas.height/2-24, w:48, h:48, speed:4}; "
    "   DO NOT use canvas.height - 24 for player y. Player starts in CENTER not bottom. "
    "3. Use Promise.all AFTER all declarations: "
    "   function loadImg(img) { return new Promise(r => { img.onload = r; }); } "
    "   Promise.all([loadImg(playerImg), loadImg(bgImg), loadImg(enemyImg)]).then(startGame); "
    "4. startGame() adds ONLY keyboard listeners and calls gameLoop - NO click listener here: "
    "   function startGame() { "
    "     window.addEventListener('keydown', e => keys.add(e.key)); "
    "     window.addEventListener('keyup', e => keys.delete(e.key)); "
    "     canvas.addEventListener('click', onShoot); "
    "     requestAnimationFrame(gameLoop); } "
    "5. gameLoop() NEVER redeclares canvas or ctx. Draw background FIRST with FULL SIZE: "
    "   ctx.drawImage(bgImg, 0, 0, canvas.width, canvas.height); "
    "   This is critical - always pass canvas.width and canvas.height as 3rd and 4th arguments. "
    "6. FOR TOP-DOWN SHOOTER movement - 4 directions, clamp inside canvas: "
    "   if (keys.has('w')||keys.has('W')||keys.has('ArrowUp'))    player.y -= player.speed; "
    "   if (keys.has('s')||keys.has('S')||keys.has('ArrowDown'))  player.y += player.speed; "
    "   if (keys.has('a')||keys.has('A')||keys.has('ArrowLeft'))  player.x -= player.speed; "
    "   if (keys.has('d')||keys.has('D')||keys.has('ArrowRight')) player.x += player.speed; "
    "   player.x = Math.max(0, Math.min(canvas.width-player.w, player.x)); "
    "   player.y = Math.max(0, Math.min(canvas.height-player.h, player.y)); "
    "7. Bullets fire on MOUSE CLICK straight UP only - no vx component: "
    "   function onShoot(e) { if (gameOver) return; "
    "     bullets.push({x:player.x+player.w/2-4, y:player.y-16, w:8, h:16, vy:-10}); } "
    "   Each frame: bullets[i].y += bullets[i].vy; draw yellow rect; remove when y+h<0. "
    "8. Enemies spawn every 120 frames, fall straight down, speed capped at 3.5: "
    "   frameCount++; "
    "   if (frameCount % 120 === 0) enemies.push({x:Math.random()*(canvas.width-32), y:0, w:32, h:32, speed: Math.min(1+score/500, 3.5)}); "
    "   Each frame: e.y += e.speed; ctx.drawImage(enemyImg,e.x,e.y,32,32); "
    "   Remove if e.y > canvas.height. If overlaps player: health--; remove enemy. "
    "9. Draw player: ctx.drawImage(playerImg, player.x, player.y, player.w, player.h). "
    "10. GAME OVER when health<=0 - set gameOver=true then draw overlay INSIDE gameLoop: "
    "    ctx.fillStyle='rgba(0,0,0,0.7)'; ctx.fillRect(0,0,canvas.width,canvas.height); "
    "    draw GAME OVER text and score at center. "
    "    draw a green restart button rect at center+60px. "
    "    add ONE-TIME click listener for restart ONLY when gameOver becomes true: "
    "    canvas.removeEventListener('click', onShoot); "
    "    canvas.addEventListener('click', restartHandler); "
    "    then call return to stop the loop. "
    "11. restartHandler resets ALL variables and restores onShoot listener: "
    "    function restartHandler() { "
    "      canvas.removeEventListener('click', restartHandler); "
    "      player.x=canvas.width/2-24; player.y=canvas.height/2-24; "
    "      bullets.length=0; enemies.length=0; "
    "      score=0; health=3; frameCount=0; gameOver=false; "
    "      canvas.addEventListener('click', onShoot); "
    "      requestAnimationFrame(gameLoop); } "
    "12. FOR PLATFORMER: gravity velY+=0.5, jump ArrowUp/W/Space velY=-12 when grounded. "
    "    Set grounded=false BEFORE platform loop. Set true and velY=0 only on landing. "
    "    Full-width ground at y=420. "
    "Output ONLY the raw HTML - no markdown fences, no explanation."
)

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.")

    try:
        client      = get_groq_client()
        user_prompt = GAME_TYPES[game_type]["prompt_template"].format(theme=theme.strip())

        # Code generation
        code_resp = client.chat.completions.create(
            model=CODE_MODEL,
            messages=[
                {"role": "system", "content": CODE_SYSTEM},
                {"role": "user",   "content": user_prompt},
            ],
            max_tokens=int(max_new_tokens),
            temperature=float(temperature),
        )
        code = code_resp.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)

        # Image prompts
        image_prompts = generate_image_prompts(theme.strip(), game_type)

        # Sprites
        sprite_map, sprite_errors = generate_sprites(image_prompts)

        # Inject
        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

        if sprite_errors:
            status = f"Code done. Images: {n_real} FLUX, {n_fallback} fallback. Errors: {' | '.join(sprite_errors)}"
        else:
            status = f"Done! {n_real} sprite(s) by FLUX.1-dev. Click Launch Game to play."

        prompt_summary = "\n\n".join(f"**{k}:**\n{v}" for k, v in image_prompts.items())
        return final_code, prompt_summary, status, _build_preview(final_code)

    except Exception as exc:
        traceback.print_exc()
        err = str(exc)
        if "401" in err or "api_key" in err.lower():
            err = "Invalid GROQ_API_KEY. Check your key at console.groq.com."
        elif "429" in err or "rate" in err.lower():
            err = "Rate limited by Groq - wait a few seconds and try again."
        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 (
        f'<div style="display:flex;align-items:center;justify-content:center;'
        f'width:{CANVAS_W}px;height:{CANVAS_H}px;background:#0d0d0d;border-radius:12px;'
        f'border:2px dashed #333;color:#555;font-family:monospace;font-size:14px;'
        f'text-align:center;padding:24px;box-sizing:border-box;">'
        f'<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 (
        f'<div style="width:{CANVAS_W}px;height:{CANVAS_H}px;overflow:hidden;border-radius:12px;">'  
        f'<iframe src="data:text/html;base64,{encoded}" '
        f'style="width:{CANVAS_W}px;height:{CANVAS_H}px;border:none;background:#000;display:block;" '
        f'scrolling="no" '
        'sandbox="allow-scripts" title="Game Preview"></iframe></div>'
    )


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"
            "Type a theme β€” the AI writes the game code, generates cinematic image prompts "
            "using **Z-Image-Engineer V4** style, then **FLUX.1-dev** renders the sprites.\n\n"
            "> Secrets needed: `GROQ_API_KEY` (console.groq.com, free, no credit card)."
        )

        with gr.Row():

            # ── Left: controls ───────────────────────────────────────────
            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. Ancient Egyptian pyramid with cursed mummies",
                    lines=3,
                    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=1000, maximum=6000, value=4000, 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._")

            # ── Right: code + game ────────────────────────────────────────
            with gr.Column(scale=2, min_width=500):

                # Collapsible code window
                with gr.Accordion("3. Generated code β€” click to expand/hide", open=False):
                    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")

                # Collapsible image prompts window
                with gr.Accordion("3b. Generated image prompts β€” click to expand/hide", open=False):
                    prompt_display = gr.Markdown(
                        value="_Image prompts will appear here after generation._"
                    )

                gr.Markdown("## 4. Live game window")

                game_frame = gr.HTML(
                    value=_placeholder_html("Generate a game to see it here."),
                )

        # ── Wiring ────────────────────────────────────────────────────────

        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, prompt_display, gen_status, game_frame],
        )

        launch_btn.click(fn=launch_game, inputs=[code_box], outputs=[game_frame])

        gr.Markdown(
            "---\n"
            "**Pipeline:** Theme β†’ [Groq Llama] game code + [Groq 70B as Z-Image-Engineer] "
            "cinematic prompts β†’ [FLUX.1-dev/fal-ai] sprites β†’ embedded in game. "
            "Game window fixed to 800Γ—450px β€” matches canvas exactly. "
            "Edit HTML and click **Launch Game** to hot-reload."
        )

    return demo


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    app = build_ui()
    app.launch()