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
234 lines
13 KiB
Markdown
234 lines
13 KiB
Markdown
# 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
|
|
```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/<name>_1.png")` at 2+ meaningful
|
|
states, prints "PASS <name>", `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.
|