"""
๐ฆ NumZoo โ Math practice with cute AI-generated animal rewards!
"""
import base64
import io
import os
import random
import gradio as gr
from math_engine import generate_question, LEVEL_NAMES, level_up_message
from image_generator import generate_reward_image
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
ANSWERS_PER_LEVEL = 5
# Animals โ ordered by lookalike groups (pets ยท canines/bears ยท big cats ยท farm ยท
# water/amphibian ยท bugs ยท mythical) so similar creatures sit together in the picker.
ANIMAL_EMOJIS = [
"๐ถ", "๐ฑ", "๐ฐ", "๐ญ", "๐ฆ", "๐บ",
"๐ป", "๐ผ", "๐จ", "๐ฆ", "๐ฏ", "๐ท",
"๐ฎ", "๐ด", "๐", "๐ธ", "๐ฆฆ", "๐ง",
"๐", "๐ฆ", "๐", "๐", "๐ฆ", "๐",
]
PLACE_EMOJIS = [ # 18 places โ renders as 3 rows of 6
"๐", "๐๏ธ", "๐ธ", "๐", "๐", "โญ", "๐ด", "๐ก", "๐บ", "๐",
"๐๏ธ", "๐ฐ", "๐ข", "โบ", "๐ชฉ", "๐ช", "โต", "๐",
]
MATH_LEVEL_NAMES = {1: "Additions", 2: "Subtractions", 3: "Multiplications", 4: "Mix", 5: "Mix +"}
# ---------------------------------------------------------------------------
# Game helpers
# ---------------------------------------------------------------------------
def _math_level(state: dict) -> int:
return min(state.get("level", 1), 5)
def _status_text(state: dict) -> str:
if not state:
return ""
diff = MATH_LEVEL_NAMES.get(_math_level(state), "")
correct = state.get("correct_this_level", 0)
level = state.get("level", 1)
return (f"๐ค {state['name']} | ๐ฎ Level: {level} | "
f"๐ {diff} | โ
{correct} / {ANSWERS_PER_LEVEL}")
def _safe_question(state: dict) -> str:
return f"## {state.get('question', '')} = ?"
def _img_to_data_url(pil_image) -> str:
buf = io.BytesIO()
pil_image.save(buf, format="JPEG", quality=80)
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
def _picker_level_label(level: int) -> str:
diff = MATH_LEVEL_NAMES.get(min(level, 5), "Mix +")
return f"## ๐ Level {level} โ {diff}"
# ---------------------------------------------------------------------------
# Collection helpers โ server-side render, no JS polling
# ---------------------------------------------------------------------------
def _add_locked(items: list, level_id: int) -> list:
"""Append a locked placeholder for level_id if not already in the list."""
id_str = str(level_id)
if any(x["id"] == id_str for x in items):
return items
return items + [{"id": id_str, "status": "locked", "src": None}]
def _unlock_item(items: list, level_id: int, src: str) -> list:
"""Replace the locked entry for level_id with an unlocked image."""
id_str = str(level_id)
updated, found = [], False
for item in items:
if item["id"] == id_str:
updated.append({"id": id_str, "status": "unlocked", "src": src})
found = True
else:
updated.append(item)
if not found:
updated.append({"id": id_str, "status": "unlocked", "src": src})
return updated
def _render_collection(items: list) -> str:
"""Render the collection as HTML, newest first. Empty string when no items."""
if not items:
return ""
cards = []
for item in reversed(items):
if item["status"] == "locked":
cards.append(
'
'
)
elif item.get("src"):
src = item["src"]
cards.append(
f''
f'

'
f'
'
)
if not cards:
return ""
return (
''
'
๐ผ๏ธ My Collection
'
'
'
+ "".join(cards)
+ '
'
)
# ---------------------------------------------------------------------------
# Step 1 โ Name entry
# ---------------------------------------------------------------------------
def enter_name(player_name: str, state: dict):
try:
name = player_name.strip() or "Player"
state = {"name": name, "level": 1, "score": 0, "streak": 0}
return (
state,
gr.update(visible=False), # welcome
gr.update(visible=True), # emoji picker
gr.update(visible=False), # game
gr.update(visible=False), # picker_level_md
random.sample(ANIMAL_EMOJIS, 1),
random.sample(PLACE_EMOJIS, 1),
)
except Exception as e:
print(f"enter_name error: {e}")
return (state, gr.update(visible=True), gr.update(visible=False),
gr.update(visible=False), gr.update(visible=False), [], [])
# ---------------------------------------------------------------------------
# Step 2 โ Emoji selection โ start level
# ---------------------------------------------------------------------------
def start_level(animal_sel: list, place_sel: list, state: dict, coll_items: list):
try:
if not animal_sel or not place_sel:
return (state, gr.update(visible=True), gr.update(visible=False),
"โ ๏ธ Pick at least one animal and one place!",
"", gr.update(), gr.update(), gr.update(), gr.update(),
gr.update(), gr.update())
level = state.get("level", 1)
state.update({
"selected_animals": animal_sel[:5],
"selected_places": place_sel[:3],
"correct_this_level": 0,
"generate_now": False,
})
math_lv = _math_level(state)
question, answer = generate_question(math_lv)
state["question"] = question
state["answer"] = answer
new_coll = _add_locked(coll_items, level)
return (state,
gr.update(visible=False), # emoji_panel
gr.update(visible=True), # game_panel
"", # picker_error
_status_text(state), # status_md
gr.update(value=_safe_question(state), visible=True), # question_md
gr.update(value="", visible=True), # answer_input
gr.update(visible=True), # check_btn
gr.update(visible=False), # reward_panel (reset)
new_coll, # coll_items
_render_collection(new_coll)) # collection_html
except Exception as e:
print(f"start_level error: {e}")
return (state, gr.update(visible=True), gr.update(visible=False),
"โ ๏ธ Something went wrong, try again.",
"", gr.update(), gr.update(), gr.update(), gr.update(),
gr.update(), gr.update())
# ---------------------------------------------------------------------------
# Background pre-generation โ fires after "Let's go!" so the image is ready
# when the user finishes the level.
# ---------------------------------------------------------------------------
def pregenerate_image(state: dict):
level = state.get("level", 1)
try:
animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
print(f"[pregenerate] level={level} | animals={animals} | places={places}")
result, prompt = generate_reward_image(animals, places, reward_id=level)
print(f"[pregenerate] level={level} done | prompt={prompt!r}")
if result is not None:
data_url = _img_to_data_url(result)
state["pending_reward_id"] = level
return state, result, data_url
state.pop("pending_reward_id", None)
return state, None, ""
except Exception as e:
import traceback
print(f"[pregenerate] โ {e}\n{traceback.format_exc()}")
state.pop("pending_reward_id", None)
return state, None, ""
# ---------------------------------------------------------------------------
# Step 3 โ Answer checking
# ---------------------------------------------------------------------------
# check_outputs order:
# state, status_md, question_md, answer_input, feedback_md,
# reward_panel, loader_html, reward_image, reward_error,
# hidden_image, hidden_data_url, coll_items, collection_html, check_btn
def check_answer(user_input: str, state: dict, pre_image, pre_data_url: str, coll_items: list):
def _no_change():
return (state, _status_text(state), gr.update(), gr.update(), "",
gr.update(visible=False), "", gr.update(visible=False), "",
gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
try:
if not state or not state.get("question"):
return _no_change()
try:
user_answer = int(user_input.strip())
except (ValueError, AttributeError):
return (state, _status_text(state), gr.update(), gr.update(),
"โ ๏ธ Numbers only!",
gr.update(visible=False), "", gr.update(visible=False), "",
gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
correct = (user_answer == state["answer"])
if correct:
state["score"] += 1
state["streak"] += 1
state["correct_this_level"] = state.get("correct_this_level", 0) + 1
level = state.get("level", 1)
correct_count = state["correct_this_level"]
remaining = ANSWERS_PER_LEVEL - correct_count
streak_fire = "๐ฅ" * min(state["streak"], 5)
if correct_count >= ANSWERS_PER_LEVEL:
# โโ Level complete โ hide quiz UI โโโโโโโโโโโโโโโโโโโโโโโโโ
state["generate_now"] = False
pending_id = state.get("pending_reward_id")
has_pre = (pre_image is not None
and bool(pre_data_url)
and pending_id == level)
if has_pre:
state.pop("pending_reward_id", None)
new_coll = _unlock_item(coll_items, level, pre_data_url)
return (state, _status_text(state),
gr.update(visible=False), # question_md
gr.update(visible=False), # answer_input
f"๐ Level {level} complete! {streak_fire}",
gr.update(visible=True), # reward_panel
"", # loader cleared
gr.update(visible=True, value=pre_image), # image shown
"",
None, "", # clear hidden_*
new_coll, _render_collection(new_coll),
gr.update(visible=False)) # check_btn
else:
state["generate_now"] = True
return (state, _status_text(state),
gr.update(visible=False), # question_md
gr.update(visible=False), # answer_input
f"๐ Level {level} complete! {streak_fire}",
gr.update(visible=True), # reward_panel
LOADER_HTML,
gr.update(visible=False),
"",
gr.update(), gr.update(),
gr.update(), gr.update(),
gr.update(visible=False)) # check_btn
else:
# โโ Keep going โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
math_lv = _math_level(state)
question, answer = generate_question(math_lv)
state["question"] = question
state["answer"] = answer
return (state, _status_text(state),
gr.update(value=_safe_question(state)), gr.update(value=""),
f"โ
{remaining} to go! {streak_fire}",
gr.update(visible=False), "", gr.update(visible=False), "",
gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
else:
correct_answer = state["answer"] # capture before overwrite
current_q = state["question"] # the expression, e.g. "15 + 9"
state["streak"] = 0
math_lv = _math_level(state)
question, answer = generate_question(math_lv)
state["question"] = question
state["answer"] = answer
return (state, _status_text(state),
gr.update(value=_safe_question(state)), gr.update(value=""),
f"โ {current_q} = **{correct_answer}**",
gr.update(visible=False), "", gr.update(visible=False), "",
gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
except Exception as e:
import traceback
print(f"check_answer error: {e}\n{traceback.format_exc()}")
state["generate_now"] = False
math_lv = _math_level(state)
question, answer = generate_question(math_lv)
state["question"] = question
state["answer"] = answer
return (state, _status_text(state),
gr.update(value=_safe_question(state)), gr.update(value=""),
"โ ๏ธ Something went wrong!",
gr.update(visible=False), "", gr.update(visible=False), "",
gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
# ---------------------------------------------------------------------------
# On-demand generation โ fallback when pre-image wasn't ready at level end
# ---------------------------------------------------------------------------
def generate_on_demand(state: dict, coll_items: list):
"""Generate reward image on demand. No-op if generate_now is False."""
if not state.get("generate_now"):
return state, "", gr.update(), "", gr.update(), gr.update()
state["generate_now"] = False
level = state.get("level", 1)
try:
animals = state.get("selected_animals", [random.choice(ANIMAL_EMOJIS)])
places = state.get("selected_places", [random.choice(PLACE_EMOJIS)])
print(f"[on_demand] level={level}")
result, prompt = generate_reward_image(animals, places, reward_id=level)
print(f"[on_demand] level={level} done")
if result is not None:
data_url = _img_to_data_url(result)
new_coll = _unlock_item(coll_items, level, data_url)
return (state, "",
gr.update(visible=True, value=result),
"",
new_coll,
_render_collection(new_coll))
return state, "", gr.update(visible=False), "โ ๏ธ Could not generate image โ try again later!", gr.update(), gr.update()
except Exception as e:
import traceback
print(f"[on_demand] โ {e}\n{traceback.format_exc()}")
return state, "", gr.update(visible=False), f"โ ๏ธ Error: {e}", gr.update(), gr.update()
# ---------------------------------------------------------------------------
# Next level โ back to picker with incremented level
# ---------------------------------------------------------------------------
def next_level(state: dict):
try:
new_level = state.get("level", 1) + 1
state["level"] = new_level
state["correct_this_level"] = 0
state.pop("pending_reward_id", None)
label = _picker_level_label(new_level)
return (state,
gr.update(visible=False), # game_panel
gr.update(visible=True), # emoji_panel
gr.update(value=label, visible=True), # picker_level_md
random.sample(ANIMAL_EMOJIS, 3),
random.sample(PLACE_EMOJIS, 3),
None, # clear hidden_image
"") # clear hidden_data_url
except Exception as e:
print(f"next_level error: {e}")
return (state, gr.update(), gr.update(), gr.update(),
gr.update(), gr.update(), None, "")
# ---------------------------------------------------------------------------
# Restart
# ---------------------------------------------------------------------------
def restart(state: dict):
return ({}, gr.update(visible=True), gr.update(visible=False),
gr.update(visible=False), gr.update(), "", None, "", [], "")
# ---------------------------------------------------------------------------
# Banner image โ loaded once at startup, inlined as base64
# ---------------------------------------------------------------------------
def _load_banner() -> str:
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"samples", "panda-frog-fox-wave.jpeg")
try:
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:image/jpeg;base64,{b64}"
except Exception:
return ""
_BANNER_URL = _load_banner()
BANNER_HTML = f"""
NumZoo
Do maths. Win cute animals!
"""
# ---------------------------------------------------------------------------
# Gradio UI
# ---------------------------------------------------------------------------
CSS = """
.gradio-container { max-width: 560px !important; margin: 0 auto !important; }
/* All top-level panels fill the container identically */
.gradio-group { width: 100% !important; box-sizing: border-box !important; }
.gradio-group > .form { padding: 20px 24px !important; box-sizing: border-box !important; width: 100% !important; }
.prose h3 { margin-top: 14px !important; margin-bottom: 8px !important; padding-left: 4px !important; }
#question-box { text-align: center; font-size: 2.6em; font-weight: bold; padding: 0.5em; }
#feedback-box { text-align: center; font-size: 1.3em; min-height: 2em; }
#status-box { text-align: center; padding: 0.4em; border-radius: 8px; }
#reward-img { border-radius: 16px; }
#picker-level { text-align: center; padding: 0.3em 0 0.6em; }
.answer-input input { font-size: 2em !important; text-align: center !important; }
/* Emoji picker grid */
.emoji-group .wrap {
display: grid !important; gap: 8px !important;
justify-content: center !important; justify-items: center !important;
}
#animal-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
#place-picker .wrap { grid-template-columns: repeat(6, 52px) !important; }
.emoji-group label {
width: 52px !important; height: 52px !important;
display: flex !important; align-items: center !important; justify-content: center !important;
font-size: 1.8em !important; cursor: pointer !important;
border-radius: 12px !important; border: 2px solid transparent !important;
transition: all 0.15s !important; user-select: none !important;
}
.emoji-group label:has(input:checked) {
background: #e9d5ff !important; border-color: #7c3aed !important;
}
.emoji-group input[type="checkbox"] { display: none !important; }
/* Collection */
@keyframes numzoo-pulse { 0%,100% { opacity:.4; } 50% { opacity:.9; } }
#nz-coll-wrap { margin-top: 20px; }
.nz-coll-title {
font-size: 1.1em; font-weight: bold; text-align: center;
padding: 8px 20px; margin: 0 0 12px;
background: rgba(124, 58, 237, 0.12) !important;
border: 1.5px solid rgba(124, 58, 237, 0.35) !important;
border-radius: 14px; color: #a78bfa !important; letter-spacing: 0.02em;
}
#nz-coll-grid { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.nz-card { width: 80px; height: 80px; border-radius: 12px; overflow: hidden;
flex-shrink: 0; position: relative; }
.nz-card-locked { background: #1e1e2e; border: 2px dashed #555; }
.nz-card-pulse { position: absolute; inset: 0;
background: linear-gradient(135deg,#2d2d44,#111128);
animation: numzoo-pulse 2s ease-in-out infinite; }
.nz-card-icon { width: 100%; height: 100%; display: flex; align-items: center;
justify-content: center; font-size: 2em; z-index: 1; position: relative; }
.nz-card img { width: 100%; height: 100%; object-fit: cover; cursor: pointer; display: block; }
"""
LOADER_HTML = """
๐จ
Generating your rewardโฆ
"""
ZOOM_MODAL_HTML = """
"""
with gr.Blocks(title="๐ฆ NumZoo") as demo:
state = gr.State({})
coll_items = gr.State([])
hidden_image = gr.Image(visible=False, label="", type="pil")
hidden_data_url = gr.Textbox(visible=False, value="")
gr.HTML(BANNER_HTML)
gr.HTML("""
""")
# โโ Welcome โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Group(visible=True) as welcome_panel:
gr.Markdown("### Who are you?")
player_name = gr.Textbox(placeholder="Your nameโฆ", label=" ", show_label=False, max_lines=1)
next_btn = gr.Button("Next โก๏ธ", variant="primary", size="lg")
# โโ Emoji picker โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Group(visible=False) as emoji_panel:
picker_level_md = gr.Markdown("", visible=False, elem_id="picker-level")
gr.Markdown("### Pick your animals ๐พ")
animal_picker = gr.CheckboxGroup(choices=ANIMAL_EMOJIS, label="",
show_label=False, elem_classes=["emoji-group"],
elem_id="animal-picker")
gr.Markdown("### Pick your places ๐")
place_picker = gr.CheckboxGroup(choices=PLACE_EMOJIS, label="",
show_label=False, elem_classes=["emoji-group"],
elem_id="place-picker")
picker_error = gr.Markdown("")
go_btn = gr.Button("Let's go! ๐", variant="primary", size="lg")
# โโ Game โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
with gr.Group(visible=False) as game_panel:
status_md = gr.Markdown("", elem_id="status-box", container=True)
question_md = gr.Markdown("", elem_id="question-box")
answer_input = gr.Textbox(placeholder="?", label="Your answer",
max_lines=1, elem_classes=["answer-input"])
check_btn = gr.Button("โ๏ธ Check!", variant="primary")
feedback_md = gr.Markdown("", elem_id="feedback-box")
with gr.Group(visible=False) as reward_panel:
gr.Markdown("### ๐ Your reward!")
loader_html = gr.HTML("")
reward_image = gr.Image(label="", show_label=False,
elem_id="reward-img", height=400, visible=False)
reward_error = gr.Markdown("")
next_level_btn = gr.Button("Next Level โก๏ธ", variant="primary", size="lg")
# โโ Collection (server-side rendered) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
collection_html = gr.HTML("", elem_id="nz-collection-html")
gr.HTML(ZOOM_MODAL_HTML)
restart_btn = gr.Button("๐ Restart", variant="secondary", size="sm")
# โโ Wiring โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
enter_name_outputs = [state, welcome_panel, emoji_panel, game_panel,
picker_level_md, animal_picker, place_picker]
next_btn.click(enter_name, [player_name, state], enter_name_outputs)
player_name.submit(enter_name, [player_name, state], enter_name_outputs)
# start_level resets quiz visibility + hides reward_panel from prior level
start_level_outputs = [state, emoji_panel, game_panel, picker_error,
status_md, question_md, answer_input, check_btn,
reward_panel, coll_items, collection_html]
go_btn.click(
start_level, [animal_picker, place_picker, state, coll_items],
start_level_outputs, show_progress="hidden"
).then(
pregenerate_image, [state], [state, hidden_image, hidden_data_url],
show_progress="hidden"
)
check_outputs = [
state, status_md, question_md, answer_input,
feedback_md, reward_panel, loader_html, reward_image, reward_error,
hidden_image, hidden_data_url, coll_items, collection_html, check_btn,
]
ondemand_outputs = [state, loader_html, reward_image, reward_error,
coll_items, collection_html]
for trigger in [check_btn.click, answer_input.submit]:
trigger(
check_answer,
[answer_input, state, hidden_image, hidden_data_url, coll_items],
check_outputs,
show_progress="hidden",
).then(
generate_on_demand, [state, coll_items], ondemand_outputs,
show_progress="hidden",
)
next_level_outputs = [state, game_panel, emoji_panel, picker_level_md,
animal_picker, place_picker, hidden_image, hidden_data_url]
next_level_btn.click(next_level, [state], next_level_outputs)
restart_btn.click(
restart, [state],
[state, welcome_panel, emoji_panel, game_panel,
player_name, picker_error, hidden_image, hidden_data_url, coll_items, collection_html]
)
if __name__ == "__main__":
demo.launch(
css=CSS,
theme=gr.themes.Soft(primary_hue="purple", secondary_hue="pink"),
)