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
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.pymust dosys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))soimport 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.pngworks because cwd isbuild/. Texture:mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)— construct ONCE incore/assets.py, share everywhere.
1. Engine gotchas (hard-won; violating these wastes hours)
- 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.
- 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. mcrfpy.Texture(path, w, h)— positional, not grid_size kwarg.Sprite.scaleis a single float.Caption.font_sizeis the text size.Caption.sizeis READ-ONLY text dimensions (use it to center:cap.x = cx - cap.size.x / 2AFTER setting text/font_size).- Frames/Captions/Sprites accept constructor kwargs for most properties
(
fill_color=,outline=, ...).Colorargs:mcrfpy.Color(r, g, b[, a]). - Scene:
s = mcrfpy.Scene(name);s.childrencollection; activate viamcrfpy.current_scene = s. Keyboard:s.on_key = fn(key, state)withmcrfpy.Key.*/mcrfpy.InputState.PRESSED/RELEASED. Compare enums with==(a quirk in!=was fixed but don't tempt it). - 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=...)thengrid.add_layer(l),l.set((x,y), color),l.fill(color). Negative z renders under entities. - Entities:
mcrfpy.Entity(grid_pos=(x,y), texture=TEX, sprite_index=i)thengrid.entities.append(e). Remove withe.die().e.draw_posis the fractional render position (use for bob/lerp),e.grid_posis logic. - Everything animatable:
obj.animate("x", 500.0, 0.3, mcrfpy.Easing.EASE_OUT); callback kwarg receives(target, prop, value);loop=Truefor idle bobs. Animate color components via property paths like"fill_color.a"— but NOTframe.fill_color.r = xdirectly (Color reads are copies; whole-value assignment or animate() only). clip_children=Trueon Frame for panels that scroll/slide; explicitz_indexwhenever two siblings overlap (ties break unpredictably).- 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. - Scene-level Frames at 1024x768; design for exactly that resolution.
automation.keyDown/keyUp/typewriteexist 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 withmcrfpy.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_*: everynextand 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.