import random import threading import time import gradio as gr # ZeroGPU Spaces require at least one @spaces.GPU function at startup. This game needs no # GPU, so this is a do-nothing placeholder. It is skipped when `spaces` isn't installed # (local runs and regular CPU hardware). try: import spaces @spaces.GPU def _gpu_placeholder(): return None except ImportError: pass MAX_MOVES = 10 ROOM_TTL_SECONDS = 24 * 60 * 60 # No I, O, 0 or 1 so room codes are easy to read aloud and type on a phone. ROOM_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" DIGITS = list("123456789") # ---------- shared room store (one per running server, shared by every visitor) ---------- _LOCK = threading.Lock() _ROOMS = {} # code -> {"secret": str, "created": float} def create_room(secret: str) -> str: with _LOCK: now = time.time() for code in [c for c, r in _ROOMS.items() if now - r["created"] > ROOM_TTL_SECONDS]: del _ROOMS[code] while True: code = "".join(random.choices(ROOM_ALPHABET, k=4)) if code not in _ROOMS: break _ROOMS[code] = {"secret": secret, "created": now} return code def lookup_room(code: str): with _LOCK: room = _ROOMS.get(code.strip().upper()) if room and time.time() - room["created"] <= ROOM_TTL_SECONDS: return room["secret"] return None # ---------- game logic ---------- def new_code() -> str: """4 unique digits, 1-9 (no zeroes, no repeats).""" return "".join(random.sample("123456789", 4)) def score(secret: str, guess: str): """Return (right_place, right_number). Digits are unique, so a set overlap works.""" right_place = sum(s == g for s, g in zip(secret, guess)) right_number = len(set(secret) & set(guess)) return right_place, right_number def validate(guess: str, previous=()): if len(guess) != 4: return "You do not have the correct character amount (need exactly 4)." if not guess.isdigit() or "0" in guess: return "There are no letters or zeroes in the code." if len(set(guess)) != 4: return "There are no repeats in the code." if guess in previous: return "You already guessed that." return None def win_message(move: int) -> str: if move == 1: return "Wow, first try, that was really lucky." if move < 5: return "Wow, you actually guessed it. Good job!!!" if move < 8: return "Cutting it a little close, but good job." return "Wow, nice guess but almost too late. Good job!" def hint_message(right_number: int) -> str: if right_number == 0: return "Man, that wasn't even close." if right_number <= 2: return "You are doing well, nice guess. JK loser. HAHAHAHAHA" return "You are almost there. Don't let me win. HAHAHAHAHAHA" # ---------- per-visitor state and rendering ---------- def fresh_state(): return {"mode": "menu", "secret": "", "room_code": "", "history": [], "status": "playing"} def game_state(mode, secret, room_code=""): return {"mode": mode, "secret": secret, "room_code": room_code, "history": [], "status": "playing"} def history_table(history) -> str: """Guess history as a Markdown table (fixed layout, so it can't stretch on phones).""" if not history: return "" lines = [ "**Previous guesses**", "", "| Move | Guess | Right place | Right number |", "|:---:|:---:|:---:|:---:|", ] for i, (g, rp, rn) in enumerate(history): lines.append(f"| {i + 1} | `{g}` | {rp} | {rn} |") return "\n".join(lines) def view(s, msg="", reset_marks=False): """Updates for VIEW_OUTPUTS, in order.""" in_game = s["mode"] != "menu" over = s["status"] != "playing" used = len(s["history"]) if s["mode"] == "room": badge = f"Room **{s['room_code']}** ยท a friend set this code" else: badge = "Solo game ยท random code" if over: header = "### Game over" else: header = f"### Move {used + 1} of {MAX_MOVES}\n{MAX_MOVES - used} left" marks = gr.update(visible=in_game and not over) if reset_marks: marks = gr.update(visible=in_game and not over, value=DIGITS) history_md = history_table(s["history"]) again_label = "๐Ÿ”„ Try this code again" if s["mode"] == "room" else "๐Ÿ”„ New game" return [ s, gr.update(visible=not in_game), # menu gr.update(visible=in_game), # game badge, header, marks, gr.update(visible=in_game and not over), # guess row msg, gr.update(value=history_md, visible=used > 0), gr.update(visible=over or used > 0, value=again_label), "", # clear the guess box ] # ---------- event handlers ---------- def on_start_solo(): return view(game_state("solo", new_code()), reset_marks=True) def on_create_room(secret_in): secret_in = (secret_in or "").strip() error = validate(secret_in) if error: return f"**{error}**", "" code = create_room(secret_in) return ( f"Room created. Send this room code to your friend:\n\n## `{code}`\n\n" "They open this app, choose **Join a game**, and enter it. " "Rooms are cleared after 24 hours or when the app restarts." ), "" def on_join(room_in): code = (room_in or "").strip().upper() if len(code) != 4: return view(fresh_state()) + ["**Room codes have 4 characters.**", ""] secret = lookup_room(code) if secret is None: return view(fresh_state()) + [ "**Room not found.** Check the code, or ask your friend to set a new one " "(rooms are cleared when the app restarts).", "", ] return view(game_state("room", secret, code), reset_marks=True) + ["", ""] def on_guess(s, guess_in): if s["mode"] == "menu" or s["status"] != "playing": return view(s) guess = (guess_in or "").strip() error = validate(guess, [h[0] for h in s["history"]]) if error: return view(s, f"**{error}**") right_place, right_number = score(s["secret"], guess) s = {**s, "history": s["history"] + [(guess, right_place, right_number)]} used = len(s["history"]) if guess == s["secret"]: s["status"] = "won" msg = ( f"๐ŸŽ‰ **{win_message(used)}**\n\nThe code was **{s['secret']}**. " f"You cracked it in {used} move(s)." ) elif used >= MAX_MOVES: s["status"] = "lost" msg = f"**Out of moves!** The code was **{s['secret']}**. Better luck next time." else: msg = hint_message(right_number) return view(s, msg) def on_again(s): if s["mode"] == "room": return view(game_state("room", s["secret"], s["room_code"]), reset_marks=True) return view(game_state("solo", new_code()), reset_marks=True) def on_menu(s): return view(fresh_state()) # ---------- UI ---------- # Mobile first: everything is a single narrow column. On wider screens the page grows and the # game splits into two panes (controls on the left, feedback and history on the right). CSS = """ .gradio-container { max-width: 32rem !important; margin: 0 auto !important; padding-left: 0.75rem !important; padding-right: 0.75rem !important; } #menu { max-width: 32rem; margin: 0 auto; } #history table { width: 100%; table-layout: fixed; } #history th, #history td { text-align: center; word-break: break-word; } #marks label { min-width: 2.6rem; justify-content: center; } /* Inputs at 16px+ so iOS Safari does not zoom the page when a field is focused. */ #guess_in input, #guess_in textarea, #room_in input, #room_in textarea, #secret_in input, #secret_in textarea { font-size: 1.25rem !important; letter-spacing: 0.25em; text-align: center; } #room_in input, #room_in textarea { text-transform: uppercase; } /* Touch screens: bigger tap targets. */ @media (pointer: coarse) { button { min-height: 3rem; font-size: 1.05rem !important; } #marks label { min-width: 3rem; min-height: 3rem; font-size: 1.1rem; } #marks .wrap { gap: 0.4rem; } } /* Small phones: tighter spacing, smaller table text. */ @media (max-width: 480px) { .gradio-container { padding-left: 0.5rem !important; padding-right: 0.5rem !important; } h1 { font-size: 1.6rem !important; } #history table { font-size: 0.8rem; } #history th, #history td { padding: 0.3rem 0.2rem; } } /* Laptops and desktops: use the extra width. */ @media (min-width: 900px) { .gradio-container { max-width: 60rem !important; } #game_panes { gap: 1.5rem; } #marks label { min-width: 2.4rem; } } """ # Show the numeric keypad on phones for the digit and room-code fields, and turn off # autocorrect/autocomplete so the browser does not interfere with guesses. HEAD = """ """ with gr.Blocks(title="Crack the Code") as demo: state = gr.State(fresh_state()) gr.Markdown("# ๐Ÿ” Crack the Code") # ----- menu ----- with gr.Column(visible=True, elem_id="menu") as menu_col: gr.Markdown( "Guess a secret 4-digit code in **10 moves**. No repeats and no zeroes. " "Play alone, or set a code for a friend." ) with gr.Tabs(): with gr.Tab("Play solo"): gr.Markdown("I'll make up a random code.") solo_btn = gr.Button("Start", variant="primary") with gr.Tab("Set a code"): gr.Markdown( "Pick a secret code for a friend to guess. You'll get a room code to send them. " "They can join from any device." ) secret_in = gr.Textbox( label="Your secret code", type="password", max_length=4, placeholder="4 digits, 1-9, no repeats", elem_id="secret_in", ) create_btn = gr.Button("Create room", variant="primary") set_out = gr.Markdown() with gr.Tab("Join a game"): gr.Markdown("Enter the room code your friend sent you.") room_in = gr.Textbox( label="Room code", max_length=4, placeholder="e.g. KQ7M", elem_id="room_in" ) join_btn = gr.Button("Join", variant="primary") join_out = gr.Markdown() # ----- game ----- with gr.Column(visible=False) as game_col: badge = gr.Markdown() gr.Markdown( "The code has 4 digits: **no repeats and no zeroes**. " f"Guess it in **{MAX_MOVES} moves** or you lose. " "Good luck, HAHAHAHAHA!!!" ) header = gr.Markdown() # Two panes side by side on wide screens; they stack automatically on narrow ones. with gr.Row(equal_height=False, elem_id="game_panes"): with gr.Column(scale=1, min_width=280): marks = gr.CheckboxGroup( choices=DIGITS, value=DIGITS, label="Still possible", info="Untick a digit to cross it off. This is only a note for you.", elem_id="marks", interactive=True, # outputs-only components default to read-only; this one is a personal notepad ) with gr.Column(visible=True) as guess_row: guess_in = gr.Textbox( label="Your guess", max_length=4, placeholder="e.g. 1234", elem_id="guess_in", ) guess_btn = gr.Button("Guess", variant="primary") with gr.Column(scale=1, min_width=280): msg = gr.Markdown() history = gr.Markdown(visible=False, elem_id="history") again_btn = gr.Button("๐Ÿ”„ New game", visible=False) menu_btn = gr.Button("Back to menu") with gr.Accordion("How hints work", open=False): gr.Markdown( "- **Right place**: digits that are correct *and* in the correct position.\n" "- **Right number**: digits that are in the code at all (in any position).\n" "- Right place is always โ‰ค right number." ) VIEW_OUTPUTS = [ state, menu_col, game_col, badge, header, marks, guess_row, msg, history, again_btn, guess_in, ] solo_btn.click(on_start_solo, None, VIEW_OUTPUTS) create_btn.click(on_create_room, secret_in, [set_out, secret_in]) secret_in.submit(on_create_room, secret_in, [set_out, secret_in]) join_btn.click(on_join, room_in, VIEW_OUTPUTS + [join_out, room_in]) room_in.submit(on_join, room_in, VIEW_OUTPUTS + [join_out, room_in]) guess_btn.click(on_guess, [state, guess_in], VIEW_OUTPUTS) guess_in.submit(on_guess, [state, guess_in], VIEW_OUTPUTS) again_btn.click(on_again, state, VIEW_OUTPUTS) menu_btn.click(on_menu, state, VIEW_OUTPUTS) if __name__ == "__main__": demo.launch(css=CSS, head=HEAD)