"""Doodle Duel — human draws, Qwen (vision) guesses in real time.
Run: uv run doodle_duel/app.py (mock mode)
MODEL_BASE_URL=... uv run ... (needs a vision model endpoint)
"""
from __future__ import annotations
import os
import threading
import time
import gradio as gr
import numpy as np
import config
import game
import visuals
import vision_client
from game import DoodleState
# ── Canvas helpers ────────────────────────────────────────────────────────────
def _blank_canvas():
white = np.full((config.CANVAS_H, config.CANVAS_W, 3), 255, np.uint8)
return {"background": white, "layers": [], "composite": white}
# ── render() returns ALL UI outputs ──────────────────────────────────────────
#
# Output order (13 total):
# [0] topbar [1] ai_bubble [2] thinking [3] result
# [4..6] opt[0..2] (word-choice buttons)
# [7] easy_btn [8] medium_btn [9] hard_btn (mode buttons)
# [10] clear_btn [11] hint_btn
# [12] canvas
#
N_RENDER = 13
_DIFF_LABELS = {
"easy": "🟢 Easy",
"medium": "🟡 Medium",
"hard": "🔴 Hard",
}
def render(state: DoodleState, clear_canvas: bool = False):
p = state.status
choosing = p == "choosing"
drawing = p == "drawing"
over = p in ("won", "lost")
idle = p == "idle"
show_mode = idle or over # difficulty buttons visible between rounds
opts = []
for i in range(config.WORD_CHOICES):
if choosing and i < len(state.options):
opts.append(gr.update(value=state.options[i], visible=True))
else:
opts.append(gr.update(visible=False))
def _mode_btn(diff):
label = _DIFF_LABELS[diff]
if over and state.difficulty == diff:
label = f"{_DIFF_LABELS[diff]} ↺"
return gr.update(value=label,
variant="primary" if state.difficulty == diff else "secondary",
visible=show_mode)
easy_upd = _mode_btn("easy")
medium_upd = _mode_btn("medium")
hard_upd = _mode_btn("hard")
clear_upd = gr.update(visible=drawing)
hints_left = config.MAX_HINTS - state.hints_used
hint_upd = gr.update(value=f"💡 Hint ({hints_left} left)",
visible=drawing and hints_left > 0)
canvas_upd = gr.update(value=_blank_canvas()) if clear_canvas else gr.skip()
return (
visuals.render_topbar(state),
visuals.render_ai(state),
visuals.render_thinking(state),
visuals.render_result(state),
*opts,
easy_upd, medium_upd, hard_upd,
clear_upd, hint_upd,
canvas_upd,
)
# ── Event handlers ────────────────────────────────────────────────────────────
def _warmup_endpoint():
threading.Thread(target=vision_client.health, daemon=True).start()
# ── Sequential guess gate ─────────────────────────────────────────────────────
# Only the timer triggers guesses now (drawing just stashes the canvas). This
# module-level, per-session gate makes those guesses STRICTLY SEQUENTIAL: a new
# model request starts only when (a) none is already in flight, and (b) at least
# GUESS_MIN_INTERVAL has passed since the previous one finished. We keep this in
# a module global rather than gr.State because gr.State hands each handler a
# *copy* — concurrent timer ticks wouldn't share an in-flight flag through it,
# which is what let parallel requests through and tripped HF's 429.
_GUESS = {} # sid -> {"in_flight": bool, "last": float}
_GUESS_LOCK = threading.Lock()
def _sid(request) -> str:
return getattr(request, "session_hash", None) or "default"
def _claim_guess(sid, force=False) -> bool:
"""Try to claim the single guess slot for this session. Returns True only if
no guess is in flight and (unless force) the interval has elapsed; the caller
MUST call _release_guess(sid) when the request completes."""
now = time.monotonic()
with _GUESS_LOCK:
g = _GUESS.setdefault(sid, {"in_flight": False, "last": 0.0})
if g["in_flight"]:
return False
if not force and now - g["last"] < config.GUESS_MIN_INTERVAL:
return False
g["in_flight"] = True
return True
def _release_guess(sid):
"""Release the slot and restart the interval clock once a response returns."""
with _GUESS_LOCK:
g = _GUESS.setdefault(sid, {"in_flight": False, "last": 0.0})
g["in_flight"] = False
g["last"] = time.monotonic()
# ── Model status pill ─────────────────────────────────────────────────────────
# The model runs on an HF Inference Endpoint that scales to zero when idle, so
# the first request after a quiet spell triggers a cold start (~1-2 min). The
# pill below tells the player whether the AI is ready, waking, or offline so an
# empty "Analyzing..." panel during a cold start isn't mistaken for a crash.
_health = {"checked": False, "ok": False, "fails": 0}
def _status_pill(text, color, pulse=False):
dot = (f'')
# Fixed-height, single-line wrapper. The pill text flips from the long
# "Waking the AI…" string to a short "AI ready" exactly once — right when the
# first prediction lands and the model warms up. If the long text is allowed
# to wrap, that flip shrinks the pill from two lines to one, shifting the
# canvas below it UP mid-stroke and leaving a stray diagonal. Reserving a
# constant height and forbidding wrap keeps the layout perfectly still.
return (
'
'
''
)
def render_status():
if config.MOCK_MODE:
return _status_pill("Mock mode — no model connected", "#a78bfa")
if not _health["checked"]:
return _status_pill("Connecting to the AI…", "#f4c04e", pulse=True)
if _health["ok"]:
return _status_pill("AI ready", "#46d39a")
if _health["fails"] <= 6:
return _status_pill("Waking the AI… (cold start, up to ~2 min)", "#f4c04e", pulse=True)
return _status_pill("AI offline — retrying…", "#ef5d6c", pulse=True)
def on_health():
ok, _ = vision_client.health()
_health["checked"] = True
_health["ok"] = ok
_health["fails"] = 0 if ok else _health["fails"] + 1
return render_status()
def on_load():
_warmup_endpoint()
s = DoodleState()
return (s, *render(s, clear_canvas=True))
def on_mode(s, difficulty):
"""User clicked Easy / Medium / Hard — offer 3 words from that pool."""
if s is None:
s = DoodleState()
game.offer_words(s, difficulty)
return (s, *render(s, clear_canvas=True))
def on_choose(s, i):
if s is None:
return (s, *render(DoodleState(), clear_canvas=True))
game.choose_word(s, i)
return (s, *render(s, clear_canvas=True))
def on_capture(s, canvas):
"""Stroke-end (canvas.change): ONLY stash the canvas and update the hidden
state. This event outputs to `st` ALONE (see binding) — never the canvas or
any visible component — so a stroke triggers ZERO re-render: no flicker, no
'reload' feel, and crucially no value pushed back to the Sketchpad (pushing
a value mid/post-stroke is what left the stray diagonal line and disturbed
the canvas). The timer reads this stashed canvas to do all the guessing."""
if s is not None:
game.stash_canvas(s, canvas)
return s
def on_timer(s, request: gr.Request):
"""Timer tick: always update the countdown; guess only when we can claim the
sequential slot (no request in flight + interval elapsed). The guess runs
synchronously here, but other ticks run concurrently and just render the
countdown, so a slow / cold-start call never freezes the clock. The player
keeps getting fresh guesses even while paused — without overlapping calls.
Outputs everything EXCEPT the canvas, so the timer never disturbs drawing."""
if s is None:
return (s, *([gr.skip()] * (N_RENDER - 1)))
game.tick_time(s) # countdown / timeout, no network
if s.status == "drawing":
sid = _sid(request)
if _claim_guess(sid):
try:
game.poll_guess(s, s.last_canvas)
finally:
_release_guess(sid)
*other_outs, _ = render(s) # drop the trailing canvas update
return (s, *other_outs)
def on_hint(s, canvas, request: gr.Request):
if s is None:
return (s, *([gr.skip()] * (N_RENDER - 1)))
game.use_hint(s)
game.stash_canvas(s, canvas) # capture the current drawing
sid = _sid(request)
if _claim_guess(sid, force=True): # guess now (bypass interval, respect in-flight)
try:
game.poll_guess(s, canvas)
finally:
_release_guess(sid)
*other_outs, _ = render(s) # drop the trailing canvas update
return (s, *other_outs)
# ── Injected JS / CSS ─────────────────────────────────────────────────────────
_css_path = "style.css" if os.path.exists("style.css") else "doodle_duel/style.css"
try:
with open(_css_path) as _f:
_CSS_TEXT = _f.read()
except FileNotFoundError:
_CSS_TEXT = ""
_JS = """
"""
_HEAD_HTML = "" + _JS
# ── Build the Gradio UI ───────────────────────────────────────────────────────
with gr.Blocks(title="Doodle Duel") as demo:
st = gr.State()
gr.HTML(_HEAD_HTML)
gr.HTML('🎨 Doodle Duel
'
'Draw a word — the robot tries to guess it in real time!
')
model_status = gr.HTML(render_status())
topbar = gr.HTML()
with gr.Row(elem_id="word-choices"):
opt = [gr.Button("…", visible=False) for _ in range(config.WORD_CHOICES)]
# Side by side again, but every dimension is frozen (see CSS): both columns
# are FIXED pixel widths (immune to the scrollbar changing the container
# width), heights are decoupled, the thoughts panel is a fixed-size box that
# scrolls internally, and the chip bubble lives BELOW the stage so nothing in
# the canvas's own column can ever grow. Result: the canvas can't move or
# rescale when a guess lands.
with gr.Row(elem_id="stage"):
with gr.Column(elem_id="canvas-col"):
canvas = gr.Sketchpad(
height=config.CANVAS_H, type="numpy", label="", show_label=False,
canvas_size=(config.CANVAS_W, config.CANVAS_H),
brush=gr.Brush(default_size=config.BRUSH_SIZE, default_color="#111111"),
)
with gr.Column(elem_id="thoughts-col"):
thinking = gr.HTML()
ai_bubble = gr.HTML()
result = gr.HTML()
with gr.Row(elem_id="controls"):
easy_btn = gr.Button("🟢 Easy", variant="secondary", scale=2)
medium_btn = gr.Button("🟡 Medium", variant="primary", scale=2)
hard_btn = gr.Button("🔴 Hard", variant="secondary", scale=2)
hint_btn = gr.Button("💡 Hint", scale=2, visible=False)
clear_btn = gr.Button("🧽 Clear", scale=1, visible=False)
timer = gr.Timer(1.0) # 1 Hz: countdown shows mm:ss, and halves the
# request rate vs the old 0.5 s tick (eased the 429)
health_timer = gr.Timer(5.0) # poll model health (lightweight /health route)
render_outs = [
topbar, ai_bubble, thinking, result,
*opt,
easy_btn, medium_btn, hard_btn,
clear_btn, hint_btn,
canvas,
]
assert len(render_outs) == N_RENDER, f"{len(render_outs)} != {N_RENDER}"
all_outs = [st, *render_outs]
# Same outputs minus the canvas (which is render_outs[-1]). Events that must
# never touch the Sketchpad (timer, hint, stroke-capture) use this so they
# can't push a value back to the canvas and disturb the drawing.
all_outs_no_canvas = all_outs[:-1]
demo.load(on_load, outputs=all_outs)
demo.load(on_health, outputs=[model_status])
health_timer.tick(on_health, outputs=[model_status])
easy_btn.click(lambda s: on_mode(s, "easy"), inputs=st, outputs=all_outs)
medium_btn.click(lambda s: on_mode(s, "medium"), inputs=st, outputs=all_outs)
hard_btn.click(lambda s: on_mode(s, "hard"), inputs=st, outputs=all_outs)
clear_btn.click(lambda s: (s, *render(s, clear_canvas=True)),
inputs=st, outputs=all_outs)
hint_btn.click(on_hint, inputs=[st, canvas], outputs=all_outs_no_canvas)
for i in range(config.WORD_CHOICES):
opt[i].click(lambda s, i=i: on_choose(s, i), inputs=st, outputs=all_outs)
# Stroke-end only stashes the canvas into the hidden state — it outputs to
# `st` ALONE, so a stroke re-renders nothing and never writes back to the
# canvas. The timer is the sole guesser; it reads the stashed canvas, fires
# one sequential guess at a time, and outputs everything except the canvas.
canvas.change(on_capture, inputs=[st, canvas], outputs=[st])
timer.tick(on_timer, inputs=[st], outputs=all_outs_no_canvas)
# Allow timer ticks to run concurrently: while one tick is blocked on a slow
# (or cold-start) model call, the others still render the countdown, so the
# clock never freezes. The per-session in-flight gate keeps actual model
# calls sequential regardless of how many ticks run at once.
demo.queue(default_concurrency_limit=16)
if __name__ == "__main__":
ok, detail = vision_client.health()
print(f"🎨 Doodle Duel — model backend: {'OK ' if ok else 'DOWN '}{detail}")
port = int(os.environ.get("GRADIO_SERVER_PORT", 7860))
demo.launch(server_name="0.0.0.0", server_port=port,
css_paths=[_css_path], theme=gr.themes.Base())