MatchWise / app.py
tejasashinde's picture
feat(gameplay): implement progressive difficulty mechanics and peek cooldown
6edd11d verified
Raw
History Blame Contribute Delete
352 kB
from __future__ import annotations
import ast
import base64
from difflib import SequenceMatcher
import json
import math
import os
import random
import re
import sqlite3
import threading
import subprocess
import tarfile
import time
import uuid
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
import gradio as gr
import regex
# =========================================================
# CONFIG
# =========================================================
MAX_LIVES = 5
PREVIEW_SECONDS = 4
RUNTIME_DIR = Path(
os.getenv(
"MATCHWISE_RUNTIME_DIR",
os.getenv("MEMORY_FLIP_RUNTIME_DIR", ".runtime/matchwise"),
)
)
MODEL_REPO_ID = "openbmb/MiniCPM5-1B-GGUF"
MODEL_FILENAME = "MiniCPM5-1B-Q4_K_M.gguf"
MODEL_CHAT_TEMPLATE_KWARGS = os.getenv("LLAMA_CHAT_TEMPLATE_KWARGS", '{"enable_thinking": false}')
MODEL_OVERRIDE = os.getenv("MATCHWISE_MODEL_PATH", os.getenv("MEMORY_FLIP_MODEL_PATH"))
MODEL_PATH = Path(MODEL_OVERRIDE) if MODEL_OVERRIDE else RUNTIME_DIR / "models" / MODEL_FILENAME
LLAMA_RELEASE_URL = (
"https://github.com/ggml-org/llama.cpp/releases/download/b9305/"
"llama-b9305-bin-ubuntu-x64.tar.gz"
)
LLAMA_ARCHIVE_PATH = RUNTIME_DIR / "downloads" / "llama-b9305-bin-ubuntu-x64.tar.gz"
LLAMA_EXTRACT_DIR = RUNTIME_DIR / "llama_cpp"
LLAMA_SERVER_HOST = os.getenv("LLAMA_SERVER_HOST", "0.0.0.0")
LLAMA_SERVER_PORT = int(os.getenv("LLAMA_SERVER_PORT", "8080"))
LLAMA_SERVER_BASE_URL = f"http://{LLAMA_SERVER_HOST}:{LLAMA_SERVER_PORT}"
LLAMA_SERVER_PROCESS: subprocess.Popen[str] | None = None
LLAMA_SERVER_BINARY_PATH: Path | None = None
N_GPU_LAYERS = int(os.getenv("N_GPU_LAYERS", "0"))
LLAMA_REQUEST_TIMEOUT_SECONDS = int(os.getenv("LLAMA_REQUEST_TIMEOUT_SECONDS", "420"))
LLAMA_MAX_PREDICT_TOKENS = int(os.getenv("LLAMA_MAX_PREDICT_TOKENS", "384"))
LEVEL_GENERATION_EXECUTOR = ThreadPoolExecutor(max_workers=1)
GENERATION_LOCK = threading.Lock()
MIN_TRANSITION_VISIBLE_MS = 500
LOGO_PATH = Path(__file__).with_name("logo.png")
LOGO_DATA_URI = ""
if LOGO_PATH.exists():
LOGO_DATA_URI = "data:image/png;base64," + base64.b64encode(LOGO_PATH.read_bytes()).decode("ascii")
LEADERBOARD_DIR = Path(os.getenv("MATCHWISE_LEADERBOARD_DIR", "/data/matchwise-leaderboard"))
if not LEADERBOARD_DIR.is_absolute() or not str(LEADERBOARD_DIR).startswith("/data/"):
LEADERBOARD_DIR = Path("/data/matchwise-leaderboard")
LEADERBOARD_DB_PATH = LEADERBOARD_DIR / "leaderboard.sqlite3"
LEADERBOARD_LEGACY_JSON_PATH = LEADERBOARD_DIR / "leaderboard.json"
# Mount a Hugging Face Storage Bucket at LEADERBOARD_DIR in Spaces for persistence.
LEADERBOARD_LOCK = threading.Lock()
MAX_RECENT_THEMES = 8
MAX_RECENT_THEME_FAMILIES = 8
MAX_RECENT_EMOJIS = 40
MAX_RECENT_CHALLENGE_QUESTIONS = 12
MAX_REPEAT_COUNT_ITEMS = 10
MAX_GENERATION_ATTEMPTS = 3
LEVEL_GENERATION_TEMPERATURE = 0.15
LEVEL_GENERATION_MAX_TOKENS = min(96, LLAMA_MAX_PREDICT_TOKENS)
CHALLENGE_LEVEL_GENERATION_TEMPERATURE = 0.7
CHALLENGE_LEVEL_GENERATION_MAX_TOKENS = min(300, LLAMA_MAX_PREDICT_TOKENS)
LLM_DEBUG = os.getenv("MATCHWISE_LLM_DEBUG", os.getenv("MEMORY_FLIP_LLM_DEBUG", "1")).strip().lower() not in {"0", "false", "no", "off"}
LEADERBOARD_DEBUG = os.getenv("MATCHWISE_LEADERBOARD_DEBUG", "1").strip().lower() not in {"0", "false", "no", "off"}
MAX_REPEAT_SUMMARY_ITEMS = MAX_REPEAT_COUNT_ITEMS
THEME_PHASE_FOCUSED_LIMIT = 16
THEME_PHASE_EXPANDED_LIMIT = 48
METER_POINTS_PER_CORRECT_MATCH = 20
METER_CHALLENGE_THRESHOLD = 100
CHALLENGE_LEVEL_SEQUENCE = {3, 6, 10, 15, 21}
NORMAL_LEVEL_SUBJECT_POOL = [
"Traffic Signs",
"Kitchen Tools",
"School Supplies",
"Garden Plants",
"Weather Changes",
"Musical Instruments",
"Ocean Life",
"Space Objects",
"Transport Vehicles",
"Winter Clothing",
"Breakfast Foods",
"Farm Produce",
"Bathroom Items",
"Laundry Items",
"Camping Gear",
"Construction Tools",
"Library Books",
"Sports Gear",
"Art Supplies",
"Classroom Furniture",
]
NORMAL_THEME_FAMILY_POOL = [
"Animals",
"Nature",
"Weather",
"Food",
"Music",
"Space",
"Transport",
"School",
"Sports",
"Ocean",
"Science",
"Garden",
"Insects",
"Clothing",
"Kitchen",
]
NORMAL_LEVEL_VICTORY_MESSAGES = [
"Great memory. You cleared the {theme} board.",
"Nice work. You matched all the {theme} cards.",
"Well done. The {theme} pairs are all complete.",
"Strong job. You remembered the {theme} cards well.",
]
NORMAL_LEVEL_FAILURE_MESSAGES = [
"Take another look at the {theme} cards and try again.",
"Keep going. The {theme} matches will get easier.",
"Try again and watch where the {theme} cards land.",
"You are close. Study the {theme} cards one more time.",
]
NORMAL_LEVEL_EMOJI_POOL = [
"🧥", "🧣", "🧤", "🧢", "🥾", "👢", "🧦", "👕", "👖", "👚",
"🪖", "🧺", "🛏️", "🪑", "🪟", "🧽", "🧴", "🧹", "🪠", "🪣",
"🚲", "🛴", "🚌", "🚐", "🚚", "🚜", "🚒", "🚕", "🚂", "✈️",
"📚", "📒", "✏️", "🖍️", "📎", "🧮", "📐", "🎨", "🎹", "🥁",
"🌱", "🌼", "🌷", "🪴", "🍎", "🥕", "🫛", "🥐", "🥛", "🧀",
"⚙️", "🔧", "🪛", "🔨", "🪚", "🧰", "🪜", "🧲", "🧪", "🔬",
"🪐", "🌙", "⭐", "☁️", "🌧️", "🌦️", "🌤️", "❄️", "🌊", "🐚",
]
NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY = {
"kitchen": ["🍽️", "🥄", "🍴", "🥣", "🫙", "🫗", "🧂", "🫖", "🫕", "🧊"],
"school": ["📚", "📒", "✏️", "🖍️", "🧮", "📐", "📎", "🗂️", "📝", "🪄"],
"transport": ["🚲", "🚌", "🚐", "🚚", "🚜", "🚂", "✈️", "🚕", "🛴", "🛳️"],
"clothing": ["🧥", "🧣", "🧤", "🧢", "🥾", "👢", "🧦", "👕", "👖", "👚"],
"nature": ["🌱", "🌼", "🌷", "🪴", "🌿", "🍄", "🍀", "🌲", "🌳", "🌻"],
"weather": ["☁️", "🌧️", "🌦️", "🌤️", "❄️", "🌈", "🌬️", "🌪️", "🌫️", "☀️"],
"space": ["🪐", "🌙", "⭐", "☄️", "🌌", "🛰️", "🚀", "🌠", "🔭", "🛸"],
"food": ["🍎", "🥕", "🫛", "🥐", "🥛", "🧀", "🍞", "🥬", "🍓", "🍇"],
"science": ["⚙️", "🔧", "🪛", "🔬", "🧪", "🧲", "🧰", "🪜", "🧫", "📡"],
"music": ["🎹", "🥁", "🎸", "🎺", "🎻", "🪘", "🪇", "🎼", "🎤", "🎧"],
}
LEVEL_GENERATION_PROMPT = """
Return one JSON object only.
Level: {level}
Theme family: {theme_family}
Subject: {subject}
Write:
- level_title
- victory_message
- failure_message
Rules:
- Keep it short, concrete, and child-friendly.
- Use the Subject as the only topic.
- level_title must clearly name the Subject or a very close phrase.
- victory_message and failure_message must also mention the Subject.
- Do not introduce any other theme, object set, or concept.
- No extra keys.
Schema:
{{
"level_title": "string",
"victory_message": "string",
"failure_message": "string"
}}
"""
CHALLENGE_LEVEL_PROMPT = """
You are a child-friendly game designer.
Return ONLY JSON.
Invent a fresh school-knowledge topic for this challenge.
The topic must be specific, concrete, and broadly taught in school across subjects such as science, geography, history, math, language, art, music, or health.
Do not reuse recent topics. Make it creative but still something a school student should know.
Recent challenge questions: {recent_challenge_questions}
Recent challenge topics: {recent_challenge_topics}
Recent challenge modes: {recent_challenge_modes}
Rules:
- First choose a new challenge_topic.
- Then build one matching-board challenge about exactly that challenge_topic.
- Choose level_mode using this mix: 50% fact_match, 50% category_match.
- fact_match rules:
- Match an unique unicode emoji with a short fact keyword or concept.
- The keyword must clearly identify the emoji.
- Keep answers extremely short, 1 to 3 words.
- Example:
🐝 ↔ Honey
🌻 ↔ Sunflower
🐧 ↔ Penguin
- category_match rules:
- Match an emoji with its category.
- Categories must be simple and child-friendly.
- Example:
🦁 ↔ Mammal
🦅 ↔ Bird
🦈 ↔ Fish
- Match targets must be short noun phrases, not sentences.
- Match targets must be clear and educational.
- Every emoji in `emoji_pairs` must have exactly one matching entry in `match_targets` and one matching entry in `pair_facts`.
- Do not leave any emoji without a target or fact.
- Before answering, verify that the keys in `match_targets` and `pair_facts` exactly cover every emoji in `emoji_pairs`.
- Do not use placeholder text, partial phrases, or incomplete values.
- Do not use boolean words like true/false.
- Every item in emoji_pairs must be a plain emoji glyph only, with no text, labels, or descriptions attached.
- Keep the mode_popup_title and mode_popup_message short and helpful.
- Also write short victory_message and failure_message lines for the round.
- Do not use normal mode or MCQ question/answer format.
- Do not add extra keys.
{{
"challenge_topic": "string",
"level_mode": "fact_match",
"mode_popup_title": "string",
"mode_popup_message": "string",
"victory_message": "string",
"failure_message": "string",
"emoji_pairs": ["emoji1", "emoji2"],
"match_targets": {{"emoji1": "target"}},
"pair_facts": {{"emoji1": "fact"}}
}}
"""
MINICPM5_LEVEL_PROMPT_PREFIX = """
Return only valid JSON and nothing else.
"""
# =========================================================
# RUNTIME BOOTSTRAP
# =========================================================
def _download_text(url: str, timeout: int = 120) -> str:
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read().decode("utf-8", errors="replace")
def _leaderboard_debug(message: str) -> None:
if LEADERBOARD_DEBUG:
_debug_log(f"[leaderboard] {message}")
def _normalize_leaderboard_username(profile: Any | None) -> str:
if profile is None:
return ""
if isinstance(profile, str):
return _clean_one_line_text(profile, 80)
if isinstance(profile, dict):
username = profile.get("preferred_username") or profile.get("username") or ""
else:
username = getattr(profile, "preferred_username", "") or getattr(profile, "username", "")
return _clean_one_line_text(username, 80)
def _connect_leaderboard_db() -> sqlite3.Connection:
LEADERBOARD_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(
LEADERBOARD_DB_PATH,
timeout=5.0,
isolation_level=None,
)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout = 5000")
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = DELETE")
conn.execute("PRAGMA synchronous = NORMAL")
return conn
def _bootstrap_leaderboard_db() -> None:
with _connect_leaderboard_db() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS leaderboard (
username TEXT PRIMARY KEY,
high_score INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
def _migrate_legacy_leaderboard_json_if_needed(conn: sqlite3.Connection) -> None:
row = conn.execute("SELECT COUNT(*) AS count FROM leaderboard").fetchone()
existing_count = int(row["count"] if row is not None else 0)
if existing_count > 0:
return
if not LEADERBOARD_LEGACY_JSON_PATH.exists():
return
try:
raw = json.loads(LEADERBOARD_LEGACY_JSON_PATH.read_text(encoding="utf-8"))
except Exception:
return
if not isinstance(raw, dict):
return
imported = 0
for username, value in raw.items():
cleaned_username = _normalize_leaderboard_username(username)
if not cleaned_username:
continue
try:
score = int(value)
except Exception:
continue
conn.execute(
"""
INSERT INTO leaderboard (username, high_score, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(username) DO UPDATE SET
high_score = CASE
WHEN excluded.high_score > leaderboard.high_score THEN excluded.high_score
ELSE leaderboard.high_score
END,
updated_at = CASE
WHEN excluded.high_score > leaderboard.high_score THEN CURRENT_TIMESTAMP
ELSE leaderboard.updated_at
END
""",
(cleaned_username, score),
)
imported += 1
if imported:
_leaderboard_debug(f"migrated legacy json path={LEADERBOARD_LEGACY_JSON_PATH} entries={imported}")
def _load_leaderboard() -> dict[str, int]:
_leaderboard_debug(f"load path={LEADERBOARD_DB_PATH}")
with LEADERBOARD_LOCK:
_bootstrap_leaderboard_db()
with _connect_leaderboard_db() as conn:
_migrate_legacy_leaderboard_json_if_needed(conn)
rows = conn.execute(
"""
SELECT username, high_score
FROM leaderboard
ORDER BY high_score DESC, LOWER(username) ASC
"""
).fetchall()
leaderboard: dict[str, int] = {}
for row in rows:
username = _normalize_leaderboard_username(row["username"])
if not username:
continue
try:
score = int(row["high_score"])
except Exception:
continue
leaderboard[username] = max(leaderboard.get(username, 0), score)
return leaderboard
def _apply_leaderboard_update(profile: Any | None, score: int) -> dict[str, Any]:
username = _normalize_leaderboard_username(profile)
_leaderboard_debug(f"apply username={username!r} score={int(score or 0)} profile_present={profile is not None}")
if not username:
return {
"leaderboard_signed_in": False,
"leaderboard_username": "",
"leaderboard_high_score": 0,
"leaderboard_saved": False,
}
current_score = int(score or 0)
with LEADERBOARD_LOCK:
_bootstrap_leaderboard_db()
with _connect_leaderboard_db() as conn:
_migrate_legacy_leaderboard_json_if_needed(conn)
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT high_score FROM leaderboard WHERE username = ?",
(username,),
).fetchone()
previous_score = int(row["high_score"]) if row is not None else 0
saved = current_score > previous_score
if saved:
conn.execute(
"""
INSERT INTO leaderboard (username, high_score, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(username) DO UPDATE SET
high_score = excluded.high_score,
updated_at = CURRENT_TIMESTAMP
""",
(username, current_score),
)
previous_score = current_score
_leaderboard_debug(f"saved username={username!r} high_score={previous_score}")
else:
_leaderboard_debug(f"skipped username={username!r} existing={previous_score} current={current_score}")
conn.commit()
return {
"leaderboard_signed_in": True,
"leaderboard_username": username,
"leaderboard_high_score": previous_score,
"leaderboard_saved": saved,
}
def _get_leaderboard_status(profile: Any | None) -> dict[str, Any]:
username = _normalize_leaderboard_username(profile)
_leaderboard_debug(f"status username={username!r} profile_present={profile is not None}")
if not username:
return {
"leaderboard_signed_in": False,
"leaderboard_username": "",
"leaderboard_high_score": 0,
"leaderboard_saved": False,
}
with LEADERBOARD_LOCK:
_bootstrap_leaderboard_db()
with _connect_leaderboard_db() as conn:
_migrate_legacy_leaderboard_json_if_needed(conn)
row = conn.execute(
"SELECT high_score FROM leaderboard WHERE username = ?",
(username,),
).fetchone()
high_score = int(row["high_score"]) if row is not None else 0
return {
"leaderboard_signed_in": True,
"leaderboard_username": username,
"leaderboard_high_score": high_score,
"leaderboard_saved": False,
}
def _build_leaderboard_entries(limit: int = 10) -> list[dict[str, Any]]:
with LEADERBOARD_LOCK:
_bootstrap_leaderboard_db()
with _connect_leaderboard_db() as conn:
_migrate_legacy_leaderboard_json_if_needed(conn)
rows = conn.execute(
"""
SELECT username, high_score
FROM leaderboard
ORDER BY high_score DESC, LOWER(username) ASC
LIMIT ?
""",
(max(1, int(limit)),),
).fetchall()
rendered: list[dict[str, Any]] = []
for index, row in enumerate(rows, start=1):
rendered.append(
{
"rank": index,
"username": str(row["username"]),
"score": int(row["high_score"]),
}
)
return rendered
def ensure_model_downloaded() -> Path:
global MODEL_PATH
if MODEL_OVERRIDE:
if not MODEL_PATH.exists():
raise FileNotFoundError(f"Model path does not exist: {MODEL_PATH}")
return MODEL_PATH
MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
if MODEL_PATH.exists():
return MODEL_PATH
from huggingface_hub import hf_hub_download
downloaded = hf_hub_download(
repo_id=MODEL_REPO_ID,
filename=MODEL_FILENAME,
local_dir=str(MODEL_PATH.parent),
)
MODEL_PATH = Path(downloaded)
return MODEL_PATH
def bootstrap_llama_runtime() -> None:
ensure_model_downloaded()
start_llama_server()
def _safe_extract_tar(archive_path: Path, destination: Path) -> None:
destination.mkdir(parents=True, exist_ok=True)
def is_within_directory(directory: Path, target: Path) -> bool:
abs_directory = directory.resolve()
abs_target = target.resolve()
return str(abs_target).startswith(str(abs_directory))
with tarfile.open(archive_path, "r:gz") as tar:
for member in tar.getmembers():
member_path = destination / member.name
if not is_within_directory(destination, member_path):
raise RuntimeError(f"Unsafe archive path detected: {member.name}")
tar.extractall(destination)
def ensure_llama_server_binary() -> Path:
global LLAMA_SERVER_BINARY_PATH
if LLAMA_SERVER_BINARY_PATH and LLAMA_SERVER_BINARY_PATH.exists():
return LLAMA_SERVER_BINARY_PATH
candidate = next((path for path in LLAMA_EXTRACT_DIR.rglob("llama-server") if path.is_file()), None)
if candidate and os.access(candidate, os.X_OK):
LLAMA_SERVER_BINARY_PATH = candidate
return candidate
if not LLAMA_ARCHIVE_PATH.exists():
LLAMA_ARCHIVE_PATH.parent.mkdir(parents=True, exist_ok=True)
_debug_log(f"[bootstrap] Downloading llama.cpp server bundle from {LLAMA_RELEASE_URL}")
urllib.request.urlretrieve(LLAMA_RELEASE_URL, LLAMA_ARCHIVE_PATH)
_debug_log(f"[bootstrap] Extracting llama.cpp server bundle to {LLAMA_EXTRACT_DIR}")
_safe_extract_tar(LLAMA_ARCHIVE_PATH, LLAMA_EXTRACT_DIR)
candidate = next((path for path in LLAMA_EXTRACT_DIR.rglob("llama-server") if path.is_file()), None)
if candidate is None:
raise FileNotFoundError("Could not find llama-server in extracted archive")
candidate.chmod(candidate.stat().st_mode | 0o111)
LLAMA_SERVER_BINARY_PATH = candidate
return candidate
def _pump_process_output(name: str, process: subprocess.Popen[str]) -> None:
assert process.stdout is not None
for line in iter(process.stdout.readline, ""):
if not line:
break
_debug_log(f"[{name}] {line.rstrip()}")
def wait_for_server(base_url: str, timeout_seconds: int = 180) -> None:
deadline = time.time() + timeout_seconds
checks = ("/v1/health", "/health", "/v1/models")
last_error: str | None = None
while time.time() < deadline:
for suffix in checks:
try:
with urllib.request.urlopen(base_url + suffix, timeout=2) as response:
if 200 <= getattr(response, "status", 200) < 500:
return
except Exception as exc:
last_error = str(exc)
time.sleep(1)
raise RuntimeError(f"llama-server did not become ready at {base_url}: {last_error}")
def start_llama_server() -> None:
global LLAMA_SERVER_PROCESS
if LLAMA_SERVER_PROCESS and LLAMA_SERVER_PROCESS.poll() is None:
return
model_path = ensure_model_downloaded()
binary_path = ensure_llama_server_binary()
cmd = [
str(binary_path),
"-m",
str(model_path),
"--host",
LLAMA_SERVER_HOST,
"--port",
str(LLAMA_SERVER_PORT),
"-c",
os.getenv("LLAMA_CONTEXT_SIZE", "4096"),
"-n",
os.getenv("LLAMA_MAX_PREDICT_TOKENS", "384"),
"-ngl",
os.getenv("LLAMA_GPU_LAYERS", str(N_GPU_LAYERS)),
"-fa",
os.getenv("LLAMA_ARG_FLASH_ATTN", "auto"),
]
if MODEL_CHAT_TEMPLATE_KWARGS.strip():
cmd.extend(["--chat-template-kwargs", MODEL_CHAT_TEMPLATE_KWARGS.strip()])
_debug_log("[bootstrap] Starting llama-server: " + " ".join(cmd))
LLAMA_SERVER_PROCESS = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
thread = threading.Thread(
target=_pump_process_output,
args=("llama-server", LLAMA_SERVER_PROCESS),
daemon=True,
)
thread.start()
try:
wait_for_server(LLAMA_SERVER_BASE_URL)
_debug_log(f"[bootstrap] llama-server is ready at {LLAMA_SERVER_BASE_URL}")
except Exception:
if LLAMA_SERVER_PROCESS and LLAMA_SERVER_PROCESS.poll() is None:
LLAMA_SERVER_PROCESS.terminate()
raise
# =========================================================
# GRID PROGRESSION
# =========================================================
def get_grid_config(completed_challenge_levels: int) -> dict[str, int]:
challenge_count = max(0, int(completed_challenge_levels or 0))
if challenge_count <= 0:
return {"rows": 2, "cols": 2, "pairs": 2}
if challenge_count == 1:
return {"rows": 2, "cols": 3, "pairs": 3}
if challenge_count == 2:
return {"rows": 3, "cols": 4, "pairs": 6}
if challenge_count == 3:
return {"rows": 4, "cols": 4, "pairs": 8}
if challenge_count == 4:
return {"rows": 4, "cols": 5, "pairs": 10}
pairs = min(18, 10 + 2 * (challenge_count - 4))
cols = 6
rows = math.ceil((pairs * 2) / cols)
return {"rows": min(6, rows), "cols": cols, "pairs": pairs}
def is_challenge_level(level: int, meter_value: int = 0, challenge_due: bool = False) -> bool:
return bool(challenge_due) or int(meter_value) >= METER_CHALLENGE_THRESHOLD
# =========================================================
# AI LEVEL GENERATION
# =========================================================
def _normalize_emoji_token(emoji: str) -> str:
return emoji.strip()
EMOJI_ALIAS_RE = re.compile(r"^:[A-Za-z0-9_+\-]+:$")
def _is_actual_emoji_token(token: Any) -> bool:
if not isinstance(token, str):
return False
token = token.strip()
if not token:
return False
if token.startswith(":") or token.endswith(":") or EMOJI_ALIAS_RE.fullmatch(token):
return False
if re.search(r"[A-Za-z0-9_\s]", token):
return False
return bool(regex.search(r"\p{Emoji}", token))
def _clean_one_line_text(value: Any, max_length: int) -> str:
text = re.sub(r"[\r\n]+", " ", str(value or "")).strip()
text = re.sub(r"\s+", " ", text)
if len(text) <= max_length:
return text
truncated = text[:max_length].rstrip()
last_space = truncated.rfind(" ")
if last_space >= 24:
truncated = truncated[:last_space].rstrip()
return truncated[:max_length].rstrip()
def normalize_label(value: Any) -> str:
text = _clean_one_line_text(value, 80).lower()
text = re.sub(r"[^a-z0-9]+", " ", text).strip()
return text
def _theme_phrase(theme: str) -> str:
phrase = _clean_one_line_text(theme, 120)
return phrase.lower() if phrase else "the theme"
def _is_vague_theme_text(value: Any) -> bool:
text = normalize_label(value)
if not text:
return True
vague_phrases = {
"memory",
"learning",
"education",
"educational",
"game",
"theme",
"level",
"fun",
"general",
"random",
"mixed",
"various",
"simple",
"basic",
"kids",
"child friendly",
"child safe",
}
if text in vague_phrases:
return True
if len(text) < 3:
return True
return any(phrase in text for phrase in ("memory game", "a game", "themed", "lesson", "topic"))
def _concrete_theme_from_family(theme_family: str, emoji_pairs: list[str] | None = None) -> str:
family = normalize_label(theme_family)
if "animal" in family:
return "Animal Shapes"
if "nature" in family:
return "Forest Plants"
if "flower" in family:
return "Garden Flowers"
if "vehicle" in family or "transport" in family:
return "Travel Vehicles"
if "science" in family:
return "Science Tools"
if "space" in family:
return "Space Objects"
if "ocean" in family or "sea" in family:
return "Ocean Animals"
if "food" in family:
return "Healthy Foods"
if "music" in family:
return "Music Instruments"
if "weather" in family:
return "Weather Signs"
if "farm" in family:
return "Farm Animals"
if "school" in family:
return "School Supplies"
if "insect" in family or "bug" in family:
return "Insects"
if "dinosaur" in family:
return "Dinosaurs"
if emoji_pairs:
return "Picture Match"
return _clean_one_line_text(theme_family, 80).title() or "Picture Match"
def _theme_family_from_theme(theme: str) -> str:
label = normalize_label(theme)
if not label:
return "General"
if any(token in label for token in ("animal", "cat", "dog", "bird", "fish", "bug", "insect", "dinosaur", "pet")):
return "Animals"
if any(token in label for token in ("kitchen", "cook", "cooking", "utensil", "spoon", "fork", "plate", "bowl", "pan", "pot", "whisk")):
return "Kitchen"
if any(token in label for token in ("school", "class", "book", "pencil", "desk", "paper", "classroom", "notebook")):
return "School"
if any(token in label for token in ("flower", "garden", "tree", "plant", "leaf", "forest")):
return "Nature"
if any(token in label for token in ("vehicle", "car", "train", "plane", "bus", "boat", "truck", "transport")):
return "Transport"
if any(token in label for token in ("space", "rocket", "planet", "star", "moon", "galaxy")):
return "Space"
if any(token in label for token in ("food", "fruit", "vegetable", "snack", "meal", "dessert")):
return "Food"
if any(token in label for token in ("music", "instrument", "song", "drum", "guitar", "piano")):
return "Music"
if any(token in label for token in ("science", "tool", "lab", "experiment", "machine", "gadget")):
return "Science"
if any(token in label for token in ("weather", "rain", "snow", "wind", "cloud", "storm", "sun")):
return "Weather"
return _clean_one_line_text(theme, 80).title() or "General"
def _theme_key(value: Any) -> str:
return normalize_label(value)
def _question_key(value: Any) -> str:
return normalize_label(value)
def _is_similar_text(a: str, b: str) -> bool:
a_key = _question_key(a)
b_key = _question_key(b)
if not a_key or not b_key:
return False
if a_key == b_key:
return True
return SequenceMatcher(None, a_key, b_key).ratio() >= 0.86
def _extend_unique_theme_history(items: list[str], value: str) -> None:
candidate = _clean_one_line_text(value, 120)
if not candidate:
return
candidate_key = _theme_key(candidate)
if not candidate_key:
return
existing_keys = {_theme_key(item) for item in items}
if candidate_key not in existing_keys:
items.append(candidate)
def _theme_phase_for_session(memory: dict[str, Any] | None, level: int) -> str:
memory = memory or {}
used_themes = list(memory.get("used_themes", []))
used_theme_families = list(memory.get("used_theme_families", []))
diversity = max(len(used_themes), len(used_theme_families))
if level <= 6 or diversity < THEME_PHASE_FOCUSED_LIMIT:
return "focused"
if diversity < THEME_PHASE_EXPANDED_LIMIT:
return "expanded"
return "mixed"
def _compact_tail(values: list[str], limit: int) -> list[str]:
return list(values[-limit:])
def _compact_counts(values: dict[str, Any], limit: int) -> dict[str, int]:
items: list[tuple[str, int]] = []
for key, value in values.items():
try:
count = int(value)
except Exception:
continue
if count > 0:
items.append((str(key), count))
items.sort(key=lambda item: (-item[1], item[0]))
return dict(items[:limit])
def _extract_director_memory_snapshot(memory: dict[str, Any] | None) -> dict[str, Any]:
memory = memory or {}
return {
"recent_themes": _compact_tail(list(memory.get("recent_themes", [])), MAX_RECENT_THEMES),
"recent_theme_families": _compact_tail(list(memory.get("recent_theme_families", [])), MAX_RECENT_THEME_FAMILIES),
"recent_challenge_questions": _compact_tail(list(memory.get("recent_challenge_questions", [])), MAX_RECENT_CHALLENGE_QUESTIONS),
"recent_challenge_topics": _compact_tail(list(memory.get("recent_challenge_topics", [])), MAX_RECENT_CHALLENGE_QUESTIONS),
"recent_challenge_modes": _compact_tail(list(memory.get("recent_challenge_modes", [])), MAX_RECENT_CHALLENGE_QUESTIONS),
"used_themes": list(memory.get("used_themes", [])),
"used_theme_families": list(memory.get("used_theme_families", [])),
"recent_emojis": _compact_tail(list(memory.get("recent_emojis", [])), MAX_RECENT_EMOJIS),
"theme_repeat_counts": _compact_counts(dict(memory.get("theme_repeat_counts", {})), MAX_REPEAT_COUNT_ITEMS),
"family_repeat_counts": _compact_counts(dict(memory.get("family_repeat_counts", {})), MAX_REPEAT_COUNT_ITEMS),
}
def _make_director_memory() -> dict[str, Any]:
return {
"recent_themes": [],
"recent_theme_families": [],
"recent_challenge_questions": [],
"recent_challenge_topics": [],
"recent_challenge_modes": [],
"used_themes": [],
"used_theme_families": [],
"recent_emojis": [],
"theme_repeat_counts": {},
"family_repeat_counts": {},
}
def _build_theme_specific_focus(theme: str) -> str:
phrase = _theme_phrase(theme)
return _clean_one_line_text(f"Recognizing {phrase} symbols.", 120)
def _build_theme_specific_victory(theme: str) -> str:
phrase = _theme_phrase(theme)
template = random.choice(NORMAL_LEVEL_VICTORY_MESSAGES)
return _clean_one_line_text(template.format(theme=phrase), 160)
def _build_theme_specific_failure(theme: str) -> str:
phrase = _theme_phrase(theme)
template = random.choice(NORMAL_LEVEL_FAILURE_MESSAGES)
return _clean_one_line_text(template.format(theme=phrase), 160)
def _record_challenge_memory(memory: dict[str, Any], topic: str, question: str) -> None:
recent_questions = memory.setdefault("recent_challenge_questions", [])
recent_topics = memory.setdefault("recent_challenge_topics", [])
topic_text = _clean_one_line_text(topic, 120)
question_text = _clean_one_line_text(question, 120)
if topic_text:
_extend_unique_theme_history(recent_topics, topic_text)
recent_topics[:] = _compact_tail(recent_topics, MAX_RECENT_CHALLENGE_QUESTIONS)
if question_text:
if _question_key(question_text) not in {_question_key(item) for item in recent_questions}:
recent_questions.append(question_text)
recent_questions[:] = _compact_tail(recent_questions, MAX_RECENT_CHALLENGE_QUESTIONS)
def _pick_challenge_level_mode(level: int) -> str:
return "fact_match" if random.random() < 0.5 else "category_match"
def _record_challenge_mode(memory: dict[str, Any], mode: str) -> None:
recent_modes = memory.setdefault("recent_challenge_modes", [])
mode_text = _clean_one_line_text(mode, 40)
if not mode_text:
return
if mode_text not in recent_modes:
recent_modes.append(mode_text)
recent_modes[:] = _compact_tail(recent_modes, MAX_RECENT_CHALLENGE_QUESTIONS)
def _repair_blueprint_output(
data: Any,
pair_count: int,
rows: int,
cols: int,
director_memory: dict[str, Any],
performance_for_prompt: dict[str, Any],
) -> dict[str, Any]:
parsed = dict(data) if isinstance(data, dict) else {}
theme_family = _clean_one_line_text(parsed.get("theme_family", ""), 80)
theme = _clean_one_line_text(parsed.get("theme", ""), 100)
if not theme or _is_vague_theme_text(theme):
theme = _concrete_theme_from_family(theme_family, parsed.get("emoji_pairs") if isinstance(parsed.get("emoji_pairs"), list) else None)
theme = _clean_one_line_text(theme, 100)
if not theme:
theme = "School Objects"
if theme and theme[0].islower():
theme = theme[:1].upper() + theme[1:]
if not theme_family:
theme_family = _theme_family_from_theme(theme)
victory_message = _clean_one_line_text(parsed.get("victory_message", ""), 160)
if not victory_message or _is_vague_theme_text(victory_message):
victory_message = _build_theme_specific_victory(theme)
failure_message = _clean_one_line_text(parsed.get("failure_message", ""), 160)
if not failure_message or _is_vague_theme_text(failure_message):
failure_message = _build_theme_specific_failure(theme)
grid_advice = {
"recommended_rows": rows,
"recommended_cols": cols,
"pair_count": pair_count,
}
return {
"theme_family": theme_family,
"theme": theme,
"grid_advice": grid_advice,
"victory_message": victory_message,
"failure_message": failure_message,
}
def collect_level_blueprint_violations(
data: Any,
pair_count: int,
rows: int,
cols: int,
recent_emojis: list[str] | None = None,
used_themes: list[str] | None = None,
used_theme_families: list[str] | None = None,
) -> list[str]:
violations: list[str] = []
if not isinstance(data, dict):
return ["Response must be a JSON object."]
allowed_keys = {"theme", "grid_advice"}
extra_keys = sorted(set(data.keys()) - allowed_keys)
if extra_keys:
violations.append(f"Unexpected keys are not allowed: {', '.join(extra_keys)}.")
theme = _clean_one_line_text(data.get("theme", ""), 100)
if not theme or _is_vague_theme_text(theme):
violations.append("theme must be a clear, specific string.")
grid_advice = data.get("grid_advice")
if not isinstance(grid_advice, dict):
violations.append("grid_advice must be an object.")
else:
allowed_grid_keys = {"recommended_rows", "recommended_cols", "pair_count"}
extra_grid_keys = sorted(set(grid_advice.keys()) - allowed_grid_keys)
if extra_grid_keys:
violations.append(f"grid_advice has unexpected keys: {', '.join(extra_grid_keys)}.")
if "recommended_rows" not in grid_advice or "recommended_cols" not in grid_advice or "pair_count" not in grid_advice:
violations.append("grid_advice must include recommended_rows, recommended_cols, and pair_count.")
else:
try:
grid_rows = int(grid_advice.get("recommended_rows"))
grid_cols = int(grid_advice.get("recommended_cols"))
count = int(grid_advice.get("pair_count"))
except Exception:
violations.append("grid_advice values must be integers.")
else:
if grid_rows != rows:
violations.append(f"grid_advice.recommended_rows must be {rows}.")
if grid_cols != cols:
violations.append(f"grid_advice.recommended_cols must be {cols}.")
if grid_rows <= 0 or grid_cols <= 0:
violations.append("grid_advice rows and cols must be positive integers.")
if count != pair_count:
violations.append(f"grid_advice pair_count must be {pair_count}.")
return violations
def validate_level_blueprint(
data: Any,
pair_count: int,
rows: int,
cols: int,
recent_emojis: list[str] | None = None,
used_themes: list[str] | None = None,
used_theme_families: list[str] | None = None,
) -> bool:
return not collect_level_blueprint_violations(data, pair_count, rows, cols, recent_emojis, used_themes, used_theme_families)
def parse_level_blueprint(data: Any) -> dict[str, Any] | None:
if not isinstance(data, dict):
return None
return dict(data)
def _normalize_output(value: object) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
if "parsed" in value:
return _normalize_output(value.get("parsed"))
if "content" in value or "text" in value:
normalized = _normalize_output(value.get("content") or value.get("text"))
if normalized:
return normalized
return json.dumps(value, ensure_ascii=False)
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, dict):
parts.append((item.get("text") or item.get("content") or "").strip())
else:
parts.append(str(item).strip())
return "".join(part for part in parts if part).strip()
return str(value).strip()
def _extract_response_text(response: object) -> str:
choices = []
if isinstance(response, dict):
choices = response.get("choices") or []
if not choices:
return ""
choice = choices[0]
message = choice.get("message") if isinstance(choice, dict) else None
if isinstance(choice, dict):
for candidate in (
choice.get("text"),
choice.get("content"),
(message or {}).get("content") if isinstance(message, dict) else None,
):
text = _normalize_output(candidate)
if text:
return text
return ""
def _extract_json_candidate(text: str) -> str:
stripped = text.strip()
if not stripped:
return ""
stripped = re.sub(r"<think>.*?</think>", "", stripped, flags=re.IGNORECASE | re.DOTALL)
if stripped.startswith("```"):
stripped = re.sub(r"^```(?:json)?\s*", "", stripped, flags=re.IGNORECASE)
stripped = re.sub(r"\s*```$", "", stripped)
start = stripped.find("{")
end = stripped.rfind("}")
if 0 <= start < end:
return stripped[start : end + 1].strip()
return stripped
def _debug_preview(value: Any, limit: int = 2000) -> str:
try:
if isinstance(value, str):
text = value
else:
text = json.dumps(value, ensure_ascii=False, default=str)
except Exception:
text = str(value)
text = text.replace("\n", "\\n")
if len(text) > limit:
return text[:limit] + "...<truncated>"
return text
def _debug_log(message: str) -> None:
if LLM_DEBUG:
return
def build_level_blueprint_response_schema(pair_count: int, rows: int, cols: int) -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"required": [
"level_title",
"victory_message",
"failure_message",
],
"properties": {
"level_title": {"type": "string", "minLength": 1, "maxLength": 100},
"victory_message": {"type": "string", "minLength": 1, "maxLength": 160},
"failure_message": {"type": "string", "minLength": 1, "maxLength": 160},
},
}
def build_challenge_blueprint_response_schema() -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"required": [
"challenge_topic",
"level_mode",
"mode_popup_title",
"mode_popup_message",
"victory_message",
"failure_message",
"emoji_pairs",
"match_targets",
"pair_facts",
],
"properties": {
"challenge_topic": {"type": "string", "minLength": 1, "maxLength": 80},
"level_mode": {
"type": "string",
"enum": ["fact_match", "category_match"],
},
"mode_popup_title": {"type": "string", "minLength": 1, "maxLength": 80},
"mode_popup_message": {"type": "string", "minLength": 1, "maxLength": 180},
"victory_message": {"type": "string", "minLength": 1, "maxLength": 160},
"failure_message": {"type": "string", "minLength": 1, "maxLength": 160},
"emoji_pairs": {
"type": "array",
"minItems": 2,
"maxItems": 10,
"uniqueItems": True,
"items": {"type": "string", "minLength": 1, "maxLength": 16},
},
"match_targets": {
"type": "object",
"additionalProperties": {"type": "string", "minLength": 1, "maxLength": 24},
},
"pair_facts": {
"type": "object",
"additionalProperties": {"type": "string", "minLength": 1, "maxLength": 160},
},
},
}
def build_response_format(schema: dict[str, Any]) -> dict[str, Any] | None:
return {"type": "json_object", "schema": schema}
def build_featured_fact_response_schema(pair_count: int) -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"required": ["featured_fact", "emoji_pairs"],
"properties": {
"featured_fact": {"type": "string", "minLength": 1, "maxLength": 220},
"emoji_pairs": {
"type": "array",
"minItems": pair_count,
"maxItems": pair_count,
"uniqueItems": True,
"items": {"type": "string", "minLength": 1, "maxLength": 16},
},
},
}
def build_featured_fact_prompt(
theme_family: str,
level_title: str,
subject: str,
pair_count: int,
) -> str:
return (
"Return one JSON object only.\n"
f"Theme family: {theme_family}\n"
f"Level title: {level_title}\n"
f"Subject: {subject}\n"
f"Choose exactly {pair_count} unique single Unicode emojis.\n"
"Every emoji must clearly match the Level title and the Subject.\n"
"Do not repeat any emoji, do not use near-duplicates, and do not use the same emoji twice.\n"
"If you cannot find enough distinct emojis for this exact title, choose a different emoji set that still fits the title.\n"
"Write one short objective fact about the same Subject.\n"
"The fact must mention the Subject by name or a very close phrase.\n"
"No aliases. No extra text.\n\n"
"Schema:\n"
"{\n"
' "featured_fact": "short fact sentence",\n'
f' "emoji_pairs": ["emoji1", "emoji2"{", ..." if pair_count > 2 else ""}]\n'
"}"
)
def collect_featured_fact_violations(data: Any, pair_count: int) -> list[str]:
violations: list[str] = []
if not isinstance(data, dict):
return ["Response must be a JSON object."]
allowed_keys = {"featured_fact", "emoji_pairs"}
extra_keys = set(data.keys()) - allowed_keys
if extra_keys:
violations.append(f"No extra keys allowed: {sorted(extra_keys)}")
featured_fact = str(data.get("featured_fact", "")).strip()
if not featured_fact:
violations.append("featured_fact must be a non-empty string.")
return violations
sentence_count = len([part for part in re.split(r"[.!?]+", featured_fact) if part.strip()])
if sentence_count > 2:
violations.append("featured_fact must be at most 2 sentences.")
if len(featured_fact) > 220:
violations.append("featured_fact must be 220 characters or less.")
if re.search(r"\b(i|me|my|mine|we|our|ours)\b", featured_fact, flags=re.IGNORECASE):
violations.append("featured_fact must not use first-person language.")
emoji_pairs = data.get("emoji_pairs")
if not isinstance(emoji_pairs, list):
violations.append("emoji_pairs must be an array.")
else:
normalized_pairs = [_normalize_emoji_token(item) for item in emoji_pairs if isinstance(item, str)]
if len(normalized_pairs) != len(emoji_pairs):
violations.append("emoji_pairs must contain only strings.")
if len(normalized_pairs) != pair_count:
violations.append(f"emoji_pairs must contain exactly {pair_count} emojis.")
if len(set(normalized_pairs)) != len(normalized_pairs):
violations.append("emoji_pairs must contain unique emojis.")
invalid_emojis = [emoji for emoji in normalized_pairs if not _is_actual_emoji_token(emoji)]
if invalid_emojis:
violations.append(
"emoji_pairs must contain real Unicode emoji glyphs only: "
+ ", ".join(repr(item) for item in invalid_emojis[:5])
)
return violations
def _build_fallback_featured_fact(theme_family: str, level_title: str, subject: str) -> str:
phrase = _clean_one_line_text(level_title, 100) or _clean_one_line_text(subject, 100) or _clean_one_line_text(theme_family, 80) or "this level"
fact_map = {
"animals": "Animals live in many habitats and have body parts that help them survive.",
"nature": "Nature includes living things like plants and animals, plus weather and landforms.",
"weather": "Weather can change from day to day because air, water, and sunlight keep moving around Earth.",
"food": "Foods can come from plants or animals and give the body energy to grow and move.",
"music": "Musical instruments make sound when they are hit, blown, shaken, or played with strings.",
"space": "Space contains stars, planets, moons, and many objects that move through the universe.",
"transport": "Vehicles are designed to help people and goods move from one place to another.",
"school": "School tools help people read, write, measure, organize, and learn new ideas.",
"sports": "Sports use rules, movement, and practice to build teamwork and coordination.",
"ocean": "The ocean is home to many animals and covers most of Earth's surface.",
"science": "Science tools help people observe, measure, test, and learn how the world works.",
"garden": "Gardens can grow flowers, fruits, and vegetables with sunlight, soil, and water.",
"insects": "Insects have six legs and three main body parts.",
"clothing": "Clothing helps protect the body in different seasons and activities.",
"kitchen": "Kitchen tools are made to help prepare, cook, and serve food safely.",
}
family_key = normalize_label(theme_family)
for key, fact in fact_map.items():
if key in family_key:
return fact
return _clean_one_line_text(f"{phrase} can be sorted by shape, use, color, or where it is found.", 220)
def _subject_theme_key(theme_family: str, subject: str) -> str:
text = normalize_label(f"{theme_family} {subject}")
for key in NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY.keys():
if key in text:
return key
if "clothing" in text or "winter" in text:
return "clothing"
if "transport" in text or "vehicle" in text:
return "transport"
if "kitchen" in text or "cook" in text:
return "kitchen"
if "school" in text or "class" in text:
return "school"
if "weather" in text or "rain" in text or "snow" in text:
return "weather"
if "music" in text or "instrument" in text:
return "music"
if "space" in text:
return "space"
if "food" in text or "fruit" in text or "vegetable" in text:
return "food"
if "science" in text or "tool" in text or "machine" in text:
return "science"
if "nature" in text or "garden" in text:
return "nature"
return ""
def _pick_repair_emoji_pool(theme_family: str, subject: str) -> list[str]:
family_key = _subject_theme_key(theme_family, subject)
if family_key and family_key in NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY:
return list(NORMAL_LEVEL_EMOJI_POOL_BY_FAMILY[family_key])
return list(NORMAL_LEVEL_EMOJI_POOL)
def _repair_unique_emoji_pairs(
emoji_pairs: list[str] | None,
theme_family: str,
subject: str,
pair_count: int,
) -> list[str]:
pool = _pick_repair_emoji_pool(theme_family, subject)
repaired: list[str] = []
seen: set[str] = set()
for emoji in emoji_pairs or []:
if not isinstance(emoji, str):
continue
token = _normalize_emoji_token(emoji)
if not _is_actual_emoji_token(token):
continue
if token in seen:
continue
seen.add(token)
repaired.append(token)
if len(repaired) >= pair_count:
return repaired
for emoji in pool:
token = _normalize_emoji_token(emoji)
if not _is_actual_emoji_token(token):
continue
if token in seen:
continue
seen.add(token)
repaired.append(token)
if len(repaired) >= pair_count:
break
return repaired[:pair_count]
def collect_challenge_blueprint_violations(
data: Any,
used_themes: list[str] | None = None,
recent_themes: list[str] | None = None,
challenge_theme: str | None = None,
) -> list[str]:
if not isinstance(data, dict):
return ["Response must be a JSON object."]
# Challenge mode is intentionally permissive now so the backend can
# accept the model's JSON without rejecting repeats, extra fields, or
# imperfect challenge wording.
return []
def _parse_strict_json_object(text: str) -> dict[str, Any] | None:
if not text:
return None
if isinstance(text, dict):
return text
if isinstance(text, list):
if len(text) == 1 and isinstance(text[0], dict):
return text[0]
return None
candidate = _extract_json_candidate(str(text))
try:
parsed = json.loads(candidate)
except json.JSONDecodeError:
try:
parsed = ast.literal_eval(candidate)
except (ValueError, SyntaxError):
return None
return parsed if isinstance(parsed, dict) else None
def generate_answer(
user_prompt: str,
response_format: dict[str, Any] | None = None,
temperature: float = 0.45,
max_tokens: int = 384,
system_prompt: str | None = None,
) -> str:
_debug_log("[llm] calling llama-server")
messages = []
system_text = (system_prompt or "").strip()
if system_text:
messages.append(
{
"role": "system",
"content": system_text,
}
)
else:
messages.append(
{
"role": "system",
"content": (
"Return exactly one valid JSON object and nothing else. "
"Do not wrap it in markdown, code fences, or explanations."
),
}
)
payload = {
"model": Path(MODEL_PATH).name,
"messages": messages + [{"role": "user", "content": user_prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"response_format": response_format or {"type": "json_object"},
"stream": False,
}
try:
request = urllib.request.Request(
f"{LLAMA_SERVER_BASE_URL}/v1/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=LLAMA_REQUEST_TIMEOUT_SECONDS) as response:
raw_response_text = response.read().decode("utf-8", errors="replace")
_debug_log(f"[llm] llama-server raw http response={_debug_preview(raw_response_text, 6000)}")
body = json.loads(raw_response_text)
except Exception as exc:
_debug_log(f"[llm] llama-server call failed: {type(exc).__name__}: {exc}")
raise
output = _extract_response_text(body)
if not output:
raise RuntimeError("llama-server returned an empty response")
_debug_log(f"[llm] llama-server response body={_debug_preview(body)}")
_debug_log(f"[llm] llama-server extracted text={_debug_preview(output)}")
return output
def _call_llama_server(
prompt: str,
response_format: dict[str, Any] | None = None,
temperature: float = 0.0,
max_tokens: int = 384,
system_prompt: str | None = None,
) -> dict[str, Any] | None:
try:
text = generate_answer(
prompt,
response_format=response_format,
temperature=temperature,
max_tokens=max_tokens,
system_prompt=system_prompt,
)
except Exception as exc:
_debug_log(f"[llm] llama-server call exception={type(exc).__name__}: {exc}")
return None
_debug_log(f"[llm] llama-server raw text={_debug_preview(text)}")
parsed = _parse_strict_json_object(text)
if parsed is None:
_debug_log(f"[llm] llama-server parse failed candidate={_debug_preview(_extract_json_candidate(text))}")
return parsed
def _generate_featured_fact(
theme_family: str,
level_title: str,
subject: str,
pair_count: int,
) -> dict[str, Any]:
prompt = build_featured_fact_prompt(
theme_family=theme_family,
level_title=level_title,
subject=subject,
pair_count=pair_count,
)
response_format = build_response_format(build_featured_fact_response_schema(pair_count))
validation_feedback = ""
for attempt in range(1, MAX_GENERATION_ATTEMPTS + 1):
attempt_prompt = prompt if not validation_feedback else (
prompt
+ "\n\nIMPORTANT FIXES NEEDED:\n"
+ validation_feedback
+ "\nReturn a corrected JSON object only."
)
ai_data = _call_llama_server(
attempt_prompt,
response_format=response_format,
temperature=LEVEL_GENERATION_TEMPERATURE,
max_tokens=72,
)
if ai_data is None:
validation_feedback = "The response was not parseable as JSON."
continue
featured_fact = _clean_one_line_text(str(ai_data.get("featured_fact", "")).strip(), 220)
if not featured_fact:
validation_feedback = "featured_fact must be a non-empty string."
_debug_log(f"[llm] featured fact validation failed: {validation_feedback}")
continue
if len(featured_fact) > 220:
validation_feedback = "featured_fact must be 220 characters or less."
_debug_log(f"[llm] featured fact validation failed: {validation_feedback}")
continue
if re.search(r"\b(i|me|my|mine|we|our|ours)\b", featured_fact, flags=re.IGNORECASE):
validation_feedback = "featured_fact must not use first-person language."
_debug_log(f"[llm] featured fact validation failed: {validation_feedback}")
continue
emoji_pairs = _repair_unique_emoji_pairs(
ai_data.get("emoji_pairs") if isinstance(ai_data.get("emoji_pairs"), list) else [],
theme_family,
subject,
pair_count,
)
if len(emoji_pairs) != pair_count:
validation_feedback = f"Could not repair emoji_pairs to exactly {pair_count} unique emojis."
_debug_log(f"[llm] featured fact validation failed: {validation_feedback}")
continue
if emoji_pairs != list(ai_data.get("emoji_pairs", [])):
_debug_log(
f"[llm] featured fact repaired emoji_pairs level_title={level_title!r} "
f"raw={_debug_preview(ai_data.get('emoji_pairs', []))} repaired={_debug_preview(emoji_pairs)}"
)
return {
"featured_fact": featured_fact,
"emoji_pairs": emoji_pairs,
}
fallback_fact = _build_fallback_featured_fact(theme_family, level_title, subject)
_debug_log(f"[llm] featured fact fallback used for title={level_title!r}")
return {
"featured_fact": fallback_fact,
"emoji_pairs": [],
}
def build_challenge_generation_payload(level: int, director_memory: dict[str, Any]) -> dict[str, Any]:
snapshot = _extract_director_memory_snapshot(director_memory)
return {
"level": level,
"challenge_topic": "",
"level_mode": _pick_challenge_level_mode(level),
"recent_challenge_questions": json.dumps(snapshot.get("recent_challenge_questions", []), ensure_ascii=False),
"recent_challenge_topics": json.dumps(snapshot.get("recent_challenge_topics", []), ensure_ascii=False),
"recent_challenge_modes": json.dumps(snapshot.get("recent_challenge_modes", []), ensure_ascii=False),
}
def build_challenge_generation_prompt(payload: dict[str, Any]) -> str:
return (
MINICPM5_LEVEL_PROMPT_PREFIX
+ "\n"
+ CHALLENGE_LEVEL_PROMPT.format(
recent_challenge_questions=payload["recent_challenge_questions"],
recent_challenge_topics=payload["recent_challenge_topics"],
recent_challenge_modes=payload["recent_challenge_modes"],
)
)
def _infer_challenge_theme(question: str, ans_fact: str) -> str:
text = normalize_label(f"{question} {ans_fact}")
theme_keywords = [
("Traffic Signs", ["traffic sign", "stop sign", "road sign", "speed limit", "crosswalk"]),
("Musical Instruments", ["musical instrument", "instrument", "piano", "guitar", "drum", "violin"]),
("Space", ["planet", "space", "moon", "star", "rocket", "astronaut"]),
("Animals", ["animal", "mammal", "bird", "fish", "reptile", "insect"]),
("Nature", ["flower", "tree", "plant", "leaf", "garden", "nature"]),
("Geography", ["country", "capital", "map", "continent", "ocean", "mountain"]),
("Mathematics", ["math", "number", "shape", "triangle", "circle", "equation"]),
("Technology", ["computer", "device", "technology", "internet", "keyboard", "phone"]),
("History", ["history", "ancient", "war", "king", "queen", "past"]),
("Literature", ["book", "story", "poem", "author", "novel", "literature"]),
("Language", ["word", "letter", "grammar", "sentence", "language"]),
("Art", ["paint", "art", "drawing", "color", "brush", "canvas"]),
("Sports", ["sport", "ball", "score", "player", "team", "game"]),
("Health", ["health", "body", "exercise", "diet", "sleep"]),
("Human Body", ["heart", "brain", "bone", "skin", "muscle"]),
("Environment", ["environment", "recycle", "pollution", "earth", "nature"]),
("Inventions", ["invention", "invented", "inventor", "machine"]),
("Famous Landmarks", ["landmark", "tower", "statue", "temple", "bridge"]),
("World Cultures", ["culture", "festival", "country", "tradition"]),
]
for theme, keywords in theme_keywords:
if any(keyword in text for keyword in keywords):
return theme
if text:
return "General Knowledge"
return "General Knowledge"
def _challenge_grid_dimensions(pair_count: int) -> tuple[int, int]:
total_cards = max(4, pair_count * 2)
if total_cards <= 4:
return 2, 2
if total_cards <= 6:
return 2, 3
if total_cards <= 8:
return 2, 4
if total_cards <= 12:
return 3, 4
if total_cards <= 16:
return 4, 4
cols = 5
rows = math.ceil(total_cards / cols)
return rows, cols
def _build_llm_challenge_cards(
emoji_pairs: list[str],
match_targets: dict[str, Any],
pair_facts: dict[str, Any],
) -> list[dict[str, Any]]:
cards: list[dict[str, Any]] = []
for emoji in emoji_pairs:
target = match_targets.get(emoji, "")
if not isinstance(target, str) or not target.strip():
target = pair_facts.get(emoji, "")
if not isinstance(target, str):
target = str(target)
target = _clean_one_line_text(target, 24)
if not target:
target = _clean_one_line_text(emoji, 24)
fact = pair_facts.get(emoji, "")
if not isinstance(fact, str):
fact = str(fact)
fact = _clean_one_line_text(fact, 160)
cards.append({
"display": emoji,
"match_key": target,
"card_type": "emoji",
"pair_fact": fact,
})
cards.append({
"display": target,
"match_key": target,
"card_type": "text",
"pair_fact": fact,
})
random.shuffle(cards)
return cards
def generate_challenge_content(
level: int,
director_memory: dict[str, Any],
fallback_theme: str = "",
) -> dict[str, Any]:
payload = build_challenge_generation_payload(level, director_memory)
challenge_topic = _clean_one_line_text(payload.get("challenge_topic") or fallback_theme or "Challenge", 80)
level_mode = payload["level_mode"]
_debug_log(f"[llm] challenge generator level={level} topic={challenge_topic!r} mode={level_mode!r}")
prompt = build_challenge_generation_prompt(payload)
response_format = build_response_format(build_challenge_blueprint_response_schema())
ai_data = _call_llama_server(
prompt,
response_format=response_format,
temperature=CHALLENGE_LEVEL_GENERATION_TEMPERATURE,
max_tokens=CHALLENGE_LEVEL_GENERATION_MAX_TOKENS,
)
if ai_data is None:
raise RuntimeError("llama-server returned an invalid challenge blueprint")
parsed = parse_level_blueprint(ai_data)
if parsed is None:
raise RuntimeError("llama-server returned an invalid challenge blueprint")
challenge_topic = str(parsed.get("challenge_topic", "")).strip()
if not challenge_topic:
raise RuntimeError("llama-server returned a challenge blueprint without challenge_topic")
level_mode = str(parsed.get("level_mode", level_mode)).strip()
mode_popup_title = parsed.get("mode_popup_title", "")
mode_popup_message = parsed.get("mode_popup_message", "")
victory_message = parsed.get("victory_message", "")
failure_message = parsed.get("failure_message", "")
emoji_pairs = list(parsed.get("emoji_pairs", []))
match_targets_raw = parsed.get("match_targets", {})
pair_facts_raw = parsed.get("pair_facts", {})
if not isinstance(match_targets_raw, dict) or not isinstance(pair_facts_raw, dict):
raise RuntimeError("llama-server returned an invalid challenge blueprint")
match_targets = dict(match_targets_raw)
pair_facts = dict(pair_facts_raw)
cards = _build_llm_challenge_cards(emoji_pairs, match_targets, pair_facts)
pair_count = len(emoji_pairs)
rows, cols = _challenge_grid_dimensions(pair_count)
theme_family = _theme_family_from_theme(challenge_topic)
if isinstance(director_memory, dict):
memory_modes = director_memory.setdefault("recent_challenge_modes", [])
mode_text = str(level_mode).strip()
if mode_text and mode_text not in memory_modes:
memory_modes.append(mode_text)
memory_modes[:] = _compact_tail(memory_modes, MAX_RECENT_CHALLENGE_QUESTIONS)
_record_challenge_memory(director_memory, challenge_topic, mode_popup_title or challenge_topic)
featured_fact = ""
for emoji in emoji_pairs:
fact = pair_facts.get(emoji, "")
if not isinstance(fact, str):
fact = str(fact)
fact = fact.strip()
if fact:
featured_fact = fact
break
parsed["level_type"] = "challenge"
parsed["level_mode"] = level_mode
parsed["mode_popup_title"] = mode_popup_title
parsed["mode_popup_message"] = mode_popup_message
parsed["victory_message"] = victory_message
parsed["failure_message"] = failure_message
parsed["emoji_pairs"] = emoji_pairs
parsed["match_targets"] = match_targets
parsed["pair_facts"] = pair_facts
parsed["cards"] = cards
parsed["pair_count"] = pair_count
parsed["rows"] = rows
parsed["cols"] = cols
parsed["grid_advice"] = {
"recommended_rows": rows,
"recommended_cols": cols,
"pair_count": pair_count,
}
parsed["theme_family"] = theme_family
parsed["theme"] = challenge_topic
parsed["challenge_theme"] = challenge_topic
parsed["level_title"] = mode_popup_title or f"{challenge_topic} Challenge"
parsed["educational_focus"] = mode_popup_message or f"Matching {challenge_topic}."
parsed["featured_fact_emoji"] = emoji_pairs[0] if emoji_pairs else ""
parsed["featured_fact"] = featured_fact
parsed["challenge_modal_title"] = mode_popup_title or "Challenge Level"
parsed["challenge_modal_message"] = mode_popup_message
parsed["challenge_question"] = ""
parsed["challenge_options"] = []
parsed["challenge_correct_index"] = None
parsed["theme_family"] = theme_family
if isinstance(director_memory, dict):
_record_challenge_mode(director_memory, str(parsed.get("level_mode", "")))
_debug_log(f"[llm] final challenge blueprint level={level} provider=llama-server data={_debug_preview(parsed)}")
_debug_log(f"[llm] accepted generated challenge for level={level}")
return parsed
def _cycle_index(names: list[str], fallback: str) -> str:
if names:
return names[-1]
return fallback
def _pick_theme_family_hint(director_memory: dict[str, Any]) -> str:
snapshot = _extract_director_memory_snapshot(director_memory)
recent_families = {
_theme_key(item)
for item in (
list(snapshot.get("recent_theme_families", []))[-4:]
+ list(snapshot.get("used_theme_families", []))[-4:]
)
if str(item).strip()
}
candidates = [
family
for family in NORMAL_THEME_FAMILY_POOL
if _theme_key(family) not in recent_families
]
if not candidates:
candidates = list(NORMAL_THEME_FAMILY_POOL)
return random.choice(candidates)
def build_player_performance(
state: dict[str, Any] | None,
level: int,
) -> dict[str, Any]:
state = state or {}
director_memory = state.get("director_memory", {}) or {}
return {
"current_level": level,
"score": int(state.get("score", 0)),
"lives_remaining": int(state.get("lives", MAX_LIVES)),
"performance_meter": int(state.get("performance_meter", 0)),
"challenge_due": bool(state.get("challenge_due", False)),
"win_streak": int(state.get("win_streak", 0)),
"previous_level_moves": int(state.get("moves", 0)),
"previous_level_perfect_moves": int(state.get("total_pairs", 0)),
"previous_level_wrong_attempts": int(state.get("wrong_attempts", 0)),
"previous_level_time_seconds": int(state.get("previous_level_time_seconds", 0)),
"previous_level_completed": bool(state.get("previous_level_completed", False)),
"previous_theme": str(state.get("theme", "")),
"recent_themes": list(director_memory.get("recent_themes", []))[-MAX_RECENT_THEMES:],
"recent_theme_families": list(director_memory.get("recent_theme_families", []))[-MAX_RECENT_THEME_FAMILIES:],
"recent_emojis": list(director_memory.get("recent_emojis", []))[-MAX_RECENT_EMOJIS:],
"player_strengths": list(state.get("player_strengths", [])),
"player_weaknesses": list(state.get("player_weaknesses", [])),
"theme_repeat_counts": dict(director_memory.get("theme_repeat_counts", {})),
"family_repeat_counts": dict(director_memory.get("family_repeat_counts", {})),
}
def build_level_generation_payload(
level: int,
pair_count: int,
rows: int,
cols: int,
director_memory: dict[str, Any],
) -> dict[str, Any]:
snapshot = _extract_director_memory_snapshot(director_memory)
theme_family = _pick_theme_family_hint(director_memory)
used_themes = {
_theme_key(item)
for item in snapshot["used_themes"]
if _theme_key(item)
}
subject_candidates = [subject for subject in NORMAL_LEVEL_SUBJECT_POOL if _theme_key(subject) not in used_themes]
subject = random.choice(subject_candidates or NORMAL_LEVEL_SUBJECT_POOL)
return {
"level": level,
"theme_family": theme_family,
"subject": subject,
"rows": rows,
"cols": cols,
"pair_count": pair_count,
"used_themes_list": snapshot["used_themes"],
"used_themes": json.dumps(snapshot["used_themes"], ensure_ascii=False),
}
def build_level_generation_prompt(payload: dict[str, Any]) -> str:
return (
MINICPM5_LEVEL_PROMPT_PREFIX
+ "\n"
+ LEVEL_GENERATION_PROMPT.format(
level=payload["level"],
theme_family=payload["theme_family"],
subject=payload["subject"],
)
)
def collect_level_title_violations(data: Any) -> list[str]:
violations: list[str] = []
if not isinstance(data, dict):
return ["Response must be a JSON object."]
level_title = _clean_one_line_text(data.get("level_title", ""), 100)
if not level_title:
violations.append("level_title must be a non-empty string.")
elif _is_vague_theme_text(level_title):
violations.append("level_title must be concrete and specific.")
victory_message = _clean_one_line_text(data.get("victory_message", ""), 160)
if not victory_message:
violations.append("victory_message must be a non-empty string.")
failure_message = _clean_one_line_text(data.get("failure_message", ""), 160)
if not failure_message:
violations.append("failure_message must be a non-empty string.")
return violations
def generate_level_content(
level: int,
pair_count: int,
rows: int,
cols: int,
performance_for_prompt: dict[str, Any],
director_memory: dict[str, Any],
) -> dict[str, Any]:
payload = build_level_generation_payload(
level=level,
pair_count=pair_count,
rows=rows,
cols=cols,
director_memory=director_memory,
)
prompt = build_level_generation_prompt(payload)
subject = payload["subject"]
used_themes_list = payload["used_themes_list"]
_debug_log(
f"[llm] prompt summary level={level} rows={rows} cols={cols} pairs={pair_count} "
f"subject={subject!r} used_themes={payload['used_themes_list']} "
f"provider=llama-server"
)
response_format = build_response_format(
build_level_blueprint_response_schema(pair_count, rows, cols),
)
validation_feedback = ""
parsed_title_data = None
for attempt in range(1, MAX_GENERATION_ATTEMPTS + 1):
attempt_prompt = prompt if not validation_feedback else (
prompt
+ "\n\nIMPORTANT FIXES NEEDED:\n"
+ validation_feedback
+ "\nReturn a corrected JSON object only."
)
_debug_log(
f"[llm] title attempt prompt level={level} attempt={attempt} chars={len(attempt_prompt)} "
f"prompt={_debug_preview(attempt_prompt, 1800)}"
)
ai_data = _call_llama_server(
attempt_prompt,
response_format=response_format,
temperature=LEVEL_GENERATION_TEMPERATURE,
max_tokens=LEVEL_GENERATION_MAX_TOKENS,
)
if ai_data is None:
validation_feedback = "The response was not parseable as JSON."
_debug_log(f"[llm] title parse failed level={level} attempt={attempt} provider=llama-server")
continue
parsed_preview = parse_level_blueprint(ai_data)
if parsed_preview is None:
validation_feedback = "The response shape was invalid. Return the exact JSON schema."
_debug_log(
f"[llm] title shape failed level={level} attempt={attempt} provider=llama-server "
f"raw={_debug_preview(ai_data)}"
)
continue
_debug_log(
f"[llm] title parsed level={level} attempt={attempt} data={_debug_preview(parsed_preview)}"
)
violations = collect_level_title_violations(parsed_preview)
if violations:
validation_feedback = " ".join(violations)
_debug_log(f"[llm] title validation failed level={level}: {validation_feedback}")
continue
parsed_title_data = parsed_preview
break
if parsed_title_data is None:
raise RuntimeError("llama-server returned an invalid level title blueprint")
level_title = _clean_one_line_text(parsed_title_data.get("level_title", ""), 100)
victory_message = _clean_one_line_text(parsed_title_data.get("victory_message", ""), 160)
failure_message = _clean_one_line_text(parsed_title_data.get("failure_message", ""), 160)
if not level_title:
level_title = f"{subject} Match"
if not victory_message:
victory_message = _build_theme_specific_victory(subject)
if not failure_message:
failure_message = _build_theme_specific_failure(subject)
theme_family = _theme_family_from_theme(subject)
featured_fact_bundle = _generate_featured_fact(
theme_family=theme_family,
level_title=level_title,
subject=subject,
pair_count=pair_count,
)
emoji_pairs = list(featured_fact_bundle.get("emoji_pairs", []))
parsed = {
"theme_family": theme_family,
"theme": subject,
"level_title": level_title,
"educational_focus": _build_theme_specific_focus(subject),
"emoji_pairs": emoji_pairs,
"grid_advice": {
"recommended_rows": rows,
"recommended_cols": cols,
"pair_count": pair_count,
"reason": "Fits the current board size.",
},
"victory_message": victory_message,
"failure_message": failure_message,
"featured_fact_emoji": emoji_pairs[0] if emoji_pairs else "",
"featured_fact": featured_fact_bundle["featured_fact"],
}
_debug_log(f"[llm] final blueprint level={level} provider=llama-server data={_debug_preview(parsed)}")
_debug_log(f"[llm] accepted llama-server blueprint for level={level}")
return parsed
def generate_challenge_safely(
level_number: int,
director_memory: dict[str, Any],
) -> dict[str, Any]:
_debug_log(f"[llm] generate_challenge_safely level={level_number}")
fallback_theme = ""
if isinstance(director_memory, dict):
recent_themes = list(director_memory.get("recent_themes", []))
if recent_themes:
fallback_theme = str(recent_themes[-1])
blueprint = generate_challenge_content(
level=level_number,
director_memory=director_memory,
fallback_theme=fallback_theme,
)
level_instance_id = f"challenge-{level_number}-{uuid.uuid4().hex[:8]}"
_debug_log(
f"[llm] generated challenge level={level_number} instance={level_instance_id} theme={blueprint.get('theme', '')}"
)
return {
"level_number": level_number,
"level_type": "challenge",
"blueprint": blueprint,
"cards": list(blueprint.get("cards", [])),
"pair_count": int(blueprint.get("pair_count", 0) or 0),
"rows": int(blueprint.get("rows", 2) or 2),
"cols": int(blueprint.get("cols", 2) or 2),
"level_instance_id": level_instance_id,
}
def create_cards(emojis: list[str]) -> list[str]:
cards = list(emojis) * 2
random.shuffle(cards)
return cards
def generate_level_safely(
level_number: int,
performance_for_prompt: dict[str, Any],
director_memory: dict[str, Any],
completed_challenge_levels: int = 0,
force_challenge: bool = False,
) -> dict[str, Any]:
_debug_log(f"[llm] generate_level_safely level={level_number}")
if force_challenge:
return generate_challenge_safely(level_number, director_memory)
config = get_grid_config(completed_challenge_levels)
rows = config["rows"]
cols = config["cols"]
pair_count = config["pairs"]
blueprint = generate_level_content(
level=level_number,
pair_count=pair_count,
rows=rows,
cols=cols,
performance_for_prompt=performance_for_prompt,
director_memory=director_memory,
)
level_instance_id = f"level-{level_number}-{uuid.uuid4().hex[:8]}"
cards = create_cards(blueprint["emoji_pairs"])
_debug_log(
f"[llm] generated level={level_number} instance={level_instance_id} "
f"theme={blueprint.get('theme', '')} pairs={len(blueprint.get('emoji_pairs', []))}"
)
return {
"level_number": level_number,
"level_type": "normal",
"blueprint": blueprint,
"cards": cards,
"pair_count": pair_count,
"rows": rows,
"cols": cols,
"level_instance_id": level_instance_id,
}
def build_predicted_performance(state: dict[str, Any]) -> dict[str, Any]:
active = state.get("active_level", {}) or {}
director_memory = state.get("director_memory", {}) or {}
return {
"generation_mode": "predictive_next_level",
"current_level": active.get("level_number", 1),
"lives_remaining": state.get("lives", MAX_LIVES),
"win_streak": state.get("win_streak", 0),
"score": state.get("score", 0),
"performance_meter": int(state.get("performance_meter", 0)),
"challenge_due": bool(state.get("challenge_due", False)),
"recent_average_wrong_attempts": state.get("last_performance", {}).get("wrong_attempts", 0),
"recent_average_moves_ratio": state.get("last_performance", {}).get("moves_ratio", 0),
"recent_themes": director_memory.get("recent_themes", []),
"recent_theme_families": director_memory.get("recent_theme_families", []),
"recent_emojis": director_memory.get("recent_emojis", []),
"theme_repeat_counts": director_memory.get("theme_repeat_counts", {}),
"family_repeat_counts": director_memory.get("family_repeat_counts", {}),
"note": "This is predictive generation while current level is being played.",
}
def calculate_level_performance(event: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
active = state.get("active_level", {}) or {}
director_memory = state.get("director_memory", {}) or {}
pair_count = int(active.get("pair_count", 0))
moves = int(event.get("moves", 0))
wrong_attempts = int(event.get("wrong_attempts", 0))
if pair_count <= 0:
pair_count = max(1, len(active.get("cards", [])) // 2)
return {
"generation_mode": "actual_performance_adjusted",
"completed_level": active.get("level_number", 1),
"score": state.get("score", 0),
"lives_remaining": state.get("lives", MAX_LIVES),
"win_streak": state.get("win_streak", 0),
"moves": moves,
"perfect_moves": pair_count,
"wrong_attempts": wrong_attempts,
"time_seconds": int(event.get("time_seconds", 0)),
"previous_theme": active.get("blueprint", {}).get("theme", ""),
"recent_themes": director_memory.get("recent_themes", []),
"recent_theme_families": director_memory.get("recent_theme_families", []),
"recent_emojis": director_memory.get("recent_emojis", []),
"theme_repeat_counts": director_memory.get("theme_repeat_counts", {}),
"family_repeat_counts": director_memory.get("family_repeat_counts", {}),
"moves_ratio": round(moves / max(1, pair_count), 3),
}
def calculate_challenge_performance(state: dict[str, Any], selected_index: int, is_correct: bool) -> dict[str, Any]:
active = state.get("active_level", {}) or {}
blueprint = active.get("blueprint", {}) or {}
return {
"generation_mode": "challenge_response",
"completed_level": active.get("level_number", 1),
"level_type": "challenge",
"theme": blueprint.get("theme", ""),
"challenge_selected_index": int(selected_index),
"challenge_correct_index": int(blueprint.get("challenge_correct_index", -1))
if isinstance(blueprint.get("challenge_correct_index", -1), int)
else -1,
"challenge_correct": bool(is_correct),
"score": state.get("score", 0),
"lives_remaining": state.get("lives", MAX_LIVES),
"win_streak": state.get("win_streak", 0),
"recent_themes": (state.get("director_memory", {}) or {}).get("recent_themes", []),
"recent_theme_families": (state.get("director_memory", {}) or {}).get("recent_theme_families", []),
"recent_emojis": (state.get("director_memory", {}) or {}).get("recent_emojis", []),
"theme_repeat_counts": (state.get("director_memory", {}) or {}).get("theme_repeat_counts", {}),
"family_repeat_counts": (state.get("director_memory", {}) or {}).get("family_repeat_counts", {}),
}
def update_director_memory_from_active_level(state: dict[str, Any]) -> None:
active = state.get("active_level", {}) or {}
blueprint = active.get("blueprint", {}) or {}
memory = state.setdefault("director_memory", {})
used_themes = memory.setdefault("used_themes", [])
theme = str(blueprint.get("theme", "")).strip()
if theme:
_extend_unique_theme_history(used_themes, theme)
memory["used_themes"] = used_themes
def build_shell_state() -> dict[str, Any]:
return {
"game_id": str(uuid.uuid4()),
"game_started": False,
"score": 0,
"performance_meter": 0,
"challenge_due": False,
"completed_challenge_levels": 0,
"lives": MAX_LIVES,
"max_lives": MAX_LIVES,
"hints": 1,
"max_hints": 5,
"win_streak": 0,
"last_performance": {},
"history": [],
"leaderboard_signed_in": False,
"leaderboard_username": "",
"leaderboard_high_score": 0,
"leaderboard_saved": False,
"director_memory": {
"recent_themes": [],
"recent_theme_families": [],
"recent_challenge_questions": [],
"recent_challenge_topics": [],
"recent_challenge_modes": [],
"used_themes": [],
"used_theme_families": [],
"recent_emojis": [],
"theme_repeat_counts": {},
"family_repeat_counts": {},
},
"active_level": {
"level_number": 1,
"level_type": "normal",
"blueprint": {
"theme_family": "",
"level_title": "",
"theme": "",
"educational_focus": "",
"featured_fact_emoji": "",
"featured_fact": "",
"challenge_modal_title": "",
"challenge_modal_message": "",
"challenge_question": "",
"challenge_options": [],
"challenge_correct_index": None,
"level_mode": "normal",
"mode_popup_title": "",
"mode_popup_message": "",
"match_targets": {},
"pair_facts": {},
"grid_advice": {},
"victory_message": "",
"failure_message": "",
},
"cards": [],
"pair_count": 0,
"rows": 2,
"cols": 2,
"level_instance_id": "",
},
"generation_status": {"level_number": None, "status": "idle", "error": ""},
"game_over": False,
"level_complete": False,
"transitioning_level": False,
"status": "preview",
}
def build_initial_game_state(
game_id: str | None = None,
) -> dict[str, Any]:
bootstrap_llama_runtime()
base_state = {
"game_id": game_id or str(uuid.uuid4()),
"game_started": True,
"score": 0,
"performance_meter": 0,
"challenge_due": False,
"completed_challenge_levels": 0,
"lives": MAX_LIVES,
"max_lives": MAX_LIVES,
"hints": 1,
"max_hints": 5,
"win_streak": 0,
"last_performance": {},
"history": [],
"leaderboard_signed_in": False,
"leaderboard_username": "",
"leaderboard_high_score": 0,
"leaderboard_saved": False,
"director_memory": {
"recent_themes": [],
"recent_theme_families": [],
"recent_challenge_questions": [],
"recent_challenge_topics": [],
"recent_challenge_modes": [],
"used_themes": [],
"used_theme_families": [],
"recent_emojis": [],
"theme_repeat_counts": {},
"family_repeat_counts": {},
},
"active_level": None,
"generation_status": {"level_number": None, "status": "idle", "error": ""},
"game_over": False,
"level_complete": False,
"transitioning_level": False,
"challenge_started": False,
"challenge_result": "",
}
active_level = generate_level_safely(
1,
{
"generation_mode": "initial_level",
"lives_remaining": MAX_LIVES,
"win_streak": 0,
"score": 0,
"recent_themes": [],
"recent_theme_families": [],
"recent_emojis": [],
"theme_repeat_counts": {},
"family_repeat_counts": {},
},
base_state["director_memory"],
completed_challenge_levels=0,
)
base_state["active_level"] = active_level
base_state["level_complete"] = False
base_state["game_over"] = False
update_director_memory_from_active_level(base_state)
return base_state
def public_game_state(state: dict[str, Any]) -> dict[str, Any]:
payload = dict(state)
return payload
def _extract_session_hash(incoming: dict[str, Any] | None = None) -> str:
incoming = incoming or {}
session_hash = str(incoming.get("session_hash") or "").strip()
if session_hash:
return session_hash
game_id = str(incoming.get("game_id") or "").strip()
if game_id:
return game_id
return ""
# =========================================================
# GAME MANAGER
# =========================================================
class GameManager:
def __init__(self) -> None:
self.sessions: dict[str, dict[str, Any]] = {}
self.game_state: dict[str, Any] = {}
def _compute_card_size(self, cols: int) -> int:
if cols <= 2:
return 160
if cols == 3:
return 150
if cols == 4:
return 132
return 118
def _merge_session_state(self, state_json: str) -> dict[str, Any]:
incoming = json.loads(state_json)
game_id = incoming.get("game_id")
existing = self.sessions.get(game_id, {}) if game_id else {}
merged = dict(existing)
for key, value in incoming.items():
if value is not None:
merged[key] = value
if game_id:
merged["game_id"] = game_id
elif "game_id" not in merged:
merged["game_id"] = str(uuid.uuid4())
if "director_memory" not in merged:
merged["director_memory"] = {
"recent_themes": [],
"recent_theme_families": [],
"recent_challenge_questions": [],
"recent_challenge_topics": [],
"recent_challenge_modes": [],
"used_themes": [],
"used_theme_families": [],
"recent_emojis": [],
"theme_repeat_counts": {},
"family_repeat_counts": {},
}
if "history" not in merged:
merged["history"] = []
if "leaderboard_signed_in" not in merged:
merged["leaderboard_signed_in"] = False
if "leaderboard_username" not in merged:
merged["leaderboard_username"] = ""
if "leaderboard_high_score" not in merged:
merged["leaderboard_high_score"] = 0
if "leaderboard_saved" not in merged:
merged["leaderboard_saved"] = False
if "last_performance" not in merged:
merged["last_performance"] = {}
if "generation_status" not in merged:
merged["generation_status"] = {"level_number": None, "status": "idle", "error": ""}
if "score" not in merged:
merged["score"] = 0
if "performance_meter" not in merged:
merged["performance_meter"] = 0
if "challenge_due" not in merged:
merged["challenge_due"] = False
if "completed_challenge_levels" not in merged:
merged["completed_challenge_levels"] = 0
if "lives" not in merged:
merged["lives"] = MAX_LIVES
if "max_lives" not in merged:
merged["max_lives"] = MAX_LIVES
if "hints" not in merged:
merged["hints"] = 1
if "max_hints" not in merged:
merged["max_hints"] = 5
if "win_streak" not in merged:
merged["win_streak"] = 0
if "challenge_started" not in merged:
merged["challenge_started"] = False
if "challenge_result" not in merged:
merged["challenge_result"] = ""
if "active_level" not in merged:
merged["active_level"] = None
elif isinstance(merged["active_level"], dict):
merged["active_level"].setdefault("level_type", "normal")
blueprint = merged["active_level"].setdefault("blueprint", {})
blueprint.setdefault("theme_family", "")
blueprint.setdefault("level_title", "")
blueprint.setdefault("theme", "")
blueprint.setdefault("educational_focus", "")
blueprint.setdefault("emoji_pairs", [])
blueprint.setdefault("featured_fact_emoji", "")
blueprint.setdefault("featured_fact", "")
blueprint.setdefault("challenge_modal_title", "")
blueprint.setdefault("challenge_modal_message", "")
blueprint.setdefault("challenge_question", "")
blueprint.setdefault("challenge_options", [])
blueprint.setdefault("challenge_correct_index", None)
blueprint.setdefault("level_mode", "normal")
blueprint.setdefault("mode_popup_title", "")
blueprint.setdefault("mode_popup_message", "")
blueprint.setdefault("match_targets", {})
blueprint.setdefault("pair_facts", {})
blueprint.setdefault("grid_advice", {})
blueprint.setdefault("victory_message", "")
blueprint.setdefault("failure_message", "")
self.sessions[merged["game_id"]] = merged
self.game_state = merged
return merged
def bootstrap_game(self) -> dict[str, Any]:
state = build_shell_state()
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def load_level_state(self, state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
incoming = json.loads(state_json or "{}")
session_hash = _extract_session_hash(incoming)
if not session_hash:
return {
"ok": False,
"error": "missing_session",
}
existing = self.sessions.get(session_hash)
if isinstance(existing, dict) and existing.get("active_level"):
if profile is not None:
existing.update(_get_leaderboard_status(profile))
self.game_state = existing
return {
"ok": True,
"session_hash": session_hash,
"state": public_game_state(existing),
"ui_action": "load_level_and_start_preview",
"html_payload": existing["active_level"],
}
state = build_initial_game_state(
session_hash,
)
if profile is not None:
state.update(_get_leaderboard_status(profile))
self.sessions[session_hash] = state
self.game_state = state
return {
"ok": True,
"session_hash": session_hash,
"state": public_game_state(state),
"ui_action": "load_level_and_start_preview",
"html_payload": state["active_level"],
}
def start_game(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
incoming = json.loads(state_json)
state = build_initial_game_state(
incoming.get("game_id"),
)
state.update(_get_leaderboard_status(profile))
self.sessions[state["game_id"]] = state
self.game_state = state
return {
"state": public_game_state(state),
"ui_action": "load_level_and_start_preview",
"html_payload": state["active_level"],
}
def sync_state(self, state_json: str) -> dict[str, Any]:
state = self._merge_session_state(state_json)
self.sessions[state["game_id"]] = state
self.game_state = state
return {"ok": True}
def reset_game(self, state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
state = build_initial_game_state(
self.game_state.get("game_id"),
)
if profile is not None:
state.update(_get_leaderboard_status(profile))
else:
state["leaderboard_signed_in"] = bool(self.game_state.get("leaderboard_signed_in", False))
state["leaderboard_username"] = str(self.game_state.get("leaderboard_username", ""))
state["leaderboard_high_score"] = int(self.game_state.get("leaderboard_high_score", 0) or 0)
state["leaderboard_saved"] = bool(self.game_state.get("leaderboard_saved", False))
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def on_hint_used(self, state_json: str) -> dict[str, Any]:
state = self._merge_session_state(state_json)
hints_remaining = int(state.get("hints", 1))
state["hints"] = max(0, min(int(state.get("max_hints", 5)), hints_remaining))
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def on_challenge_answer(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
state = self._merge_session_state(state_json)
active = state.get("active_level", {}) or {}
blueprint = active.get("blueprint", {}) or {}
selected_index = state.get("challenge_selected_index")
try:
selected_index = int(selected_index)
except Exception:
selected_index = -1
correct_index = blueprint.get("challenge_correct_index")
try:
correct_index = int(correct_index)
except Exception:
correct_index = -1
is_correct = selected_index == correct_index and correct_index >= 0
state["challenge_last_selected_index"] = selected_index
state["challenge_last_result"] = "correct" if is_correct else "wrong"
state["challenge_started"] = True
state["challenge_attempts"] = int(state.get("challenge_attempts", 0)) + 1
state["completed_challenge_levels"] = int(state.get("completed_challenge_levels", 0)) + 1
if is_correct:
update_director_memory_from_active_level(state)
state["level_complete"] = True
state["game_over"] = False
state["status"] = "complete"
state["score"] = int(state.get("score", 0)) + 20
state["win_streak"] = int(state.get("win_streak", 0)) + 1
state["performance_meter"] = 0
state["challenge_due"] = False
state["last_performance"] = calculate_challenge_performance(state, selected_index, True)
state.setdefault("history", []).append(state["last_performance"])
state["generation_status"] = {"level_number": None, "status": "idle", "error": ""}
else:
state["lives"] = max(0, int(state.get("lives", MAX_LIVES)) - 1)
state["level_complete"] = True
state["game_over"] = False
state["status"] = "complete"
state["performance_meter"] = 100
state["challenge_due"] = False
state["last_performance"] = calculate_challenge_performance(state, selected_index, False)
state.update(_apply_leaderboard_update(profile, int(state.get("score", 0))))
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def on_wrong_match(self, state_json: str) -> dict[str, Any]:
state = self._merge_session_state(state_json)
active = state.get("active_level", {}) or {}
is_challenge = active.get("level_type") == "challenge"
state["lives"] = max(0, int(state.get("lives", MAX_LIVES)) - 1)
if is_challenge:
state["performance_meter"] = 100
state["challenge_due"] = False
else:
state["performance_meter"] = max(
0,
int(state.get("performance_meter", 0)) - METER_POINTS_PER_CORRECT_MATCH,
)
state["challenge_due"] = bool(state.get("performance_meter", 0) >= METER_CHALLENGE_THRESHOLD)
state["level_complete"] = False
if state["lives"] <= 0:
state["game_over"] = True
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def on_level_complete(self, state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
state = self._merge_session_state(state_json)
active = state.get("active_level", {}) or {}
is_challenge_level = active.get("level_type") == "challenge"
event = {
"moves": int(state.get("moves", 0)),
"wrong_attempts": int(state.get("wrong_attempts", 0)),
"time_seconds": int(state.get("previous_level_time_seconds", 0)),
}
performance = calculate_level_performance(event, state)
state["last_performance"] = performance
state.setdefault("history", []).append(performance)
state["level_complete"] = True
state["game_over"] = False
state["transitioning_level"] = False
update_director_memory_from_active_level(state)
if is_challenge_level:
state["completed_challenge_levels"] = int(state.get("completed_challenge_levels", 0)) + 1
state["score"] = int(state.get("score", 0))
state["win_streak"] = int(state.get("win_streak", 0)) + 1
state["generation_status"] = {"level_number": None, "status": "idle", "error": ""}
state.update(_apply_leaderboard_update(profile, int(state.get("score", 0))))
self.sessions[state["game_id"]] = state
self.game_state = state
return public_game_state(state)
def on_next_level_click(self, state_json: str) -> dict[str, Any]:
state = self._merge_session_state(state_json)
expected_next_level = int(state["active_level"]["level_number"]) + 1
force_challenge = bool(state.get("challenge_due")) or int(state.get("performance_meter", 0)) >= METER_CHALLENGE_THRESHOLD
state["transitioning_level"] = True
performance_for_prompt = state.get("last_performance") or build_predicted_performance(state)
prepared = generate_level_safely(
expected_next_level,
performance_for_prompt,
state.setdefault("director_memory", {}),
completed_challenge_levels=int(state.get("completed_challenge_levels", 0)),
force_challenge=force_challenge,
)
state["active_level"] = prepared
state["level_complete"] = False
state["game_over"] = False
state["transitioning_level"] = False
state["moves"] = 0
state["wrong_attempts"] = 0
state["challenge_started"] = False
state["challenge_result"] = ""
state["challenge_selected_index"] = None
state["challenge_last_selected_index"] = None
state["challenge_last_result"] = ""
state["challenge_attempts"] = 0
state["active_level"]["blueprint"]["preview_seconds"] = PREVIEW_SECONDS
if force_challenge:
state["challenge_due"] = False
state["generation_status"] = {"level_number": None, "status": "idle", "error": ""}
self.sessions[state["game_id"]] = state
self.game_state = state
return {
"state": public_game_state(state),
"ui_action": "load_level_and_start_preview",
"html_payload": state["active_level"],
}
manager = GameManager()
def load_level_state(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return manager.load_level_state(state_json, profile)
def sync_state(state_json: str) -> dict[str, Any]:
return manager.sync_state(state_json)
def on_wrong_match(state_json: str) -> dict[str, Any]:
return manager.on_wrong_match(state_json)
def on_level_complete(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return manager.on_level_complete(state_json, profile)
def on_hint_used(state_json: str) -> dict[str, Any]:
return manager.on_hint_used(state_json)
def on_challenge_answer(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return manager.on_challenge_answer(state_json, profile)
def start_game(state_json: str, profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return manager.start_game(state_json, profile)
def on_next_level_click(state_json: str) -> dict[str, Any]:
return manager.on_next_level_click(state_json)
def reset_game(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return manager.reset_game(state_json, profile)
def get_leaderboard_data(state_json: str = "{}", profile: gr.OAuthProfile | None = None) -> dict[str, Any]:
return {
"current_user": _get_leaderboard_status(profile),
"entries": _build_leaderboard_entries(10),
}
# =========================================================
# HTML TEMPLATE
# =========================================================
GRID_HTML = """
<div class="memory-container" data-game-id="${game_id}">
<div class="start-overlay" id="start-overlay" aria-hidden="false">
<div class="start-stage">
<div class="start-orbit"></div>
<div class="start-rings"></div>
<div class="start-stars"></div>
<div class="start-hero">
<div class="start-mark" aria-hidden="true">
<img class="start-mark-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true">
</div>
<div class="start-title-wrap">
<div class="start-title">MatchWise</div>
<div class="start-tagline">
<span class="tag-green">Remember.</span>
<span class="tag-blue">Match.</span>
<span class="tag-purple">Progress.</span>
</div>
</div>
<div class="start-subtitle">
Match pairs, discover facts, and see how far your memory can go.
</div>
<div class="start-preview" aria-hidden="true">
<div class="preview-card preview-flower">
<div class="preview-emoji">🌼</div>
</div>
<div class="preview-card preview-flower">
<div class="preview-emoji">🌼</div>
</div>
<div class="preview-card preview-blossom">
<div class="preview-emoji">🌸</div>
</div>
<div class="preview-card preview-blossom">
<div class="preview-emoji">🌸</div>
</div>
</div>
<div class="start-utility-row">
<button type="button" class="start-help-button" id="how-to-play-btn">
<span class="start-help-icon">?</span>
<span class="start-help-text">How to Play</span>
</button>
<button type="button" class="music-toggle-button start-music-button" id="music-toggle-start-btn">
<span class="music-toggle-icon">♫</span>
<span class="music-toggle-text">Sound: ON</span>
</button>
<div class="high-score-pill" id="high-score-pill">
<span class="high-score-icon">⭐</span>
<span class="high-score-copy">
<span class="high-score-label" id="high-score-label">My High Score</span>
<span class="high-score-user" id="high-score-user">Guest</span>
</span>
<span class="high-score-value" id="high-score-value">0</span>
</div>
<button type="button" class="leaderboard-button start-leaderboard-button" id="leaderboard-btn">
<span class="leaderboard-icon">🏆</span>
<span class="leaderboard-text">Leaderboard</span>
</button>
</div>
<div class="start-footer-row" aria-label="Start screen footer">
<div class="start-footer-item">
<span class="start-footer-value"><a href="https://huggingface.co/build-small-hackathon" target="_blank" rel="noopener noreferrer">The Build Small Hackathon</a></span>
</div>
<div class="start-footer-item">
<span class="start-footer-value">TRACK:🍄 An Adventure in Thousand Token Wood</span>
</div>
</div>
<div class="start-attribution">
Built with ❤️ by <a href="https://huggingface.co/tejasashinde" target="_blank" rel="noopener noreferrer">tejasashinde</a> with Gradio, MiniCPM5-1B via Llama.cpp
</div>
<button type="button" class="start-button" id="start-game-btn">
<span class="start-button-icon">▶</span>
<span class="start-button-text">START GAME</span>
</button>
<div class="start-note" id="start-note">Press Start to generate your first level.</div>
<div class="dialog-overlay hidden start-transition-overlay" id="start-transition-overlay" aria-hidden="true">
</div>
</div>
<div class="dialog-overlay hidden start-transition-overlay" id="start-transition-overlay" aria-hidden="true">
<div class="dialog-card dialog-start-loading">
<div class="dialog-title" id="start-transition-title">Preparing Game</div>
<div class="dialog-text" id="start-transition-message">Generating your first level...</div>
</div>
</div>
</div>
</div>
<div class="dialog-overlay hidden" id="how-to-play-overlay" aria-hidden="true">
<div class="dialog-card dialog-howto">
<button type="button" class="howto-close-btn" id="how-to-play-close-btn" aria-label="Close how to play modal">×</button>
<div class="howto-logo-wrap" aria-hidden="true">
<img class="howto-logo" src="__LOGO_DATA_URI__" alt="">
</div>
<div class="howto-kicker">HOW TO PLAY</div>
<div class="howto-headline">Remember cards. Match pairs. Survive the run.</div>
<div class="howto-steps">
<div class="howto-step-card purple">
<div class="howto-step-icon">👀</div>
<div class="howto-step-copy">
<div class="howto-step-title">1. Preview</div>
<div class="howto-step-text">Memorize the board during the preview.</div>
</div>
</div>
<div class="howto-step-arrow">→</div>
<div class="howto-step-card blue">
<div class="howto-step-icon">🃏</div>
<div class="howto-step-copy">
<div class="howto-step-title">2. Match</div>
<div class="howto-step-text">Find matching pairs to score.</div>
</div>
</div>
<div class="howto-step-arrow">→</div>
<div class="howto-step-card green">
<div class="howto-step-icon">🏆</div>
<div class="howto-step-copy">
<div class="howto-step-title">3. Progress</div>
<div class="howto-step-text">Beat challenges and survive the run.</div>
</div>
</div>
</div>
<div class="howto-main">
<div class="howto-left">
<div class="howto-rule">
<div class="howto-rule-icon blue">⏱️</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">1. No Time Limit</div>
<div class="howto-rule-text">Play at your own pace. There is no countdown timer.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon purple">🧠</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">2. Memorize First</div>
<div class="howto-rule-text">Each level shows the board briefly before the cards flip back.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon red">❤️</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">3. Lives</div>
<div class="howto-rule-text">Start with <span class="howto-chip danger">5 lives</span>. Wrong matches cost 1 life, and challenge wins can add more lives.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon amber">💡</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">4. Peeks</div>
<div class="howto-rule-text">Use <strong>PEEK</strong> to reveal one matching pair for a moment. You start with <span class="howto-chip warning">1 peek</span> and can earn more on normal level clears.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon gold">🏅</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">5. Scoring</div>
<div class="howto-rule-text">Correct matches raise score. Wrong matches reduce score by 10.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon orange">🔥</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">6. Challenge Level</div>
<div class="howto-rule-text">Some levels switch to a matching challenge about a school topic. Match emoji cards to clue words or categories. Correct answers give <span class="howto-chip success">+20 score</span> and <span class="howto-chip success">+1 life</span>, while wrong answers remove <span class="howto-chip danger">1 life</span>.</div>
</div>
</div>
<div class="howto-rule">
<div class="howto-rule-icon purple">🤖</div>
<div class="howto-rule-copy">
<div class="howto-rule-title">7. Endless AI Levels</div>
<div class="howto-rule-text">New LLM-driven levels keep coming until your lives reach 0.</div>
</div>
</div>
</div>
<div class="howto-right">
<div class="howto-panel-title">Reward Table</div>
<table class="howto-rewards-table">
<thead>
<tr>
<th>Action</th>
<th>Reward</th>
</tr>
</thead>
<tbody>
<tr>
<td>Normal pair matched</td>
<td class="reward-good">+20 score</td>
</tr>
<tr>
<td>Normal wrong match</td>
<td class="reward-bad">-10 score, -1 life</td>
</tr>
<tr>
<td>Normal level cleared</td>
<td class="reward-warn">Can earn +1 peek</td>
</tr>
<tr>
<td>Challenge answer correct</td>
<td class="reward-good">+20 score, +1 life</td>
</tr>
<tr>
<td>Challenge answer wrong</td>
<td class="reward-bad">-1 life</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="dialog-overlay hidden" id="leaderboard-overlay" aria-hidden="true">
<div class="dialog-card dialog-leaderboard">
<button type="button" class="howto-close-btn leaderboard-close-btn" id="leaderboard-close-btn" aria-label="Close leaderboard modal">×</button>
<div class="leaderboard-logo-wrap" aria-hidden="true">
<img class="leaderboard-logo" src="__LOGO_DATA_URI__" alt="">
</div>
<div class="leaderboard-kicker">LEADERBOARD</div>
<div class="leaderboard-headline">See the highest MatchWise scores.</div>
<div class="leaderboard-my-score">
<div class="leaderboard-my-score-label">My High Score</div>
<div class="leaderboard-my-score-row">
<span class="leaderboard-my-score-user" id="leaderboard-my-score-user">Guest</span>
<span class="leaderboard-my-score-value" id="leaderboard-my-score-value">0</span>
</div>
</div>
<div class="leaderboard-list" id="leaderboard-list"></div>
</div>
</div>
<div class="game-brand" aria-label="MatchWise">
<div class="game-brand-mark" aria-hidden="true">
<img class="game-brand-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true">
</div>
<div class="game-brand-copy">
<div class="game-brand-title">MatchWise</div>
<div class="game-brand-subtitle">Match pairs, discover facts, and see how far your memory can go.</div>
</div>
</div>
<div class="board-frame">
<div class="top-utility-bar">
<button type="button" class="utility-button home-button" id="home-btn">
<span class="utility-button-icon">⌂</span>
<span class="utility-button-text">Main menu</span>
</button>
<button type="button" class="music-toggle-button top-music-button" id="music-toggle-top-btn">
<span class="music-toggle-icon">♫</span>
<span class="music-toggle-text">Sound: ON</span>
</button>
<div class="high-score-pill top-high-score-pill" id="high-score-pill">
<span class="high-score-icon">⭐</span>
<span class="high-score-copy">
<span class="high-score-label" id="high-score-label">My High Score</span>
<span class="high-score-user" id="high-score-user">Guest</span>
</span>
<span class="high-score-value" id="high-score-value">0</span>
</div>
</div>
<div class="game-header">
<div class="performance-meter-shell">
<div class="performance-meter-title">PERFORMANCE METER</div>
<div class="meter-card">
<div class="side-label easy">
<div class="icon-box">🍃</div>
EASY
</div>
<div class="meter" id="meter">
<div class="meter-fill-spark" id="spark"></div>
<div class="ticks">
<span></span><span></span><span></span><span></span><span></span>
<span></span><span></span><span></span><span></span><span></span>
</div>
<div class="knob" id="knob">
<div class="knob-core"></div>
</div>
</div>
<div class="side-label challenge">
<div class="icon-box">🔥</div>
Challenge Me
</div>
</div>
</div>
<div class="game-header-row">
<div class="game-stats">
<div class="stat-block stat-level">
<span class="stat-icon">🏁</span>
<span class="stat-label">LEVEL</span>
<span class="stat-value" id="level-display">${level}</span>
</div>
<div class="stat-block stat-moves">
<span class="stat-icon">⇄</span>
<span class="stat-label">MOVES</span>
<span class="stat-value" id="moves-display">0</span>
</div>
<div class="stat-block stat-matches">
<span class="stat-icon">👥</span>
<span class="stat-label">MATCHES</span>
<span class="stat-value" id="matches-display">0 / <span id="total-pairs">0</span></span>
</div>
<div class="stat-block stat-score">
<span class="stat-icon">🏆</span>
<span class="stat-label">SCORE</span>
<span class="stat-value" id="score-display">${score}</span>
</div>
<div class="stat-block stat-lives">
<span class="stat-icon">♥</span>
<span class="stat-label">LIVES</span>
<span class="stat-value" id="lives-display">${lives}</span>
</div>
</div>
<div class="game-state-badge" id="game-state-badge">MEMORIZE</div>
</div>
</div>
<div id="status-layout">
<div class="game-status" id="game-status-area">
<div class="status-text" id="status-text">${level_title}</div>
<div class="status-subtext" id="status-subtext">${educational_focus}</div>
</div>
<div class="transition-overlay hidden" id="transition-overlay" aria-hidden="true">
<div class="transition-card">
<div class="transition-title" id="transition-title">Loading</div>
<div class="transition-subtitle" id="transition-subtitle">Please wait...</div>
</div>
</div>
<div class="dialog-overlay hidden" id="reset-confirm-overlay" aria-hidden="true">
<div class="dialog-card dialog-warning">
<div class="dialog-title">Start a new game?</div>
<div class="dialog-text">Are you sure, starting new game will remove current progress?</div>
<div class="dialog-actions">
<button type="button" class="dialog-button dialog-secondary" id="reset-cancel-btn">Cancel</button>
<button type="button" class="dialog-button dialog-primary" id="reset-confirm-btn">Yes,Start New Game</button>
</div>
</div>
</div>
<div class="dialog-overlay hidden" id="gameover-overlay" aria-hidden="true">
<div class="dialog-card dialog-gameover">
<div class="gameover-icon-wrap" aria-hidden="true">
<div class="gameover-icon-halo"></div>
<div class="gameover-icon">🎮</div>
</div>
<div class="gameover-title">Game Over</div>
<div class="gameover-divider" aria-hidden="true">
<span></span>
<span class="gameover-diamond">◆</span>
<span></span>
</div>
<div class="gameover-subtitle">
You finished with a score of <span id="gameover-score">0</span>.
</div>
<div class="gameover-score-pill" aria-label="Score">
<span class="gameover-score-icon">🏆</span>
<span class="gameover-score-label">SCORE</span>
<span class="gameover-score-sep"></span>
<span class="gameover-score-value" id="gameover-score-pill">0</span>
</div>
</div>
</div>
<div class="dialog-overlay hidden" id="challenge-intro-overlay" aria-hidden="true">
<div class="dialog-card dialog-challenge dialog-challenge-polished">
<button type="button" class="challenge-close-btn" id="challenge-close-btn" aria-label="Close challenge dialog">×</button>
<div class="challenge-hero" aria-hidden="true">
<div class="challenge-hero-ring">
<div class="challenge-hero-icon">🔥</div>
</div>
</div>
<div class="challenge-title" id="challenge-modal-title">Challenge Level</div>
<div class="challenge-subtitle" id="challenge-modal-subtitle">This is a matching challenge. Match the cards, then tap OK to begin.</div>
<div class="challenge-divider" aria-hidden="true"><span></span><i></i><span></span></div>
<div class="challenge-points">
<div class="challenge-point"><span class="challenge-point-icon">?</span><span>Match an emoji to a word or its identical pair.</span></div>
<div class="challenge-point"><span class="challenge-point-icon">◎</span><span>Keep matching until the board is clear.</span></div>
</div>
<div class="challenge-rewards">
<div class="challenge-reward good"><span class="challenge-reward-icon">❤</span><span>+1 life for a cleared challenge</span></div>
<div class="challenge-reward bad"><span class="challenge-reward-icon">💔</span><span>-1 life for wrong match</span></div>
</div>
<div class="challenge-note"><span class="challenge-note-icon">i</span><span>Challenge boards can use clue words or categories.</span></div>
<div class="dialog-actions challenge-actions">
<button type="button" class="challenge-start-btn" id="challenge-ok-btn"><span class="challenge-start-icon">▶</span><span>Start Challenge</span></button>
</div>
</div>
</div>
<div class="game-layout">
<div class="level-panel">
<div class="level-meta">
<div class="level-title" id="level-title">${level_title}</div>
<div class="level-chips">
<span class="theme-chip" id="theme-chip">${theme}</span>
</div>
<div class="level-focus" id="focus-line">${educational_focus}</div>
</div>
</div>
<div class="challenge-panel hidden" id="challenge-panel">
<div class="challenge-card">
<div class="challenge-label">Challenge Question</div>
<div class="challenge-theme" id="challenge-theme"></div>
<div class="challenge-question" id="challenge-question"></div>
<div class="challenge-options" id="challenge-options"></div>
<div class="challenge-focus" id="challenge-focus"></div>
</div>
</div>
<div class="cards-panel">
<div class="preview-overlay" id="preview-overlay">
<div class="preview-content">
<div class="preview-countdown" id="preview-countdown">3</div>
<div class="preview-subtitle">Memorize the cards</div>
</div>
</div>
<div class="memory-grid" id="memory-grid" style="--grid-cols:${grid_cols}; --grid-rows:${grid_rows}; --card-size:${card_size}px;">
@children
</div>
</div>
</div>
</div>
<div class="controls-row" id="controls-row" style="display:none;">
<button type="button" class="hint-button" id="hint-btn">
<span class="hint-prefix">🔍 PEEK</span>
<span class="hint-count" id="hint-count">${hints} / ${max_hints}</span>
</button>
<button type="button" class="next-level-button" id="next-btn" disabled>⬆ NEXT LEVEL</button>
<button type="button" class="reset-button" id="reset-btn">🔄 START NEW GAME</button>
</div>
</div>
</div>
"""
GRID_HTML = GRID_HTML.replace("__LOGO_DATA_URI__", LOGO_DATA_URI)
_LEVEL_SPLIT_INDEX = GRID_HTML.index('<div class="game-brand"')
START_HTML = GRID_HTML[:_LEVEL_SPLIT_INDEX] + "</div>\n"
LEVEL_HTML = '<div class="memory-container" data-game-id="${game_id}">\n' + GRID_HTML[_LEVEL_SPLIT_INDEX:]
# =========================================================
# CSS
# =========================================================
CSS = """
html,
body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
.gradio-container {
width: 100vw !important;
}
#game-shell {
width: 100% !important;
}
footer {
display: none !important;
}
.memory-container {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: clamp(12px, 1.5vw, 18px);
padding: clamp(10px, 1.6vw, 22px);
background:
radial-gradient(circle at top left, rgba(99,102,241,0.28), transparent 32%),
radial-gradient(circle at bottom right, rgba(14,165,233,0.18), transparent 30%),
linear-gradient(135deg, #0b1020 0%, #111827 52%, #020617 100%);
border-radius: 28px;
position: relative;
width: 100%;
max-width: 100vw;
min-height: 0;
height: auto;
max-height: none;
color: #f8fafc;
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.45);
box-sizing: border-box;
}
.memory-container.level-loading .board-frame {
visibility: hidden;
}
.game-brand {
width: min(100%, 1480px);
z-index: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 14px;
padding: 4px 4px 0;
box-sizing: border-box;
}
.game-brand-mark {
width: clamp(54px, 4.8vw, 72px);
height: clamp(54px, 4.8vw, 72px);
flex: 0 0 auto;
display: grid;
place-items: center;
border-radius: 18px;
background:
radial-gradient(circle at 35% 35%, rgba(255,255,255,0.18), transparent 42%),
radial-gradient(circle at 50% 50%, rgba(168, 85, 247, 0.2), rgba(168, 85, 247, 0.04) 70%);
box-shadow:
0 0 0 1px rgba(196, 181, 253, 0.18) inset,
0 0 18px rgba(168, 85, 247, 0.22);
}
.game-brand-logo {
width: 70%;
height: 70%;
object-fit: contain;
display: block;
}
.game-brand-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.game-brand-title {
font-size: clamp(28px, 2.9vw, 42px);
font-weight: 900;
letter-spacing: -0.03em;
color: #f8fafc;
}
.game-brand-subtitle {
font-size: clamp(14px, 1.05vw, 17px);
line-height: 1.45;
color: #cbd5e1;
max-width: 72ch;
}
.memory-container::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
background-image:
linear-gradient(rgba(148,163,184,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,163,184,0.04) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
opacity: 0.35;
}
.board-frame {
width: min(100%, 1480px);
max-width: 100%;
height: auto;
max-height: none;
position: relative;
z-index: 1;
--board-frame-padding: clamp(14px, 1.8vw, 26px);
border-radius: 28px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(180deg, rgba(15, 23, 42, 0.72), rgba(2, 6, 23, 0.84));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 28px 90px rgba(2, 6, 23, 0.42);
padding: var(--board-frame-padding);
display: flex;
flex-direction: column;
gap: clamp(16px, 2vw, 24px);
box-sizing: border-box;
overflow: visible;
}
#status-layout {
position: relative;
width: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: clamp(14px, 1.6vw, 20px);
overflow: visible;
}
.preview-overlay {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.46);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
border-radius: 28px;
transition: opacity 0.45s ease;
pointer-events: none;
}
.memory-container.preview-wobbling .preview-overlay {
background: rgba(2, 6, 23, 0.18);
backdrop-filter: blur(1px);
}
.memory-container.preview-wobbling .preview-content {
transform: scale(0.96);
transition: transform 0.25s ease, opacity 0.25s ease;
opacity: 0.95;
}
.preview-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.transition-overlay {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.82);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 45;
border-radius: 28px;
transition: opacity 0.25s ease;
}
.transition-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.dialog-overlay {
position: absolute;
inset: 0;
background: rgba(2, 6, 23, 0.78);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
z-index: 55;
border-radius: 28px;
transition: opacity 0.25s ease;
}
#leaderboard-overlay {
position: fixed;
inset: 0;
z-index: 85;
}
.dialog-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.start-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
min-height: 100vh;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
background:
radial-gradient(circle at 50% 28%, rgba(139, 92, 246, 0.16), transparent 18%),
radial-gradient(circle at 50% 64%, rgba(14, 165, 233, 0.24), transparent 20%),
radial-gradient(circle at 50% 64%, rgba(168, 85, 247, 0.18), transparent 30%),
linear-gradient(180deg, #040714 0%, #06122a 55%, #020617 100%);
backdrop-filter: blur(8px);
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.start-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.start-stage {
position: relative;
width: 200vw;
height: 200dvh;
min-height: 200vh;
margin: 0 auto;
padding: clamp(18px, 3vw, 48px) clamp(18px, 5vw, 64px);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
transform: scale(0.5);
transform-origin: center center;
}
.start-hero {
position: relative;
z-index: 2;
width: min(100%, 820px);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: clamp(14px, 1.6vw, 22px);
}
.start-mark {
width: clamp(58px, 7vw, 86px);
height: clamp(58px, 7vw, 86px);
display: grid;
place-items: center;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, rgba(255,255,255,0.2), transparent 40%),
radial-gradient(circle at 50% 50%, rgba(168, 85, 247, 0.24), rgba(168, 85, 247, 0.02) 70%);
box-shadow:
0 0 0 1px rgba(196, 181, 253, 0.22) inset,
0 0 18px rgba(168, 85, 247, 0.35),
0 0 40px rgba(168, 85, 247, 0.2);
}
.start-mark-icon {
font-size: clamp(28px, 3.4vw, 42px);
filter: drop-shadow(0 0 14px rgba(168, 85, 247, 0.65));
}
.start-mark-logo {
width: 70%;
height: 70%;
object-fit: contain;
display: block;
filter: drop-shadow(0 0 14px rgba(168, 85, 247, 0.45));
}
.start-title-wrap {
display: flex;
flex-direction: column;
gap: clamp(8px, 1vw, 12px);
align-items: center;
}
.start-title {
margin-top: 0;
font-size: clamp(48px, 10vw, 118px);
line-height: 0.92;
font-weight: 1000;
letter-spacing: -0.06em;
color: #f7f2ff;
text-shadow:
0 1px 0 #ffffff,
0 2px 0 #d7cfff,
0 5px 0 rgba(102, 0, 255, 0.85),
0 18px 34px rgba(110, 29, 255, 0.35),
0 0 28px rgba(168, 85, 247, 0.3);
-webkit-text-stroke: 1px rgba(255,255,255,0.35);
}
.start-tagline {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3em 0.45em;
font-size: clamp(18px, 2.4vw, 34px);
font-weight: 900;
letter-spacing: -0.02em;
}
.tag-green { color: #86efac; text-shadow: 0 0 16px rgba(34,197,94,0.45); }
.tag-blue { color: #67e8f9; text-shadow: 0 0 16px rgba(6,182,212,0.45); }
.tag-purple { color: #d8b4fe; text-shadow: 0 0 16px rgba(168,85,247,0.45); }
.start-subtitle {
margin-top: 0;
color: #eef2ff;
font-size: clamp(17px, 2vw, 34px);
line-height: 1.45;
text-wrap: balance;
text-shadow: 0 4px 18px rgba(2, 6, 23, 0.65);
}
.start-preview {
width: min(100%, 400px);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(10px, 1.7vw, 16px);
padding: clamp(4px, 1vw, 10px);
margin-top: clamp(4px, 1vw, 8px);
}
.preview-card {
aspect-ratio: 1 / 1;
border-radius: clamp(20px, 2.6vw, 32px);
background:
radial-gradient(circle at 24% 18%, rgba(255,255,255,0.34), transparent 22%),
linear-gradient(145deg, #b3f9d1 0%, #a5f3a0 100%);
box-shadow:
0 10px 0 rgba(16, 185, 129, 0.5),
0 24px 34px rgba(0, 0, 0, 0.36);
display: grid;
place-items: center;
border: 1px solid rgba(168, 255, 211, 0.4);
}
.preview-emoji {
font-size: clamp(34px, 5.2vw, 68px);
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.18));
}
.start-help-button {
width: min(100%, 560px);
margin-top: clamp(8px, 1.2vw, 14px);
padding: 16px 20px;
border: 1px solid rgba(125, 211, 252, 0.28);
border-radius: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 28%),
linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(15, 23, 42, 0.96));
color: #e0f2fe;
font-size: clamp(17px, 2vw, 24px);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 18px 40px rgba(2, 6, 23, 0.35);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.start-help-button:hover {
transform: translateY(-1px);
border-color: rgba(125, 211, 252, 0.46);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.1),
0 20px 44px rgba(2, 6, 23, 0.42);
}
.start-utility-row {
width: min(100%, 760px);
display: flex;
align-items: stretch;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
margin-top: clamp(8px, 1.2vw, 14px);
}
.start-utility-row .start-help-button,
.start-utility-row .music-toggle-button {
width: auto;
flex: 1 1 260px;
margin-top: 0;
}
.start-utility-row .start-help-button {
min-width: 240px;
}
.start-utility-row .music-toggle-button {
min-width: 220px;
}
.start-utility-row .leaderboard-button {
min-width: 220px;
}
.start-utility-row .high-score-pill {
min-width: 240px;
position: relative;
overflow: hidden;
padding: 15px 18px;
border-radius: 24px;
border-color: rgba(34, 211, 238, 0.42);
background:
radial-gradient(circle at 16% 18%, rgba(255,255,255,0.18), transparent 24%),
radial-gradient(circle at 84% 18%, rgba(34, 211, 238, 0.18), transparent 22%),
radial-gradient(circle at 50% 110%, rgba(16, 185, 129, 0.2), transparent 34%),
linear-gradient(135deg, rgba(5, 92, 68, 0.98), rgba(12, 18, 35, 0.99));
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.08),
0 0 0 1px rgba(34, 211, 238, 0.08) inset,
0 18px 44px rgba(2, 6, 23, 0.44),
0 0 28px rgba(34, 211, 238, 0.16);
}
.start-utility-row .high-score-pill::before {
content: "";
position: absolute;
inset: 1px;
border-radius: inherit;
background: linear-gradient(120deg, rgba(255,255,255,0.14), transparent 34%, rgba(255,255,255,0.05));
pointer-events: none;
}
.start-utility-row .high-score-icon {
width: 30px;
height: 30px;
font-size: 17px;
color: #fef3c7;
background:
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.24), transparent 42%),
linear-gradient(135deg, rgba(250, 204, 21, 0.24), rgba(34, 197, 94, 0.14));
box-shadow:
inset 0 0 0 1px rgba(251, 191, 36, 0.24),
0 0 18px rgba(250, 204, 21, 0.12);
}
.start-help-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 50%;
color: #22d3ee;
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.18), transparent 42%), rgba(34, 211, 238, 0.12);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.22);
font-size: 20px;
line-height: 1;
}
.start-help-text {
line-height: 1;
}
.music-toggle-button,
.utility-button {
appearance: none;
border: 1px solid rgba(125, 211, 252, 0.28);
border-radius: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.12), transparent 28%),
linear-gradient(135deg, rgba(8, 47, 73, 0.96), rgba(15, 23, 42, 0.96));
color: #e0f2fe;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 18px 40px rgba(2, 6, 23, 0.35);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
min-width: 0;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.leaderboard-button {
appearance: none;
border: 1px solid rgba(245, 158, 11, 0.34);
border-radius: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%),
linear-gradient(135deg, rgba(78, 32, 0, 0.96), rgba(15, 23, 42, 0.96));
color: #fde68a;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 18px 40px rgba(2, 6, 23, 0.35);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 12px;
min-width: 0;
padding: 16px 20px;
font-size: clamp(15px, 1.8vw, 22px);
width: auto;
flex: 1 1 260px;
}
.leaderboard-button:hover {
transform: translateY(-1px);
border-color: rgba(251, 191, 36, 0.5);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.1),
0 20px 44px rgba(2, 6, 23, 0.42);
}
.leaderboard-button .leaderboard-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 50%;
color: #fbbf24;
background: rgba(251, 191, 36, 0.12);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.22);
font-size: 20px;
line-height: 1;
flex: 0 0 auto;
}
.leaderboard-text {
line-height: 1;
}
.high-score-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 0;
padding: 14px 18px;
border-radius: 24px;
border: 1px solid rgba(52, 211, 153, 0.28);
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.08), transparent 28%),
linear-gradient(135deg, rgba(6, 95, 70, 0.94), rgba(15, 23, 42, 0.96));
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 18px 40px rgba(2, 6, 23, 0.35);
color: #d1fae5;
flex: 1 1 260px;
}
.high-score-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(34, 197, 94, 0.14);
box-shadow: inset 0 0 0 1px rgba(110, 231, 183, 0.22);
font-size: 19px;
line-height: 1;
flex: 0 0 auto;
}
.high-score-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.high-score-label {
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(209, 250, 229, 0.78);
line-height: 1;
}
.high-score-user {
font-size: 12px;
font-weight: 800;
line-height: 1;
color: #e2e8f0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 18ch;
}
.high-score-value {
font-size: clamp(18px, 1.8vw, 24px);
font-weight: 1000;
letter-spacing: -0.03em;
color: #86efac;
line-height: 1;
flex: 0 0 auto;
}
.music-toggle-button:hover,
.utility-button:hover {
transform: translateY(-1px);
border-color: rgba(125, 211, 252, 0.46);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.1),
0 20px 44px rgba(2, 6, 23, 0.42);
}
.music-toggle-button {
width: min(100%, 280px);
padding: 16px 20px;
font-size: clamp(15px, 1.8vw, 22px);
}
.utility-button {
padding: 12px 18px;
font-size: clamp(13px, 1.4vw, 18px);
border-color: rgba(167, 139, 250, 0.34);
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%),
linear-gradient(135deg, rgba(41, 26, 84, 0.96), rgba(15, 23, 42, 0.96));
}
.utility-button:hover {
border-color: rgba(196, 181, 253, 0.5);
}
.utility-button-icon,
.music-toggle-icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 50%;
font-size: 18px;
line-height: 1;
flex: 0 0 auto;
}
.utility-button-icon {
color: #d8b4fe;
background: rgba(168, 85, 247, 0.14);
box-shadow: inset 0 0 0 1px rgba(196, 181, 253, 0.22);
}
.music-toggle-icon {
color: #67e8f9;
background: rgba(34, 211, 238, 0.12);
box-shadow: inset 0 0 0 1px rgba(34, 211, 238, 0.22);
}
.music-toggle-off {
border-color: rgba(248, 113, 113, 0.36);
color: #ffe4e6;
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.1), transparent 28%),
linear-gradient(135deg, rgba(69, 10, 10, 0.96), rgba(15, 23, 42, 0.96));
}
.music-toggle-off .music-toggle-icon {
color: #fca5a5;
background: rgba(248, 113, 113, 0.12);
box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.22);
}
.top-utility-bar {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 2px;
}
.top-utility-bar .utility-button,
.top-utility-bar .music-toggle-button {
padding: 10px 14px;
font-size: 13px;
border-radius: 18px;
}
.top-utility-bar .utility-button {
min-width: 120px;
}
.top-utility-bar .music-toggle-button {
min-width: 180px;
}
.top-utility-bar .high-score-pill {
min-width: 230px;
flex: 0 1 250px;
padding: 10px 14px;
border-radius: 18px;
}
.top-utility-bar .high-score-icon {
width: 28px;
height: 28px;
font-size: 16px;
}
.top-utility-bar .high-score-label {
font-size: 10px;
}
.top-utility-bar .high-score-user {
font-size: 11px;
max-width: 14ch;
}
.top-utility-bar .high-score-value {
font-size: 20px;
}
.start-utility-row .high-score-copy {
gap: 3px;
}
.start-utility-row .high-score-label {
font-size: 10px;
letter-spacing: 0.2em;
color: rgba(209, 250, 229, 0.9);
}
.start-utility-row .high-score-user {
font-size: clamp(16px, 1.45vw, 20px);
font-weight: 1000;
color: #ffffff;
max-width: 18ch;
line-height: 1.05;
letter-spacing: -0.03em;
text-shadow: 0 0 10px rgba(255,255,255,0.08);
}
.start-utility-row .high-score-value {
font-size: clamp(22px, 2.2vw, 28px);
color: #86efac;
text-shadow: 0 0 12px rgba(52, 211, 153, 0.28);
}
.start-button {
width: min(100%, 680px);
margin-top: clamp(10px, 1.6vw, 18px);
padding: 18px 22px;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 26px;
background: linear-gradient(135deg, #1463ff 0%, #2c53ff 34%, #b52cff 100%);
color: #fff;
font-size: clamp(19px, 2.4vw, 31px);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow:
0 8px 0 rgba(77, 12, 156, 0.55),
0 16px 36px rgba(119, 31, 255, 0.34),
0 0 34px rgba(126, 34, 206, 0.18);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.start-button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.92em;
transform: translateY(-1px);
}
.start-note {
margin-top: 10px;
color: #94a3b8;
font-size: 12px;
line-height: 1.4;
max-width: 64ch;
}
.start-footer-row {
width: min(100%, 900px);
margin-top: 8px;
margin-bottom: 4px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-items: stretch;
}
.start-footer-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
padding: 10px 14px;
border-radius: 18px;
border: 1px solid rgba(125, 211, 252, 0.18);
background: rgba(8, 15, 35, 0.55);
color: #dbeafe;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
.start-footer-label {
color: #94a3b8;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.start-footer-value {
color: #f8fafc;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
overflow-wrap: anywhere;
word-break: break-word;
}
.start-attribution {
width: min(100%, 900px);
margin-top: 6px;
color: #dbeafe;
font-size: clamp(12px, 1vw, 14px);
font-weight: 700;
line-height: 1.45;
text-align: center;
}
.start-attribution a {
color: #67e8f9;
text-decoration: none;
}
.start-attribution a:hover {
text-decoration: underline;
}
.start-orbit {
position: absolute;
inset: 6% 4% auto;
height: 64%;
border-radius: 50%;
background:
radial-gradient(circle at center, rgba(34, 211, 238, 0.22) 0%, rgba(34, 211, 238, 0.06) 20%, transparent 42%),
radial-gradient(circle at center, transparent 44%, rgba(99, 102, 241, 0.2) 45%, transparent 48%),
radial-gradient(circle at center, transparent 53%, rgba(168, 85, 247, 0.16) 54%, transparent 57%);
filter: blur(0.3px);
opacity: 0.7;
pointer-events: none;
}
.start-rings {
position: absolute;
inset: 45% 8% 7%;
border-radius: 50%;
background:
radial-gradient(circle at center, transparent 0 63%, rgba(34, 211, 238, 0.08) 64%, transparent 65%),
radial-gradient(circle at center, transparent 0 72%, rgba(168, 85, 247, 0.08) 73%, transparent 74%),
radial-gradient(circle at center, transparent 0 81%, rgba(59, 130, 246, 0.08) 82%, transparent 83%);
opacity: 0.7;
pointer-events: none;
}
.start-stars {
position: absolute;
inset: 0;
background-image:
radial-gradient(circle, rgba(255,255,255,0.55) 0 1px, transparent 1px),
radial-gradient(circle, rgba(168,85,247,0.6) 0 1px, transparent 1px),
radial-gradient(circle, rgba(34,197,94,0.35) 0 1px, transparent 1px);
background-size: 36px 36px, 54px 54px, 78px 78px;
background-position: 0 0, 18px 18px, 30px 12px;
opacity: 0.12;
pointer-events: none;
animation: starDrift 18s linear infinite;
}
@keyframes starDrift {
from { transform: translateY(0); }
to { transform: translateY(18px); }
}
.dialog-card {
width: min(100%, 420px);
margin: 0 18px;
padding: 22px 24px;
border-radius: 24px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.96);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.56);
}
.dialog-warning {
border-color: rgba(248, 180, 57, 0.36);
box-shadow: 0 24px 80px rgba(120, 53, 15, 0.42);
}
.dialog-gameover {
position: relative;
width: min(82vw, 720px);
max-width: none;
min-height: min(54dvh, 540px);
padding: clamp(22px, 2.2vw, 34px) clamp(20px, 2.4vw, 40px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
overflow: hidden;
text-align: center;
border-radius: 30px;
border: 2px solid rgba(255, 90, 145, 0.58);
background:
radial-gradient(circle at 50% 22%, rgba(255, 74, 112, 0.20), transparent 18%),
radial-gradient(circle at 20% 0%, rgba(96, 89, 255, 0.18), transparent 28%),
radial-gradient(circle at 80% 100%, rgba(95, 122, 255, 0.10), transparent 26%),
linear-gradient(180deg, rgba(12, 20, 52, 0.98), rgba(6, 12, 34, 0.98));
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05) inset,
0 0 0 1px rgba(255, 96, 167, 0.20),
0 0 26px rgba(255, 71, 114, 0.18),
0 0 42px rgba(120, 100, 255, 0.16),
0 24px 90px rgba(2, 6, 23, 0.68);
}
.dialog-gameover::before {
content: "";
position: absolute;
inset: auto 0 0;
height: 20px;
background: linear-gradient(90deg, transparent 0%, rgba(110, 104, 255, 0.48) 18%, rgba(255, 95, 160, 0.46) 52%, rgba(110, 104, 255, 0.48) 82%, transparent 100%);
filter: blur(16px);
opacity: 0.7;
pointer-events: none;
}
.dialog-gameover::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 50% 20%, rgba(255,255,255,0.05), transparent 14%),
radial-gradient(circle at 50% 32%, rgba(255, 78, 119, 0.10), transparent 24%);
opacity: 0.8;
}
.gameover-icon-wrap {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
margin-top: 2px;
z-index: 1;
}
.gameover-icon-halo {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(255, 63, 114, 0.92);
box-shadow:
0 0 24px rgba(255, 56, 109, 0.55),
0 0 46px rgba(255, 56, 109, 0.30),
inset 0 0 12px rgba(255, 56, 109, 0.18);
background:
radial-gradient(circle at 50% 50%, rgba(255, 80, 120, 0.08), transparent 58%);
}
.gameover-icon {
position: relative;
font-size: 46px;
line-height: 1;
filter: drop-shadow(0 0 14px rgba(255, 66, 120, 0.75));
}
.gameover-title {
position: relative;
z-index: 1;
color: #f8fafc;
font-size: clamp(42px, 4.8vw, 66px);
font-weight: 1000;
letter-spacing: -0.06em;
line-height: 0.95;
text-shadow:
0 1px 0 rgba(255,255,255,0.35),
0 8px 24px rgba(0, 0, 0, 0.35);
}
.gameover-divider {
position: relative;
z-index: 1;
width: min(100%, 280px);
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 10px;
margin-top: 2px;
}
.gameover-divider span {
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(255, 80, 120, 0.96), transparent);
box-shadow: 0 0 18px rgba(255, 80, 120, 0.28);
}
.gameover-diamond {
color: #ff577b;
font-size: 18px;
text-shadow: 0 0 16px rgba(255, 80, 120, 0.68);
}
.gameover-subtitle {
position: relative;
z-index: 1;
color: #d2d7e6;
font-size: clamp(16px, 1.7vw, 24px);
line-height: 1.35;
font-weight: 500;
max-width: 30ch;
}
.gameover-score-pill {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
margin-top: 8px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(10, 18, 42, 0.76);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.05),
0 0 0 1px rgba(255, 255, 255, 0.04),
0 18px 38px rgba(2, 6, 23, 0.34);
}
.gameover-score-icon {
font-size: 34px;
filter: drop-shadow(0 0 10px rgba(255, 191, 53, 0.34));
}
.gameover-score-label {
color: #c7cedd;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.03em;
}
.gameover-score-sep {
width: 1px;
height: 30px;
background: rgba(148, 163, 184, 0.34);
}
.gameover-score-value {
min-width: 28px;
color: #ff5574;
font-size: 46px;
font-weight: 900;
letter-spacing: -0.06em;
line-height: 1;
text-shadow: 0 0 18px rgba(255, 85, 116, 0.25);
}
.dialog-howto {
position: relative;
width: min(94vw, 1520px);
max-width: none;
max-height: min(90dvh, 880px);
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding: clamp(20px, 1.8vw, 30px);
border-radius: 28px;
border: 1px solid rgba(164, 94, 255, 0.55);
background:
radial-gradient(circle at 18% 0%, rgba(87, 52, 255, 0.14), transparent 24%),
radial-gradient(circle at 82% 8%, rgba(255, 78, 173, 0.10), transparent 22%),
linear-gradient(180deg, rgba(6, 14, 35, 0.99), rgba(4, 10, 28, 0.99));
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 0 0 1px rgba(255, 111, 196, 0.18),
0 0 40px rgba(114, 74, 255, 0.18),
0 20px 80px rgba(2, 6, 23, 0.7);
}
.dialog-howto::-webkit-scrollbar {
width: 10px;
}
.dialog-howto::-webkit-scrollbar-track {
background: rgba(10, 18, 40, 0.55);
border-radius: 999px;
}
.dialog-howto::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(173, 114, 255, 0.86), rgba(79, 196, 255, 0.86));
border-radius: 999px;
border: 2px solid rgba(4, 10, 28, 0.95);
}
#how-to-play-overlay {
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
z-index: 80;
padding: clamp(12px, 1.6vw, 22px);
box-sizing: border-box;
}
.howto-close-btn {
position: absolute;
top: 16px;
right: 16px;
width: 58px;
height: 58px;
border-radius: 50%;
border: 1px solid rgba(255, 120, 200, 0.55);
background: radial-gradient(circle at 30% 30%, rgba(255,255,255,0.08), transparent 42%), rgba(10, 18, 40, 0.96);
color: #fff;
font-size: 42px;
line-height: 1;
cursor: pointer;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset, 0 0 22px rgba(255, 118, 199, 0.18);
}
.howto-logo-wrap {
display: flex;
justify-content: center;
align-items: center;
margin: 2px 0 4px;
}
.howto-logo {
width: clamp(26px, 2.6vw, 36px);
height: clamp(26px, 2.6vw, 36px);
object-fit: contain;
display: block;
filter: drop-shadow(0 0 12px rgba(173, 114, 255, 0.35));
}
.howto-kicker {
color: #ad72ff;
font-size: clamp(15px, 1.2vw, 20px);
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
}
.howto-headline {
margin-top: 8px;
color: #f4f4f7;
font-size: clamp(18px, 1.95vw, 30px);
line-height: 1.08;
font-weight: 800;
letter-spacing: -0.04em;
text-align: center;
text-wrap: balance;
}
.howto-steps {
margin-top: clamp(14px, 1.6vw, 22px);
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 12px;
min-height: 0;
}
.howto-step-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 20px;
border: 1px solid rgba(90, 141, 255, 0.24);
background:
radial-gradient(circle at 20% 0%, rgba(255,255,255,0.04), transparent 28%),
rgba(8, 15, 35, 0.72);
min-height: 104px;
}
.howto-step-card.purple {
border-color: rgba(173, 114, 255, 0.45);
box-shadow: 0 0 0 1px rgba(173, 114, 255, 0.08) inset;
}
.howto-step-card.blue {
border-color: rgba(56, 165, 255, 0.45);
}
.howto-step-card.green {
border-color: rgba(38, 210, 120, 0.45);
}
.howto-step-icon {
width: 58px;
height: 58px;
border-radius: 50%;
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 30px;
background: rgba(255,255,255,0.04);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.05);
}
.howto-step-card.purple .howto-step-icon {
border: 1px solid rgba(173, 114, 255, 0.42);
box-shadow: 0 0 0 4px rgba(173, 114, 255, 0.08), inset 0 0 0 1px rgba(173, 114, 255, 0.18);
}
.howto-step-card.blue .howto-step-icon {
border: 1px solid rgba(56, 165, 255, 0.42);
box-shadow: 0 0 0 4px rgba(56, 165, 255, 0.08), inset 0 0 0 1px rgba(56, 165, 255, 0.18);
}
.howto-step-card.green .howto-step-icon {
border: 1px solid rgba(38, 210, 120, 0.42);
box-shadow: 0 0 0 4px rgba(38, 210, 120, 0.08), inset 0 0 0 1px rgba(38, 210, 120, 0.18);
}
.howto-step-copy {
min-width: 0;
}
.howto-step-title {
font-size: clamp(15px, 1.1vw, 18px);
font-weight: 800;
letter-spacing: -0.02em;
}
.howto-step-card.purple .howto-step-title { color: #bf8cff; }
.howto-step-card.blue .howto-step-title { color: #4fc4ff; }
.howto-step-card.green .howto-step-title { color: #39eb8b; }
.howto-step-text {
margin-top: 6px;
color: #d9e1f2;
font-size: clamp(11px, 0.75vw, 13px);
line-height: 1.26;
overflow-wrap: anywhere;
word-break: break-word;
}
.howto-step-arrow {
display: grid;
place-items: center;
color: #cfe0ff;
font-size: clamp(34px, 2.5vw, 50px);
font-weight: 300;
align-self: center;
text-shadow: 0 0 16px rgba(145, 189, 255, 0.6);
}
.howto-main {
margin-top: clamp(14px, 1.6vw, 22px);
display: grid;
grid-template-columns: 1.05fr 1fr;
gap: 12px;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.howto-left,
.howto-right {
border-radius: 20px;
border: 1px solid rgba(104, 139, 255, 0.16);
background: rgba(7, 14, 34, 0.68);
padding: 11px;
min-width: 0;
min-height: 0;
}
.howto-left {
display: grid;
gap: 6px;
}
.howto-rule {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 9px;
border-radius: 14px;
border: 1px solid rgba(149, 166, 201, 0.12);
background: rgba(255,255,255,0.015);
}
.howto-rule-icon {
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 20px;
background: rgba(255,255,255,0.04);
}
.howto-rule-icon.blue { border: 1px solid rgba(56, 165, 255, 0.4); box-shadow: 0 0 0 4px rgba(56,165,255,0.08); }
.howto-rule-icon.purple { border: 1px solid rgba(173, 114, 255, 0.4); box-shadow: 0 0 0 4px rgba(173,114,255,0.08); }
.howto-rule-icon.red { border: 1px solid rgba(255, 90, 90, 0.4); box-shadow: 0 0 0 4px rgba(255,90,90,0.08); }
.howto-rule-icon.amber { border: 1px solid rgba(255, 200, 74, 0.4); box-shadow: 0 0 0 4px rgba(255,200,74,0.08); }
.howto-rule-icon.gold { border: 1px solid rgba(255, 194, 56, 0.4); box-shadow: 0 0 0 4px rgba(255,194,56,0.08); }
.howto-rule-icon.orange { border: 1px solid rgba(255, 142, 58, 0.4); box-shadow: 0 0 0 4px rgba(255,142,58,0.08); }
.howto-rule-copy {
min-width: 0;
}
.howto-rule-title {
color: #f4f7ff;
font-size: clamp(14px, 0.95vw, 16px);
font-weight: 800;
letter-spacing: -0.02em;
}
.howto-rule-text {
margin-top: 4px;
color: #c5d2ea;
font-size: clamp(10px, 0.75vw, 12px);
line-height: 1.22;
overflow-wrap: anywhere;
word-break: break-word;
}
.howto-chip {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid rgba(255,255,255,0.12);
font-size: 0.92em;
white-space: nowrap;
}
.howto-chip.danger { color: #ff7b7b; border-color: rgba(255, 94, 94, 0.3); background: rgba(255, 77, 77, 0.08); }
.howto-chip.warning { color: #f7c53d; border-color: rgba(247, 197, 61, 0.3); background: rgba(247, 197, 61, 0.08); }
.howto-chip.success { color: #42db7f; border-color: rgba(66, 219, 127, 0.3); background: rgba(66, 219, 127, 0.08); }
.howto-right {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.howto-panel-title {
color: #b573ff;
font-size: clamp(13px, 0.95vw, 16px);
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
margin: 2px 0 12px;
}
.howto-rewards-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
color: #dbeafe;
font-size: clamp(10px, 0.75vw, 12px);
line-height: 1.2;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(149, 166, 201, 0.16);
}
.howto-rewards-table th,
.howto-rewards-table td {
padding: 9px 10px;
border-bottom: 1px solid rgba(149, 166, 201, 0.12);
border-right: 1px solid rgba(149, 166, 201, 0.12);
vertical-align: middle;
text-align: left;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.howto-rewards-table th {
color: #f4f7ff;
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 900;
}
.howto-rewards-table tbody tr:last-child td {
border-bottom: 0;
}
.howto-rewards-table th:last-child,
.howto-rewards-table td:last-child {
border-right: 0;
}
.howto-rewards-table td:last-child {
width: 40%;
}
.reward-good { color: #43db7f; }
.reward-bad { color: #ff6d6d; }
.reward-warn { color: #ffc94b; }
.howto-actions {
justify-content: flex-end;
gap: 0;
margin-top: clamp(12px, 1.2vw, 16px);
padding-top: 4px;
}
.howto-secondary-btn {
min-width: 0;
width: min(100%, 240px);
padding: 12px 18px;
border-radius: 16px;
font-size: clamp(13px, 0.95vw, 16px);
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
}
.howto-secondary-btn {
border: 1px solid rgba(85, 164, 255, 0.82);
background: rgba(15, 24, 47, 0.92);
color: #f4f7ff;
box-shadow: 0 0 0 1px rgba(85, 164, 255, 0.16) inset, 0 0 20px rgba(85, 164, 255, 0.14);
}
.dialog-leaderboard {
position: relative;
width: min(94vw, 920px);
max-width: none;
max-height: min(90dvh, 860px);
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding: clamp(20px, 2vw, 30px);
border-radius: 28px;
border: 1px solid rgba(245, 158, 11, 0.42);
background:
radial-gradient(circle at 18% 0%, rgba(245, 158, 11, 0.16), transparent 24%),
radial-gradient(circle at 82% 8%, rgba(16, 185, 129, 0.10), transparent 22%),
linear-gradient(180deg, rgba(6, 14, 35, 0.99), rgba(4, 10, 28, 0.99));
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.04) inset,
0 0 0 1px rgba(245, 158, 11, 0.16),
0 0 40px rgba(245, 158, 11, 0.12),
0 20px 80px rgba(2, 6, 23, 0.7);
}
.dialog-leaderboard::-webkit-scrollbar {
width: 10px;
}
.dialog-leaderboard::-webkit-scrollbar-track {
background: rgba(10, 18, 40, 0.55);
border-radius: 999px;
}
.dialog-leaderboard::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(245, 158, 11, 0.88), rgba(34, 197, 94, 0.82));
border-radius: 999px;
border: 2px solid rgba(4, 10, 28, 0.95);
}
.leaderboard-logo-wrap {
display: flex;
justify-content: center;
align-items: center;
margin: 2px 0 4px;
}
.leaderboard-logo {
width: clamp(26px, 2.6vw, 36px);
height: clamp(26px, 2.6vw, 36px);
object-fit: contain;
display: block;
filter: drop-shadow(0 0 12px rgba(245, 158, 11, 0.32));
}
.leaderboard-kicker {
color: #f59e0b;
font-size: clamp(15px, 1.2vw, 20px);
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
text-align: center;
}
.leaderboard-headline {
margin-top: 8px;
color: #f4f4f7;
font-size: clamp(18px, 1.95vw, 28px);
line-height: 1.08;
font-weight: 800;
letter-spacing: -0.04em;
text-align: center;
text-wrap: balance;
}
.leaderboard-my-score {
margin-top: 18px;
padding: 16px 18px;
border-radius: 20px;
border: 1px solid rgba(245, 158, 11, 0.18);
background: rgba(8, 15, 35, 0.58);
display: flex;
flex-direction: column;
gap: 10px;
}
.leaderboard-my-score-label {
color: #fcd34d;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.leaderboard-my-score-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.leaderboard-my-score-user {
color: #dbeafe;
font-size: 14px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-my-score-value {
color: #86efac;
font-size: clamp(22px, 2vw, 34px);
font-weight: 1000;
letter-spacing: -0.04em;
}
.leaderboard-list {
margin-top: 18px;
display: block;
}
.leaderboard-table-wrap {
width: 100%;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(8, 15, 35, 0.58);
}
.leaderboard-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.leaderboard-table thead th {
padding: 14px 16px;
text-align: left;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #fcd34d;
background: rgba(15, 23, 42, 0.76);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.leaderboard-table tbody td {
padding: 14px 16px;
border-top: 1px solid rgba(148, 163, 184, 0.08);
color: #e2e8f0;
font-size: 15px;
font-weight: 700;
vertical-align: middle;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-table tbody tr:first-child td {
border-top: none;
}
.leaderboard-table tbody tr.current-user td {
background:
radial-gradient(circle at 18% 0%, rgba(34, 197, 94, 0.12), transparent 22%),
rgba(8, 15, 35, 0.72);
}
.leaderboard-table .leaderboard-rank-cell {
width: 96px;
color: #fbbf24;
font-weight: 1000;
}
.leaderboard-table .leaderboard-score-cell {
width: 120px;
color: #86efac;
font-weight: 1000;
text-align: right;
}
.leaderboard-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(8, 15, 35, 0.58);
}
.leaderboard-row.current-user {
border-color: rgba(34, 197, 94, 0.36);
background:
radial-gradient(circle at 18% 0%, rgba(34, 197, 94, 0.12), transparent 22%),
rgba(8, 15, 35, 0.72);
}
.leaderboard-rank {
width: 56px;
height: 56px;
display: grid;
place-items: center;
border-radius: 16px;
background: rgba(245, 158, 11, 0.12);
color: #fbbf24;
font-size: 20px;
font-weight: 1000;
}
.leaderboard-name {
min-width: 0;
color: #f8fafc;
font-size: 15px;
font-weight: 800;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.leaderboard-score {
color: #86efac;
font-size: 18px;
font-weight: 1000;
letter-spacing: -0.03em;
}
.leaderboard-empty {
padding: 18px 16px;
text-align: center;
color: #cbd5e1;
border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 18px;
background: rgba(8, 15, 35, 0.45);
}
.dialog-challenge {
border-color: rgba(14, 165, 233, 0.34);
box-shadow: 0 24px 80px rgba(14, 165, 233, 0.32);
text-align: center;
}
.dialog-challenge.dialog-challenge-polished {
position: relative;
width: min(90vw, 430px);
padding: clamp(20px, 2.4vw, 28px) clamp(18px, 2.2vw, 24px) clamp(20px, 2.4vw, 24px);
border-radius: 28px;
border: 1px solid rgba(50, 215, 255, 0.84);
background:
radial-gradient(circle at 50% 0%, rgba(56, 189, 248, 0.18), transparent 22%),
radial-gradient(circle at 50% 100%, rgba(2, 132, 199, 0.12), transparent 34%),
linear-gradient(180deg, rgba(12, 18, 35, 0.94), rgba(8, 13, 28, 0.94));
box-shadow:
0 0 0 1px rgba(56, 189, 248, 0.06) inset,
0 0 0 1px rgba(255, 255, 255, 0.02),
0 20px 80px rgba(2, 6, 23, 0.64),
0 0 26px rgba(56, 189, 248, 0.12);
text-align: center;
overflow: hidden;
}
.dialog-challenge.dialog-challenge-polished::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.52), rgba(59, 130, 246, 0.10), rgba(245, 158, 11, 0.22));
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.dialog-challenge.dialog-challenge-polished::after {
content: "";
position: absolute;
inset: 8px;
border-radius: 22px;
border: 1px solid rgba(255,255,255,0.03);
pointer-events: none;
}
.challenge-close-btn {
position: absolute;
top: 12px;
right: 12px;
width: 34px;
height: 34px;
border: 1px solid rgba(255,255,255,0.10);
border-radius: 50%;
background: rgba(11, 17, 34, 0.62);
color: rgba(255,255,255,0.88);
font-size: 28px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
z-index: 2;
}
.challenge-close-btn:hover {
border-color: rgba(255,255,255,0.22);
background: rgba(16, 22, 40, 0.76);
}
.challenge-hero {
display: flex;
justify-content: center;
margin-top: 2px;
margin-bottom: 8px;
position: relative;
z-index: 1;
}
.challenge-hero-ring {
width: 72px;
height: 72px;
border-radius: 50%;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.10), transparent 54%),
linear-gradient(180deg, rgba(16, 185, 255, 0.26), rgba(59, 130, 246, 0.20));
border: 2px solid rgba(56, 189, 248, 0.96);
box-shadow:
0 0 0 5px rgba(56, 189, 248, 0.08),
0 0 28px rgba(56, 189, 248, 0.36);
}
.challenge-hero-icon {
width: 52px;
height: 52px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(24, 38, 71, 0.92);
font-size: 30px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.06),
0 0 0 1px rgba(255,255,255,0.04);
}
.challenge-pill {
width: fit-content;
margin: 0 auto 10px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid rgba(56, 189, 248, 0.34);
background: rgba(11, 27, 48, 0.76);
color: #1ed5ff;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.09em;
text-transform: uppercase;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
position: relative;
z-index: 1;
}
.challenge-title {
position: relative;
z-index: 1;
margin: 0;
font-size: clamp(24px, 5.6vw, 34px);
line-height: 1.04;
font-weight: 1000;
letter-spacing: -0.04em;
color: #f4f7fb;
text-shadow: 0 4px 18px rgba(2, 6, 23, 0.5);
}
.challenge-subtitle {
position: relative;
z-index: 1;
margin: 8px auto 0;
width: min(100%, 30ch);
color: #39aef8;
font-size: clamp(13px, 3.8vw, 16px);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.22;
}
.challenge-divider {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 12px 0 14px;
}
.challenge-divider span {
width: 34px;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(56, 189, 248, 0.55), transparent);
}
.challenge-divider i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #2fb5ff;
box-shadow: 0 0 12px rgba(47, 181, 255, 0.9);
}
.challenge-points {
position: relative;
z-index: 1;
display: grid;
gap: 10px;
width: 100%;
margin: 0 auto;
text-align: left;
}
.challenge-point {
display: flex;
align-items: center;
gap: 10px;
color: #e7eef8;
font-size: clamp(12px, 3.2vw, 14px);
line-height: 1.3;
}
.challenge-point-icon,
.challenge-note-icon {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
flex: 0 0 auto;
color: #38bdf8;
border: 1px solid rgba(56, 189, 248, 0.35);
background: rgba(11, 27, 48, 0.85);
font-weight: 900;
}
.challenge-rewards {
position: relative;
z-index: 1;
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin: 14px auto 10px;
}
.challenge-reward {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
min-height: 38px;
border-radius: 999px;
font-size: clamp(11px, 3vw, 13px);
font-weight: 700;
letter-spacing: -0.01em;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
}
.challenge-reward.good {
color: #dfffe8;
border: 1px solid rgba(34, 197, 94, 0.34);
background: rgba(10, 42, 30, 0.74);
}
.challenge-reward.bad {
color: #ffd6d8;
border: 1px solid rgba(239, 68, 68, 0.34);
background: rgba(51, 15, 22, 0.74);
}
.challenge-reward-icon {
font-size: 16px;
line-height: 1;
}
.challenge-note {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0 auto 16px;
color: #dce6f2;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1.3;
}
.challenge-actions {
justify-content: center;
margin-top: 0;
}
.challenge-start-btn {
appearance: none;
border: 1px solid rgba(63, 224, 255, 0.42);
background: linear-gradient(90deg, #16d8c8 0%, #2fb1ff 100%);
color: #f9fdff;
border-radius: 24px;
min-width: 100%;
padding: 14px 20px;
min-height: 52px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
font-size: clamp(16px, 4vw, 20px);
font-weight: 900;
letter-spacing: -0.02em;
cursor: pointer;
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 0 18px rgba(16, 216, 200, 0.28),
0 16px 34px rgba(3, 105, 161, 0.32);
position: relative;
z-index: 1;
}
.challenge-start-btn:hover {
transform: translateY(-1px);
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 0 26px rgba(16, 216, 200, 0.36),
0 20px 42px rgba(3, 105, 161, 0.38);
}
.challenge-start-icon {
font-size: 0.96em;
}
}
.dialog-title {
font-size: 22px;
font-weight: 900;
letter-spacing: -0.02em;
color: #f8fafc;
}
.dialog-challenge .dialog-title {
color: #ef4444;
font-size: 24px;
text-align: center;
}
.dialog-text {
margin-top: 10px;
color: #cbd5e1;
font-size: 15px;
line-height: 1.5;
}
.dialog-challenge .dialog-text {
text-align: center;
margin-top: 12px;
color: #dbeafe;
font-size: 16px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
flex-wrap: wrap;
}
.dialog-button {
padding: 12px 18px;
border-radius: 999px;
border: none;
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.dialog-secondary {
background: rgba(51, 65, 85, 0.9);
color: #e2e8f0;
}
.dialog-primary {
background: linear-gradient(135deg, #fb7185 0%, #f97316 100%);
color: white;
box-shadow: 0 12px 30px rgba(249, 115, 22, 0.28);
}
.dialog-gameover .dialog-primary {
background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%);
box-shadow: 0 12px 30px rgba(14, 165, 233, 0.28);
}
.dialog-challenge .dialog-primary {
background: linear-gradient(135deg, #22c55e 0%, #0ea5e9 100%);
box-shadow: 0 12px 30px rgba(34, 197, 94, 0.24);
}
.dialog-button:hover {
transform: translateY(-1px);
}
.transition-card {
padding: 24px 28px;
border-radius: 22px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.88);
box-shadow: 0 20px 60px rgba(2, 6, 23, 0.5);
text-align: center;
min-width: min(320px, calc(100% - 48px));
}
.transition-title {
font-size: 26px;
font-weight: 900;
letter-spacing: -0.02em;
color: #f8fafc;
}
.transition-subtitle {
margin-top: 8px;
color: #cbd5e1;
font-size: 15px;
line-height: 1.45;
}
.preview-content {
text-align: center;
animation: previewFadeIn 0.35s ease;
}
@keyframes previewFadeIn {
from { opacity: 0; transform: scale(0.94); }
to { opacity: 1; transform: scale(1); }
}
.preview-countdown {
font-size: clamp(72px, 12vw, 132px);
font-weight: 900;
line-height: 1;
background: white;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: countPulse 1s ease infinite;
}
@keyframes countPulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.12); opacity: 0.86; }
100% { transform: scale(1); opacity: 1; }
}
.preview-subtitle {
margin-top: 10px;
text-transform: uppercase;
letter-spacing: 0.24em;
font-size: 13px;
font-weight: 700;
color: #cbd5e1;
}
.game-header {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(12px, 1.4vw, 16px);
z-index: 1;
}
.game-header-row {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: clamp(12px, 1.4vw, 16px);
}
.game-stats {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: clamp(8px, 1vw, 14px);
align-items: stretch;
min-width: 0;
}
.stat-block {
display: grid;
grid-template-columns: 44px 1fr;
align-items: center;
gap: 6px 10px;
min-width: 0;
padding: clamp(12px, 1.2vw, 16px) clamp(12px, 1.3vw, 18px);
border-radius: clamp(14px, 1.5vw, 18px);
border: 1px solid rgba(148,163,184,0.18);
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(10px);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04);
box-sizing: border-box;
}
.stat-icon {
grid-row: span 2;
width: clamp(34px, 3vw, 44px);
height: clamp(34px, 3vw, 44px);
border-radius: clamp(11px, 1.2vw, 14px);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: clamp(16px, 1.6vw, 20px);
background: rgba(255,255,255,0.08);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
}
.stat-label {
font-size: clamp(9px, 0.85vw, 11px);
font-weight: 800;
color: #7c8599;
letter-spacing: 0.18em;
text-transform: uppercase;
line-height: 1;
}
.stat-value {
font-size: clamp(16px, 1.5vw, 20px);
font-weight: 900;
color: #f8fafc;
margin-top: -1px;
line-height: 1.1;
grid-column: 2;
}
.stat-level { border-color: rgba(34,197,94,0.28); box-shadow: 0 0 0 1px rgba(34,197,94,0.08) inset; }
.stat-level .stat-icon { background: linear-gradient(135deg, rgba(74,222,128,0.18), rgba(34,197,94,0.16)); color: #86efac; }
.stat-moves { border-color: rgba(59,130,246,0.24); }
.stat-moves .stat-icon { background: linear-gradient(135deg, rgba(96,165,250,0.18), rgba(14,165,233,0.16)); color: #7dd3fc; }
.stat-matches { border-color: rgba(168,85,247,0.24); }
.stat-matches .stat-icon { background: linear-gradient(135deg, rgba(192,132,252,0.18), rgba(139,92,246,0.16)); color: #c4b5fd; }
.stat-score { border-color: rgba(245,158,11,0.24); }
.stat-score .stat-icon { background: linear-gradient(135deg, rgba(251,191,36,0.18), rgba(245,158,11,0.16)); color: #fbbf24; }
.stat-lives { border-color: rgba(248,113,113,0.24); }
.stat-lives .stat-icon { background: linear-gradient(135deg, rgba(248,113,113,0.18), rgba(239,68,68,0.16)); color: #fca5a5; }
.game-state-badge {
justify-self: end;
align-self: center;
padding: 8px 14px 9px;
border-radius: 999px;
border: 1px solid rgba(251, 191, 36, 0.35);
background: rgba(251, 191, 36, 0.12);
color: #fbbf24;
font-size: clamp(11px, 0.9vw, 12px);
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
white-space: nowrap;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1;
gap: 4px;
min-width: 92px;
}
.game-state-badge .badge-main {
font-size: 0.95em;
line-height: 1;
}
.game-state-badge .badge-sub {
font-size: 0.8em;
letter-spacing: 0.08em;
line-height: 1;
color: #fff7cc;
text-shadow: 0 0 8px rgba(253, 230, 138, 0.18);
}
.game-state-badge.playing {
border-color: rgba(52, 211, 153, 0.35);
background: rgba(52, 211, 153, 0.12);
color: #34d399;
}
.game-state-badge.complete {
border-color: rgba(250, 204, 21, 0.38);
background: rgba(250, 204, 21, 0.16);
color: #6CF527;
text-shadow: 0 0 10px rgba(253, 230, 138, 0.24);
}
.game-state-badge.gameover {
border-color: rgba(248, 113, 113, 0.35);
background: rgba(248, 113, 113, 0.14);
color: #f87171;
}
.level-meta {
width: 100%;
display: flex;
flex-direction: column;
gap: clamp(8px, 1vw, 10px);
z-index: 1;
}
.level-title {
font-size: clamp(26px, 3vw, 38px);
font-weight: 900;
letter-spacing: -0.03em;
color: #fff;
}
.level-personality {
color: #f8fafc;
opacity: 0.9;
font-size: clamp(13px, 1.1vw, 15px);
font-weight: 700;
letter-spacing: 0.02em;
}
.level-chips {
display: flex;
flex-wrap: nowrap;
gap: 10px;
min-width: 0;
}
.theme-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 7px 12px;
font-size: clamp(10px, 0.85vw, 12px);
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
min-width: 0;
}
.theme-chip {
background: rgba(14, 165, 233, 0.15);
color: #7dd3fc;
border: 1px solid rgba(14, 165, 233, 0.26);
}
.level-focus {
color: #cbd5e1;
font-size: clamp(13px, 1.1vw, 15px);
line-height: 1.5;
max-width: 72ch;
}
.memory-grid {
--grid-gap: clamp(8px, 1vw, 16px);
display: grid;
grid-template-columns: repeat(var(--grid-cols, 2), minmax(0, var(--card-fit-size, var(--card-size, 150px))));
gap: var(--grid-gap);
width: fit-content;
max-width: 100%;
z-index: 1;
margin-top: 0;
justify-content: center;
align-content: start;
}
.game-layout {
width: 100%;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: clamp(14px, 1.8vw, 24px);
align-items: start;
overflow: visible;
}
.level-panel {
display: flex;
justify-content: center;
}
.challenge-panel {
display: none;
justify-content: center;
align-items: stretch;
width: 100%;
}
.memory-container.challenge-mode .challenge-panel {
display: flex;
}
.challenge-card {
width: min(100%, 920px);
padding: clamp(18px, 2vw, 28px);
border-radius: 24px;
border: 1px solid rgba(96, 165, 250, 0.26);
background:
radial-gradient(circle at 20% 10%, rgba(14, 165, 233, 0.14), transparent 34%),
linear-gradient(180deg, rgba(15, 23, 42, 0.88), rgba(2, 6, 23, 0.92));
box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 24px 70px rgba(2, 6, 23, 0.48);
display: flex;
flex-direction: column;
gap: 14px;
}
.challenge-label {
font-size: 12px;
font-weight: 900;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #7dd3fc;
}
.challenge-theme {
color: #f8fafc;
font-size: clamp(14px, 1.25vw, 18px);
font-weight: 800;
letter-spacing: 0.02em;
}
.challenge-question {
color: #f8fafc;
font-size: clamp(22px, 2.3vw, 34px);
line-height: 1.25;
font-weight: 900;
letter-spacing: -0.03em;
}
.challenge-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.challenge-option {
appearance: none;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(15, 23, 42, 0.78);
color: #f8fafc;
border-radius: 18px;
padding: 16px 18px;
min-height: 70px;
font-size: clamp(16px, 1.4vw, 20px);
font-weight: 800;
letter-spacing: 0.01em;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
}
.challenge-option:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(125, 211, 252, 0.45);
}
.challenge-option:disabled {
cursor: default;
opacity: 0.72;
}
.challenge-option.correct-choice {
border-color: rgba(34, 197, 94, 0.75);
background: linear-gradient(135deg, rgba(22, 163, 74, 0.24), rgba(34, 197, 94, 0.34));
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.16) inset, 0 0 26px rgba(34, 197, 94, 0.28);
}
.challenge-option.wrong-choice {
border-color: rgba(248, 113, 113, 0.75);
background: linear-gradient(135deg, rgba(185, 28, 28, 0.22), rgba(248, 113, 113, 0.34));
box-shadow: 0 0 0 1px rgba(248, 113, 113, 0.16) inset, 0 0 26px rgba(248, 113, 113, 0.22);
}
.challenge-option.reveal-correct {
border-color: rgba(250, 204, 21, 0.7);
background: linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(245, 158, 11, 0.28));
box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.14) inset, 0 0 22px rgba(250, 204, 21, 0.24);
}
.challenge-focus {
color: #cbd5e1;
font-size: 14px;
line-height: 1.45;
}
.game-state-badge.challenge {
border-color: rgba(14, 165, 233, 0.35);
background: rgba(14, 165, 233, 0.12);
color: #7dd3fc;
}
.cards-panel {
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 0;
position: relative;
z-index: 1;
width: 100%;
}
.memory-card {
width: var(--card-fit-size, var(--card-size, 150px));
aspect-ratio: 1 / 1;
height: auto;
perspective: 1200px;
cursor: pointer;
user-select: none;
transform-origin: center center;
}
.memory-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.55s cubic-bezier(0.2, 0.8, 0.2, 1);
transform-style: preserve-3d;
}
.memory-card.flipped .memory-inner {
transform: rotateY(180deg);
}
.memory-front,
.memory-back {
position: absolute;
inset: 0;
border-radius: clamp(18px, 1.8vw, 24px);
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(32px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.34), 64px);
box-shadow: 0 18px 30px rgba(2, 6, 23, 0.32);
}
.memory-front {
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.35), transparent 24%),
linear-gradient(135deg, #334155 0%, #6366f1 55%, #8b5cf6 100%);
color: white;
border: 1px solid rgba(255,255,255,0.1);
overflow: hidden;
}
.memory-container.challenge-mode .memory-front {
background:
radial-gradient(circle at 20% 20%, rgba(255, 237, 213, 0.38), transparent 24%),
radial-gradient(circle at 80% 18%, rgba(255, 107, 53, 0.26), transparent 26%),
linear-gradient(135deg, #7f1d1d 0%, #dc2626 52%, #fb923c 100%);
border-color: rgba(251, 146, 60, 0.24);
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.08),
0 0 0 1px rgba(251, 146, 60, 0.08),
0 18px 30px rgba(69, 10, 10, 0.36);
}
.memory-front-logo {
width: clamp(34px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.38), 76px);
height: auto;
object-fit: contain;
display: block;
filter: drop-shadow(0 0 12px rgba(255, 255, 255, 0.22));
}
.memory-back {
background:
radial-gradient(circle at 25% 20%, rgba(255,255,255,0.38), transparent 22%),
linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
transform: rotateY(180deg);
color: #0f172a;
}
.memory-card.text-card .memory-back {
color: #020617;
font-size: clamp(12px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.19), 28px);
font-weight: 800;
line-height: 1.12;
letter-spacing: -0.02em;
text-align: center;
padding: clamp(8px, 1vw, 14px);
box-sizing: border-box;
overflow-wrap: anywhere;
word-break: break-word;
hyphens: auto;
}
.memory-card:hover:not(.preview-locked):not(.matched) .memory-front {
transform: scale(1.025);
}
.memory-card.matched {
cursor: default;
pointer-events: none;
}
.memory-card.matched .memory-back {
background:
radial-gradient(circle at 25% 20%, rgba(255,255,255,0.42), transparent 22%),
linear-gradient(135deg, #bbf7d0 0%, #86efac 100%);
animation: matchGlow 0.8s ease;
}
@keyframes matchGlow {
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.4); }
60% { box-shadow: 0 0 0 14px rgba(52,211,153,0); }
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
}
.memory-card.wrong .memory-back {
background:
radial-gradient(circle at 25% 20%, rgba(255,255,255,0.24), transparent 24%),
linear-gradient(135deg, #fca5a5 0%, #fb7185 100%);
animation: shake 0.45s ease;
}
@keyframes shake {
0%, 100% { transform: rotateY(180deg) translateX(0); }
20% { transform: rotateY(180deg) translateX(-7px); }
40% { transform: rotateY(180deg) translateX(7px); }
60% { transform: rotateY(180deg) translateX(-4px); }
80% { transform: rotateY(180deg) translateX(4px); }
}
.memory-card.preview-locked {
cursor: default;
pointer-events: none;
}
.memory-card.hint-peek .memory-back {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
box-shadow: 0 0 0 8px rgba(250, 204, 21, 0.2);
}
.game-status {
width: 100%;
margin-top: 0;
padding: clamp(14px, 1.4vw, 18px) clamp(16px, 1.8vw, 22px);
background: rgba(15, 23, 42, 0.55);
border-radius: clamp(16px, 1.5vw, 18px);
border: 1px solid rgba(148, 163, 184, 0.14);
color: white;
backdrop-filter: blur(8px);
z-index: 1;
box-sizing: border-box;
}
.game-status.show {
border-color: rgba(250, 204, 21, 0.34);
background: rgba(113, 63, 18, 0.45);
}
.game-status.error {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(127, 29, 29, 0.45);
}
.game-status.win {
border-color: rgba(52, 211, 153, 0.34);
background: rgba(6, 78, 59, 0.45);
}
.status-text {
font-size: clamp(15px, 1.3vw, 18px);
font-weight: 800;
}
.status-subtext {
margin-top: 6px;
color: #cbd5e1;
font-size: clamp(12px, 1vw, 14px);
line-height: 1.45;
}
.performance-meter-shell {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
padding: 18px 18px 20px;
border-radius: 30px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(7, 11, 24, 0.36);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 20px 40px rgba(2, 6, 23, 0.22);
box-sizing: border-box;
}
.performance-meter-title {
text-align: center;
font-size: 14px;
font-weight: 900;
letter-spacing: 0.20em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.58);
line-height: 1;
}
.meter-card {
width: 100%;
padding: 18px 24px;
border-radius: 999px;
background: rgba(8, 12, 23, 0.78);
border: 1px solid rgba(255,255,255,0.06);
box-shadow:
0 18px 45px rgba(0, 0, 0, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 20px;
box-sizing: border-box;
min-width: 0;
}
.side-label {
display: flex;
align-items: center;
gap: 10px;
font-size: clamp(15px, 1.65vw, 22px);
font-weight: 900;
white-space: nowrap;
flex: 0 0 auto;
min-width: max-content;
}
.easy {
color: #4ced7f;
}
.challenge {
color: #ff684f;
}
.icon-box {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 16px;
font-size: 24px;
box-shadow:
inset 0 1px 0 rgba(255,255,255,0.16),
0 0 0 1px rgba(255,255,255,0.05);
}
.easy .icon-box {
background: rgba(34,197,94,0.14);
}
.challenge .icon-box {
background: rgba(239,68,68,0.14);
}
.meter {
--meter-value: 0%;
--meter-side-padding: 18px;
--meter-knob-size: 42px;
--knob-color: #17a34a;
--knob-glow: rgba(23, 163, 74, 0.65);
position: relative;
width: 100%;
min-width: 0;
height: 28px;
padding-inline: var(--meter-side-padding);
box-sizing: border-box;
border-radius: 999px;
background: linear-gradient(
90deg,
#17a34a 0%,
#97c51e 28%,
#ffd522 50%,
#ff981f 72%,
#ff2e25 100%
);
overflow: visible;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.26),
0 0 0 1px rgba(255,255,255,0.08),
0 0 24px rgba(255, 147, 31, 0.18);
}
.meter::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
180deg,
rgba(255,255,255,0.38),
rgba(255,255,255,0.02)
);
pointer-events: none;
}
.ticks {
position: absolute;
inset: 0 var(--meter-side-padding);
display: flex;
justify-content: space-between;
align-items: center;
pointer-events: none;
}
.ticks span {
width: 3px;
height: 12px;
border-radius: 99px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 1px 3px rgba(0,0,0,0.18);
}
.meter-fill-spark {
position: absolute;
top: 0;
left: 0;
width: var(--meter-value);
height: 100%;
border-radius: inherit;
background: linear-gradient(
90deg,
rgba(255,255,255,0.04),
rgba(255,255,255,0.35),
rgba(255,255,255,0.06)
);
transition: width 720ms cubic-bezier(.2, .9, .2, 1.15);
pointer-events: none;
}
.knob {
position: absolute;
top: 50%;
left: clamp(
var(--meter-side-padding),
calc(var(--meter-side-padding) + var(--meter-value)),
calc(100% - var(--meter-side-padding))
);
width: var(--meter-knob-size);
height: var(--meter-knob-size);
transform: translate(-50%, -50%);
border-radius: 50%;
z-index: 5;
display: grid;
place-items: center;
transition:
left 720ms cubic-bezier(.2, .9, .2, 1.15),
transform 180ms ease;
filter:
drop-shadow(0 8px 14px rgba(0, 0, 0, 0.24))
drop-shadow(0 0 18px var(--knob-glow));
}
.knob::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
padding: 3px;
background:
conic-gradient(
from 120deg,
rgba(255,255,255,0.95),
var(--knob-color),
rgba(255,255,255,0.95),
var(--knob-color),
rgba(255,255,255,0.95)
);
box-shadow:
0 0 10px var(--knob-glow),
0 0 20px var(--knob-glow),
inset 0 0 8px rgba(255,255,255,0.9);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
animation: neonSpin 1.4s linear infinite;
}
@keyframes neonSpin {
from {
rotate: 0deg;
}
to {
rotate: 360deg;
}
}
.knob-core {
position: relative;
width: calc(var(--meter-knob-size) * 0.76);
height: calc(var(--meter-knob-size) * 0.76);
border-radius: 50%;
background: linear-gradient(180deg, #ffffff, #f3f4f6);
box-shadow:
inset 0 3px 7px rgba(255, 255, 255, 1),
inset 0 -5px 10px rgba(0, 0, 0, 0.08);
display: grid;
place-items: center;
}
.knob-core::after {
content: "";
width: calc(var(--meter-knob-size) * 0.07);
height: calc(var(--meter-knob-size) * 0.30);
border-radius: 99px;
background: var(--knob-color);
box-shadow:
0 0 10px var(--knob-glow),
0 0 18px var(--knob-glow);
transition:
background 720ms ease,
box-shadow 720ms ease;
}
.knob.bump {
animation: knobBump 420ms ease;
}
@keyframes knobBump {
0% {
transform: translate(-50%, -50%) scale(1);
}
35% {
transform: translate(-50%, -50%) scale(1.14);
}
65% {
transform: translate(-50%, -50%) scale(0.96);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
.meter.flash {
animation: meterFlash 520ms ease;
}
@keyframes meterFlash {
0% {
filter: brightness(1);
}
40% {
filter: brightness(1.25) saturate(1.28);
}
100% {
filter: brightness(1);
}
}
.controls-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
justify-content: center;
gap: clamp(10px, 1.2vw, 14px);
z-index: 1;
margin-top: 0;
width: min(100%, 760px);
margin-inline: auto;
box-sizing: border-box;
}
.memory-container:not(.game-playing) .controls-row { display: none !important; }
.memory-container.game-playing .controls-row { display: grid !important; }
.reset-button,
.hint-button,
.next-level-button {
width: 100%;
min-width: 0;
padding: clamp(10px, 1vw, 12px) clamp(14px, 1.4vw, 20px);
font-size: clamp(11px, 0.9vw, 13px);
font-weight: 900;
color: white;
border: none;
border-radius: 999px;
cursor: pointer;
transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.25s ease;
letter-spacing: 0.08em;
text-transform: uppercase;
}
#controls-row .reset-button,
#controls-row .reset-button:disabled {
background: linear-gradient(135deg, #ef4444 0%, #f97316 100%) !important;
box-shadow: 0 12px 30px rgba(239,68,68,0.32) !important;
color: #ffffff !important;
}
#controls-row .hint-button,
#controls-row .hint-button:disabled {
background: linear-gradient(135deg, #fde68a 0%, #facc15 100%) !important;
box-shadow: 0 10px 30px rgba(250, 204, 21, 0.25) !important;
color: #1e293b !important;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
line-height: 1;
}
.hint-prefix,
.hint-count {
display: inline-block;
}
.hint-count {
font-weight: 1000;
letter-spacing: 0.04em;
}
.hint-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 14px 36px rgba(250, 204, 21, 0.35);
}
#controls-row .next-level-button,
#controls-row .next-level-button:disabled {
background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%) !important;
box-shadow: 0 12px 30px rgba(14,165,233,0.28) !important;
color: #f5f8ff !important;
}
.reset-button:hover:not(:disabled),
.next-level-button:hover:not(:disabled) {
transform: translateY(-2px);
}
.reset-button:disabled,
.hint-button:disabled,
.next-level-button:disabled {
opacity: 0.9;
cursor: not-allowed;
filter: saturate(0.95) brightness(0.96);
}
.hud-glow-peek,
.hud-glow-lives,
.hud-glow-lives-gain {
animation: hudPulseGlow 0.8s ease-out;
}
.hud-glow-peek {
text-shadow:
0 0 0 rgba(134, 239, 172, 0),
0 0 10px rgba(34, 197, 94, 0.65),
0 0 18px rgba(134, 239, 172, 0.75),
0 0 28px rgba(34, 197, 94, 0.45);
box-shadow:
0 0 0 1px rgba(34, 197, 94, 0.15) inset,
0 0 18px rgba(34, 197, 94, 0.24),
0 0 30px rgba(134, 239, 172, 0.14);
}
.hud-glow-lives {
text-shadow:
0 0 0 rgba(248, 113, 113, 0),
0 0 10px rgba(248, 113, 113, 0.68),
0 0 18px rgba(252, 165, 165, 0.75),
0 0 28px rgba(248, 113, 113, 0.46);
box-shadow:
0 0 0 1px rgba(248, 113, 113, 0.15) inset,
0 0 18px rgba(248, 113, 113, 0.24),
0 0 30px rgba(252, 165, 165, 0.14);
}
.hud-glow-lives-gain {
text-shadow:
0 0 0 rgba(74, 222, 128, 0),
0 0 10px rgba(34, 197, 94, 0.68),
0 0 18px rgba(134, 239, 172, 0.78),
0 0 28px rgba(74, 222, 128, 0.46);
box-shadow:
0 0 0 1px rgba(34, 197, 94, 0.15) inset,
0 0 18px rgba(34, 197, 94, 0.24),
0 0 30px rgba(134, 239, 172, 0.14);
}
@keyframes hudPulseGlow {
0% {
filter: brightness(1);
transform: translateY(0) scale(1);
}
35% {
filter: brightness(1.18);
transform: translateY(-1px) scale(1.015);
}
100% {
filter: brightness(1);
transform: translateY(0) scale(1);
}
}
@media (max-width: 900px) {
.board-frame {
padding: 14px;
gap: 14px;
--board-frame-padding: 14px;
}
.performance-meter-shell {
padding: 12px 12px 14px;
gap: 8px;
}
.performance-meter-title {
font-size: 11px;
}
.meter-card {
padding: 15px 18px;
gap: 14px;
}
.game-stats {
gap: 8px;
}
.dialog-gameover {
width: min(88vw, 660px);
min-height: min(50dvh, 500px);
padding: clamp(20px, 2.2vw, 30px) clamp(18px, 2.4vw, 34px);
border-radius: 28px;
gap: 12px;
}
.gameover-icon-wrap {
width: 88px;
height: 88px;
}
.gameover-icon {
font-size: 42px;
}
.gameover-title {
font-size: clamp(38px, 5vw, 60px);
}
.gameover-subtitle {
font-size: clamp(15px, 1.7vw, 22px);
}
.gameover-score-pill {
padding: 13px 16px;
gap: 12px;
}
.gameover-score-icon {
font-size: 30px;
}
.gameover-score-label {
font-size: 18px;
}
.gameover-score-value {
font-size: 40px;
}
.dialog-challenge.dialog-challenge-polished {
width: min(94vw, 560px);
padding: 24px 18px 22px;
border-radius: 28px;
}
.dialog-howto {
width: min(98vw, 1200px);
max-height: min(94dvh, 900px);
padding: 18px;
overflow-y: auto;
overscroll-behavior: contain;
}
.howto-close-btn {
width: 50px;
height: 50px;
font-size: 34px;
top: 12px;
right: 12px;
}
.howto-logo {
width: 30px;
height: 30px;
}
.howto-headline {
font-size: clamp(17px, 3.4vw, 24px);
}
.howto-steps {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 8px;
}
.howto-step-arrow {
display: grid;
font-size: clamp(20px, 3vw, 28px);
}
.howto-main {
grid-template-columns: 1fr;
gap: 10px;
}
.howto-step-card {
min-height: 96px;
padding: 9px 10px;
gap: 8px;
flex-direction: column;
justify-content: flex-start;
align-items: center;
text-align: center;
}
.howto-step-icon {
width: 48px;
height: 48px;
font-size: 24px;
}
.howto-step-title {
font-size: 12px;
}
.howto-step-text {
font-size: 9.5px;
line-height: 1.16;
text-align: center;
max-width: 12ch;
}
.howto-step-copy {
display: flex;
flex-direction: column;
align-items: center;
}
.howto-left,
.howto-right {
padding: 9px;
border-radius: 16px;
}
.howto-rule {
padding: 7px 8px;
gap: 7px;
border-radius: 12px;
}
.howto-rule-icon {
width: 32px;
height: 32px;
font-size: 16px;
}
.howto-rule-title {
font-size: 10px;
}
.howto-rule-text {
font-size: 9px;
line-height: 1.16;
}
.howto-panel-title {
font-size: 11px;
margin: 1px 0 8px;
}
.howto-rewards-table {
font-size: 9px;
line-height: 1.16;
table-layout: fixed;
width: 100%;
}
.howto-rewards-table th,
.howto-rewards-table td {
padding: 6px 6px;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.howto-rewards-table th {
font-size: 7px;
letter-spacing: 0.08em;
}
.howto-rewards-table td:last-child {
width: 38%;
}
.howto-secondary-btn {
width: min(100%, 220px);
padding: 12px 16px;
font-size: 13px;
border-radius: 14px;
}
.dialog-leaderboard {
width: min(98vw, 1200px);
max-height: min(94dvh, 900px);
padding: 18px;
overflow-y: auto;
overscroll-behavior: contain;
}
.leaderboard-row {
grid-template-columns: 58px minmax(0, 1fr) auto;
padding: 10px 12px;
gap: 10px;
}
.leaderboard-rank {
width: 46px;
height: 46px;
border-radius: 14px;
font-size: 16px;
}
.leaderboard-name {
font-size: 13px;
}
.leaderboard-score {
font-size: 16px;
}
.challenge-close-btn {
top: 14px;
right: 14px;
width: 38px;
height: 38px;
font-size: 24px;
}
.challenge-hero-ring {
width: 82px;
height: 82px;
}
.challenge-hero-icon {
width: 60px;
height: 60px;
font-size: 34px;
}
.challenge-pill {
margin-bottom: 12px;
padding: 8px 14px;
font-size: 12px;
}
.challenge-title {
font-size: clamp(28px, 5.5vw, 40px);
}
.challenge-subtitle {
font-size: clamp(15px, 3.4vw, 20px);
max-width: 34ch;
}
.challenge-divider {
margin: 14px 0 16px;
}
.challenge-points {
gap: 10px;
width: min(100%, 360px);
}
.challenge-point {
gap: 10px;
font-size: 14px;
}
.challenge-point-icon,
.challenge-note-icon {
width: 26px;
height: 26px;
}
.challenge-rewards {
gap: 10px;
margin: 18px auto 14px;
}
.challenge-reward {
min-height: 48px;
padding: 10px 14px;
font-size: 14px;
}
.challenge-note {
margin-bottom: 20px;
font-size: 13px;
gap: 10px;
}
.challenge-start-btn {
min-width: min(100%, 440px);
min-height: 64px;
padding: 16px 22px;
font-size: clamp(18px, 4.4vw, 24px);
border-radius: 20px;
}
}
@media (max-width: 820px) {
.memory-container {
padding: 8px;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
gap: 8px;
border-radius: 16px;
}
.memory-container:not(.game-playing) .board-frame {
display: none !important;
}
.game-brand {
width: 100%;
justify-content: center;
gap: 10px;
padding-top: 2px;
}
.game-brand-mark {
width: 48px;
height: 48px;
border-radius: 14px;
}
.game-brand-title {
font-size: clamp(22px, 6vw, 30px);
}
.game-brand-subtitle {
font-size: 12px;
line-height: 1.35;
max-width: 44ch;
}
.board-frame {
padding: 12px;
padding-bottom: 18px;
border-radius: 18px;
gap: 12px;
--board-frame-padding: 12px;
}
.performance-meter-shell {
padding: 10px 10px 12px;
gap: 8px;
}
.performance-meter-title {
font-size: 9px;
letter-spacing: 0.16em;
}
#status-layout,
.game-layout,
.level-panel,
.cards-panel {
flex: 0 0 auto;
}
.game-header-row {
grid-template-columns: 1fr;
gap: 6px;
}
.game-stats {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.meter-card {
padding: 11px 12px;
gap: 10px;
border-radius: 28px;
}
.meter {
--meter-side-padding: 10px;
--meter-knob-size: 32px;
height: 22px;
}
.ticks span {
height: 9px;
}
.side-label {
font-size: 8px;
gap: 5px;
}
.icon-box {
width: 16px;
height: 16px;
border-radius: 6px;
font-size: 9px;
}
.game-state-badge {
display: inline-flex;
grid-column: 1;
justify-self: center;
align-self: start;
margin-top: 2px;
padding: 6px 10px;
min-width: 0;
width: fit-content;
max-width: 100%;
flex-direction: row;
gap: 6px;
white-space: nowrap;
letter-spacing: 0.12em;
font-size: 10px;
}
.stat-block {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
justify-items: center;
align-items: center;
text-align: center;
gap: 1px;
padding: 7px 4px 6px;
}
.stat-icon {
width: 22px;
height: 22px;
font-size: 11px;
grid-row: 1;
grid-column: 1;
justify-self: center;
}
.stat-label {
grid-column: 1;
grid-row: 2;
align-self: center;
letter-spacing: 0.08em;
font-size: 6px;
line-height: 1;
}
.stat-value {
grid-column: 1;
grid-row: 3;
align-self: center;
margin-top: 0;
font-size: 10px;
line-height: 1;
}
.memory-grid {
--grid-gap: 10px;
width: 100%;
}
.game-layout {
gap: 12px;
}
.start-overlay {
position: relative;
inset: auto;
width: 100%;
height: auto;
min-height: auto;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: visible;
}
.level-panel {
justify-content: stretch;
}
.level-chips {
flex-wrap: wrap;
}
.start-card {
width: min(100%, calc(100vw - 24px));
padding: 20px 18px 18px;
border-radius: 22px;
}
.start-key-row {
margin-top: 12px;
}
.top-utility-bar {
gap: 8px;
}
.top-utility-bar .utility-button,
.top-utility-bar .music-toggle-button {
padding: 9px 12px;
font-size: 12px;
border-radius: 16px;
}
.top-utility-bar .utility-button {
min-width: 92px;
}
.top-utility-bar .music-toggle-button {
min-width: 150px;
}
.top-utility-bar .high-score-pill {
min-width: 160px;
flex: 1 1 160px;
}
.start-utility-row {
width: min(100%, 680px);
gap: 8px;
}
.start-utility-row .start-help-button,
.start-utility-row .music-toggle-button {
min-width: 0;
flex: 1 1 0;
padding: 14px 16px;
font-size: 14px;
}
.start-utility-row .leaderboard-button,
.start-utility-row .high-score-pill {
min-width: 0;
flex: 1 1 0;
padding: 14px 16px;
font-size: 14px;
}
.start-utility-row .start-help-icon,
.start-utility-row .music-toggle-icon {
width: 28px;
height: 28px;
font-size: 16px;
}
.start-utility-row .leaderboard-icon,
.start-utility-row .high-score-icon {
width: 28px;
height: 28px;
font-size: 16px;
}
.start-utility-row .high-score-copy {
gap: 2px;
}
.start-utility-row .high-score-label {
font-size: 8px;
letter-spacing: 0.16em;
}
.start-utility-row .high-score-user {
font-size: 15px;
font-weight: 1000;
max-width: 12ch;
}
.start-utility-row .high-score-value {
font-size: 22px;
}
.cards-panel {
width: 100%;
}
.controls-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-top: 2px;
width: min(100%, 560px);
margin-inline: auto;
}
.next-level-button {
grid-column: auto;
justify-self: stretch;
width: 100%;
}
.reset-button,
.hint-button,
.next-level-button {
padding: 10px 10px;
font-size: 10px;
letter-spacing: 0.06em;
}
.hint-button {
gap: 6px;
}
.level-title {
font-size: clamp(22px, 6vw, 30px);
}
.level-personality {
font-size: 13px;
}
.level-focus {
font-size: 14px;
}
.status-text {
font-size: 15px;
}
.status-subtext {
font-size: 12px;
}
.memory-front,
.memory-back {
border-radius: 18px;
}
.start-stage {
width: 100%;
height: auto;
min-height: 0;
padding: 18px 18px 24px;
transform: none;
align-items: flex-start;
}
.start-hero {
width: min(100%, 680px);
gap: 14px;
}
.start-title {
font-size: clamp(40px, 11vw, 86px);
}
.start-tagline {
font-size: clamp(16px, 3vw, 24px);
}
.start-subtitle {
max-width: 28ch;
font-size: clamp(16px, 2.8vw, 24px);
}
.start-preview {
width: min(100%, 520px);
gap: 10px;
}
.start-help-button {
width: min(100%, 680px);
font-size: clamp(15px, 2.8vw, 20px);
padding: 14px 16px;
}
.start-button {
width: min(100%, 560px);
font-size: clamp(17px, 3.2vw, 24px);
padding: 16px 18px;
}
}
@media (max-width: 420px) {
.memory-container {
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.performance-meter-shell {
padding: 8px 8px 10px;
gap: 6px;
}
.performance-meter-title {
font-size: 8px;
letter-spacing: 0.14em;
}
.meter-card {
padding: 9px 10px;
gap: 8px;
border-radius: 24px;
}
.meter {
--meter-side-padding: 8px;
--meter-knob-size: 28px;
height: 20px;
}
.ticks span {
height: 8px;
}
.knob-core::after {
height: calc(var(--meter-knob-size) * 0.30);
}
.side-label {
font-size: 8px;
gap: 3px;
}
.icon-box {
width: 16px;
height: 16px;
border-radius: 5px;
font-size: 9px;
}
.game-brand {
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.game-brand-mark {
width: 42px;
height: 42px;
}
.game-brand-title {
font-size: 21px;
}
.game-brand-subtitle {
font-size: 11px;
line-height: 1.3;
max-width: 36ch;
}
.dialog-gameover {
width: min(92vw, 460px);
min-height: auto;
padding: 16px 14px 14px;
border-radius: 22px;
gap: 10px;
}
.gameover-icon-wrap {
width: 72px;
height: 72px;
}
.gameover-icon {
font-size: 34px;
}
.gameover-title {
font-size: clamp(30px, 10vw, 44px);
}
.gameover-divider {
width: min(100%, 180px);
gap: 6px;
}
.gameover-diamond {
font-size: 16px;
}
.gameover-subtitle {
font-size: 14px;
max-width: 24ch;
}
.gameover-score-pill {
padding: 11px 13px;
gap: 10px;
}
.gameover-score-icon {
font-size: 24px;
}
.gameover-score-label {
font-size: 14px;
}
.gameover-score-sep {
height: 22px;
}
.gameover-score-value {
font-size: 30px;
}
.start-utility-row {
width: min(100%, 560px);
gap: 8px;
}
.start-utility-row .start-help-button,
.start-utility-row .music-toggle-button {
padding: 13px 14px;
font-size: 13px;
border-radius: 20px;
}
.start-utility-row .start-help-icon,
.start-utility-row .music-toggle-icon {
width: 26px;
height: 26px;
font-size: 15px;
}
.top-utility-bar {
gap: 6px;
}
.top-utility-bar .utility-button,
.top-utility-bar .music-toggle-button {
padding: 8px 10px;
font-size: 11px;
border-radius: 16px;
}
.top-utility-bar .utility-button {
min-width: 84px;
}
.top-utility-bar .music-toggle-button {
min-width: 138px;
}
.top-utility-bar .high-score-pill {
min-width: 150px;
flex: 1 1 150px;
padding: 8px 10px;
}
.top-utility-bar .high-score-user {
max-width: 10ch;
}
.dialog-challenge.dialog-challenge-polished {
width: min(calc(100vw - 20px), 460px);
padding: 20px 14px 18px;
border-radius: 24px;
}
.challenge-close-btn {
top: 12px;
right: 12px;
width: 34px;
height: 34px;
font-size: 21px;
}
.challenge-hero {
margin-top: 0;
margin-bottom: 8px;
}
.challenge-hero-ring {
width: 74px;
height: 74px;
}
.challenge-hero-icon {
width: 54px;
height: 54px;
font-size: 30px;
}
.challenge-pill {
margin-bottom: 10px;
padding: 7px 12px;
font-size: 11px;
letter-spacing: 0.08em;
}
.challenge-title {
font-size: clamp(24px, 8vw, 34px);
}
.challenge-subtitle {
margin-top: 8px;
font-size: clamp(13px, 4.2vw, 16px);
max-width: 30ch;
}
.challenge-divider {
margin: 12px 0 14px;
gap: 8px;
}
.challenge-divider span {
width: 36px;
}
.challenge-points {
gap: 8px;
width: min(100%, 100%);
}
.challenge-point {
gap: 8px;
font-size: 13px;
}
.challenge-point-icon,
.challenge-note-icon {
width: 22px;
height: 22px;
font-size: 13px;
}
.challenge-rewards {
gap: 8px;
margin: 16px auto 12px;
}
.challenge-reward {
min-height: 42px;
padding: 9px 12px;
font-size: 12px;
}
.challenge-reward-icon {
font-size: 18px;
}
.challenge-note {
margin-bottom: 16px;
gap: 8px;
font-size: 12px;
}
.challenge-start-btn {
min-width: 100%;
min-height: 58px;
padding: 14px 18px;
font-size: 18px;
border-radius: 18px;
}
.dialog-card {
width: min(100%, calc(100vw - 24px));
padding: 18px 18px 16px;
border-radius: 20px;
}
#how-to-play-overlay {
padding: 8px;
}
.dialog-howto {
width: min(100%, calc(100vw - 16px));
max-height: calc(100dvh - 16px);
padding: 14px 12px 12px;
overflow-y: auto;
overscroll-behavior: contain;
}
.howto-logo-wrap {
margin: 0 0 2px;
}
.howto-logo {
width: 24px;
height: 24px;
}
.dialog-title {
font-size: 19px;
}
.dialog-text {
font-size: 13px;
}
.howto-kicker {
font-size: 12px;
letter-spacing: 0.14em;
}
.howto-headline {
font-size: clamp(15px, 4vw, 18px);
line-height: 1.08;
}
.howto-step-card {
padding: 8px 10px;
gap: 7px;
min-height: 0;
flex-direction: column;
justify-content: flex-start;
align-items: center;
text-align: center;
}
.howto-step-icon {
width: 36px;
height: 36px;
font-size: 18px;
}
.howto-step-title {
font-size: 11px;
}
.howto-step-text,
.howto-rule-text,
.howto-rewards-table {
font-size: 8.5px;
line-height: 1.15;
}
.howto-step-text {
text-align: center;
max-width: 13ch;
}
.howto-step-copy {
display: flex;
flex-direction: column;
align-items: center;
}
.howto-rewards-table {
width: 100%;
}
.howto-rule {
padding: 6px 7px;
gap: 6px;
}
.howto-rule-icon {
width: 28px;
height: 28px;
font-size: 15px;
}
.howto-rule-title {
font-size: 9px;
}
.howto-panel-title {
font-size: 10px;
margin: 1px 0 7px;
}
.howto-left,
.howto-right {
padding: 7px;
border-radius: 14px;
}
.howto-rewards-table th,
.howto-rewards-table td {
padding: 5px 4px;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.howto-actions {
justify-content: flex-end;
gap: 0;
margin-top: 6px;
}
.howto-secondary-btn {
width: min(100%, 136px);
padding: 7px 9px;
font-size: 9px;
}
.dialog-leaderboard {
width: min(100%, calc(100vw - 16px));
max-height: calc(100dvh - 16px);
padding: 14px 12px 12px;
overflow-y: auto;
overscroll-behavior: contain;
}
.leaderboard-logo-wrap {
margin: 0 0 2px;
}
.leaderboard-logo {
width: 24px;
height: 24px;
}
.leaderboard-headline {
font-size: clamp(15px, 4vw, 18px);
}
.leaderboard-my-score {
margin-top: 12px;
padding: 12px 14px;
gap: 8px;
}
.leaderboard-my-score-label {
font-size: 10px;
letter-spacing: 0.14em;
}
.leaderboard-my-score-user {
font-size: 11px;
}
.leaderboard-my-score-value {
font-size: 22px;
}
.leaderboard-list {
margin-top: 12px;
display: block;
}
.leaderboard-table thead th {
padding: 10px 10px;
font-size: 10px;
}
.leaderboard-table tbody td {
padding: 10px 10px;
font-size: 12px;
}
.leaderboard-name {
font-size: 11px;
}
.leaderboard-score {
font-size: 14px;
}
.start-stage {
width: 100%;
height: auto;
min-height: 0;
padding: 16px 12px 18px;
transform: none;
align-items: flex-start;
}
.start-hero {
width: min(100%, 560px);
gap: 12px;
}
.start-mark {
width: 54px;
height: 54px;
}
.start-mark-icon {
font-size: 28px;
}
.start-title {
font-size: clamp(34px, 15vw, 60px);
letter-spacing: -0.05em;
}
.start-tagline {
font-size: 15px;
gap: 0.25em 0.35em;
}
.start-subtitle {
max-width: 28ch;
font-size: 15px;
}
.start-preview {
width: min(100%, 320px);
gap: 8px;
}
.preview-card {
border-radius: 20px;
box-shadow:
0 8px 0 rgba(16, 185, 129, 0.48),
0 16px 24px rgba(0, 0, 0, 0.3);
}
.preview-emoji {
font-size: 30px;
}
.start-help-button {
width: 100%;
padding: 13px 14px;
font-size: 15px;
border-radius: 20px;
gap: 10px;
}
.start-help-icon {
width: 28px;
height: 28px;
font-size: 16px;
}
.start-utility-row {
width: 100%;
gap: 8px;
}
.start-utility-row .start-help-button,
.start-utility-row .music-toggle-button,
.start-utility-row .leaderboard-button,
.start-utility-row .high-score-pill {
flex: 1 1 100%;
width: 100%;
min-width: 0;
padding: 12px 14px;
font-size: 13px;
border-radius: 18px;
}
.start-utility-row .start-help-icon,
.start-utility-row .music-toggle-icon,
.start-utility-row .leaderboard-icon,
.start-utility-row .high-score-icon {
width: 24px;
height: 24px;
font-size: 14px;
}
.start-utility-row .high-score-copy {
gap: 1px;
}
.start-utility-row .high-score-label {
font-size: 8px;
letter-spacing: 0.18em;
}
.start-utility-row .high-score-user {
font-size: 12px;
font-weight: 900;
max-width: 12ch;
}
.start-utility-row .high-score-value {
font-size: 18px;
}
.section-title-row {
gap: 10px;
}
.section-title {
font-size: 14px;
}
.start-select {
padding: 16px 16px;
font-size: 15px;
border-radius: 16px;
}
.start-button {
width: 100%;
font-size: 16px;
padding: 14px 16px;
gap: 12px;
border-radius: 22px;
}
.start-button-icon {
font-size: 0.9em;
}
.start-note {
font-size: 11px;
line-height: 1.35;
text-align: center;
}
.start-footer-row {
grid-template-columns: 1fr;
width: min(100%, 560px);
gap: 8px;
}
.start-footer-item {
justify-content: center;
text-align: center;
padding: 8px 10px;
border-radius: 14px;
}
.start-footer-label {
font-size: 9px;
}
.start-footer-value {
font-size: 10px;
}
.game-stats {
gap: 3px;
}
.stat-block {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
justify-items: center;
align-items: center;
text-align: center;
gap: 1px;
padding: 6px 3px 5px;
}
.stat-icon {
width: 20px;
height: 20px;
font-size: 10px;
grid-row: 1;
grid-column: 1;
justify-self: center;
}
.stat-label {
letter-spacing: 0.1em;
font-size: 6px;
grid-column: 1;
grid-row: 2;
align-self: center;
line-height: 1;
}
.stat-value {
font-size: 10px;
grid-column: 1;
grid-row: 3;
margin-top: 0;
align-self: center;
line-height: 1;
}
.game-state-badge {
padding: 5px 8px;
font-size: 9px;
gap: 4px;
letter-spacing: 0.1em;
}
.game-state-badge .badge-sub {
display: none;
}
.controls-row {
grid-template-columns: 1fr;
width: min(100%, 520px);
margin-inline: auto;
}
.next-level-button {
width: 100%;
grid-column: auto;
}
.memory-grid {
--grid-gap: 6px;
}
.memory-front,
.memory-back {
border-radius: 16px;
}
}
"""
# =========================================================
# JAVASCRIPT
# =========================================================
JS = r"""
const PREVIEW_SECONDS = 4;
class MatchWiseApp {
constructor() {
this.root = element;
this.grid = element.querySelector('#memory-grid');
this.cardsPanel = element.querySelector('.cards-panel');
this.overlay = element.querySelector('#preview-overlay');
this.transitionOverlay = element.querySelector('#transition-overlay');
this.transitionTitle = element.querySelector('#transition-title');
this.transitionSubtitle = element.querySelector('#transition-subtitle');
this.performanceMeterEl = element.querySelector('#meter');
this.performanceMeterSparkEl = element.querySelector('#spark');
this.performanceMeterKnobEl = element.querySelector('#knob');
this.boardFrame = element.querySelector('#board-frame') || element.querySelector('.board-frame');
this.memoryContainer = element.querySelector('.memory-container') || element;
this.popupHost = element.querySelector('.game-layout') || this.memoryContainer;
this.topUtilityBar = element.querySelector('.top-utility-bar');
this.startUtilityRow = element.querySelector('.start-utility-row');
this.countdown = element.querySelector('#preview-countdown');
this.movesEl = element.querySelector('#moves-display');
this.matchesEl = element.querySelector('#matches-display');
this.scoreEl = element.querySelector('#score-display');
this.livesEl = element.querySelector('#lives-display');
this.levelEl = element.querySelector('#level-display');
this.themeChip = element.querySelector('#theme-chip');
this.controlsRow = element.querySelector('#controls-row');
this.startOverlay = element.querySelector('#start-overlay');
this.startGameBtn = element.querySelector('#start-game-btn');
this.startNote = element.querySelector('#start-note');
this.howToPlayBtn = element.querySelector('#how-to-play-btn');
this.howToPlayOverlay = element.querySelector('#how-to-play-overlay');
this.howToPlayCloseBtn = element.querySelector('#how-to-play-close-btn');
this.controlsRow = element.querySelector('#controls-row');
this.hintCountEl = element.querySelector('#hint-count');
this.stateBadge = element.querySelector('#game-state-badge');
this.statusEl = element.querySelector('#game-status-area');
this.statusTextEl = element.querySelector('#status-text');
this.statusSubtextEl = element.querySelector('#status-subtext');
this.resetBtn = element.querySelector('#reset-btn');
this.hintBtn = element.querySelector('#hint-btn');
this.nextBtn = element.querySelector('#next-btn');
this.resetConfirmOverlay = element.querySelector('#reset-confirm-overlay');
this.resetConfirmBtn = element.querySelector('#reset-confirm-btn');
this.resetCancelBtn = element.querySelector('#reset-cancel-btn');
this.gameOverOverlay = element.querySelector('#gameover-overlay');
this.gameOverScore = element.querySelector('#gameover-score');
this.gameOverCloseBtn = element.querySelector('#gameover-close-btn');
this.challengeIntroOverlay = element.querySelector('#challenge-intro-overlay');
this.challengeCloseBtn = element.querySelector('#challenge-close-btn');
this.challengeModalTitleEl = element.querySelector('#challenge-modal-title');
this.challengeModalMessageEl = element.querySelector('#challenge-modal-message');
this.challengeModalSubtitleEl = element.querySelector('#challenge-modal-subtitle');
this.challengeOkBtn = element.querySelector('#challenge-ok-btn');
this.challengePanel = element.querySelector('#challenge-panel');
this.challengeThemeEl = element.querySelector('#challenge-theme');
this.challengeQuestionEl = element.querySelector('#challenge-question');
this.challengeOptionsEl = element.querySelector('#challenge-options');
this.challengeFocusEl = element.querySelector('#challenge-focus');
this.leaderboardBtn = element.querySelector('#leaderboard-btn');
this.leaderboardOverlay = element.querySelector('#leaderboard-overlay');
this.leaderboardCloseBtn = element.querySelector('#leaderboard-close-btn');
this.leaderboardListEl = element.querySelector('#leaderboard-list');
this.leaderboardMyScoreUserEl = element.querySelector('#leaderboard-my-score-user');
this.leaderboardMyScoreValueEl = element.querySelector('#leaderboard-my-score-value');
this.highScoreLabelEl = element.querySelector('#high-score-label');
this.highScoreUserEl = element.querySelector('#high-score-user');
this.highScoreValueEl = element.querySelector('#high-score-value');
this.homeBtn = element.querySelector('#home-btn');
this.pageMode = this.grid ? 'level' : 'start';
this.sessionHash = this.getSessionHashFromUrl();
this.state = JSON.parse(props.state_json || '{}');
this.cards = Array.isArray(this.state.cards) ? [...this.state.cards] : [];
this.flipped = [];
this.matched = [];
const initialHints = Number.parseInt(props.hints ?? this.state.hints ?? '1', 10);
const initialMaxHints = Number.parseInt(props.max_hints ?? this.state.max_hints ?? '5', 10);
this.hints = Number.isNaN(initialHints) ? 1 : initialHints;
this.maxHints = Number.isNaN(initialMaxHints) ? 5 : initialMaxHints;
this.hinting = false;
this.hintedPairs = new Set();
this.moves = 0;
this.locked = false;
this.previewTimer = null;
this.previewShuffleTimer = null;
this.previewShuffleAnimationMs = 520;
this.previewFogTimers = [];
this.previewHideTimers = [];
this.previewCount = PREVIEW_SECONDS;
this.pendingTransition = false;
this.levelStartTime = null;
this.transitionShownAt = 0;
this.transitionHideTimer = null;
this.comboStreak = 0;
this.lastRenderedHints = null;
this.lastRenderedLives = null;
this.lastRenderedPerformanceMeter = null;
this.peekGlowTimer = null;
this.livesGlowTimer = null;
this.wrongMatchTimer = null;
this.distractorSparkleTimer = null;
this.distractorSparkleClearTimer = null;
this.recentlyFlippedCardIndexes = [];
this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0);
this.challengeStarted = false;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.challengeCorrectIndex = null;
this.challengeOptions = [];
this.leaderboardData = { entries: [], current_user: {} };
this.leaderboardRefreshTimer = null;
this.loginButtonObserver = null;
this.loginButtonRefreshEl = null;
this.musicToggleStartBtn = element.querySelector('#music-toggle-start-btn');
this.musicToggleTopBtn = element.querySelector('#music-toggle-top-btn');
this.musicToggleButtons = [];
this.musicEnabled = this.loadMusicPreference();
this.audioContext = null;
this.injectPopupRuntimeStyles();
this.ensurePopupRuntimeContainer();
this.installEvents();
this.installButtonHandlers();
this.syncMusicToggleButtons();
this.attachLoginButtonToTopBar();
requestAnimationFrame(() => this.attachLoginButtonToTopBar());
this.installLeaderboardRefreshHooks();
void this.refreshLeaderboardData();
this.installViewportHandlers();
this.hideResetConfirm();
this.hideGameOverModal();
this.stopDistractorSparkles();
this.hideHowToPlayModal();
if (this.pageMode === 'start') {
if (this.startNote) {
this.startNote.textContent = 'Press Start to generate your first level.';
}
return;
}
this.root.classList.add('level-loading');
if (this.memoryContainer && this.memoryContainer !== this.root) {
this.memoryContainer.classList.add('level-loading');
}
this.injectPopupRuntimeStyles();
this.ensurePopupRuntimeContainer();
void this.bootstrapLevelPage();
}
syncControlsVisibility() {
if (!this.controlsRow) return;
this.controlsRow.style.setProperty('display', this.state.game_started ? 'grid' : 'none', 'important');
}
resetComboFeedback() {
this.comboStreak = 0;
}
clearWrongMatchTimer() {
if (this.wrongMatchTimer) {
clearTimeout(this.wrongMatchTimer);
this.wrongMatchTimer = null;
}
}
getSessionHashFromUrl() {
try {
const url = new URL(window.location.href);
return String(url.searchParams.get('session_hash') || '').trim();
} catch (err) {
return '';
}
}
injectPopupRuntimeStyles() {
if (document.getElementById('pop-runtime-style')) {
return;
}
const style = document.createElement('style');
style.id = 'pop-runtime-style';
style.textContent = `
.pop-wrapper-runtime {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 999999;
width: 100%;
height: 100%;
overflow: visible;
display: flex;
align-items: center;
justify-content: center;
}
.pop-text-runtime {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0) rotate(-15deg);
opacity: 0;
font-family: Impact, "Arial Black", system-ui, sans-serif;
font-size: clamp(28px, 5.6vw, 72px);
font-weight: 900;
text-transform: uppercase;
letter-spacing: 1px;
white-space: pre-line;
line-height: 1;
text-align: center;
max-width: min(92vw, 720px);
-webkit-text-stroke: 3px #ffffff;
filter:
drop-shadow(0 4px 0px rgba(0, 0, 0, 0.45))
drop-shadow(0 10px 16px rgba(0, 0, 0, 0.35));
will-change: transform, opacity;
}
.pop-text-runtime.animate {
animation: popBounceRuntime 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.pop-good-runtime {
background: linear-gradient(to bottom, #ffffff 18%, #6CF527 58%, #15803d 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.pop-win-runtime {
background: linear-gradient(to bottom, #ffffff 18%, #ffe66d 42%, #fb5607 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.pop-bad-runtime {
background: linear-gradient(to bottom, #ffffff 18%, #f87171 58%, #dc2626 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.memory-card.distractor-sparkle .memory-front::after {
content: '✦';
position: absolute;
right: 9%;
top: 8%;
width: 28%;
height: 28%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: rgba(255, 255, 255, 0.95);
background: radial-gradient(circle, rgba(255,255,255,0.78), rgba(255,255,255,0.12) 58%, transparent 72%);
filter: drop-shadow(0 0 10px rgba(255,255,255,0.85));
font-size: clamp(14px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.18), 28px);
pointer-events: none;
animation: distractorSparkleRuntime 680ms ease-out forwards;
z-index: 4;
}
.memory-card.distractor-sparkle .memory-back::after {
content: '✦';
position: absolute;
left: 9%;
top: 8%;
width: 28%;
height: 28%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999px;
color: rgba(255, 255, 255, 0.95);
background: radial-gradient(circle, rgba(255,255,255,0.7), rgba(255,255,255,0.1) 58%, transparent 72%);
filter: drop-shadow(0 0 10px rgba(255,255,255,0.75));
font-size: clamp(14px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.16), 26px);
pointer-events: none;
animation: distractorSparkleRuntime 680ms ease-out forwards;
z-index: 4;
}
.memory-container.preview-fog-blink .memory-card.preview-locked .memory-back,
.matchwise-root.preview-fog-blink .memory-card.preview-locked .memory-back {
animation: previewFogBlinkRuntime 260ms ease-in-out forwards;
}
.memory-card.preview-hidden-card .memory-back {
animation: previewCardHideRuntime 620ms ease-in-out forwards;
}
.memory-card.preview-hidden-card::after {
content: '☁️';
position: absolute;
inset: 10%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 24px;
font-size: clamp(24px, calc(var(--card-fit-size, var(--card-size, 150px)) * 0.28), 46px);
background: radial-gradient(circle, rgba(255,255,255,0.95), rgba(255,255,255,0.62) 55%, rgba(255,255,255,0.14));
filter: drop-shadow(0 10px 18px rgba(132, 90, 255, 0.2));
pointer-events: none;
z-index: 8;
animation: previewCloudCoverRuntime 620ms ease-in-out forwards;
}
@keyframes distractorSparkleRuntime {
0% { transform: scale(0.35) rotate(-18deg); opacity: 0; }
28% { transform: scale(1.18) rotate(10deg); opacity: 1; }
100% { transform: scale(0.65) rotate(24deg); opacity: 0; }
}
@keyframes previewFogBlinkRuntime {
0% { filter: blur(0px) brightness(1); opacity: 1; }
45% { filter: blur(5px) brightness(0.94); opacity: 0.72; }
100% { filter: blur(0px) brightness(1); opacity: 1; }
}
@keyframes previewCardHideRuntime {
0% { filter: blur(0px); opacity: 1; }
18% { filter: blur(3px); opacity: 0.18; }
82% { filter: blur(3px); opacity: 0.18; }
100% { filter: blur(0px); opacity: 1; }
}
@keyframes previewCloudCoverRuntime {
0% { transform: scale(0.86); opacity: 0; }
18% { transform: scale(1); opacity: 1; }
82% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.06); opacity: 0; }
}
@keyframes popBounceRuntime {
0% {
transform: translate(-50%, -50%) scale(0) rotate(-15deg);
opacity: 0;
}
15% {
transform: translate(-50%, -50%) scale(1.35) rotate(5deg);
opacity: 1;
}
30% {
transform: translate(-50%, -50%) scale(0.92) rotate(-4deg);
opacity: 1;
}
45% {
transform: translate(-50%, -50%) scale(1.08) rotate(3deg);
opacity: 1;
}
60% {
transform: translate(-50%, -50%) scale(0.98) rotate(-1deg);
opacity: 1;
}
72% {
transform: translate(-50%, -50%) scale(1) rotate(0deg);
opacity: 1;
}
100% {
transform: translate(-50%, -85%) scale(0.78) rotate(-5deg);
opacity: 0;
}
}
@media (max-width: 520px) {
.pop-text-runtime {
font-size: clamp(12px, 4.6vw, 24px);
letter-spacing: 0.2px;
max-width: calc(100vw - 24px);
-webkit-text-stroke: 1px #ffffff;
}
}
`;
document.head.appendChild(style);
}
ensurePopupRuntimeContainer() {
const host = this.popupHost || this.memoryContainer;
if (!host) return;
let popupContainer = host.querySelector('.pop-wrapper-runtime');
if (!popupContainer) {
popupContainer = document.createElement('div');
popupContainer.className = 'pop-wrapper-runtime';
host.appendChild(popupContainer);
}
this.popupContainer = popupContainer;
}
loadMusicPreference() {
try {
const stored = window.localStorage.getItem('matchwise_music_enabled');
if (stored === null) return true;
return stored !== '0' && stored !== 'false';
} catch (err) {
return true;
}
}
saveMusicPreference(enabled) {
try {
window.localStorage.setItem('matchwise_music_enabled', enabled ? '1' : '0');
} catch (err) {
// ignore storage failures
}
}
syncMusicToggleButtons() {
this.musicToggleButtons = [
this.musicToggleStartBtn,
this.musicToggleTopBtn,
].filter(Boolean);
this.musicToggleButtons.forEach((button) => {
const textEl = button.querySelector('.music-toggle-text');
if (textEl) {
textEl.textContent = this.musicEnabled ? 'Sound: ON' : 'Sound: OFF';
}
button.classList.toggle('music-toggle-off', !this.musicEnabled);
button.setAttribute('aria-pressed', this.musicEnabled ? 'true' : 'false');
});
}
setMusicEnabled(enabled) {
this.musicEnabled = Boolean(enabled);
this.saveMusicPreference(this.musicEnabled);
this.syncMusicToggleButtons();
}
toggleMusic() {
this.setMusicEnabled(!this.musicEnabled);
}
getAudioContext() {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
if (!this.audioContext) {
this.audioContext = new AudioContextClass();
}
if (this.audioContext.state === 'suspended') {
void this.audioContext.resume();
}
return this.audioContext;
}
playCorrectSound() {
if (!this.musicEnabled) return;
const audioCtx = this.getAudioContext();
if (!audioCtx) return;
const now = audioCtx.currentTime;
const osc1 = audioCtx.createOscillator();
const gain1 = audioCtx.createGain();
osc1.type = 'sine';
osc1.frequency.setValueAtTime(659, now);
gain1.gain.setValueAtTime(0.3, now);
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
osc1.connect(gain1);
gain1.connect(audioCtx.destination);
const osc2 = audioCtx.createOscillator();
const gain2 = audioCtx.createGain();
osc2.type = 'sine';
osc2.frequency.setValueAtTime(880, now + 0.08);
gain2.gain.setValueAtTime(0.3, now + 0.08);
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
osc2.connect(gain2);
gain2.connect(audioCtx.destination);
osc1.start(now);
osc1.stop(now + 0.12);
osc2.start(now + 0.08);
osc2.stop(now + 0.35);
}
playWrongSound() {
if (!this.musicEnabled) return;
const audioCtx = this.getAudioContext();
if (!audioCtx) return;
const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(150, now);
osc.frequency.linearRampToValueAtTime(70, now + 0.25);
gain.gain.setValueAtTime(0.4, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.25);
}
async bootstrapLevelPage() {
const requestedSessionHash = this.getSessionHashFromUrl() || this.sessionHash;
if (!requestedSessionHash) {
window.location.replace('/');
return;
}
this.sessionHash = requestedSessionHash;
try {
const response = this.normalizeServerResponse(
await server.load_level_state(JSON.stringify({
session_hash: this.sessionHash,
}))
);
if (!response || !response.state) {
if (response && response.error === 'missing_session') {
window.location.replace('/');
return;
}
throw new Error(response?.error || 'Unable to load session');
}
if (response.session_hash) {
this.sessionHash = String(response.session_hash);
const nextUrl = new URL(window.location.href);
nextUrl.pathname = '/level';
nextUrl.searchParams.set('session_hash', this.sessionHash);
window.history.replaceState({}, '', nextUrl.toString());
}
this.state = {
...this.state,
...response.state,
game_started: true,
};
this.renderState(response.state, true);
this.lastRenderedHints = this.hints;
this.lastRenderedLives = Number(this.state.lives ?? 0);
this.syncControlsVisibility();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.root.classList.remove('level-loading');
if (this.memoryContainer && this.memoryContainer !== this.root) {
this.memoryContainer.classList.remove('level-loading');
}
this.startPreview();
});
});
} catch (err) {
console.error('load_level_state failed', err);
this.root.classList.remove('level-loading');
if (this.memoryContainer && this.memoryContainer !== this.root) {
this.memoryContainer.classList.remove('level-loading');
}
this.setStatus('Could not load the level.', 'error', 'Please return to the start page and try again.');
}
}
triggerPopup(text, styleClass = 'pop-good-runtime') {
if (!this.popupContainer) {
this.ensurePopupRuntimeContainer();
}
if (!this.popupContainer) return;
const popup = document.createElement('div');
popup.className = `pop-text-runtime ${styleClass}`;
popup.textContent = String(text || '').trim() || 'Tasty!';
this.popupContainer.appendChild(popup);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
popup.classList.add('animate');
});
});
window.setTimeout(() => {
if (popup && popup.parentNode) {
popup.parentNode.removeChild(popup);
}
}, 1300);
}
showWrongMatchPopup() {
this.triggerPopup('Wrong Match:\n -1 lives!', 'pop-bad-runtime');
}
showLevelCompletePopup(message = 'Level Complete!') {
this.triggerPopup(message, 'pop-win-runtime');
}
showFeedbackOverlay(kicker = '', text = '', type = 'positive', lock = false) {
if (type === 'negative') {
this.showWrongMatchPopup();
return;
}
if (type === 'level') {
this.showLevelCompletePopup(text || 'Level Complete!');
return;
}
if (type === 'combo' || type === 'positive') {
this.triggerPopup('Matched!', 'pop-good-runtime');
return;
}
this.triggerPopup('Matched!', 'pop-good-runtime');
}
showComboFeedback() {
this.triggerPopup('Combo!', 'pop-good-runtime');
}
hexToRgb(hex) {
const clean = String(hex || '').replace('#', '');
return {
r: parseInt(clean.substring(0, 2), 16),
g: parseInt(clean.substring(2, 4), 16),
b: parseInt(clean.substring(4, 6), 16),
};
}
rgbToHex(r, g, b) {
return `#${[r, g, b].map((value) => Number(value).toString(16).padStart(2, '0')).join('')}`;
}
mixColor(colorA, colorB, amount) {
const a = this.hexToRgb(colorA);
const b = this.hexToRgb(colorB);
const mix = Math.max(0, Math.min(1, Number(amount)));
const r = Math.round(a.r + (b.r - a.r) * mix);
const g = Math.round(a.g + (b.g - a.g) * mix);
const blue = Math.round(a.b + (b.b - a.b) * mix);
return this.rgbToHex(r, g, blue);
}
hexToRgba(hex, alpha) {
const rgb = this.hexToRgb(hex);
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
getMeterGradientColor(value) {
const safeValue = Math.max(0, Math.min(100, Number(value)));
const gradientStops = [
{ point: 0, color: '#17a34a' },
{ point: 28, color: '#97c51e' },
{ point: 50, color: '#ffd522' },
{ point: 72, color: '#ff981f' },
{ point: 100, color: '#ff2e25' },
];
for (let i = 0; i < gradientStops.length - 1; i += 1) {
const start = gradientStops[i];
const end = gradientStops[i + 1];
if (safeValue >= start.point && safeValue <= end.point) {
const localAmount = (safeValue - start.point) / (end.point - start.point);
return this.mixColor(start.color, end.color, localAmount);
}
}
return gradientStops[gradientStops.length - 1].color;
}
updatePerformanceMeter(value, animate = false) {
const safeValue = Math.max(0, Math.min(100, Number(value || 0)));
const dynamicColor = this.getMeterGradientColor(safeValue);
const dynamicGlow = this.hexToRgba(dynamicColor, 0.72);
if (this.performanceMeterEl) {
this.performanceMeterEl.style.setProperty('--meter-value', `${safeValue}%`);
this.performanceMeterEl.style.setProperty('--knob-color', dynamicColor);
this.performanceMeterEl.style.setProperty('--knob-glow', dynamicGlow);
}
if (!animate) {
return;
}
if (this.performanceMeterKnobEl) {
this.performanceMeterKnobEl.classList.remove('bump');
void this.performanceMeterKnobEl.offsetWidth;
this.performanceMeterKnobEl.classList.add('bump');
}
if (this.performanceMeterEl) {
this.performanceMeterEl.classList.remove('flash');
void this.performanceMeterEl.offsetWidth;
this.performanceMeterEl.classList.add('flash');
}
}
installEvents() {
this.root.addEventListener('click', async (e) => {
const card = e.target.closest('.memory-card');
if (card && !this.locked) {
await this.handleCardClick(card);
return;
}
});
}
installButtonHandlers() {
if (this.resetBtn) {
this.resetBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
if (this.state.game_over || this.state.status === 'game_over') {
await this.resetGame();
return;
}
this.showResetConfirm();
});
}
if (this.resetConfirmBtn) {
this.resetConfirmBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
this.hideResetConfirm();
await this.resetGame();
});
}
if (this.resetCancelBtn) {
this.resetCancelBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.hideResetConfirm();
});
}
if (this.homeBtn) {
this.homeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
window.location.assign('/');
});
}
if (this.gameOverCloseBtn) {
this.gameOverCloseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.hideGameOverModal();
});
}
if (this.challengeCloseBtn) {
this.challengeCloseBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.openChallengeQuestion();
});
}
if (this.challengeOkBtn) {
this.challengeOkBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.openChallengeQuestion();
});
}
if (this.hintBtn) {
this.hintBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.useHint();
});
}
if (this.nextBtn) {
this.nextBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.advanceLevel();
});
}
if (this.startGameBtn) {
this.startGameBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.startGame();
});
}
if (this.howToPlayBtn) {
this.howToPlayBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.showHowToPlayModal();
});
}
if (this.musicToggleStartBtn) {
this.musicToggleStartBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleMusic();
});
}
if (this.musicToggleTopBtn) {
this.musicToggleTopBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleMusic();
});
}
this.installLeaderboardRefreshHooks();
if (this.leaderboardBtn) {
this.leaderboardBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.openLeaderboardModal();
});
}
if (this.howToPlayCloseBtn) {
this.howToPlayCloseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.hideHowToPlayModal();
});
}
if (this.howToPlayOverlay) {
this.howToPlayOverlay.addEventListener('click', (e) => {
if (e.target === this.howToPlayOverlay) {
this.hideHowToPlayModal();
}
});
}
if (this.leaderboardCloseBtn) {
this.leaderboardCloseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.hideLeaderboardModal();
});
}
if (this.leaderboardOverlay) {
this.leaderboardOverlay.addEventListener('click', (e) => {
if (e.target === this.leaderboardOverlay) {
this.hideLeaderboardModal();
}
});
}
if (this.challengeOptionsEl) {
this.challengeOptionsEl.addEventListener('click', async (e) => {
const button = e.target.closest('.challenge-option');
if (!button) return;
await this.handleChallengeChoice(button);
});
}
}
installViewportHandlers() {
const rerenderCardSize = () => {
this.updateCardSize();
};
window.addEventListener('resize', rerenderCardSize, { passive: true });
window.addEventListener('orientationchange', () => {
setTimeout(rerenderCardSize, 150);
}, { passive: true });
}
attachLoginButtonToTopBar() {
if (!this.topUtilityBar) return;
const loginButton = document.getElementById('hf-login-button');
if (!loginButton) return;
if (loginButton.parentElement === this.topUtilityBar) return;
this.topUtilityBar.appendChild(loginButton);
}
scheduleLeaderboardRefresh(delayMs = 0) {
if (this.leaderboardRefreshTimer) {
clearTimeout(this.leaderboardRefreshTimer);
this.leaderboardRefreshTimer = null;
}
this.leaderboardRefreshTimer = setTimeout(() => {
this.leaderboardRefreshTimer = null;
void this.refreshLeaderboardData();
}, Math.max(0, Number(delayMs) || 0));
}
installLeaderboardRefreshHooks() {
const loginButton = document.getElementById('hf-login-button');
if (!loginButton || loginButton === this.loginButtonRefreshEl) return;
if (this.loginButtonObserver) {
this.loginButtonObserver.disconnect();
this.loginButtonObserver = null;
}
this.loginButtonRefreshEl = loginButton;
loginButton.addEventListener('click', () => {
this.scheduleLeaderboardRefresh(900);
});
this.loginButtonObserver = new MutationObserver(() => {
this.scheduleLeaderboardRefresh(250);
});
this.loginButtonObserver.observe(loginButton, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
}
updateLeaderboardDisplay() {
const signedIn = Boolean(this.state.leaderboard_signed_in);
const username = String(this.state.leaderboard_username || '').trim();
const highScore = Number(this.state.leaderboard_high_score || 0);
if (this.highScoreLabelEl) {
this.highScoreLabelEl.textContent = signedIn ? 'My High Score' : 'Guest Score';
}
if (this.highScoreUserEl) {
this.highScoreUserEl.textContent = username || (signedIn ? 'Signed in' : 'Guest');
}
if (this.highScoreValueEl) {
this.highScoreValueEl.textContent = String(highScore);
}
if (this.leaderboardMyScoreUserEl) {
this.leaderboardMyScoreUserEl.textContent = username || (signedIn ? 'Signed in' : 'Guest');
}
if (this.leaderboardMyScoreValueEl) {
this.leaderboardMyScoreValueEl.textContent = String(highScore);
}
}
renderLeaderboardEntries(entries) {
if (!this.leaderboardListEl) return;
const rows = this.getRankedLeaderboardEntries(entries);
if (!rows.length) {
this.leaderboardListEl.innerHTML = '<div class="leaderboard-empty">No scores yet. Be the first to play.</div>';
return;
}
const currentUsername = String(this.state.leaderboard_username || '').trim().toLowerCase();
this.leaderboardListEl.innerHTML = `
<div class="leaderboard-table-wrap">
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>HF Username</th>
<th>High Score</th>
</tr>
</thead>
<tbody>
${rows.map((entry) => {
const username = String(entry.username || 'Guest').trim();
const score = Number(entry.score || 0);
const isCurrentUser = currentUsername && username.toLowerCase() === currentUsername;
return `
<tr class="${isCurrentUser ? 'current-user' : ''}">
<td class="leaderboard-rank-cell">#${Number(entry.rank || 0)}</td>
<td class="leaderboard-user-cell">${this.escapeHtml(username)}${isCurrentUser ? ' (You)' : ''}</td>
<td class="leaderboard-score-cell">${score}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
}
getRankedLeaderboardEntries(entries) {
const rows = Array.isArray(entries) ? entries : [];
return rows
.map((entry) => ({
username: String(entry?.username || 'Guest').trim(),
score: Number(entry?.score || 0),
}))
.filter((entry) => entry.username)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.username.localeCompare(b.username, undefined, { sensitivity: 'base' });
})
.map((entry, index) => ({
...entry,
rank: index + 1,
}));
}
async refreshLeaderboardData() {
try {
const response = this.normalizeServerResponse(
await server.get_leaderboard_data(JSON.stringify({
game_id: this.state.game_id || '',
}))
);
if (!response) return null;
const currentUser = response.current_user || {};
this.state.leaderboard_signed_in = Boolean(currentUser.leaderboard_signed_in);
this.state.leaderboard_username = String(currentUser.leaderboard_username || '');
this.state.leaderboard_high_score = Number(currentUser.leaderboard_high_score || 0);
this.state.leaderboard_saved = Boolean(currentUser.leaderboard_saved);
this.leaderboardData = {
entries: Array.isArray(response.entries) ? response.entries : [],
current_user: currentUser,
};
this.updateLeaderboardDisplay();
this.renderLeaderboardEntries(this.leaderboardData.entries);
return this.leaderboardData;
} catch (err) {
return null;
}
}
async openLeaderboardModal() {
this.showLeaderboardModal();
if (this.leaderboardListEl) {
this.leaderboardListEl.innerHTML = '<div class="leaderboard-empty">Loading leaderboard...</div>';
}
await this.refreshLeaderboardData();
this.showLeaderboardModal();
}
snapshot() {
const activeLevel = {
...(this.activeLevel || {}),
blueprint: {
...((this.activeLevel && this.activeLevel.blueprint) || {}),
},
cards: [...(this.cards || [])],
};
return {
...this.state,
active_level: activeLevel,
cards: [...this.cards],
flipped: [...this.flipped],
matched: [...this.matched],
moves: this.moves,
hints: this.hints,
max_hints: this.maxHints,
performance_meter: this.state.performance_meter,
challenge_due: this.state.challenge_due,
game_started: this.state.game_started,
status: this.state.status,
level_complete: this.state.level_complete,
game_over: this.state.game_over,
preview_seconds: PREVIEW_SECONDS,
};
}
async syncBackend() {
try {
await server.sync_state(JSON.stringify(this.snapshot()));
} catch (err) {
return;
}
}
setBadge(text, kind, subtext = '') {
if (!this.stateBadge) return;
if (kind === 'complete' && subtext) {
this.stateBadge.innerHTML = `<span class="badge-main">${text}</span><span class="badge-sub">${subtext}</span>`;
} else {
this.stateBadge.textContent = text;
}
this.stateBadge.classList.remove('playing', 'complete', 'gameover', 'challenge');
if (kind) this.stateBadge.classList.add(kind);
}
getCompletionStarCount() {
const totalPairs = Math.max(1, Number(this.state.total_pairs || this.cards.length / 2 || 1));
const moves = Math.max(0, Number(this.state.moves ?? this.moves ?? 0));
const wrongAttempts = Math.max(0, Number(this.state.wrong_attempts || 0));
if (wrongAttempts === 0 && moves === totalPairs) {
return 3;
}
if (wrongAttempts <= 1 && moves <= totalPairs + 2) {
return 2;
}
return 1;
}
getCompletionBadgeText() {
return '⭐'.repeat(this.getCompletionStarCount());
}
getCompletionRatingLabel() {
const stars = this.getCompletionStarCount();
if (stars >= 3) return 'Perfect';
if (stars === 2) return 'Good';
return 'Average';
}
setStatus(text, kind = '', subtext = '') {
if (this.statusTextEl) this.statusTextEl.textContent = text;
if (this.statusSubtextEl) this.statusSubtextEl.textContent = subtext;
if (!this.statusEl) return;
this.statusEl.classList.remove('show', 'error', 'win');
if (kind) this.statusEl.classList.add(kind);
}
pulseHudGlow(target, glowClass, timerKey) {
if (!target) return;
const timerName = `${timerKey}GlowTimer`;
if (this[timerName]) {
clearTimeout(this[timerName]);
this[timerName] = null;
}
target.classList.remove(glowClass);
void target.offsetWidth;
target.classList.add(glowClass);
this[timerName] = setTimeout(() => {
target.classList.remove(glowClass);
this[timerName] = null;
}, 850);
}
getEffectiveMaxHints() {
const baseMax = Math.max(1, Number(this.maxHints || this.state.max_hints || 5));
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
// Hint cap gently tightens as levels progress.
// L1-5: normal cap, L6-10: -1, L11-16: -2, L17+: -3.
const reduction = level <= 5 ? 0 : level <= 10 ? 1 : level <= 16 ? 2 : 3;
return Math.max(1, baseMax - reduction);
}
syncProgressiveHintLimit(announce = false) {
const effectiveMaxHints = this.getEffectiveMaxHints();
const previousHints = Number(this.hints || 0);
this.hints = Math.max(0, Math.min(effectiveMaxHints, previousHints));
this.state.hints = this.hints;
if (announce && previousHints > this.hints) {
this.setStatus(
'Harder level: peeks are rarer now.',
'show',
`Peek limit for this level: ${this.hints} / ${effectiveMaxHints}`
);
}
return effectiveMaxHints;
}
getHintCooldownLengthMoves() {
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
// Early levels stay friendly. Later levels make Peek a small decision.
if (level < 7) return 0;
if (level < 14) return 1;
return 2;
}
decrementPeekCooldownAfterMove() {
if (this.peekCooldownMoves > 0) {
this.peekCooldownMoves = Math.max(0, this.peekCooldownMoves - 1);
this.state.peek_cooldown_moves = this.peekCooldownMoves;
this.updateHintsDisplay();
}
}
getWrongFlipBackDelayMs() {
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
// Level 1-5: 900ms, Level 6-12: 700ms, Level 13+: 550ms.
if (level <= 5) return 900;
if (level <= 12) return 700;
return 550;
}
getCompletedChallengeLevels() {
return Math.max(0, Number(this.state.completed_challenge_levels || 0));
}
rememberRecentlyFlipped(index) {
const value = Number(index);
if (!Number.isFinite(value)) return;
this.recentlyFlippedCardIndexes = [
value,
...this.recentlyFlippedCardIndexes.filter((item) => item !== value),
].slice(0, 6);
}
getDistractorSparkleIntervalMs() {
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
// Very light distraction: slower early, slightly quicker later.
return Math.max(1700, 3900 - Math.min(1700, (level - 1) * 95));
}
triggerDistractorSparkleOnce() {
if (!this.grid || this.state.status !== 'playing' || this.locked || this.state.game_over || this.state.level_complete) {
return;
}
const faceDownCandidates = Array.from(this.grid.querySelectorAll('.memory-card:not(.matched):not(.flipped):not(.preview-locked)'));
const recentlyFlippedCandidates = this.recentlyFlippedCardIndexes
.map((index) => this.grid.querySelector(`[data-index="${index}"]`))
.filter((card) => card && !card.classList.contains('matched') && !card.classList.contains('preview-locked'));
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
const canUseRecentDecoy = level >= 8 && recentlyFlippedCandidates.length > 0;
const useRecentDecoy = canUseRecentDecoy && Math.random() < 0.34;
const candidates = useRecentDecoy ? recentlyFlippedCandidates : faceDownCandidates;
if (!candidates.length) return;
const card = candidates[Math.floor(Math.random() * candidates.length)];
if (!card) return;
card.classList.remove('distractor-sparkle');
void card.offsetWidth;
card.classList.add('distractor-sparkle');
if (this.distractorSparkleClearTimer) {
clearTimeout(this.distractorSparkleClearTimer);
this.distractorSparkleClearTimer = null;
}
this.distractorSparkleClearTimer = setTimeout(() => {
card.classList.remove('distractor-sparkle');
this.distractorSparkleClearTimer = null;
}, 740);
}
startDistractorSparkles() {
if (this.distractorSparkleTimer || !this.grid || this.state.status !== 'playing') {
return;
}
const interval = this.getDistractorSparkleIntervalMs();
this.distractorSparkleTimer = setInterval(() => {
if (window.__activeMemoryLevelId !== this.levelId || this.state.status !== 'playing') {
this.stopDistractorSparkles();
return;
}
this.triggerDistractorSparkleOnce();
}, interval);
}
stopDistractorSparkles() {
if (this.distractorSparkleTimer) {
clearInterval(this.distractorSparkleTimer);
this.distractorSparkleTimer = null;
}
if (this.distractorSparkleClearTimer) {
clearTimeout(this.distractorSparkleClearTimer);
this.distractorSparkleClearTimer = null;
}
if (this.grid) {
this.grid.querySelectorAll('.memory-card.distractor-sparkle').forEach((card) => {
card.classList.remove('distractor-sparkle');
});
}
}
updateHintsDisplay() {
const effectiveMaxHints = this.syncProgressiveHintLimit(false);
if (this.hintCountEl) {
this.hintCountEl.textContent = `${this.hints} / ${effectiveMaxHints}`;
}
const currentHints = Number(this.hints || 0);
if (this.lastRenderedHints !== null && currentHints > this.lastRenderedHints) {
this.pulseHudGlow(this.hintCountEl || this.hintBtn, 'hud-glow-peek', 'peek');
}
this.lastRenderedHints = currentHints;
if (this.hintBtn) {
const oddFaceUp = this.flipped.length % 2 === 1;
const disabled =
this.state.level_type === 'challenge' ||
this.hints <= 0 ||
this.state.status !== 'playing' ||
this.hinting ||
this.peekCooldownMoves > 0 ||
oddFaceUp ||
this.state.game_over ||
this.state.level_complete;
this.hintBtn.disabled = disabled;
}
}
showResetConfirm() {
if (this.resetConfirmOverlay) {
this.resetConfirmOverlay.classList.remove('hidden');
this.resetConfirmOverlay.setAttribute('aria-hidden', 'false');
}
}
hideResetConfirm() {
if (this.resetConfirmOverlay) {
this.resetConfirmOverlay.classList.add('hidden');
this.resetConfirmOverlay.setAttribute('aria-hidden', 'true');
}
}
showGameOverModal(score) {
if (this.gameOverScore) {
this.gameOverScore.textContent = String(score ?? this.state.score ?? 0);
}
const scorePillValue = this.root.querySelector('#gameover-score-pill');
if (scorePillValue) {
scorePillValue.textContent = String(score ?? this.state.score ?? 0);
}
this.disableControlsForGameOver();
if (this.gameOverOverlay) {
this.gameOverOverlay.classList.remove('hidden');
this.gameOverOverlay.setAttribute('aria-hidden', 'false');
}
}
hideGameOverModal() {
if (this.gameOverOverlay) {
this.gameOverOverlay.classList.add('hidden');
this.gameOverOverlay.setAttribute('aria-hidden', 'true');
}
this.restoreControlsAfterGameOver();
}
disableControlsForGameOver() {
if (this.resetBtn) this.resetBtn.disabled = false;
if (this.hintBtn) this.hintBtn.disabled = true;
if (this.nextBtn) this.nextBtn.disabled = true;
if (this.howToPlayBtn) this.howToPlayBtn.disabled = true;
if (this.homeBtn) this.homeBtn.disabled = true;
if (this.musicToggleStartBtn) this.musicToggleStartBtn.disabled = true;
if (this.musicToggleTopBtn) this.musicToggleTopBtn.disabled = true;
if (this.challengeOkBtn) this.challengeOkBtn.disabled = true;
if (this.challengeCloseBtn) this.challengeCloseBtn.disabled = true;
if (this.gameOverCloseBtn) this.gameOverCloseBtn.disabled = true;
}
restoreControlsAfterGameOver() {
if (this.howToPlayBtn) this.howToPlayBtn.disabled = false;
if (this.homeBtn) this.homeBtn.disabled = false;
if (this.musicToggleStartBtn) this.musicToggleStartBtn.disabled = false;
if (this.musicToggleTopBtn) this.musicToggleTopBtn.disabled = false;
if (this.challengeOkBtn) this.challengeOkBtn.disabled = false;
if (this.challengeCloseBtn) this.challengeCloseBtn.disabled = false;
if (this.gameOverCloseBtn) this.gameOverCloseBtn.disabled = false;
this.updateHintsDisplay();
}
showHowToPlayModal() {
if (this.howToPlayOverlay) {
this.howToPlayOverlay.classList.remove('hidden');
this.howToPlayOverlay.setAttribute('aria-hidden', 'false');
}
}
hideHowToPlayModal() {
if (this.howToPlayOverlay) {
this.howToPlayOverlay.classList.add('hidden');
this.howToPlayOverlay.setAttribute('aria-hidden', 'true');
}
}
showLeaderboardModal() {
if (this.leaderboardOverlay) {
this.leaderboardOverlay.classList.remove('hidden');
this.leaderboardOverlay.setAttribute('aria-hidden', 'false');
}
}
hideLeaderboardModal() {
if (this.leaderboardOverlay) {
this.leaderboardOverlay.classList.add('hidden');
this.leaderboardOverlay.setAttribute('aria-hidden', 'true');
}
}
showChallengeIntroModal() {
if (this.challengeIntroOverlay) {
this.challengeIntroOverlay.classList.remove('hidden');
this.challengeIntroOverlay.setAttribute('aria-hidden', 'false');
}
}
hideChallengeIntroModal() {
if (this.challengeIntroOverlay) {
this.challengeIntroOverlay.classList.add('hidden');
this.challengeIntroOverlay.setAttribute('aria-hidden', 'true');
}
}
showChallengePanel() {
if (this.challengePanel) {
this.challengePanel.classList.remove('hidden');
}
if (this.memoryContainer) {
this.memoryContainer.classList.add('challenge-mode');
}
}
hideChallengePanel() {
if (this.challengePanel) {
this.challengePanel.classList.add('hidden');
}
if (this.memoryContainer) {
this.memoryContainer.classList.remove('challenge-mode');
}
}
getChallengeIntroCopy() {
const title = String(this.state.mode_popup_title || this.blueprint.mode_popup_title || this.state.challenge_modal_title || 'Challenge Level').trim();
const message = String(this.state.mode_popup_message || this.blueprint.mode_popup_message || this.state.challenge_modal_message || '').trim();
return {
title,
message: message || 'Match the cards to continue.',
};
}
renderChallengePanel() {
if (!this.challengePanel) return;
const theme = String(this.state.theme || this.blueprint.theme || '').trim();
const modeLabel = String(this.state.level_mode || this.blueprint.level_mode || 'normal').trim();
const title = String(this.state.mode_popup_title || this.blueprint.mode_popup_title || theme || 'Challenge').trim();
const focus = String(this.state.mode_popup_message || this.blueprint.mode_popup_message || this.state.featured_fact || this.blueprint.featured_fact || theme || '').trim();
if (this.challengeThemeEl) {
this.challengeThemeEl.textContent = modeLabel ? `Mode: ${modeLabel.replace(/_/g, ' ')}` : '';
}
if (this.challengeQuestionEl) {
this.challengeQuestionEl.textContent = title;
}
if (this.challengeFocusEl) {
this.challengeFocusEl.textContent = focus;
}
if (this.challengeOptionsEl) {
this.challengeOptionsEl.innerHTML = '';
}
}
enableChallengeOptions(enabled) {
if (!this.challengeOptionsEl) return;
this.challengeOptionsEl.querySelectorAll('.challenge-option').forEach((button) => {
button.disabled = !enabled || this.state.status === 'complete' || this.state.game_over || this.challengeSelectionLocked;
});
}
setChallengeOptionState(selectedIndex, correctIndex, isCorrect) {
if (!this.challengeOptionsEl) return;
const buttons = Array.from(this.challengeOptionsEl.querySelectorAll('.challenge-option'));
const lockAll = this.challengeSelectionLocked || this.state.status === 'complete' || this.state.game_over || Boolean(this.state.challenge_result);
buttons.forEach((button) => {
const index = Number(button.dataset.index);
button.classList.remove('correct-choice', 'wrong-choice', 'reveal-correct');
if (index === correctIndex) {
button.classList.add('reveal-correct');
}
if (index === selectedIndex) {
button.classList.add(isCorrect ? 'correct-choice' : 'wrong-choice');
}
button.disabled = lockAll || (isCorrect && index !== correctIndex);
});
}
async openChallengeQuestion() {
this.hideChallengeIntroModal();
await this.startChallengePreview();
}
async handleChallengeChoice(button) {
if (this.state.level_type !== 'challenge') return;
if (!this.challengeStarted || this.challengeSelectionLocked || this.state.status === 'complete') return;
const selectedIndex = Number(button.dataset.index);
if (Number.isNaN(selectedIndex)) return;
const correctIndex = Number(this.challengeCorrectIndex);
const isCorrect = selectedIndex === correctIndex;
this.state.challenge_selected_index = selectedIndex;
this.challengeResultIndex = selectedIndex;
this.challengeSelectionLocked = true;
this.state.challenge_result = isCorrect ? 'correct' : 'wrong';
this.state.level_complete = true;
this.state.previous_level_completed = true;
this.state.status = 'complete';
this.state.performance_meter = isCorrect ? 0 : 100;
this.state.challenge_due = false;
this.setChallengeOptionState(selectedIndex, correctIndex, isCorrect);
if (isCorrect) {
this.state.win_streak = Number(this.state.win_streak || 0) + 1;
this.state.lives = Number(this.state.lives || this.lives || MAX_LIVES) + 1;
this.playCorrectSound();
this.lives = Number(this.state.lives);
this.state.performance_rating = this.getCompletionBadgeText();
this.state.performance_label = this.getCompletionRatingLabel();
this.updateHud();
this.setStatus(
this.state.victory_message || 'Great answer! You cleared the challenge.',
'win',
`Correct: ${String(this.challengeOptions[correctIndex] || '').trim()}`
);
this.setBadge('COMPLETE', 'complete', `${this.getCompletionRatingLabel()} ${this.getCompletionBadgeText()}`);
if (this.nextBtn) this.nextBtn.disabled = false;
this.enableChallengeOptions(false);
await this.syncBackendEvent('challenge');
return;
}
this.setStatus(
this.state.failure_message || 'Not quite. Try again.',
'error',
`Correct answer: ${String(this.challengeOptions[correctIndex] || '').trim()}`
);
this.playWrongSound();
if (this.nextBtn) this.nextBtn.disabled = false;
this.enableChallengeOptions(false);
await this.syncBackendEvent('challenge');
}
showStartOverlay() {
if (this.startOverlay) {
this.startOverlay.classList.remove('hidden');
this.startOverlay.setAttribute('aria-hidden', 'false');
}
}
hideStartOverlay() {
if (this.startOverlay) {
this.startOverlay.classList.add('hidden');
this.startOverlay.setAttribute('aria-hidden', 'true');
}
}
showTransitionOverlay(title, subtitle) {
if (this.transitionHideTimer) {
clearTimeout(this.transitionHideTimer);
this.transitionHideTimer = null;
}
this.transitionShownAt = performance.now ? performance.now() : Date.now();
if (this.transitionTitle) this.transitionTitle.textContent = title;
if (this.transitionSubtitle) this.transitionSubtitle.textContent = subtitle;
if (this.transitionOverlay) {
this.transitionOverlay.classList.remove('hidden');
this.transitionOverlay.setAttribute('aria-hidden', 'false');
}
}
hideTransitionOverlay(force = false) {
const now = performance.now ? performance.now() : Date.now();
const elapsed = now - (this.transitionShownAt || 0);
if (!force && elapsed < MIN_TRANSITION_VISIBLE_MS) {
if (this.transitionHideTimer) {
clearTimeout(this.transitionHideTimer);
}
this.transitionHideTimer = setTimeout(() => {
this.transitionHideTimer = null;
this.hideTransitionOverlay(true);
}, MIN_TRANSITION_VISIBLE_MS - elapsed);
return;
}
if (this.transitionHideTimer) {
clearTimeout(this.transitionHideTimer);
this.transitionHideTimer = null;
}
if (this.transitionOverlay) {
this.transitionOverlay.classList.add('hidden');
this.transitionOverlay.setAttribute('aria-hidden', 'true');
}
}
normalizeServerResponse(result) {
if (!result) return null;
if (typeof result === 'string') {
try {
return JSON.parse(result);
} catch (err) {
return null;
}
}
if (typeof result === 'object') {
return result;
}
return null;
}
getFeaturedFact() {
const fact = this.blueprint.featured_fact || this.state.featured_fact || '';
return typeof fact === 'string' ? fact.trim() : '';
}
formatFeaturedFact(fact) {
const text = String(fact || '').trim();
if (!text) return '';
if (/^did you know\b/i.test(text)) {
return text.toUpperCase().startsWith('DID YOU KNOW:') ? text : `DID YOU KNOW: ${text.replace(/^did you know[:\s-]*/i, '').trim()}`;
}
return text.toUpperCase().startsWith('FACT:') ? text : `FACT: ${text}`;
}
escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
getCardDisplayText(card) {
if (card && typeof card === 'object') {
return String(card.display ?? card.value ?? card.text ?? '').trim();
}
return String(card ?? '').trim();
}
getCardMatchKey(card) {
if (card && typeof card === 'object') {
const key = card.match_key ?? card.matchKey ?? card.key ?? card.display ?? card.value ?? card.text ?? '';
return String(key).trim();
}
return String(card ?? '').trim();
}
getCardType(card) {
if (card && typeof card === 'object') {
return String(card.card_type ?? card.type ?? 'emoji').trim();
}
return 'emoji';
}
getCardPairFact(card) {
if (card && typeof card === 'object') {
return String(card.pair_fact ?? card.fact ?? '').trim();
}
return '';
}
shuffleBoardCards(cards) {
const shuffled = Array.isArray(cards) ? [...cards] : [];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
if (j !== i) {
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
}
return shuffled;
}
isSpecialChallengeMode() {
return this.state.level_type === 'challenge' && ['fact_match', 'category_match'].includes(String(this.state.level_mode || this.blueprint.level_mode || '').trim());
}
bindActiveLevelState() {
const previousLevelId = this.levelId || '';
this.activeLevel = this.state.active_level || {};
this.blueprint = this.activeLevel.blueprint || {};
this.levelId = this.activeLevel.level_instance_id || this.levelId || '';
this.levelNumber = Number(this.activeLevel.level_number || this.levelNumber || 1);
if (this.levelId && this.levelId !== previousLevelId) {
this.activeLevel.cards = this.shuffleBoardCards(this.activeLevel.cards);
this.challengeStarted = false;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.challengeCorrectIndex = null;
}
this.cards = Array.isArray(this.activeLevel.cards) ? [...this.activeLevel.cards] : [];
this.state.level = this.levelNumber;
this.state.total_pairs = Number(this.activeLevel.pair_count || this.state.total_pairs || 0);
this.state.grid_rows = Number(this.activeLevel.rows || this.state.grid_rows || 2);
this.state.grid_cols = Number(this.activeLevel.cols || this.state.grid_cols || 2);
this.state.hints = Number.isFinite(Number(this.state.hints)) ? Number(this.state.hints) : this.hints;
this.state.max_hints = Number.isFinite(Number(this.state.max_hints)) ? Number(this.state.max_hints) : this.maxHints;
this.state.max_lives = Number.isFinite(Number(this.state.max_lives)) ? Number(this.state.max_lives) : MAX_LIVES;
this.state.level_title = this.blueprint.level_title || this.state.level_title || '';
this.state.theme = this.blueprint.theme || this.state.theme || '';
this.state.educational_focus = this.blueprint.educational_focus || this.state.educational_focus || '';
this.state.featured_fact_emoji = this.blueprint.featured_fact_emoji || this.state.featured_fact_emoji || '';
this.state.featured_fact = this.blueprint.featured_fact || this.state.featured_fact || '';
this.state.level_type = this.activeLevel.level_type || this.state.level_type || 'normal';
this.state.challenge_modal_title = this.blueprint.challenge_modal_title || this.state.challenge_modal_title || '';
this.state.challenge_modal_message = this.blueprint.challenge_modal_message || this.state.challenge_modal_message || '';
this.state.challenge_question = this.blueprint.challenge_question || this.state.challenge_question || '';
this.state.challenge_options = Array.isArray(this.blueprint.challenge_options) ? [...this.blueprint.challenge_options] : (this.state.challenge_options || []);
this.state.challenge_correct_index = Number.isFinite(Number(this.blueprint.challenge_correct_index))
? Number(this.blueprint.challenge_correct_index)
: this.state.challenge_correct_index;
this.state.level_mode = this.blueprint.level_mode || this.state.level_mode || 'normal';
this.state.mode_popup_title = this.blueprint.mode_popup_title || this.state.mode_popup_title || '';
this.state.mode_popup_message = this.blueprint.mode_popup_message || this.state.mode_popup_message || '';
this.state.match_targets = this.blueprint.match_targets || this.state.match_targets || {};
this.state.pair_facts = this.blueprint.pair_facts || this.state.pair_facts || {};
this.state.victory_message = this.blueprint.victory_message || this.state.victory_message || '';
this.state.failure_message = this.blueprint.failure_message || this.state.failure_message || '';
this.state.grid_advice = this.blueprint.grid_advice || this.state.grid_advice || {};
this.hints = Number.isFinite(Number(this.state.hints)) ? Number(this.state.hints) : this.hints;
this.maxHints = Number.isFinite(Number(this.state.max_hints)) ? Number(this.state.max_hints) : this.maxHints;
this.challengeOptions = Array.isArray(this.state.challenge_options) ? [...this.state.challenge_options] : [];
this.challengeCorrectIndex = Number.isFinite(Number(this.state.challenge_correct_index))
? Number(this.state.challenge_correct_index)
: null;
this.challengeLevelMode = String(this.state.level_mode || this.blueprint.level_mode || 'normal').trim();
}
isChallengeMode() {
return this.state.level_type === 'challenge';
}
updateHud() {
if (this.movesEl) this.movesEl.textContent = String(this.moves);
const matchedPairs = Math.floor(this.matched.length / 2);
if (this.matchesEl) this.matchesEl.textContent = `${matchedPairs} / ${this.state.total_pairs}`;
if (this.scoreEl) this.scoreEl.textContent = String(this.state.score);
const currentMeter = Math.max(0, Math.min(100, Number(this.state.performance_meter || 0)));
const meterChanged = this.lastRenderedPerformanceMeter !== null && currentMeter !== this.lastRenderedPerformanceMeter;
this.updatePerformanceMeter(currentMeter, meterChanged);
this.lastRenderedPerformanceMeter = currentMeter;
const currentLives = Number(this.state.lives || 0);
if (this.livesEl) this.livesEl.textContent = String(currentLives);
const livesBlock = this.livesEl?.closest('.stat-block') || this.livesEl;
if (this.lastRenderedLives !== null && currentLives < this.lastRenderedLives) {
this.pulseHudGlow(livesBlock, 'hud-glow-lives', 'lives');
this.pulseHudGlow(this.livesEl, 'hud-glow-lives', 'livesValue');
} else if (this.lastRenderedLives !== null && currentLives > this.lastRenderedLives) {
this.pulseHudGlow(livesBlock, 'hud-glow-lives-gain', 'lives');
this.pulseHudGlow(this.livesEl, 'hud-glow-lives-gain', 'livesValue');
}
this.lastRenderedLives = currentLives;
this.updateHintsDisplay();
if (this.levelEl) this.levelEl.textContent = String(this.state.level);
if (this.levelTitleEl) this.levelTitleEl.textContent = this.state.level_title || '';
if (this.themeChip) this.themeChip.textContent = this.state.theme || '';
if (this.focusLine) this.focusLine.textContent = this.state.educational_focus || this.state.featured_fact || '';
this.updateLeaderboardDisplay();
}
updateCardSize() {
if (!this.grid) return;
const cols = Math.max(2, Number(this.state.grid_cols || 2));
const rows = Math.max(2, Number(this.state.grid_rows || 2));
const mobileViewport = window.innerWidth <= 760;
const gap = mobileViewport ? (cols > 4 ? 3 : cols > 3 ? 4 : 5) : (cols > 4 ? 8 : cols > 3 ? 10 : 12);
const safeWidth = Math.max(0, window.innerWidth - (mobileViewport ? 22 : 44));
const safeHeight = Math.max(0, window.innerHeight - (mobileViewport ? 320 : 280));
const widthBased = Math.floor((safeWidth - gap * (cols - 1)) / cols);
const heightBased = Math.floor((safeHeight - gap * (rows - 1)) / rows);
const rowPenalty = rows >= 5 ? 0.72 : rows === 4 ? 0.8 : rows === 3 ? 0.9 : 1;
const mobileTightness = mobileViewport ? 0.82 : 1;
const size = Math.max(
mobileViewport ? 42 : 92,
Math.floor(Math.min(widthBased, heightBased) * rowPenalty * mobileTightness)
);
const maxCard = mobileViewport
? (cols <= 2 ? 76 : cols === 3 ? 56 : cols === 4 ? 44 : 34)
: (cols <= 2 ? 160 : cols === 3 ? 150 : cols === 4 ? 132 : 118);
const minCard = mobileViewport
? (cols <= 2 ? 54 : cols === 3 ? 42 : cols === 4 ? 34 : 28)
: (cols <= 2 ? 92 : cols === 3 ? 72 : cols === 4 ? 58 : 44);
const finalSize = Math.max(minCard, Math.min(maxCard, size));
this.grid.style.setProperty('--grid-cols', String(cols));
this.grid.style.setProperty('--grid-rows', String(rows));
this.grid.style.setProperty('--card-fit-size', `${finalSize}px`);
this.grid.style.setProperty('--card-size', `${finalSize}px`);
this.grid.style.setProperty('--grid-gap', `${gap}px`);
}
renderState(nextState, initial = false) {
this.state = {
...this.state,
...nextState,
};
const isPlaying = Boolean(this.state.game_started);
this.root.classList.toggle('game-playing', isPlaying);
if (this.memoryContainer && this.memoryContainer !== this.root) {
this.memoryContainer.classList.toggle('game-playing', isPlaying);
}
this.bindActiveLevelState();
this.resetComboFeedback();
this.flipped = [];
this.matched = [];
this.hintedPairs = new Set();
this.hinting = false;
this.moves = Number(this.state.moves || 0);
this.locked = false;
this.pendingTransition = false;
const isChallenge = this.state.level_type === 'challenge';
this.memoryContainer.classList.toggle('challenge-mode', isChallenge);
if (isChallenge) {
this.state.performance_meter = 100;
this.state.challenge_due = false;
}
if (!isChallenge) {
this.hideChallengeIntroModal();
this.hideChallengePanel();
this.challengeStarted = false;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.challengeCorrectIndex = null;
} else {
this.challengeStarted = false;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.challengeCorrectIndex = null;
}
this.stopDistractorSparkles();
this.renderCards();
this.updateHud();
this.updateCardSize();
this.applyStateStyle();
if (!initial) {
const isGameOver = this.state.status === 'game_over' || this.state.game_over;
this.setStatus(
isGameOver
? this.state.failure_message || 'Game over. Press NEW GAME to start again.'
: isChallenge
? this.state.challenge_modal_message || this.state.theme || `Challenge level ${this.state.level} loaded.`
: `Level ${this.state.level} loaded.`,
isGameOver ? 'error' : 'show',
this.state.theme || ''
);
}
}
renderCards() {
if (!this.grid) return;
const html = this.cards.map((card, index) => {
const display = this.getCardDisplayText(card);
const matchKey = this.getCardMatchKey(card);
const cardType = this.getCardType(card);
const isTextCard = cardType === 'text';
const cardClass = isTextCard ? 'text-card' : 'emoji-card';
const pairFact = this.getCardPairFact(card);
const titleAttr = pairFact ? ` title="${this.escapeHtml(pairFact)}"` : '';
return `
<div class="memory-card ${cardClass} flipped preview-locked" data-index="${index}" data-match-key="${this.escapeHtml(matchKey)}" data-card-type="${this.escapeHtml(cardType)}"${titleAttr}>
<div class="memory-inner">
<div class="memory-front">
<img class="memory-front-logo" src="__LOGO_DATA_URI__" alt="" aria-hidden="true">
</div>
<div class="memory-back">${this.escapeHtml(display)}</div>
</div>
</div>
`;
}).join('');
this.grid.innerHTML = html;
this.grid.querySelectorAll('.memory-card').forEach(card => {
card.classList.add('flipped', 'preview-locked');
});
}
getPreviewShuffleIntervalMs() {
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
// Starts gentle, then gets a little faster as levels progress.
// Lower bound prevents the board from becoming chaotic on later levels.
return Math.max(360, 760 - Math.min(320, (level - 1) * 22));
}
getPreviewShuffleAnimationMs() {
const interval = this.getPreviewShuffleIntervalMs();
// Animation stays slightly shorter than the next shuffle tick.
return Math.max(260, Math.min(560, Math.floor(interval * 0.72)));
}
makeDifferentPreviewOrder(cards) {
const original = [...cards];
const shuffled = [...cards];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
// Avoid a weak shuffle where too many cards remain in the same grid cell.
const sameSpotCount = shuffled.reduce((count, card, index) => {
return count + (card === original[index] ? 1 : 0);
}, 0);
if (shuffled.length > 2 && sameSpotCount > Math.floor(shuffled.length * 0.45)) {
shuffled.push(shuffled.shift());
} else if (shuffled.length === 2 && shuffled[0] === original[0]) {
shuffled.reverse();
}
return shuffled;
}
shufflePreviewGridOnce() {
if (!this.grid || this.state.status !== 'preview') return;
const cards = Array.from(this.grid.querySelectorAll('.memory-card.preview-locked:not(.matched)'));
if (cards.length < 2) return;
const firstRects = new Map();
cards.forEach((card) => {
firstRects.set(card, card.getBoundingClientRect());
});
const shuffledCards = this.makeDifferentPreviewOrder(cards);
const fragment = document.createDocumentFragment();
shuffledCards.forEach((card) => {
fragment.appendChild(card);
});
this.grid.appendChild(fragment);
const duration = this.getPreviewShuffleAnimationMs();
this.previewShuffleAnimationMs = duration;
shuffledCards.forEach((card) => {
const first = firstRects.get(card);
const last = card.getBoundingClientRect();
if (!first || !last) return;
const dx = first.left - last.left;
const dy = first.top - last.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return;
card.getAnimations().forEach((animation) => {
animation.cancel();
});
card.animate(
[
{
transform: `translate(${dx}px, ${dy}px) scale(1.015)`,
zIndex: 5,
},
{
transform: 'translate(0px, 0px) scale(1)',
zIndex: 1,
}
],
{
duration,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
}
);
});
}
clearPreviewChallengeEffects() {
this.previewFogTimers.forEach((timer) => clearTimeout(timer));
this.previewFogTimers = [];
this.previewHideTimers.forEach((timer) => clearTimeout(timer));
this.previewHideTimers = [];
const effectRoots = [this.memoryContainer, this.root].filter(Boolean);
effectRoots.forEach((root) => root.classList.remove('preview-fog-blink'));
if (this.grid) {
this.grid.querySelectorAll('.memory-card.preview-hidden-card').forEach((card) => {
card.classList.remove('preview-hidden-card');
});
}
}
triggerPreviewFogBlinkOnce() {
const effectRoot = this.memoryContainer || this.root;
if (!effectRoot || this.state.status !== 'preview') return;
effectRoot.classList.remove('preview-fog-blink');
void effectRoot.offsetWidth;
effectRoot.classList.add('preview-fog-blink');
const timer = setTimeout(() => {
effectRoot.classList.remove('preview-fog-blink');
}, 280);
this.previewFogTimers.push(timer);
}
startPreviewFogChallenge() {
const level = Math.max(1, Number(this.levelNumber || this.state.level || 1));
if (level < 10) return;
const blinkCount = level >= 18 ? 2 : 1;
const delays = blinkCount >= 2 ? [760, 1760] : [1260];
delays.slice(0, blinkCount).forEach((delay) => {
const timer = setTimeout(() => {
if (window.__activeMemoryLevelId === this.levelId && this.state.status === 'preview') {
this.triggerPreviewFogBlinkOnce();
}
}, delay);
this.previewFogTimers.push(timer);
});
}
getPreviewHideCardCount() {
const completedChallengeLevels = this.getCompletedChallengeLevels();
if (completedChallengeLevels < 1) return 0;
return completedChallengeLevels >= 3 ? 2 : 1;
}
hideRandomPreviewCardsOnce() {
if (!this.grid || this.state.status !== 'preview') return;
const hideCount = this.getPreviewHideCardCount();
if (hideCount <= 0) return;
const cards = Array.from(this.grid.querySelectorAll('.memory-card.preview-locked:not(.matched):not(.preview-hidden-card)'));
if (!cards.length) return;
const shuffled = this.makeDifferentPreviewOrder(cards).slice(0, Math.min(hideCount, cards.length));
shuffled.forEach((card) => {
card.classList.add('preview-hidden-card');
const timer = setTimeout(() => {
card.classList.remove('preview-hidden-card');
}, 650);
this.previewHideTimers.push(timer);
});
}
startPreviewHideChallenge() {
if (this.getPreviewHideCardCount() <= 0) return;
[920, 1880].forEach((delay, index) => {
const timer = setTimeout(() => {
if (window.__activeMemoryLevelId === this.levelId && this.state.status === 'preview') {
// The second pulse only appears once challenge progress is stronger.
if (index === 0 || this.getCompletedChallengeLevels() >= 2) {
this.hideRandomPreviewCardsOnce();
}
}
}, delay);
this.previewHideTimers.push(timer);
});
}
startPreviewDifficultyEffects() {
this.clearPreviewChallengeEffects();
this.startPreviewFogChallenge();
this.startPreviewHideChallenge();
}
startPreviewGridShuffle() {
this.stopPreviewGridShuffle();
if (!this.grid || this.state.status !== 'preview') return;
const interval = this.getPreviewShuffleIntervalMs();
// Start while countdown shows 4.
this.shufflePreviewGridOnce();
this.previewShuffleTimer = setInterval(() => {
if (window.__activeMemoryLevelId !== this.levelId || this.state.status !== 'preview') {
this.stopPreviewGridShuffle();
return;
}
this.shufflePreviewGridOnce();
}, interval);
}
stopPreviewGridShuffle() {
if (this.previewShuffleTimer) {
clearInterval(this.previewShuffleTimer);
this.previewShuffleTimer = null;
}
this.clearPreviewChallengeEffects();
if (!this.grid) return;
this.grid.querySelectorAll('.memory-card').forEach((card) => {
card.getAnimations().forEach((animation) => {
try {
animation.finish();
} catch (err) {
animation.cancel();
}
});
card.style.transform = '';
card.style.zIndex = '';
});
}
shakeCardsDuringPreview() {
if (!this.grid) return;
const allCards = this.grid.querySelectorAll('.memory-card');
allCards.forEach((cardEl, index) => {
cardEl.getAnimations().forEach((animation) => {
animation.cancel();
});
cardEl.animate(
[
{ transform: 'translateX(0px) rotate(0deg)' },
{ transform: 'translateX(-14px) rotate(-4deg)' },
{ transform: 'translateX(14px) rotate(4deg)' },
{ transform: 'translateX(-12px) rotate(-3deg)' },
{ transform: 'translateX(12px) rotate(3deg)' },
{ transform: 'translateX(-8px) rotate(-2deg)' },
{ transform: 'translateX(8px) rotate(2deg)' },
{ transform: 'translateX(0px) rotate(0deg)' }
],
{
duration: 850,
easing: 'ease-in-out',
iterations: 1,
fill: 'both',
delay: index * 70,
}
);
});
}
clearPreviewCardAnimations() {
if (!this.grid) return;
const allCards = this.grid.querySelectorAll('.memory-card');
allCards.forEach((cardEl) => {
cardEl.getAnimations().forEach((animation) => {
animation.cancel();
});
cardEl.style.transform = '';
cardEl.style.zIndex = '';
cardEl.classList.remove('preview-hidden-card', 'distractor-sparkle');
});
}
applyStateStyle() {
const status = this.state.status || 'preview';
if (status === 'playing') {
if (this.state.level_type === 'challenge') {
this.setBadge('CHALLENGE', 'challenge');
} else {
this.setBadge('PLAYING', 'playing');
}
} else if (status === 'complete') {
const ratingLabel = this.state.performance_label || this.getCompletionRatingLabel();
const stars = this.state.performance_rating || this.getCompletionBadgeText();
this.setBadge('COMPLETE', 'complete', `${ratingLabel} ${stars}`);
} else if (status === 'game_over') {
this.setBadge('GAME OVER', 'gameover');
} else {
this.setBadge(this.state.level_type === 'challenge' ? 'CHALLENGE' : 'MEMORIZE', this.state.level_type === 'challenge' ? 'challenge' : '');
}
if (this.nextBtn) {
this.nextBtn.disabled = status !== 'complete';
}
if (status === 'playing') {
this.startDistractorSparkles();
} else {
this.stopDistractorSparkles();
}
this.updateHintsDisplay();
this.syncControlsVisibility();
}
startPreview() {
if (!this.levelId) {
return;
}
if (this.state.level_type === 'challenge') {
this.startChallengeLevel();
return;
}
this.clearPreviewTimer();
this.clearPreviewCardAnimations();
window.__activeMemoryLevelId = this.levelId;
if (window.__memoryGameTimer) {
clearInterval(window.__memoryGameTimer);
window.__memoryGameTimer = null;
}
this.state.status = 'preview';
this.state.level_complete = false;
this.state.game_over = false;
this.recentlyFlippedCardIndexes = [];
this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0);
this.syncProgressiveHintLimit(true);
this.applyStateStyle();
this.setStatus(
'Memorize the cards before they flip!',
'',
this.state.theme || ''
);
const cards = this.grid ? Array.from(this.grid.querySelectorAll('.memory-card')) : [];
cards.forEach(card => {
card.classList.add('flipped', 'preview-locked');
card.classList.remove('matched', 'wrong');
});
let count = PREVIEW_SECONDS;
if (this.countdown) this.countdown.textContent = String(count);
if (this.overlay) {
this.overlay.classList.remove('hidden');
this.overlay.style.display = 'flex';
this.overlay.style.opacity = '1';
}
this.scrollPreviewIntoView();
setTimeout(() => {
this.shakeCardsDuringPreview();
}, 120);
setTimeout(() => {
this.startPreviewGridShuffle();
this.startPreviewDifficultyEffects();
}, 260);
this.updateHintsDisplay();
this.previewTimer = setInterval(() => {
if (window.__activeMemoryLevelId !== this.levelId) {
this.clearPreviewTimer();
return;
}
count -= 1;
if (this.countdown) this.countdown.textContent = String(Math.max(count, 0));
if (count <= 1) {
this.stopPreviewGridShuffle();
}
if (count <= 0) {
this.clearPreviewTimer();
this.hidePreview();
}
}, 1000);
}
async startChallengePreview() {
this.clearPreviewTimer();
this.clearPreviewCardAnimations();
window.__activeMemoryLevelId = this.levelId;
this.challengeStarted = true;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.state.challenge_started = true;
this.state.status = 'preview';
this.state.level_complete = false;
this.state.game_over = false;
this.recentlyFlippedCardIndexes = [];
this.peekCooldownMoves = Number(this.state.peek_cooldown_moves || 0);
this.syncProgressiveHintLimit(true);
if (this.overlay) {
this.overlay.style.display = 'flex';
this.overlay.style.opacity = '1';
this.overlay.classList.remove('hidden');
}
const cards = this.grid ? Array.from(this.grid.querySelectorAll('.memory-card')) : [];
cards.forEach((card) => {
card.classList.add('flipped', 'preview-locked');
card.classList.remove('matched', 'wrong');
});
let count = PREVIEW_SECONDS;
if (this.countdown) this.countdown.textContent = String(count);
this.scrollPreviewIntoView();
this.applyStateStyle();
this.setStatus(
'Memorize the cards before they flip!',
'',
this.state.theme || this.state.mode_popup_message || this.state.challenge_modal_message || ''
);
this.updateHintsDisplay();
setTimeout(() => {
this.shakeCardsDuringPreview();
}, 120);
setTimeout(() => {
this.startPreviewGridShuffle();
this.startPreviewDifficultyEffects();
}, 260);
this.previewTimer = setInterval(() => {
if (window.__activeMemoryLevelId !== this.levelId) {
this.clearPreviewTimer();
return;
}
count -= 1;
if (this.countdown) this.countdown.textContent = String(Math.max(count, 0));
if (count <= 1) {
this.stopPreviewGridShuffle();
}
if (count <= 0) {
this.clearPreviewTimer();
this.hidePreview();
}
}, 1000);
}
startChallengeLevel() {
this.clearPreviewTimer();
this.clearPreviewCardAnimations();
const introCopy = this.getChallengeIntroCopy();
const modalTitle = introCopy.title || 'Challenge Level';
const modalSubtitle = introCopy.message;
if (this.overlay) {
this.overlay.style.opacity = '0';
setTimeout(() => {
if (this.overlay) this.overlay.style.display = 'none';
}, 420);
}
if (this.grid) {
this.grid.querySelectorAll('.memory-card').forEach((card) => {
card.classList.remove('flipped', 'preview-locked');
});
}
this.scrollPreviewIntoView();
this.challengeCorrectIndex = Number.isFinite(Number(this.state.challenge_correct_index))
? Number(this.state.challenge_correct_index)
: null;
this.renderChallengePanel();
if (this.challengeModalTitleEl) {
this.challengeModalTitleEl.textContent = modalTitle;
}
if (this.challengeModalMessageEl) {
this.challengeModalMessageEl.textContent = modalSubtitle;
}
if (this.challengeModalSubtitleEl) {
this.challengeModalSubtitleEl.textContent = modalSubtitle;
}
const challengeAlreadyStarted = Boolean(this.state.challenge_started);
if (challengeAlreadyStarted) {
this.challengeStarted = true;
this.challengeSelectionLocked = false;
this.hideChallengeIntroModal();
this.hideChallengePanel();
if (this.challengeModalTitleEl) {
this.challengeModalTitleEl.textContent = introCopy.title;
}
if (this.challengeModalMessageEl) {
this.challengeModalMessageEl.textContent = introCopy.message;
}
if (this.state.status === 'complete') {
this.setStatus(
this.state.victory_message || 'Great answer! You cleared the challenge.',
'win',
`Correct: ${String(this.challengeOptions[this.challengeCorrectIndex] || '').trim()}`
);
} else {
this.setStatus(
introCopy.message,
'show',
this.state.theme || ''
);
}
this.applyStateStyle();
return;
}
this.challengeStarted = false;
this.challengeSelectionLocked = false;
this.challengeResultIndex = null;
this.state.status = 'preview';
this.state.level_complete = false;
this.state.game_over = false;
this.hideChallengePanel();
if (this.challengeModalTitleEl) {
this.challengeModalTitleEl.textContent = introCopy.title;
}
if (this.challengeModalMessageEl) {
this.challengeModalMessageEl.textContent = introCopy.message;
}
this.showChallengeIntroModal();
this.scrollPreviewIntoView();
this.setStatus(
introCopy.message,
'show',
this.state.theme || ''
);
this.applyStateStyle();
}
clearPreviewTimer() {
if (this.previewTimer) {
clearInterval(this.previewTimer);
this.previewTimer = null;
}
this.stopPreviewGridShuffle();
}
scrollPreviewIntoView() {
const target = this.state.level_type === 'challenge'
? (this.grid || this.cardsPanel || this.boardFrame)
: (this.grid || this.cardsPanel || this.boardFrame);
if (!target) return;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
try {
target.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
} catch (err) {
const fallbackTarget = target === this.boardFrame ? this.boardFrame : (this.cardsPanel || this.boardFrame);
if (!fallbackTarget) return;
const top = Math.max(
0,
window.scrollY + fallbackTarget.getBoundingClientRect().top - Math.max(12, window.innerHeight * 0.14)
);
window.scrollTo({ top, behavior: 'smooth' });
}
});
});
}
hidePreview() {
if (this.overlay) {
this.overlay.style.opacity = '0';
setTimeout(() => {
if (this.overlay) this.overlay.style.display = 'none';
}, 420);
}
this.clearPreviewCardAnimations();
this.grid.querySelectorAll('.memory-card').forEach((card, idx) => {
if (!this.matched.includes(idx)) {
card.classList.remove('flipped', 'preview-locked');
}
});
if (this.state.level_type === 'challenge') {
this.state.status = 'playing';
this.state.level_complete = false;
this.state.game_over = false;
this.applyStateStyle();
this.updateHintsDisplay();
return;
}
this.state.status = 'playing';
this.levelStartTime = Date.now();
this.applyStateStyle();
this.updateHintsDisplay();
this.setStatus(
'Find matching pairs.',
'show',
this.state.theme || 'Click two cards to test a match.'
);
}
async handleCardClick(card) {
if (this.state.status !== 'playing' || this.locked) return;
const idx = Number(card.dataset.index);
if (this.matched.includes(idx) || this.flipped.includes(idx)) {
return;
}
card.classList.add('flipped');
this.flipped.push(idx);
this.rememberRecentlyFlipped(idx);
if (this.flipped.length === 1) {
this.updateHintsDisplay();
this.setStatus('Pick one more card.', '', this.state.theme || '');
return;
}
if (this.flipped.length < 2) {
return;
}
this.locked = true;
this.moves += 1;
this.state.moves = this.moves;
this.decrementPeekCooldownAfterMove();
this.updateHud();
const [a, b] = this.flipped;
const cardAValue = this.getCardMatchKey(this.cards[a]);
const cardBValue = this.getCardMatchKey(this.cards[b]);
if (cardAValue && cardAValue === cardBValue) {
await this.onMatch(a, b);
} else {
this.onWrong(a, b);
}
}
async onMatch(a, b) {
const cardA = this.grid.querySelector(`[data-index="${a}"]`);
const cardB = this.grid.querySelector(`[data-index="${b}"]`);
if (cardA) cardA.classList.add('matched');
if (cardB) cardB.classList.add('matched');
const pairFact = this.getCardPairFact(this.cards[a]) || this.getCardPairFact(this.cards[b]);
const isChallengeBoard = this.isChallengeMode();
this.matched.push(a, b);
this.flipped = [];
this.state.matched_count = Math.floor(this.matched.length / 2);
this.state.score = Number(this.state.score || 0) + 20;
if (isChallengeBoard) {
this.state.performance_meter = 100;
this.state.challenge_due = false;
} else {
this.state.performance_meter = Math.min(
100,
Number(this.state.performance_meter || 0) + 20
);
this.state.challenge_due = Number(this.state.performance_meter || 0) >= 100;
}
this.comboStreak += 1;
this.state.combo_streak = this.comboStreak;
this.updateHud();
this.playCorrectSound();
if (this.matched.length === this.cards.length) {
const featuredFact = this.formatFeaturedFact(this.getFeaturedFact());
if (isChallengeBoard) {
this.state.lives = Number(this.state.lives || this.lives || MAX_LIVES) + 1;
this.lives = Number(this.state.lives);
this.state.performance_meter = 0;
this.state.challenge_due = false;
}
const effectiveMaxHints = this.getEffectiveMaxHints();
const shouldAwardPeek = this.hints < effectiveMaxHints;
this.showFeedbackOverlay(
'LEVEL COMPLETE',
isChallengeBoard ? 'Level Complete!\n+1 Life!' : (shouldAwardPeek ? 'Level Complete!\n+1 Peek earned!' : 'Level Complete!'),
'level',
false
);
this.state.performance_rating = this.getCompletionBadgeText();
this.state.performance_label = this.getCompletionRatingLabel();
this.state.status = 'complete';
this.state.level_complete = true;
this.state.previous_level_completed = true;
this.state.previous_level_wrong_attempts = Number(this.state.wrong_attempts || 0);
this.state.previous_level_time_seconds = this.levelStartTime
? Math.max(1, Math.round((Date.now() - this.levelStartTime) / 1000))
: Number(this.state.previous_level_time_seconds || 0);
this.state.win_streak = Number(this.state.win_streak || 0) + 1;
this.locked = true;
this.setBadge('COMPLETE', 'complete', `${this.state.performance_label} ${this.state.performance_rating}`);
this.setStatus(
this.state.victory_message || 'Level cleared. Click NEXT LEVEL to continue.',
'win',
pairFact || featuredFact || this.state.theme || `Bonus ready for level ${this.state.level}.`
);
if (!isChallengeBoard) {
this.hints = Math.min(effectiveMaxHints, this.hints + 1);
this.state.hints = this.hints;
}
this.updateHud();
this.applyStateStyle();
if (this.nextBtn) this.nextBtn.disabled = false;
const response = await this.syncBackendEvent('complete');
if (response && response.state) {
this.state = {
...this.state,
...response.state,
};
this.bindActiveLevelState();
this.updateHud();
}
const hintMessage = shouldAwardPeek
? '+1 peek earned!'
: `Max peeks earned for this level: ${this.hints} / ${effectiveMaxHints}`;
const completionDetails = [featuredFact, hintMessage].filter(Boolean).join('\n');
this.setStatus(
this.state.victory_message || 'Level cleared. Click NEXT LEVEL to continue.',
'win',
completionDetails || hintMessage
);
return;
}
if (this.comboStreak >= 2) {
this.showComboFeedback();
} else {
this.triggerPopup('Matched!', 'pop-good-runtime');
}
this.setStatus('Match found.', 'show', pairFact || this.state.theme || '');
this.locked = false;
}
onWrong(a, b) {
const cardA = this.grid.querySelector(`[data-index="${a}"]`);
const cardB = this.grid.querySelector(`[data-index="${b}"]`);
if (cardA) cardA.classList.add('wrong');
if (cardB) cardB.classList.add('wrong');
this.resetComboFeedback();
this.clearWrongMatchTimer();
this.locked = true;
const wrongFlipBackDelay = this.getWrongFlipBackDelayMs();
this.wrongMatchTimer = window.setTimeout(() => {
try {
[a, b].forEach((index) => {
const card = this.grid ? this.grid.querySelector(`[data-index="${index}"]`) : null;
if (card) {
card.classList.remove('flipped', 'wrong');
}
});
} finally {
this.flipped = [];
this.locked = false;
this.wrongMatchTimer = null;
if (this.state.lives <= 0) {
this.gameOver();
}
}
}, wrongFlipBackDelay);
try {
this.showFeedbackOverlay('WRONG MATCH', 'Lost 1 live', 'negative', false);
this.playWrongSound();
this.state.wrong_attempts = Number(this.state.wrong_attempts || 0) + 1;
this.state.score = Math.max(0, Number(this.state.score || 0) - 10);
if (this.isChallengeMode()) {
this.state.performance_meter = 100;
this.state.challenge_due = false;
} else {
this.state.performance_meter = Math.max(
0,
Number(this.state.performance_meter || 0) - 20
);
this.state.challenge_due = Number(this.state.performance_meter || 0) >= 100;
}
this.state.previous_level_completed = false;
this.updateHud();
this.setStatus(
this.state.failure_message || 'No match. Try again.',
'error',
this.state.theme || `Wrong attempts: ${this.state.wrong_attempts}`
);
} catch (err) {
console.error('onWrong UI update failed', err);
}
void this.syncBackendEvent('wrong');
}
async useHint() {
if (this.state.status !== 'playing') return;
this.syncProgressiveHintLimit(false);
if (this.hinting) return;
if (this.peekCooldownMoves > 0) {
this.setStatus('Peek is cooling down.', 'error', `${this.peekCooldownMoves} move${this.peekCooldownMoves === 1 ? '' : 's'} left before next peek.`);
this.updateHintsDisplay();
return;
}
if (this.hints <= 0) {
this.setStatus('No peeks left!', 'error', '');
this.updateHintsDisplay();
return;
}
if (this.flipped.length > 0) return;
this.resetComboFeedback();
const pair = this.findUnmatchedPairForHint();
if (!pair) {
this.setStatus('No peek available!', 'error', '');
return;
}
this.hints = Math.max(0, this.hints - 1);
this.state.hints = this.hints;
this.hinting = true;
this.locked = true;
this.updateHintsDisplay();
const [a, b] = pair;
const cardA = this.grid.querySelector(`[data-index="${a}"]`);
const cardB = this.grid.querySelector(`[data-index="${b}"]`);
if (cardA) {
cardA.classList.add('flipped', 'hint-peek');
}
if (cardB) {
cardB.classList.add('flipped', 'hint-peek');
}
this.setStatus('🔍 Peek revealed a pair!', 'show', '');
setTimeout(async () => {
if (!this.matched.includes(a) && cardA) {
cardA.classList.remove('flipped', 'hint-peek');
}
if (!this.matched.includes(b) && cardB) {
cardB.classList.remove('flipped', 'hint-peek');
}
this.hinting = false;
this.locked = false;
this.peekCooldownMoves = this.getHintCooldownLengthMoves();
this.state.peek_cooldown_moves = this.peekCooldownMoves;
this.updateHintsDisplay();
await this.syncBackendEvent('hint');
}, 1200);
}
findUnmatchedPairForHint() {
const groups = new Map();
this.cards.forEach((emoji, idx) => {
if (this.matched.includes(idx)) return;
if (!groups.has(emoji)) {
groups.set(emoji, []);
}
groups.get(emoji).push(idx);
});
const candidates = [...groups.entries()].filter(([, indices]) => indices.length >= 2);
if (!candidates.length) return null;
const fresh = candidates.find(([emoji, indices]) => {
if (this.hintedPairs.has(emoji)) return false;
return indices.every((index) => !this.flipped.includes(index));
});
const selected = fresh || candidates.find(([, indices]) => indices.every((index) => !this.flipped.includes(index))) || candidates[0];
if (!selected) return null;
this.hintedPairs.add(selected[0]);
return selected[1].slice(0, 2);
}
gameOver() {
this.state.status = 'game_over';
this.state.game_over = true;
this.state.previous_level_completed = false;
this.state.previous_level_wrong_attempts = Number(this.state.wrong_attempts || 0);
this.state.previous_level_time_seconds = this.levelStartTime
? Math.max(1, Math.round((Date.now() - this.levelStartTime) / 1000))
: Number(this.state.previous_level_time_seconds || 0);
this.state.win_streak = 0;
this.resetComboFeedback();
this.stopDistractorSparkles();
this.locked = true;
this.applyStateStyle();
this.setStatus(
this.state.failure_message || 'Game over. Press NEW GAME to try again.',
'error',
this.state.theme || 'You used all 5 lives.'
);
if (this.nextBtn) this.nextBtn.disabled = true;
this.clearPreviewTimer();
this.showGameOverModal(this.state.score || 0);
this.updateHintsDisplay();
this.scheduleLeaderboardRefresh(0);
}
async syncBackendEvent(kind) {
try {
let response = null;
if (kind === 'wrong') {
response = this.normalizeServerResponse(await server.on_wrong_match(JSON.stringify(this.snapshot())));
} else if (kind === 'complete') {
response = this.normalizeServerResponse(await server.on_level_complete(JSON.stringify(this.snapshot())));
} else if (kind === 'hint') {
response = this.normalizeServerResponse(await server.on_hint_used(JSON.stringify(this.snapshot())));
} else if (kind === 'challenge') {
response = this.normalizeServerResponse(await server.on_challenge_answer(JSON.stringify(this.snapshot())));
}
const nextState = response && (response.state || response);
if (nextState && (nextState.game_id || nextState.active_level || nextState.lives !== undefined)) {
this.state = {
...this.state,
...nextState,
};
this.bindActiveLevelState();
this.updateHud();
if (this.state.level_type === 'challenge') {
this.renderChallengePanel();
if (this.state.challenge_selected_index !== undefined && this.state.challenge_selected_index !== null) {
const isCorrect = this.state.challenge_result === 'correct';
this.setChallengeOptionState(
Number(this.state.challenge_selected_index),
Number(this.challengeCorrectIndex),
isCorrect
);
}
}
if (this.state.game_over || this.state.status === 'game_over') {
this.showGameOverModal(this.state.score || 0);
this.scheduleLeaderboardRefresh(0);
}
this.applyStateStyle();
}
return response;
} catch (err) {
return null;
}
return null;
}
async mountActiveLevelAndPreview(nextState, showPreparingOverlay = false) {
if (showPreparingOverlay) {
this.showTransitionOverlay('🤖 Preparing Next Level', 'Building the new board and loading the next challenge...');
}
try {
this.renderState(nextState);
this.hideTransitionOverlay(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.hintedPairs = new Set();
this.hinting = false;
this.startPreview();
});
});
} catch (err) {
console.error('mountActiveLevelAndPreview failed', err);
this.hideTransitionOverlay(true);
this.pendingTransition = false;
this.locked = false;
this.setStatus(
'Could not load the next level.',
'error',
'Please try again.'
);
}
}
async advanceLevel() {
if (this.pendingTransition || this.state.status !== 'complete') return;
this.pendingTransition = true;
this.locked = true;
this.showTransitionOverlay('🤖 Preparing Next Level', 'Loading your next challenge...');
if (this.nextBtn) this.nextBtn.disabled = true;
try {
const response = this.normalizeServerResponse(
await server.on_next_level_click(JSON.stringify(this.snapshot()))
);
if (!response) {
this.hideTransitionOverlay(true);
return;
}
if (response.ui_action === 'load_level_and_start_preview' && response.state) {
await this.mountActiveLevelAndPreview(response.state, true);
} else {
this.hideTransitionOverlay(true);
this.setStatus(
'Could not load the next level.',
'error',
'The server returned an unexpected response.'
);
}
} catch (err) {
console.error('on_next_level_click failed', err);
this.setStatus('Could not load the next level.', 'error', 'The local generator fell back or failed.');
this.hideTransitionOverlay();
} finally {
this.pendingTransition = false;
}
}
async startGame() {
if (this.pendingTransition) return;
this.pendingTransition = true;
this.sessionHash = this.sessionHash || ((window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`);
if (this.startGameBtn) {
this.startGameBtn.disabled = true;
this.startGameBtn.textContent = 'Starting...';
}
if (this.startNote) {
this.startNote.textContent = 'Generating your first level...';
}
try {
const startResponse = this.normalizeServerResponse(
await server.start_game(JSON.stringify({
session_hash: this.sessionHash,
game_id: this.sessionHash,
}))
);
const startState = startResponse?.state || startResponse;
if (!startState) {
throw new Error(startResponse?.error || 'Could not create session');
}
this.sessionHash = String(startState.game_id || this.sessionHash);
const nextUrl = new URL(window.location.href);
nextUrl.pathname = '/level';
nextUrl.searchParams.set('session_hash', this.sessionHash);
window.location.assign(nextUrl.toString());
} catch (err) {
console.error('start_game failed', err);
if (this.startNote) {
this.startNote.textContent = 'Could not start the game. Please try again.';
}
} finally {
this.pendingTransition = false;
if (this.startGameBtn) {
this.startGameBtn.disabled = false;
this.startGameBtn.textContent = 'Start';
}
}
}
async resetGame() {
if (this.pendingTransition) return;
this.pendingTransition = true;
this.locked = true;
await this.refreshLeaderboardData();
this.hideResetConfirm();
this.hideGameOverModal();
this.stopDistractorSparkles();
this.showTransitionOverlay('🤖 Starting New Game', 'Resetting the board and preparing a fresh run...');
if (this.nextBtn) this.nextBtn.disabled = true;
try {
const freshState = this.normalizeServerResponse(await server.reset_game());
if (freshState) {
this.renderState(freshState, true);
this.hints = Number(freshState.hints || 1);
this.maxHints = Number(freshState.max_hints || 5);
this.hintedPairs = new Set();
this.hinting = false;
this.updateHintsDisplay();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.hideTransitionOverlay(true);
requestAnimationFrame(() => {
this.startPreview();
});
});
});
}
} catch (err) {
console.error('reset_game failed', err);
this.hideTransitionOverlay();
} finally {
this.pendingTransition = false;
}
}
}
new MatchWiseApp();
"""
JS = JS.replace("__LOGO_DATA_URI__", LOGO_DATA_URI)
# =========================================================
# BUILD DEMO
# =========================================================
GLOBAL_SHELL_CSS = """
html, body {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
background: #040714 !important;
color-scheme: dark;
}
.gradio-container {
width: 100vw !important;
min-height: 100vh;
background: #040714 !important;
color-scheme: dark;
}
.gradio-container nav,
.gradio-container header {
display: none !important;
border: none !important;
box-shadow: none !important;
}
footer {
display: none !important;
}
#hf-login-button {
position: fixed !important;
top: 16px;
right: 16px;
z-index: 1200;
width: auto !important;
min-width: 0 !important;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
.top-utility-bar #hf-login-button {
position: static !important;
top: auto !important;
right: auto !important;
z-index: auto;
display: inline-flex !important;
}
#hf-login-button :is(button, a) {
min-height: 38px !important;
padding: 8px 12px !important;
border-radius: 999px !important;
font-size: 13px !important;
line-height: 1 !important;
white-space: nowrap !important;
}
#game-shell,
.gradio-container .main {
background: #040714 !important;
border-top: none !important;
box-shadow: none !important;
}
@media (max-width: 640px) {
#hf-login-button {
top: 10px;
right: 10px;
transform: scale(0.9);
transform-origin: top right;
}
#hf-login-button :is(button, a) {
min-height: 34px !important;
padding: 7px 10px !important;
font-size: 12px !important;
}
}
"""
GLOBAL_SHELL_HEAD = """
<meta name=\"color-scheme\" content=\"dark\">
"""
DARK_THEME = gr.themes.Base().set(
body_background_fill="#040714",
body_background_fill_dark="#040714",
body_text_color="#f8fafc",
body_text_color_dark="#f8fafc",
body_text_color_subdued="#cbd5e1",
body_text_color_subdued_dark="#cbd5e1",
)
def build_demo() -> gr.Blocks:
initial_state = build_shell_state()
with gr.Blocks(title="MatchWise", fill_width=True) as demo:
gr.Navbar(visible=False)
gr.LoginButton(
value="Sign in with HF",
logout_value="Logout ({})",
elem_id="hf-login-button",
)
gr.HTML(
value="",
html_template=START_HTML,
css_template=CSS,
js_on_load=JS,
elem_id="game-shell",
server_functions=[
start_game,
get_leaderboard_data,
],
state_json=json.dumps(public_game_state(initial_state), ensure_ascii=False),
game_id=initial_state["game_id"],
active_level_json=json.dumps(initial_state["active_level"], ensure_ascii=False),
generation_status_json=json.dumps(initial_state["generation_status"], ensure_ascii=False),
)
with demo.route("Level", "/level", show_in_navbar=False):
gr.Navbar(visible=False)
gr.LoginButton(
value="Sign in with HF",
logout_value="Logout ({})",
elem_id="hf-login-button",
)
gr.HTML(
value="",
html_template=LEVEL_HTML,
css_template=CSS,
js_on_load=JS,
elem_id="game-shell",
server_functions=[
load_level_state,
sync_state,
on_wrong_match,
on_level_complete,
on_hint_used,
on_challenge_answer,
on_next_level_click,
reset_game,
get_leaderboard_data,
],
state_json=json.dumps(public_game_state(initial_state), ensure_ascii=False),
game_id=initial_state["game_id"],
level=initial_state["active_level"]["level_number"],
score=initial_state["score"],
performance_meter=initial_state["performance_meter"],
lives=initial_state["lives"],
max_lives=initial_state["max_lives"],
hints=initial_state["hints"],
max_hints=initial_state["max_hints"],
total_pairs=initial_state["active_level"]["pair_count"],
grid_cols=initial_state["active_level"]["cols"],
grid_rows=initial_state["active_level"]["rows"],
card_size=initial_state["active_level"]["rows"] <= 2 and 160 or initial_state["active_level"]["rows"] <= 3 and 150 or 132,
level_title=initial_state["active_level"]["blueprint"]["level_title"],
theme=initial_state["active_level"]["blueprint"]["theme"],
educational_focus=initial_state["active_level"]["blueprint"]["educational_focus"],
victory_message=initial_state["active_level"]["blueprint"]["victory_message"],
failure_message=initial_state["active_level"]["blueprint"]["failure_message"],
active_level_json=json.dumps(initial_state["active_level"], ensure_ascii=False),
generation_status_json=json.dumps(initial_state["generation_status"], ensure_ascii=False),
)
return demo
demo: gr.Blocks | None = None
def main() -> None:
global demo
bootstrap_llama_runtime()
demo = build_demo()
demo.queue(default_concurrency_limit=1, max_size=20)
demo.launch(server_name="0.0.0.0", ssr_mode=False, css=GLOBAL_SHELL_CSS, theme=DARK_THEME, head=GLOBAL_SHELL_HEAD)
if __name__ == "__main__":
main()