"""Thousand Token Wood -- a tiny emergent economy of small-model agents. Gradio app for the Build Small Hackathon. Five woodland creatures, each driven by a different lab's small model served on Modal, trade goods for pebbles, gossip, and react to "Wood Legends" (famous market manias reskinned). You poke the economy and watch bubbles, crashes, and a widening wealth gap emerge. Run locally without a GPU: TTW_DUMMY=1 python app.py Run against the real model: python -m modal deploy serve.py && python app.py """ from __future__ import annotations import os import time import gradio as gr from ttw import narrate from ttw.council import ( PARAM_BUDGET_CAP_B, assert_param_budget, creature_engine_label, creature_lab_color, ) from ttw.game import Game from ttw.narrator import make_narrator from ttw.replay import frame_to_views, load_attract from ttw.world import GOODS, seed_world AUTO_SECONDS = 3.0 # Pause between recorded frames when watching the saga. Env-overridable so the # demo-video recorder can slow the replay to narration pace locally; the Space # keeps the snappy default. REPLAY_SECONDS = float(os.environ.get("TTW_REPLAY_SECONDS", "2.2")) # The recorded attract run, loaded once at import (empty list if not recorded yet). ATTRACT_FRAMES = load_attract() # The fixed cast names, for the Patron console dropdowns. Read once from a fresh # seed so the console and the engine never drift on who lives in the wood. CAST = list(seed_world().creatures.keys()) # Which console inputs each move actually uses -- the single source of truth for # the contextual console, read by both on_move_kind (visibility) and the matrix # test. Field names map to components: tip_text -> move_text, other -> move_other. VIS_ORDER = ["target", "other", "good", "amount", "tip_text", "tip_truth"] MOVE_FIELDS: dict[str, set[str]] = { "short": {"good"}, "tip": {"target", "good", "tip_text", "tip_truth"}, "bribe": {"target", "amount"}, "lend": {"target", "amount"}, "alliance": {"target", "other"}, "corner": {"target", "good", "amount"}, } # The six move choices for the console selector, ordered money-move first, with # player-facing labels mapped to the engine `kind` strings the policy expects. MOVE_CHOICES = [ ("๐Ÿ’น Short a good (make money)", "short"), ("๐Ÿ—ฃ๏ธ Whisper a tip", "tip"), ("๐Ÿ’ฐ Bribe a creature", "bribe"), ("๐Ÿฆ Lend pebbles", "lend"), ("๐Ÿค Broker an alliance", "alliance"), ("๐Ÿ“ฆ Fund a corner", "corner"), ] def on_move_kind(kind: str): """Show only the console inputs the selected move uses, hide the rest. Separate Gradio event from the game handlers: its outputs are the INPUT components (visibility updates), in VIS_ORDER, never the 18-wide game outputs.""" fields = MOVE_FIELDS.get(kind, set()) return tuple(gr.update(visible=(name in fields)) for name in VIS_ORDER) # A small status pill: the judge always knows whether they are watching the # instant recorded saga or a live council turn. Dark self-contained chips (light # text on dark) so they read in any Space theme without an id-scoped rule. PILL_LIVE = ( "โ— LIVE - council warm" ) PILL_REPLAY = ( "โ— REPLAY (recorded)" ) def build_council_strip() -> str: """The Council strip: five lab-colored chips naming each creature's model. Static (built once at import): the single most valuable claim of the project is that five labs' small models think side by side, so the UI says it in the first glance, above the controls. Each chip carries its lab's accent color, the same color the creature's card and thought border use, so lab identity is one consistent visual language. The budget line is derived from the param table so the number can never drift from the actual council. """ total = assert_param_budget() chips = [] for name in CAST: model, lab = creature_engine_label(name) color = creature_lab_color(name) label = f"{model} ยท {lab}" if model else "small model" chips.append( "" f"{narrate.avatar(name)}" f"{name} {label}" ) budget = ( "" f"distinct-engine budget {total:g}B โ‰ค {PARAM_BUDGET_CAP_B}B" ) return ( "
" "The Council ยท five labs, five minds
" "
" + "".join(chips) + budget + "
" ) COUNCIL_STRIP_HTML = build_council_strip() def make_policy(): """Pick the turn policy from the environment. TTW_DUMMY=1 (the no-GPU default for dev and tests) uses the random policy. TTW_COUNCIL=1 routes each creature to its assigned multi-model council engine. Otherwise a single ModalLLM drives the whole cast (the v1 path). """ if os.environ.get("TTW_DUMMY") == "1": from ttw.dummy import make_random_policy return make_random_policy(seed=7) if os.environ.get("TTW_COUNCIL") == "1": from ttw.agents import make_council_policy from ttw.llm import build_council_clients return make_council_policy(build_council_clients(), temperature=0.7) from ttw.agents import make_llm_policy from ttw.llm import ModalLLM return make_llm_policy(ModalLLM(), temperature=0.7) def new_game() -> Game: return Game(make_policy(), deck_seed=7, narrator=make_narrator()) def _views(game: Game): return ( game.town_html(), game.price_frame(), game.gini_frame(), game.journal_markdown(), game.traces_markdown(), game.legend_markdown(), game.patron_markdown(), game.investigation_markdown(), game.moves_log_markdown(), game.relationship_markdown(), game.chapter_markdown(), game.heat_bar_html(), game.relationship_map_html(), game.cutscene_html(), game.exposure_markdown(), ) def _tail(replay: bool, game: Game): """The two trailing outputs every handler must emit: the mode pill and the raw-model-output panel. Kept here so every handler stays in lockstep and the 16->18 arity contract can never drift in one place but not another.""" pill = PILL_REPLAY if replay else PILL_LIVE raw = game.raw_output_markdown() if game is not None else "" return (pill, raw) def init(): # Seed a live game (ready for the judge to Step), but if a recorded run # exists, paint its opening frame so the Space is rich and alive on load # instead of an empty turn-0 board while the council cold-starts. The opening # paint of a recorded run is REPLAY; a bare live seed is LIVE. game = new_game() if ATTRACT_FRAMES: return (game, *frame_to_views(ATTRACT_FRAMES[0]), PILL_REPLAY, "") return (game, *_views(game), *_tail(False, game)) def do_watch(game: Game): # Generator: walk the recorded run frame by frame so the saga animates in # place. The live game seeded at load is passed back UNCHANGED in the state # slot every yield (a gr.State must receive the real value, never gr.update), # so the judge can Step into their own game the moment the replay ends. Pure # data: no model, no Modal, no GPU. Every yield carries the REPLAY pill and an # empty raw panel (recorded frames store no raw output), supplied here so # replay.py's stored frames stay 15-wide and need no migration. if not ATTRACT_FRAMES: if game is not None: yield (game, *_views(game), *_tail(False, game)) return for i, frame in enumerate(ATTRACT_FRAMES): yield (game, *frame_to_views(frame), PILL_REPLAY, "") if i < len(ATTRACT_FRAMES) - 1: time.sleep(REPLAY_SECONDS) # Shown in the ticker slot while the (possibly cold) model call is running, so a # judge never stares at a frozen UI. The first step of a session pays Modal's # cold start (~60s); later steps return in a second or two. _COLD_NOTE = ( "### ๐ŸŒ™ Waking the wood...\n" "The creatures are stirring. The **first** step warms up the model on Modal " "and can take up to a minute. After that the wood moves quickly." ) _THINKING_NOTE = "### ๐Ÿฆ‰ The creatures are deciding...\nReading the market, weighing trades." def _thinking_views(game: Game): """Interim frame: keep every panel, but replace the ticker with a status note so the user sees instant feedback before the model returns.""" note = _COLD_NOTE if game.world.turn == 0 else _THINKING_NOTE return ( game.town_html(), game.price_frame(), game.gini_frame(), note, game.traces_markdown(), game.legend_markdown(), game.patron_markdown(), game.investigation_markdown(), game.moves_log_markdown(), game.relationship_markdown(), game.chapter_markdown(), game.heat_bar_html(), game.relationship_map_html(), game.cutscene_html(), game.exposure_markdown(), ) def do_step(game: Game): # Generator: yield an immediate "thinking" frame, then the real result after # the model call. Gradio streams both, so the UI never appears frozen. if game is None: # Unreachable on the Space (demo.load seeds a Game first), but never # dereference None: yield a no-op frame for every view output. The state # slot still receives the real (None) value, never gr.update(). yield (game, *([gr.update()] * 17)) return yield (game, *_thinking_views(game), *_tail(False, game)) game.step() yield (game, *_views(game), *_tail(False, game)) def do_tempt(game: Game): if game is not None: game.tempt_fate() return (game, *_views(game), *_tail(False, game)) def do_preset(game: Game): """Arm a first gambit in one click: short the good the next legend will hit, and whisper a TRUE tip on that same good (so the short settles into profit when the legend fires). peek() is non-consuming, so the later Tempt Fate draws the same legend; the queued moves echo to the ticker. The judge/player then presses Tempt Fate, then Step, to collect. truth=True is the player's own move (off-prompt as always), so the firewall holds.""" if game is not None: good = game.deck.peek().affected_good target = CAST[0] # Short uses only `good` (fixed qty/cost); the tip carries the true flag # and binds to the same legend, so the short settles into profit. game.queue_player_move("short", good=good) game.queue_player_move("tip", target=target, good=good, text=f"A crash is coming for {good}.", truth=True) return (game, *_views(game), *_tail(False, game)) def do_move(game: Game, kind: str, target: str, good: str, amount: float, text: str, other: str, tip_truth: str): """Queue one Patron power move (no auto-step: keep the ready-then-Step rhythm). `tip_truth` is the radio value ("true"/"false"); it is consumed only by a tip and converted to a boolean here. It is the hidden truth flag, kept off-prompt. """ if game is not None: game.queue_player_move( kind, target=target, good=good, amount=int(amount), text=text or "", other=other, truth=(tip_truth == "true"), ) return (game, *_views(game), *_tail(False, game)) def do_reset(): return init() # Off-Brand custom look: a storybook display font for the banner/headers, a # hand-illustrated town square (HTML cards from game.town_html), and parchment # accent panels. Inline-styled cards + forced dark text keep everything readable # in light OR dark Space themes. THEME = gr.themes.Soft( primary_hue=gr.themes.colors.green, secondary_hue=gr.themes.colors.amber, font=[gr.themes.GoogleFont("Nunito"), "system-ui", "sans-serif"], ) CSS = """ @import url('https://fonts.googleapis.com/css2?family=Fredoka:wght@500;600;700&display=swap'); @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap'); /* Operator-console terminal chrome. A dark framed shell around the whole grid; the meter numerals and WSJ headline use IBM Plex Mono for the console look. */ #ttw-console { background: #0b0f14; border: 1px solid #1c2530; border-radius: 16px; padding: 14px 16px; box-shadow: inset 0 0 24px rgba(0,0,0,.35); } #ttw-banner { background: linear-gradient(135deg, #2f6b3d 0%, #4f9d52 45%, #d8a94b 100%); border-radius: 16px; padding: 18px 22px; margin-bottom: 6px; box-shadow: 0 4px 14px rgba(40,70,40,.25); color: #fffdf5; } #ttw-banner h1 { font-family: 'Fredoka', system-ui, sans-serif; font-weight: 700; font-size: 32px; margin: 0 0 4px; color: #fffdf5; letter-spacing: .3px; } #ttw-banner p { margin: 0; font-size: 15px; opacity: .96; color: #fbf6e8; } .gradio-container h3 { font-family: 'Fredoka', system-ui, sans-serif; letter-spacing: .2px; } /* The Council strip and the raw-output panel are LIGHT surfaces, so they carry the same id-scoped !important dark-text rule as the other cream panels to stay legible on a dark Space theme. */ #council-panel {background:#faf6ea !important; border:1px solid #ddd0aa; border-radius:12px; padding:10px 14px; margin-bottom:6px;} #council-panel, #council-panel * {color:#2e2519 !important;} #raw-panel {background:#f6f1e3 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 12px;} #raw-panel, #raw-panel * {color:#2e2519 !important;} #legend-panel {background:#fff8e6 !important; border:1px solid #e6c48a; border-radius:12px; padding:10px 16px;} #ticker-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 16px;} #legend-panel, #legend-panel *, #ticker-panel, #ticker-panel * {color:#2e2519 !important;} /* The dark Space theme forces a light body-text color with !important onto the creature cards (cream background), washing them out. An id-scoped !important rule beats it so the card text stays dark and legible. */ #town-panel, #town-panel * {color:#2e2519 !important;} #patron-panel {background:#f3ead6 !important; border:1px solid #d8c79a; border-radius:12px; padding:10px 16px;} #patron-panel, #patron-panel * {color:#2e2519 !important;} #exposure-panel {background:#f3ead6 !important; border:1px solid #d8c79a; border-radius:12px; padding:10px 16px;} #exposure-panel, #exposure-panel * {color:#2e2519 !important;} #moveslog-panel {background:#f3ead6 !important; border:1px solid #d8c79a; border-radius:12px; padding:10px 16px;} #moveslog-panel, #moveslog-panel * {color:#2e2519 !important;} #investigation-panel {background:#f3e0e0 !important; border:1px solid #d8a0a0; border-radius:12px; padding:10px 16px;} #investigation-panel, #investigation-panel * {color:#2e2519 !important;} #relations-panel {background:#eef3ea !important; border:1px solid #b9cdb0; border-radius:12px; padding:10px 16px;} #relations-panel, #relations-panel * {color:#2e2519 !important;} #chapter-panel {background:#f5efe0 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 14px; margin-bottom:6px;} #chapter-panel, #chapter-panel * {color:#2e2519 !important;} /* The relationship heat-matrix is a light surface, so it carries the same id-scoped !important dark-text rule as the other cream panels. The heat-panel and cutscene-panel are DARK surfaces with light inline text (safe by construction); they intentionally get no dark !important rule. */ #relmap-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 14px;} #relmap-panel, #relmap-panel * {color:#2e2519 !important;} /* Cinematic title card: full-viewport overlay, holds ~3s, fades out, then stays invisible (forwards). pointer-events:none so it never intercepts a click. */ #ttw-titlecard { position: fixed; inset: 0; z-index: 9999; pointer-events: none; display: flex; align-items: center; justify-content: center; background: radial-gradient(ellipse at center, #1d3a24 0%, #0c1810 70%, #070f0a 100%); animation: tc-fade 4.6s ease forwards; } /* If animations are suppressed (reduced-motion), the overlay must never linger opaque over the app -- skip the cinematic entirely. */ @media (prefers-reduced-motion: reduce) { #ttw-titlecard { display: none; } } @keyframes tc-fade { 0%, 70% { opacity: 1; visibility: visible; } 100% { opacity: 0; visibility: hidden; } } #ttw-titlecard .tc-inner { text-align: center; animation: tc-rise 1.4s ease both; } @keyframes tc-rise { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } #ttw-titlecard .tc-title { font-family: 'Fredoka', system-ui, sans-serif; font-weight: 700; font-size: clamp(34px, 6vw, 64px); color: #fffdf5; letter-spacing: .5px; text-shadow: 0 4px 24px rgba(0,0,0,.55); } #ttw-titlecard .tc-sub { margin-top: 10px; font-family: 'Fredoka', system-ui, sans-serif; font-weight: 500; font-size: clamp(16px, 2.2vw, 24px); color: #d8cba4; letter-spacing: 2px; } #ttw-titlecard .tc-dots { margin-top: 18px; } #ttw-titlecard .tc-dots span { display: inline-block; width: 13px; height: 13px; border-radius: 50%; margin: 0 7px; box-shadow: 0 0 12px rgba(255,255,255,.25); } #ttw-titlecard .tc-tag { margin-top: 16px; font-family: 'IBM Plex Mono', monospace; font-size: clamp(12px, 1.5vw, 15px); color: #8fae8f; letter-spacing: 3px; text-transform: uppercase; } """ with gr.Blocks(title="Thousand Token Wood") as demo: # Cinematic title card: a pure-CSS overlay that holds ~3s then fades itself # out (keyframes, fill-mode forwards). pointer-events:none always, so it can # never block a click; static HTML outside the game outputs, zero arity risk. gr.HTML( "" ) gr.HTML( "
" "

๐Ÿ„ Thousand Token Wood

" "

You are the Patron, a shadow financier. Grow your purse by trading on " "inside information: short a good, whisper a true tip to set up its crash, Tempt Fate to " "draw the legend, then Step to collect. Every move raises your heat; cross Magistrate " "Heron's line at 70 and an inquiry opens. Watch the saga, or run your first gambit " "below.

" "
" ) game_state = gr.State(None) with gr.Column(elem_id="ttw-console"): # Lead with the proof: five labs' models, named, before any control. gr.HTML(COUNCIL_STRIP_HTML, elem_id="council-panel") # The authored arc as a persistent stage tracker, so a muted-video judge # can follow the crime story (whispers -> heat -> inquiry -> reckoning). chapter_md = gr.HTML(elem_id="chapter-panel") # The rules card: opened once, states the goal and the gambit. Plain # Markdown in theme chrome (no custom light panel), so it stays legible # in a dark Space theme exactly like the other accordions. with gr.Accordion("How to win (30s read)", open=False): gr.Markdown( "**Your score is your purse.** Grow it. The cleanest money is a crash you " "set up yourself:\n\n" "1. **Short** a good (you profit when its price falls).\n" "2. **Whisper a TRUE tip** to a creature about that same good.\n" "3. **Tempt Fate** to draw the Wood Legend you set up.\n" "4. **Step**: the price craters and your short settles into profit.\n\n" "Every move adds **heat**. Cross Magistrate Heron's line (70) and an inquiry " "opens: investigation, fine, frozen pebbles. The whole game is that tension, " "each dirty win that grows your purse also pushes your heat toward the line." ) # Prominent action row: the things a judge actually presses, plus the # live/replay status pill. "Run my first gambit" arms a real short + true # tip in one click so a new player reaches the payoff immediately. with gr.Row(): step_btn = gr.Button("Step (live council)", variant="primary") tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)") preset_btn = gr.Button("โšก Run my first gambit", variant="primary") reset_btn = gr.Button("Reset the wood") auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False) mode_pill = gr.HTML(elem_id="mode-pill") gr.Markdown( "_New? Press **โšก Run my first gambit** to auto-arm a short and a true tip, " "then **Tempt Fate** and **Step (live council)** to collect the crash._" ) # The hero path: a full dramatic run, instantly, with no warm-up. This is # the latency mitigation, so it is the most prominent thing on the page. if ATTRACT_FRAMES: watch_btn = gr.Button("โ–ถ Watch the saga (recorded run)", variant="primary") gr.Markdown( "_A full dramatic run plays instantly, no model warm-up. " "Then take the controls and Step into your own._" ) # Operator strip: the Patron's meters and live heat gauge are always # visible so cost-and-consequence reads at a glance. with gr.Row(): with gr.Column(scale=1): patron_md = gr.Markdown(elem_id="patron-panel") with gr.Column(scale=1): heat_html = gr.HTML(elem_id="heat-panel") # The live gambit exposure meter: expected payoff if the legend fires now # plus the wood-wariness gauge, so the player watches their edge decay as # they burn the wood (the v4 causation made visible). exposure_md = gr.Markdown(elem_id="exposure-panel") legend_md = gr.Markdown(elem_id="legend-panel") # Monitor row: the illustrated town cards beside the live charts. Moved # ABOVE the console so the first paint is the living economy, not a form. with gr.Row(): with gr.Column(scale=2): gr.Markdown("### The town square") town = gr.HTML(elem_id="town-panel") with gr.Accordion("What the creatures are thinking", open=True): traces = gr.Markdown() with gr.Accordion("View raw model output (is this scripted?)", open=False): raw_md = gr.Markdown(elem_id="raw-panel") with gr.Column(scale=3): gr.Markdown("### Prices") price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240, elem_id="price-plot") gr.Markdown("### Wealth gap (Gini)") gini_plot = gr.LinePlot(x="turn", y="gini", height=160, y_lim=[0, 1]) # Intel row: relationship heat-matrix, the WSJ cutscene, and the case file. with gr.Row(): with gr.Column(scale=2): gr.Markdown("### Bonds and grudges") relmap_html = gr.HTML(elem_id="relmap-panel") with gr.Accordion("Relationship details", open=False): relations_md = gr.Markdown(elem_id="relations-panel") with gr.Column(scale=2): gr.Markdown("### The Wood Street Journal") cutscene = gr.HTML(elem_id="cutscene-panel") with gr.Column(scale=2): investigation_md = gr.Markdown(elem_id="investigation-panel") # The move controls, tucked away so the judge sees the toy before the # knobs. Pick a move and only the fields THAT move uses appear (a # contextual console), then one button issues it. with gr.Accordion("๐ŸŽฉ The Patron's console - short, whisper, bribe (this is how you trade)", open=False): gr.Markdown( "_Pick a move, fill the fields it reveals, press **Issue move**, " "then **Step** to watch the wood react._" ) move_kind = gr.Radio(MOVE_CHOICES, value="short", label="Move") with gr.Row(): # Each input starts visible only if the default move ("short") # uses it, matching MOVE_FIELDS["short"] = {"good"}; on_move_kind # toggles the rest on selection. move_target = gr.Dropdown(CAST, value=CAST[0], label="Target creature", visible=False) move_other = gr.Dropdown(CAST, value=CAST[1], label="Ally (for Broker alliance)", visible=False) move_good = gr.Dropdown(GOODS, value="berries", label="Good (to short / corner)", visible=True) move_amount = gr.Number(value=50, label="Amount (pebbles)", precision=0, visible=False) with gr.Row(): move_text = gr.Textbox(label="Tip (for Whisper)", placeholder="the honey crop will fail...", visible=False) tip_truth = gr.Radio( ["true", "false"], value="true", label="Tip is... (only a TRUE tip sets up the crash)", visible=False, ) issue_btn = gr.Button("Issue move", variant="primary") gr.Markdown("### The Patron's ledger") moveslog_md = gr.Markdown(elem_id="moveslog-panel") gr.Markdown("### The wood's news") ticker = gr.Markdown(elem_id="ticker-panel") timer = gr.Timer(AUTO_SECONDS, active=False) # Order MUST match: game_state first, then _views() in order (town, price, # gini, journal, traces, legend, patron, investigation, moveslog, relations, # chapter, heat, relmap, cutscene, exposure), then the two _tail() outputs # (mode_pill, raw_md). 18 outputs = game_state + 15 views + 2 tail. The # exposure view is the LAST frame view (before the tail) so replay.py's # frame_to_views stays in lockstep and the tail stays appended after it. outputs = [ game_state, town, price_plot, gini_plot, ticker, traces, legend_md, patron_md, investigation_md, moveslog_md, relations_md, chapter_md, heat_html, relmap_html, cutscene, exposure_md, mode_pill, raw_md, ] # MUST match do_move's positional params: game, kind, target, good, amount, # text, other, tip_truth. The move-type selector supplies `kind` at index 1. move_inputs = [game_state, move_kind, move_target, move_good, move_amount, move_text, move_other, tip_truth] demo.load(init, outputs=outputs) step_btn.click(do_step, game_state, outputs) timer.tick(do_step, game_state, outputs) # Re-assert AUTO_SECONDS so toggling doesn't reset to Gradio's default interval. auto.change(lambda a: gr.Timer(AUTO_SECONDS, active=a), auto, timer) tempt_btn.click(do_tempt, game_state, outputs) preset_btn.click(do_preset, game_state, outputs) if ATTRACT_FRAMES: watch_btn.click(do_watch, game_state, outputs) # Contextual console: selecting a move reveals only the fields it uses. This # is a SEPARATE event whose outputs are the INPUT components (visibility), # never the 18-wide game outputs, in VIS_ORDER. move_kind.change( on_move_kind, move_kind, [move_target, move_other, move_good, move_amount, move_text, tip_truth], ) # One button issues the selected move; do_move reads `kind` from the selector # (move_inputs index 1) and ignores the knobs a given kind does not use. issue_btn.click(do_move, move_inputs, outputs) reset_btn.click(do_reset, outputs=outputs) if __name__ == "__main__": demo.launch(theme=THEME, css=CSS)