Spaces:
Running
Running
| """Micro RPG Engine β Gradio app. | |
| A text RPG generated live by a small (1B-4B) language model. The model imagines; | |
| Python keeps the books. See README.md for the architecture. | |
| Run: python app.py | |
| Test: MICRORPG_BACKEND=mock python app.py (no weights, no network) | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import gradio as gr | |
| from engine import GameEngine, GameState, build_backend | |
| from engine.parser import TurnResult | |
| BACKEND_KIND = os.environ.get("MICRORPG_BACKEND", "transformers") | |
| MODEL_ID = os.environ.get("MICRORPG_MODEL", "Qwen/Qwen3-4B-Instruct-2507") | |
| # Build the backend once at startup (loading weights can be slow). | |
| _backend = build_backend(BACKEND_KIND, MODEL_ID) | |
| # --------------------------------------------------------------------------- # | |
| # rendering helpers | |
| # --------------------------------------------------------------------------- # | |
| def render_story(history: list[TurnResult]) -> str: | |
| """Render the running story as markdown.""" | |
| if not history: | |
| return "_Press **Begin Adventure** to enter the world..._" | |
| parts = [] | |
| for i, turn in enumerate(history): | |
| if turn.narrative: | |
| parts.append(turn.narrative) | |
| if turn.applied: | |
| parts.append("> " + " Β· ".join(turn.applied)) | |
| return "\n\n".join(parts) | |
| def render_stats(state: GameState) -> str: | |
| hp_pct = int(100 * state.hp / max(1, state.max_hp)) | |
| bar = ( | |
| f'<div class="hpbar"><div class="hpfill" style="width:{hp_pct}%"></div></div>' | |
| ) | |
| lines = [ | |
| "### βοΈ Hero", | |
| f"**HP** {state.hp}/{state.max_hp}", | |
| bar, | |
| f"**Level** {state.level} (XP {state.xp}/{state.level*10})", | |
| f"**Gold** {state.gold} πͺ", | |
| f"**Location** {state.location}", | |
| "", | |
| "**Inventory**", | |
| ] | |
| lines += [f"- {it}" for it in state.inventory] or ["- (empty)"] | |
| if state.enemy and state.enemy.alive: | |
| e = state.enemy | |
| lines += ["", f"### π‘οΈ Combat", f"**{e.name}** β {e.hp}/{e.max_hp} HP"] | |
| if state.npcs: | |
| lines += ["", "**Known characters**"] | |
| lines += [f"- {n.name} ({n.role or n.disposition})" for n in state.npcs.values()] | |
| if state.game_over: | |
| lines += ["", "### π **GAME OVER**"] | |
| return "\n".join(lines) | |
| def choice_updates(choices: list[str]): | |
| """Map up to 3 model-proposed choices onto the three choice buttons.""" | |
| updates = [] | |
| for i in range(3): | |
| if i < len(choices): | |
| updates.append(gr.update(value=choices[i], visible=True)) | |
| else: | |
| updates.append(gr.update(visible=False)) | |
| return updates | |
| # --------------------------------------------------------------------------- # | |
| # event handlers (engine lives in gr.State so each browser session is isolated) | |
| # --------------------------------------------------------------------------- # | |
| def new_game(): | |
| engine = GameEngine(_backend) | |
| turn = engine.start() | |
| return ( | |
| engine, | |
| render_story(engine.history), | |
| render_stats(engine.state), | |
| *choice_updates(turn.choices), | |
| "", # clear the textbox | |
| ) | |
| def take_action(engine: GameEngine, action: str): | |
| if engine is None: | |
| engine = GameEngine(_backend) | |
| engine.start() | |
| if action and action.strip(): | |
| turn = engine.act(action) | |
| else: | |
| turn = engine.history[-1] if engine.history else engine.start() | |
| return ( | |
| engine, | |
| render_story(engine.history), | |
| render_stats(engine.state), | |
| *choice_updates(turn.choices), | |
| "", | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| def build_ui(): | |
| css_path = os.path.join(os.path.dirname(__file__), "style.css") | |
| css = open(css_path, encoding="utf-8").read() if os.path.exists(css_path) else "" | |
| with gr.Blocks(css=css, title="Micro RPG Engine", theme=gr.themes.Base()) as demo: | |
| engine_state = gr.State(None) | |
| gr.Markdown( | |
| f"# π Micro RPG Engine\n" | |
| f"*A world dreamed up live by a small model β `{MODEL_ID}` " | |
| f"({BACKEND_KIND}). No AI, no game.*", | |
| elem_id="title-md", | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| story = gr.Markdown(render_story([]), elem_id="story") | |
| with gr.Column(scale=1): | |
| stats = gr.Markdown("", elem_id="stats") | |
| with gr.Row(): | |
| c1 = gr.Button("...", visible=False, variant="secondary") | |
| c2 = gr.Button("...", visible=False, variant="secondary") | |
| c3 = gr.Button("...", visible=False, variant="secondary") | |
| with gr.Row(): | |
| action = gr.Textbox( | |
| placeholder="...or type your own action and press Enter", | |
| show_label=False, | |
| elem_id="action-input", | |
| scale=4, | |
| ) | |
| send = gr.Button("Act", variant="primary", scale=1) | |
| with gr.Row(): | |
| begin = gr.Button("π² Begin / Restart Adventure", variant="primary") | |
| outputs = [engine_state, story, stats, c1, c2, c3, action] | |
| begin.click(new_game, outputs=outputs) | |
| send.click(take_action, inputs=[engine_state, action], outputs=outputs) | |
| action.submit(take_action, inputs=[engine_state, action], outputs=outputs) | |
| # Clicking a choice button sends that choice text as the action. | |
| for btn in (c1, c2, c3): | |
| btn.click(take_action, inputs=[engine_state, btn], outputs=outputs) | |
| return demo | |
| if __name__ == "__main__": | |
| build_ui().launch() | |