# 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/.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 ```python 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) ```python 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 ```python 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 ```python 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):** ```python 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 ```python 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 ```python 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/_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.