import hashlib
import random
from datetime import date
from html import escape
from urllib.parse import quote
import gradio as gr
import requests
import spaces
WIKIPEDIA_API = "https://en.wikipedia.org/w/api.php"
CHALLENGES = [
("Motorcycle", "Black hole"),
("Coffee", "Moon"),
("Rubber duck", "Artificial intelligence"),
("Pizza", "Quantum mechanics"),
("Harley-Davidson", "Computer science"),
("Volcano", "Internet"),
("Shark", "Electricity"),
("Chess", "Space exploration"),
("New York City", "Dinosaur"),
("Heavy metal music", "Ancient Egypt"),
("Video game", "Solar System"),
("Submarine", "World Wide Web"),
]
@spaces.GPU(duration=1)
def zerogpu_compatibility():
"""Allows this lightweight app to start on ZeroGPU hardware."""
return True
def wikipedia_request(params: dict) -> dict:
response = requests.get(
WIKIPEDIA_API,
params={
**params,
"format": "json",
"formatversion": 2,
"origin": "*",
},
timeout=20,
headers={
"User-Agent": "WikiQuest/1.0 (Hugging Face educational game)"
},
)
response.raise_for_status()
return response.json()
def normalize_title(title: str) -> str:
return " ".join(title.strip().split())
def resolve_article(title: str) -> str:
data = wikipedia_request(
{
"action": "query",
"titles": title,
"redirects": 1,
}
)
pages = data.get("query", {}).get("pages", [])
if not pages or pages[0].get("missing"):
raise ValueError(f'Wikipedia could not find an article named "{title}".')
return pages[0].get("title", title)
def get_article_data(title: str, link_limit: int = 120) -> tuple[str, str, list[str]]:
data = wikipedia_request(
{
"action": "query",
"prop": "extracts|links",
"titles": title,
"redirects": 1,
"exintro": 1,
"explaintext": 1,
"plnamespace": 0,
"pllimit": link_limit,
}
)
pages = data.get("query", {}).get("pages", [])
if not pages or pages[0].get("missing"):
raise ValueError(f'Wikipedia could not load "{title}".')
page = pages[0]
resolved_title = page.get("title", title)
summary = page.get("extract") or "No introductory summary was available."
links = []
blocked_prefixes = (
"List of",
"Outline of",
"Index of",
"Glossary of",
"Timeline of",
)
for item in page.get("links", []):
link_title = item.get("title", "").strip()
if not link_title:
continue
if link_title.startswith(blocked_prefixes):
continue
if link_title.casefold() == resolved_title.casefold():
continue
links.append(link_title)
return resolved_title, summary[:900], sorted(set(links))
def get_link_titles(title: str, limit: int = 100) -> set[str]:
try:
_, _, links = get_article_data(title, limit)
return {item.casefold() for item in links}
except Exception:
return set()
def title_words(title: str) -> set[str]:
ignored = {
"a",
"an",
"and",
"the",
"of",
"in",
"on",
"to",
"for",
"with",
"by",
}
cleaned = "".join(
character.lower() if character.isalnum() else " "
for character in title
)
return {
word
for word in cleaned.split()
if len(word) > 2 and word not in ignored
}
def connection_score(
article: str,
target: str,
article_links: set[str] | None = None,
) -> float:
if article.casefold() == target.casefold():
return 100.0
article_words = title_words(article)
target_words = title_words(target)
word_overlap = len(article_words & target_words)
score = word_overlap * 16.0
article_lower = article.casefold()
target_lower = target.casefold()
if article_lower in target_lower or target_lower in article_lower:
score += 25.0
if article_links and target_lower in article_links:
score += 55.0
return min(score, 95.0)
def select_destinations(
links: list[str],
target: str,
amount: int = 12,
) -> list[str]:
if not links:
return []
target_lower = target.casefold()
target_terms = title_words(target)
def rank(link: str) -> tuple[int, int, int]:
link_terms = title_words(link)
exact_target = int(link.casefold() == target_lower)
shared_terms = len(link_terms & target_terms)
readable_length = -abs(len(link) - 18)
return exact_target, shared_terms, readable_length
ranked = sorted(links, key=rank, reverse=True)
priority = ranked[:5]
remaining = [link for link in links if link not in priority]
seed_text = f"{target}|{'|'.join(links[:20])}"
seed = int(hashlib.sha256(seed_text.encode()).hexdigest()[:12], 16)
local_random = random.Random(seed)
local_random.shuffle(remaining)
selected = priority + remaining[: max(0, amount - len(priority))]
if target in links and target not in selected:
selected[-1] = target
return selected[:amount]
def daily_challenge() -> tuple[str, str]:
today_number = date.today().toordinal()
return CHALLENGES[today_number % len(CHALLENGES)]
def random_challenge() -> tuple[str, str]:
return random.choice(CHALLENGES)
def make_header(
current: str,
target: str,
clicks: int,
temperature: str,
) -> str:
return f"""
CURRENT LOCATION
{escape(current)}
➜
TARGET DESTINATION
{escape(target)}
MOVES
{clicks}
SIGNAL
{escape(temperature)}
"""
def make_article_card(title: str, summary: str) -> str:
article_url = (
"https://en.wikipedia.org/wiki/"
+ quote(title.replace(" ", "_"), safe="")
)
return f"""
◉
YOU HAVE ARRIVED AT
{escape(title)}
{escape(summary)}
Read the full Wikipedia article ↗
"""
def make_path(path: list[str]) -> str:
if not path:
return """
Your journey will appear here.
"""
nodes = []
for index, title in enumerate(path):
node_class = "path-node"
if index == 0:
node_class += " start-node"
elif index == len(path) - 1:
node_class += " active-node"
nodes.append(
f"""
{index}
{escape(title)}
"""
)
if index < len(path) - 1:
nodes.append('')
return f"""
{''.join(nodes)}
"""
def temperature_message(
previous_score: float | None,
new_score: float,
won: bool,
) -> str:
if won:
return "TARGET REACHED"
if previous_score is None:
return "SEARCHING"
difference = new_score - previous_score
if new_score >= 65:
return "BURNING HOT"
if difference >= 18:
return "MUCH WARMER"
if difference >= 4:
return "GETTING WARMER"
if difference <= -18:
return "ICE COLD"
if difference <= -4:
return "GETTING COLDER"
return "UNCHANGED"
def victory_screen(path: list[str], target: str) -> str:
clicks = max(len(path) - 1, 0)
return f"""
◆
MISSION COMPLETE
You found {escape(target)}!
You crossed Wikipedia in
{clicks} move{"s" if clicks != 1 else ""}.
{escape(" → ".join(path))}
"""
def error_outputs(message: str):
return (
f"""
Unable to start this mission
{escape(message)}
""",
"",
gr.update(choices=[], value=None, interactive=False),
"",
[],
"",
"",
0.0,
)
def load_location(
article: str,
target: str,
path: list[str],
previous_score: float | None,
):
resolved_title, summary, links = get_article_data(article)
target = resolve_article(target)
if path:
path[-1] = resolved_title
else:
path = [resolved_title]
link_set = {link.casefold() for link in links}
score = connection_score(resolved_title, target, link_set)
won = resolved_title.casefold() == target.casefold()
temperature = temperature_message(previous_score, score, won)
header = make_header(
resolved_title,
target,
max(len(path) - 1, 0),
temperature,
)
if won:
content = victory_screen(path, target)
destinations = gr.update(
choices=[],
value=None,
interactive=False,
label="Mission complete",
)
else:
content = make_article_card(resolved_title, summary)
choices = select_destinations(links, target)
destinations = gr.update(
choices=choices,
value=None,
interactive=True,
label="Choose your next destination",
)
return (
header,
content,
destinations,
make_path(path),
path,
resolved_title,
target,
score,
)
def start_game(start: str, target: str):
start = normalize_title(start)
target = normalize_title(target)
if not start or not target:
return error_outputs("Enter both a starting article and a target.")
try:
resolved_start = resolve_article(start)
resolved_target = resolve_article(target)
return load_location(
resolved_start,
resolved_target,
[resolved_start],
None,
)
except (requests.RequestException, ValueError) as error:
return error_outputs(str(error))
def travel(
destination: str,
target: str,
path: list[str],
previous_score: float,
):
if not destination:
return (
gr.update(),
gr.update(),
gr.update(),
gr.update(),
path,
path[-1] if path else "",
target,
previous_score,
)
new_path = list(path)
new_path.append(destination)
try:
return load_location(
destination,
target,
new_path,
previous_score,
)
except (requests.RequestException, ValueError) as error:
return error_outputs(str(error))
def undo_move(
target: str,
path: list[str],
):
if len(path) <= 1:
current = path[0] if path else ""
if not current:
return error_outputs("Start a mission first.")
return load_location(
current,
target,
path,
None,
)
new_path = path[:-1]
previous_article = new_path[-1]
return load_location(
previous_article,
target,
new_path,
None,
)
def load_daily():
start, target = daily_challenge()
return start_game(start, target)
def load_random():
start, target = random_challenge()
return start_game(start, target)
CSS = """
:root {
--background: #070b14;
--panel: #111827;
--panel-light: #182235;
--border: rgba(255, 255, 255, 0.09);
--text: #f7f9fc;
--muted: #9aa8bd;
--accent: #7c5cff;
--accent-bright: #9c88ff;
--cyan: #4dd7ff;
--success: #48e3a5;
}
.gradio-container {
max-width: 1180px !important;
margin: 0 auto !important;
padding: 20px !important;
background:
radial-gradient(circle at 50% -20%, rgba(124, 92, 255, 0.24), transparent 40%),
var(--background) !important;
}
body,
.gradio-container {
font-family: Inter, ui-sans-serif, system-ui, sans-serif !important;
}
footer {
display: none !important;
}
.hero {
text-align: center;
padding: 38px 10px 24px;
}
.hero-badge {
display: inline-block;
color: var(--cyan);
border: 1px solid rgba(77, 215, 255, 0.32);
background: rgba(77, 215, 255, 0.08);
border-radius: 999px;
padding: 7px 13px;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.16em;
}
.hero h1 {
margin: 16px 0 6px;
color: var(--text);
font-size: clamp(2.8rem, 7vw, 5.1rem);
line-height: 0.95;
letter-spacing: -0.06em;
}
.hero h1 span {
color: var(--accent-bright);
}
.hero p {
color: var(--muted);
font-size: 1.05rem;
margin: 15px auto 0;
max-width: 630px;
}
.setup-panel,
.control-panel {
background: rgba(17, 24, 39, 0.9);
border: 1px solid var(--border);
border-radius: 22px;
padding: 20px;
box-shadow: 0 20px 55px rgba(0, 0, 0, 0.28);
}
.setup-panel label span,
.destination-grid label span {
color: var(--text) !important;
font-weight: 800 !important;
}
.mission-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 14px;
align-items: stretch;
margin: 22px 0 14px;
}
.mission-card {
min-height: 125px;
padding: 22px;
border-radius: 20px;
background: var(--panel);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: center;
}
.current-card {
border-color: rgba(77, 215, 255, 0.35);
}
.target-card {
border-color: rgba(156, 136, 255, 0.4);
}
.mission-card h2 {
color: var(--text);
margin: 6px 0 0;
font-size: 1.55rem;
}
.mission-arrow {
display: flex;
align-items: center;
color: var(--accent-bright);
font-size: 2rem;
}
.eyebrow {
color: var(--muted);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.16em;
}
.game-stats {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.stat-pill {
flex: 1;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 15px;
padding: 13px 17px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-pill strong {
color: var(--text);
}
.stat-label {
color: var(--muted);
font-size: 0.7rem;
font-weight: 900;
letter-spacing: 0.12em;
}
.heat-pill strong {
color: var(--cyan);
}
.article-panel,
.victory-panel,
.error-panel {
background:
linear-gradient(135deg, rgba(124, 92, 255, 0.09), transparent 45%),
var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: clamp(22px, 5vw, 38px);
margin: 14px 0;
color: var(--text);
}
.article-heading {
display: flex;
gap: 14px;
align-items: center;
}
.location-pin {
color: var(--cyan);
font-size: 1.7rem;
}
.article-panel h1,
.victory-panel h1 {
color: var(--text);
margin: 4px 0 0;
font-size: clamp(1.8rem, 5vw, 2.7rem);
}
.article-panel p {
color: #c7d0df;
line-height: 1.75;
font-size: 1rem;
}
.wiki-link {
display: inline-block;
color: var(--cyan) !important;
font-weight: 750;
text-decoration: none !important;
margin-top: 8px;
}
.destination-grid {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: 18px !important;
margin-top: 14px;
}
.destination-grid .wrap {
display: grid !important;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 10px !important;
}
.destination-grid label {
min-height: 62px !important;
border: 1px solid var(--border) !important;
border-radius: 14px !important;
background: var(--panel-light) !important;
color: var(--text) !important;
padding: 14px !important;
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease !important;
}
.destination-grid label:hover {
transform: translateY(-2px);
border-color: rgba(124, 92, 255, 0.8) !important;
background: rgba(124, 92, 255, 0.16) !important;
}
.primary-button {
min-height: 52px !important;
border-radius: 14px !important;
font-weight: 850 !important;
}
.path-section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 22px;
padding: 22px;
margin-top: 14px;
}
.path-title {
color: var(--text);
font-size: 0.78rem;
letter-spacing: 0.13em;
font-weight: 900;
margin-bottom: 18px;
}
.path-wrapper {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.path-node {
min-width: max-content;
background: var(--panel-light);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text);
padding: 8px 13px 8px 8px;
display: flex;
align-items: center;
gap: 8px;
}
.path-node span {
width: 27px;
height: 27px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
font-size: 0.75rem;
}
.active-node {
border-color: rgba(77, 215, 255, 0.55);
}
.active-node span {
background: var(--cyan);
color: #07101a;
}
.start-node span {
background: var(--accent);
color: white;
}
.path-line {
width: 24px;
min-width: 24px;
height: 2px;
background: rgba(255, 255, 255, 0.14);
}
.victory-panel {
text-align: center;
border-color: rgba(72, 227, 165, 0.45);
background:
radial-gradient(circle at 50% 0%, rgba(72, 227, 165, 0.19), transparent 48%),
var(--panel);
}
.victory-icon {
color: var(--success);
font-size: 3rem;
margin-bottom: 10px;
}
.victory-panel p {
color: var(--muted);
font-size: 1.05rem;
}
.victory-path {
color: var(--success);
background: rgba(72, 227, 165, 0.08);
border: 1px solid rgba(72, 227, 165, 0.2);
border-radius: 14px;
padding: 15px;
margin-top: 18px;
overflow-wrap: anywhere;
}
.empty-path {
color: var(--muted);
}
.error-panel {
border-color: rgba(255, 100, 120, 0.38);
}
@media (max-width: 760px) {
.gradio-container {
padding: 12px !important;
}
.mission-grid {
grid-template-columns: 1fr;
}
.mission-arrow {
justify-content: center;
transform: rotate(90deg);
height: 25px;
}
.destination-grid .wrap {
grid-template-columns: 1fr !important;
}
.game-stats {
flex-direction: column;
}
}
"""
with gr.Blocks(title="WikiQuest") as demo:
path_state = gr.State([])
current_state = gr.State("")
target_state = gr.State("")
score_state = gr.State(0.0)
gr.HTML(
"""
"""
)
with gr.Group(elem_classes=["setup-panel"]):
with gr.Row():
start_input = gr.Textbox(
label="Starting article",
value="Motorcycle",
placeholder="Example: Motorcycle",
)
target_input = gr.Textbox(
label="Target article",
value="Black hole",
placeholder="Example: Black hole",
)
with gr.Row():
start_button = gr.Button(
"Start Custom Mission",
variant="primary",
elem_classes=["primary-button"],
)
daily_button = gr.Button(
"Play Daily Challenge",
elem_classes=["primary-button"],
)
random_button = gr.Button(
"Generate Random Mission",
elem_classes=["primary-button"],
)
mission_header = gr.HTML()
article_display = gr.HTML(
"""
READY FOR DEPARTURE
Choose a mission above
Every destination shown during the game is a real link
from your current Wikipedia article.
"""
)
destination_picker = gr.Radio(
label="Choose your next destination",
choices=[],
interactive=True,
elem_classes=["destination-grid"],
)
with gr.Row():
travel_button = gr.Button(
"Travel to Selected Article",
variant="primary",
elem_classes=["primary-button"],
)
undo_button = gr.Button(
"Undo Last Move",
elem_classes=["primary-button"],
)
gr.HTML('YOUR JOURNEY
')
path_display = gr.HTML(
'Your journey will appear here.
'
)
gr.HTML("")
game_outputs = [
mission_header,
article_display,
destination_picker,
path_display,
path_state,
current_state,
target_state,
score_state,
]
start_button.click(
fn=start_game,
inputs=[start_input, target_input],
outputs=game_outputs,
)
daily_button.click(
fn=load_daily,
outputs=game_outputs,
)
random_button.click(
fn=load_random,
outputs=game_outputs,
)
travel_button.click(
fn=travel,
inputs=[
destination_picker,
target_state,
path_state,
score_state,
],
outputs=game_outputs,
)
destination_picker.change(
fn=travel,
inputs=[
destination_picker,
target_state,
path_state,
score_state,
],
outputs=game_outputs,
)
undo_button.click(
fn=undo_move,
inputs=[
target_state,
path_state,
],
outputs=game_outputs,
)
demo.launch(
theme=gr.themes.Base(),
css=CSS,
)