McRogueFace/games/unwritten/design/ARCHITECTURE.md
John McCardle 80908f771c UNWRITTEN: complete short-form RPG at games/unwritten/
A 30-45 minute turn-based RPG built overnight as an engine showcase:
six recruitable characters (party of three), four recurring NPCs,
choice-driven dialogue with visible-but-locked skill checks, shops,
loot, levels, two bosses with in-battle Talk options, the reverted
grey-town beat, and an epilogue assembled from the player's actual
choices. 33 dialogue scenes / 171 nodes. Terrain is ColorLayers only;
kenney_tinydungeon supplies characters, items, and props.

Creative direction, story, dialogue, maps, and systems design by
Fable; implementation by four Opus subagents (framework, battle,
overworld, integration). Verified by tests/playthrough.py: a scripted
headless run of all three acts, 20/20 beats passing, plus per-system
unit tests (script integrity, UI kit, 225-battle balance sim,
overworld).

Run: cd build && ./mcrogueface --exec ../games/unwritten/main.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vxWJ6GY384SmZiMVXgrF4
2026-07-03 10:11:12 -04:00

13 KiB

UNWRITTEN — Architecture & Contracts

Read BIBLE.md (canon) and SYSTEMS.md (numbers) first. This file is the implementation contract: module boundaries, signatures, engine gotchas, and the testing/QA harness every agent must use.

0. Run instructions

  • Game root: /home/john/Development/McRogueFace/games/unwritten/
  • Windowed: cd /home/john/Development/McRogueFace/build && ./mcrogueface --exec ../games/unwritten/main.py
  • Headless test: cd build && ./mcrogueface --headless --exec ../games/unwritten/tests/<test>.py
  • main.py must do sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) so import core.../import data.../import systems... work. Tests insert the game root the same way (they live one level down).
  • Asset path is relative to the BUILD dir: assets/kenney_tinydungeon.png works because cwd is build/. Texture: mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) — construct ONCE in core/assets.py, share everywhere.

1. Engine gotchas (hard-won; violating these wastes hours)

  1. ASCII ONLY in every .py file (the --exec loader rejects non-ASCII). No em-dash, no curly quotes, no unicode arrows — in code OR string literals.
  2. Headless: timers DO NOT fire on their own. Tests drive time with mcrfpy.step(1/60) (fires each due Timer at most once per call) or call game functions directly. automation.screenshot(path) is synchronous (renders current state immediately). Windowed: timers fire in real time.
  3. mcrfpy.Texture(path, w, h) — positional, not grid_size kwarg.
  4. Sprite.scale is a single float. Caption.font_size is the text size. Caption.size is READ-ONLY text dimensions (use it to center: cap.x = cx - cap.size.x / 2 AFTER setting text/font_size).
  5. Frames/Captions/Sprites accept constructor kwargs for most properties (fill_color=, outline=, ...). Color args: mcrfpy.Color(r, g, b[, a]).
  6. Scene: s = mcrfpy.Scene(name); s.children collection; activate via mcrfpy.current_scene = s. Keyboard: s.on_key = fn(key, state) with mcrfpy.Key.* / mcrfpy.InputState.PRESSED/RELEASED. Compare enums with == (a quirk in != was fixed but don't tempt it).
  7. Grid: mcrfpy.Grid(grid_size=(w,h), pos=..., size=...) (no texture needed when only ColorLayers draw terrain). grid.zoom, grid.center (pixels; cell = 16px pre-zoom), grid.center_camera((tx, ty)) for tile coords. Layers: mcrfpy.ColorLayer(name=..., z_index=...) then grid.add_layer(l), l.set((x,y), color), l.fill(color). Negative z renders under entities.
  8. Entities: mcrfpy.Entity(grid_pos=(x,y), texture=TEX, sprite_index=i) then grid.entities.append(e). Remove with e.die(). e.draw_pos is the fractional render position (use for bob/lerp), e.grid_pos is logic.
  9. Everything animatable: obj.animate("x", 500.0, 0.3, mcrfpy.Easing.EASE_OUT); callback kwarg receives (target, prop, value); loop=True for idle bobs. Animate color components via property paths like "fill_color.a" — but NOT frame.fill_color.r = x directly (Color reads are copies; whole-value assignment or animate() only).
  10. clip_children=True on Frame for panels that scroll/slide; explicit z_index whenever two siblings overlap (ties break unpredictably).
  11. Timer: mcrfpy.Timer(name, cb, ms), cb = (timer, runtime_ms). Names are global — prefix with your system ("bat_", "dlg_", "ow_"). .stop() dangling timers in every teardown; a timer holding a dead scene's objects is the #1 crash source.
  12. Scene-level Frames at 1024x768; design for exactly that resolution.
  13. automation.keyDown/keyUp/typewrite exist for input-injection tests, but prefer calling game functions directly in headless tests (deterministic).

2. File layout & ownership

games/unwritten/
  main.py                     # bootstrap, title screen, chapter select   [Agent A]
  core/
    palette.py                # ALL colors/fonts consts from BIBLE §6     [Agent A]
    assets.py                 # the one Texture; SPRITES dict of indices  [Agent A]
    ui.py                     # widget kit (below)                        [Agent A]
    inputstack.py             # modal input dispatch (below)              [Agent A]
    tween.py                  # helpers: fade_scene, float_text, shake    [Agent A]
  data/
    script_act1.py ... act3.py, epilogue.py   # dialogue trees   [FABLE - already written, DO NOT EDIT text]
    characters.py             # party tables from SYSTEMS §1/§3           [Agent B]
    enemies.py                # enemy tables/packs from SYSTEMS §4        [Agent B]
    items.py                  # items/equipment from SYSTEMS §5           [Agent B]
    maps.py                   # ASCII maps + placements  [FABLE - written, DO NOT EDIT layouts]
  systems/
    state.py                  # GameState singleton (below)               [Agent A]
    dialogue.py               # dialogue runner (below)                   [Agent A]
    overworld.py              # grid areas, movement, NPCs, encounters    [Agent C]
    battle.py                 # turn engine + battle screen               [Agent B]
    party_menu.py             # party/equip/items/stats menu              [Agent C]
    shop.py                   # Griselda's shop                           [Agent C]
    epilogue.py               # Book pages                                [Agent C]
  tests/
    shots/                    # screenshot output (gitignored ok)
    test_ui_kit.py            # [Agent A]
    test_battle_sim.py        # [Agent B]
    test_overworld.py         # [Agent C]
    test_script_integrity.py  # [Agent A] validates all dialogue refs
    playthrough.py            # [Agent D] scripted full run

Ownership is exclusive: never edit another agent's file; integration issues go in your report instead. data/script_*.py and data/maps.py text/layout are authored content — you may fix a syntax error, never rewrite content.

3. Core contracts (Agent A implements; B/C code against these)

systems/state.py

class GameState:            # singleton: state.GS
    party: list[str]        # active char ids in order, max 3, e.g. ["PIP","BRAMBLE","MOTH"]
    roster: dict[str, CharState]  # all recruited chars
    flags: set[str]
    points: int
    gold: int
    inventory: dict[str, int]     # item_id -> count
    key_items: list[str]
    act: int                # 1..3
    def has(self, flag) -> bool
    def add_flag(self, flag)          # idempotent
    def add_points(self, n)           # also triggers Book-hum UI hook if set
    def recruit(self, char_id, level) # adds to roster (and party if space)
    def party_tags(self) -> set[str]  # dialogue tags of ACTIVE party
    def grant(self, item_id, n=1); def spend_gold(self, n) -> bool
    def xp_gain(self, amount)         # whole roster, handles level-ups, returns list of levelup events

class CharState:  # hp, sp, level, xp, equipped weapon/trinket ids, stats() -> dict (base+growth+equipment)

core/ui.py (all widgets take a parent collection, absolute pixel pos)

class Panel:        # Frame with PANEL fill + outline; .frame, .children
class Label:        # Caption factory with palette defaults; centered variant
class MenuList:     # vertical keyboard menu: items=[(label, value, enabled)], on_pick(value), on_cancel()
                    # gold '>' cursor, W/S or arrows, Enter/Space pick, Esc cancel; disabled rows DIM with lock reason
class Bar:          # stat bar: bg + fill + optional caption "12/34"; .set(cur, max); color param
class DialogueBox:  # bottom box 940x190 at (42, 556): portrait chip (sprite scale 5 in 96px frame),
                    # name tag, typewriter body (45 chars/sec via Timer, space skips), blinking 'v' when done,
                    # choice list mode (gold arrows; locked choices DIM prefixed by their [TAG]);
                    # .show_node(node, on_choice) drives one node; hum() pulses the gold circle
class Toast:        # top-right slide-in note ("Got 3x Bread", "+1 Story Point"), auto-dismiss 2.2s, stacks
class TitleBanner:  # area-entry banner: big centered caption, fade in/out 1.8s total

core/inputstack.py

class InputStack:   # scene.on_key -> stack dispatch
    def push(self, handler, name=""); def pop(self, name=""); def replace(...)
    # handler: fn(key, state) -> bool (True = consumed). Top-most first.
    # Overworld pushes movement; DialogueBox pushes itself while open; menus likewise.

systems/dialogue.py

def run_scene(scene_id, on_done=None)
# Loads node dict from data.script_actN (see schema below), drives DialogueBox,
# applies effects to GS, executes actions. MUST support being started from
# overworld (freezes movement via InputStack) and from battle (Talk).

Dialogue node schema (already used by the script files):

SCENES = {
  "scene_id": {
    "start": "n1",
    "nodes": {
      "n1": {
        "speaker": "QUILL",         # char/npc id, or "NARRATOR" (no portrait, italic-dim style)
        "text": "one paragraph",
        "next": "n2",               # OR "choices": [...]
        "choices": [
          {"label": "...", "next": "n3",
           "req": ("tag","OATH") | ("flag","x") | ("flag_not","x") | ("party","NYX") | ("points",6),
           "effects": [("flag","x"), ("points",1), ("gold",-20), ("item","bread",2)]},
        ],
        "effects": [...],           # applied on node ENTER
        "action": "recruit:VERA:2" | "battle:pack_id" | "shop" | "heal_party"
                  | "swap_menu" | "end" | "act:3" | "goto_area:gearwood:12,4",
      },
    },
  },
}

Unmet req choices are SHOWN but disabled (DIM + tag visible) — this is a design pillar, not optional. speaker id -> portrait sprite via core.assets.PORTRAITS.

systems/battle.py (Agent B) — public surface

def start_battle(pack_id, on_victory, on_defeat, boss_scene_hooks=None)
# builds battle scene, runs to completion, restores previous scene after.
# boss_scene_hooks: {"talk_options": [...], "phase2_at": 0.5, ...} per BIBLE §4.

Battle screen layout (1024x768): turn ribbon top (portrait chips in order); enemies left half standing on a floor line (y=430), party status cards right column (3 cards: name, HP Bar, SP Bar, level); command panel bottom-left (MenuList: Attack/Skill/Item/Guard/Talk?/Swap/Flee); battle log (last 3 lines, DIM) above the command panel; area-tinted gradient backdrop (6 horizontal bands, palette per area). Damage numbers float via tween. Actors lunge on attack. Defeated enemies fade (opacity tween) then die().

systems/overworld.py (Agent C) — public surface

def enter_area(area_id, spawn=None)   # builds/reuses Grid scene from data.maps
def refresh_area()                    # re-applies palette/NPCs after flag changes (act 3 grey!)

Movement: WASD/arrows, 8ms repeat via held-key Timer, bump into NPC entity = run its dialogue scene; bump into encounter entity = start_battle; walk onto door/exit cell = area transition (fade). Player is an Entity (sprite 85). Encounter entities wander 1 cell every 900ms within a radius-2 leash. Act 3 grey: refresh_area lerps every ColorLayer cell 70% toward (58,58,62) when GS.act == 3 and area == hollowbrook and not flags["town_rewoken"].

4. Testing & QA harness (every agent)

  • Each owned system ships a headless test that: builds its screen with fake state, automation.screenshot("tests/shots/<name>_1.png") at 2+ meaningful states, prints "PASS ", sys.exit(0). Drive timers with mcrfpy.step(1/60) in a loop when needed (e.g., 120 steps = 2 simulated sec).
  • test_script_integrity.py: walks ALL scenes in data/script_*: every next and choice target resolves; every req/effect tuple well-formed; every speaker has a portrait; every action string parses; every battle pack exists; prints counts. This test protects the authored content — run it after ANY change.
  • test_battle_sim.py: run 200 scripted battles headless w/o UI sleeps (auto-pick first command) across packs incl. both bosses; assert no exceptions, victory possible, XP/loot granted; print win rates + avg turns (balance report for Fable).
  • Visual bar: screenshots must look like a finished game: no overlapping text, no default-white anything, palette exactly from core/palette.py. Fable art-directs from your screenshots and WILL send notes.

5. Performance & hygiene

  • The whole game is <15k cells and <100 entities — nothing here can challenge the engine (it holds 10k entities at 60fps). Do not prematurely optimize; DO stop Timers and clear scene references on teardown.
  • No global mutable state outside systems/state.py. No prints in the game path except the debug chapter-select (title screen keys 1/2/3 preset GS per act — Agent D wires the presets from SYSTEMS §6 flag combos).
  • "Fail Early" (John's law): no silent fallbacks. Missing sprite id, unknown flag, unresolved dialogue target = raise with a clear message. Never ship a placeholder that pretends to work.