# Theriapolis → Godot 4.6 Porting Guide

A companion to `README.md`. This document maps the React/HTML prototype onto a Godot 4.6 project, file by file, with code sketches for the parts that are not obvious. It assumes you've read the existing README and have a working Godot 4.6 install.

> **Scope.** This guide covers the character-creation wizard only — the same surface the prototype covers. It does not address how the resulting character is consumed by the rest of the game; that handoff is described under "The handoff contract" at the end.

---

## 0. The honest summary

The port is **a UI rebuild, not a translation.** What carries over verbatim:

- The four content JSON files in `data/`.
- The `state` shape used as the character record.
- All math (`abilityMod`, modifier summing, validation predicates).
- Step-gating rules and the wizard flow.
- Drag/drop *semantics* (pool→slot, slot→slot, slot→pool).

What gets rebuilt in Godot's idiom:

- All views (HTML/CSS → Control scenes + Theme).
- The hover-popover system (`TraitName`) → a custom `PopupPanel` Control.
- HTML5 drag-and-drop → Godot's `_get_drag_data` / `_can_drop_data` / `_drop_data`.
- The Tweaks panel — almost certainly delete it; bake one theme.

Plan on roughly: 1–2 days for scaffolding, ~1 day per step scene, 1–2 days for the popover and drag system, 2–3 days for theming the parchment look. Plus polish.

---

## 1. Suggested Godot project layout

```
res://
├── project.godot
├── data/                          # Copy unchanged from prototype/data/
│   ├── clades.json
│   ├── species.json
│   ├── classes.json
│   └── backgrounds.json
├── fonts/                         # Bundle these — do NOT rely on Google Fonts at runtime
│   ├── CormorantGaramond-Medium.ttf
│   ├── CormorantGaramond-MediumItalic.ttf
│   ├── CrimsonPro-Regular.ttf
│   ├── CrimsonPro-Italic.ttf
│   ├── CrimsonPro-SemiBold.ttf
│   └── JetBrainsMono-Regular.ttf
├── theme/
│   ├── parchment.tres             # Main Theme resource
│   ├── parchment_styles.gd        # Programmatic StyleBox factory (optional)
│   └── paper_grain.png            # Baked grain background
├── autoload/
│   ├── content.gd                 # Loads the four JSON files; replaces data.jsx
│   ├── character.gd               # Resource defining the character record
│   └── rules.gd                   # ABILITIES, SKILL_*, abilityMod(), validate()
├── scenes/
│   ├── wizard.tscn                # Root; replaces app.jsx
│   ├── wizard.gd
│   ├── stepper.tscn               # 7-tab header; replaces .stepper
│   ├── aside.tscn                 # Right-rail summary; replaces <Aside />
│   ├── nav_bar.tscn               # Bottom Back / Next / Confirm bar
│   ├── steps/
│   │   ├── step_clade.tscn / .gd
│   │   ├── step_species.tscn / .gd
│   │   ├── step_class.tscn / .gd
│   │   ├── step_background.tscn / .gd
│   │   ├── step_stats.tscn / .gd
│   │   ├── step_skills.tscn / .gd
│   │   └── step_review.tscn / .gd
│   └── widgets/
│       ├── codex_card.tscn        # Reusable selectable card
│       ├── trait_chip.tscn        # Replaces TraitName
│       ├── trait_popover.tscn     # The hover hint window
│       ├── ability_token.tscn     # Replaces .die / draggable score
│       ├── ability_slot.tscn      # Replaces .slot
│       └── stat_strip.tscn        # The 6-cell summary row
└── icon.svg
```

### Autoloads (Project Settings → Autoload)

| Name        | Path                    | Singleton | Why                                   |
|-------------|-------------------------|-----------|---------------------------------------|
| `Content`   | `res://autoload/content.gd` | Yes       | One-shot JSON loader.                 |
| `Rules`     | `res://autoload/rules.gd`   | Yes       | Static tables and pure functions.     |
| (no autoload for the character) | -- | -- | The character is a `Resource` instance owned by `wizard.tscn`. |

---

## 2. The data layer — concrete GDScript

### 2.1 `Character` resource

Mirrors the JS `state` shape exactly. Using `Resource` makes it serializable (`ResourceSaver.save`) and trivially debuggable in the inspector.

```gdscript
# autoload/character.gd
class_name Character
extends Resource

@export var clade_id: StringName = &"canidae"
@export var species_id: StringName = &""
@export var class_id: StringName = &"fangsworn"
@export var background_id: StringName = &"pack_raised"

@export var stat_method: StringName = &"array"          # "array" | "roll"
@export var stat_pool: Array[int] = [15, 14, 13, 12, 10, 8]
@export var stat_assign: Dictionary = {}                # { "STR": 15, ... }
@export var stat_history: Array[Dictionary] = []        # [{ vals: [int], ts: int }]

@export var chosen_skills: Array[StringName] = []
@export var name_text: String = ""
@export var portrait_style: StringName = &"silhouette"

signal changed                                          # emit on any mutation

func patch(d: Dictionary) -> void:
    for k in d:
        set(k, d[k])
    changed.emit()
```

Wizard steps connect to `changed` and re-render.

### 2.2 `Content` autoload — replaces `loadData()`

```gdscript
# autoload/content.gd
extends Node

var clades: Array = []
var species: Array = []
var classes: Array = []
var backgrounds: Array = []

func _ready() -> void:
    clades      = _load_json("res://data/clades.json")
    species     = _load_json("res://data/species.json")
    classes     = _load_json("res://data/classes.json")
    backgrounds = _load_json("res://data/backgrounds.json")

func _load_json(path: String) -> Array:
    var f := FileAccess.open(path, FileAccess.READ)
    assert(f, "Missing %s" % path)
    var parsed = JSON.parse_string(f.get_as_text())
    return parsed if parsed is Array else []

func clade(id: StringName):     return _find(clades, id)
func species_of(id: StringName): return _find(species, id)
func cls(id: StringName):        return _find(classes, id)
func background(id: StringName): return _find(backgrounds, id)

func _find(arr: Array, id: StringName):
    for o in arr:
        if StringName(o.id) == id: return o
    return null
```

### 2.3 `Rules` autoload — replaces `data.jsx` constants and helpers

```gdscript
# autoload/rules.gd
extends Node

const ABILITIES := ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
const STANDARD_ARRAY := [15, 14, 13, 12, 10, 8]

const ABILITY_LABELS := {
    "STR": "Strength", "DEX": "Dexterity", "CON": "Constitution",
    "INT": "Intellect", "WIS": "Wisdom", "CHA": "Charisma",
}

const SKILL_ABILITY := {
    "acrobatics": "DEX", "athletics": "STR",
    # ... copy verbatim from src/data.jsx
}

const SKILL_LABEL := { ... }     # copy from src/data.jsx
const SKILL_DESC  := { ... }     # copy from src/data.jsx
const LANGUAGES   := { ... }     # copy from src/data.jsx

const CLASS_CLADE_REC := {
    "fangsworn":       ["canidae", "felidae", "ursidae"],
    # ... copy verbatim
}

func ability_mod(score: int) -> int:
    return int(floor((score - 10) / 2.0))

func signed_str(n: int) -> String:
    return "+%d" % n if n >= 0 else str(n)

# Validation per step. Mirrors the JS validate() in app.jsx.
# Returns "" if valid, otherwise an error message.
func validate_step(step: int, ch: Character) -> String:
    match step:
        0: return "" if ch.clade_id else "Pick a clade."
        1: return "" if ch.species_id else "Pick a species."
        2: return "" if ch.class_id else "Pick a calling."
        3: return "" if ch.background_id else "Pick a background."
        4:
            var n := ch.stat_assign.size()
            return "" if n == 6 else "Assign all six abilities (%d/6)." % n
        5:
            var c = Content.cls(ch.class_id)
            var need: int = c.skills_choose if c else 0
            var got := ch.chosen_skills.size()
            return "" if got == need else "Pick exactly %d (%d/%d)." % [need, got, need]
        6: return "" if ch.name_text.strip_edges() else "Enter a name."
    return ""
```

> Tip: copy the full `SKILL_DESC`, `LANGUAGES`, and `TRAIT_READING` dictionaries straight from `src/data.jsx` — the JS object literal is valid as a GDScript dictionary with two find/replace passes (`:` is fine; you mostly need to swap `\n` strings and remove backticks).

---

## 3. Mapping React concepts to Godot

| React / web concept                | Godot 4.6 equivalent                                         |
|------------------------------------|--------------------------------------------------------------|
| Component (`StepClade`)            | `Control`-derived scene + script                             |
| Props (`{ state, set }`)           | Scene exports + a shared `Character` resource reference      |
| `useState`                          | Plain `var` on the script + `signal` for change notifications |
| `useEffect` on dep change          | Connect to `Character.changed`; gate work with diffs         |
| `useMemo`                           | Cache in `_ready()` or recompute in `_process` only on dirty |
| Conditional render                  | `node.visible = ...` (avoid `queue_free` for steps; reuse)   |
| `<style>` + CSS variables          | `Theme` resource + `theme_type_variation`                    |
| Google Font import                  | `FontFile` resources, referenced in Theme                    |
| `ReactDOM.createPortal` (popovers) | A `Popup` / `PopupPanel` instanced on the root viewport      |
| HTML5 drag-and-drop                 | `_get_drag_data` / `_can_drop_data` / `_drop_data`           |
| `fetch().then(...)`                 | `FileAccess` + `JSON.parse_string` (synchronous; fine here)  |
| `localStorage`                      | `ConfigFile` or `ResourceSaver.save(user://character.tres)`  |

---

## 4. The wizard shell — `wizard.tscn` + `wizard.gd`

Translates `src/app.jsx`. Scene tree:

```
Wizard (Control, full-rect)
├── AppFrame (PanelContainer)             # the bordered codex frame
│   ├── Margins (MarginContainer)
│   │   └── Layout (VBoxContainer)
│   │       ├── CodexHeader (HBoxContainer)
│   │       ├── Stepper (HBoxContainer)   # 7 step tabs
│   │       └── Page (HBoxContainer)
│   │           ├── PageMain (MarginContainer)
│   │           │   └── StepHost (Control)   # one child per step, only one visible
│   │           │       ├── StepClade
│   │           │       ├── StepSpecies
│   │           │       ├── ...
│   │           └── PageAside (PanelContainer)
│   │               └── Aside
│   └── NavBar (HBoxContainer)
└── PopoverLayer (CanvasLayer)             # see §6 — popovers live here, above everything
```

Key script logic:

```gdscript
# scenes/wizard.gd
extends Control

@export var character: Character

const STEP_KEYS := ["clade", "species", "class", "background",
                    "stats", "skills", "review"]
const STEP_NAMES := ["Clade", "Species", "Calling", "History",
                     "Abilities", "Skills", "Sign"]

var step: int = 0

@onready var step_host: Control = %StepHost
@onready var stepper: HBoxContainer = %Stepper
@onready var nav_back: Button = %NavBack
@onready var nav_next: Button = %NavNext

func _ready() -> void:
    if character == null:
        character = Character.new()
        # Match the prototype's defaults:
        var first_canid := Content.species.filter(
            func(s): return s.clade_id == "canidae")
        if first_canid.size() > 0:
            character.species_id = first_canid[0].id
    character.changed.connect(_redraw)
    _build_stepper()
    _show_step(0)
    _redraw()

func _show_step(i: int) -> void:
    step = i
    for child in step_host.get_children():
        child.visible = false
    var target = step_host.get_node(STEP_KEYS[i].capitalize() + "Step")
    target.visible = true
    target.refresh()  # each step exposes refresh()
    _redraw()

func _redraw() -> void:
    # Update stepper locked/active/complete states + nav button enable
    var first_incomplete := -1
    for i in 7:
        if Rules.validate_step(i, character) != "":
            first_incomplete = i
            break
    for i in 7:
        var tab: Button = stepper.get_child(i)
        var locked := i > step and first_incomplete != -1 and first_incomplete < i
        var complete := Rules.validate_step(i, character) == "" and i != step
        tab.disabled = locked
        tab.set_meta(&"active", i == step)
        tab.set_meta(&"complete", complete)
        tab.queue_redraw()  # if you draw the active underline in _draw

    nav_back.disabled = step == 0
    nav_next.disabled = Rules.validate_step(step, character) != ""

func _on_class_changed() -> void:
    # Mirrors the React useEffect that resets skill picks when class changes.
    character.chosen_skills.clear()

func _on_clade_changed() -> void:
    var sp = Content.species_of(character.species_id)
    if sp == null or StringName(sp.clade_id) != character.clade_id:
        var match := Content.species.filter(
            func(s): return s.clade_id == character.clade_id)
        if match.size() > 0:
            character.species_id = match[0].id
```

The two `_on_*_changed` helpers replace the prototype's `useEffect` blocks. Wire them by connecting to `Character.changed` and diffing against a remembered last-value, or — simpler — call them explicitly from the click handlers in `step_clade.gd` and `step_class.gd`.

---

## 5. Step scenes — pattern

Every step scene follows the same shape:

```gdscript
# scenes/steps/step_clade.gd
extends VBoxContainer

@onready var grid: GridContainer = %CardGrid
var character: Character

func _ready() -> void:
    character = (owner as Wizard).character

func refresh() -> void:
    # Clear and rebuild. For tiny lists (<50 items) this is fine; if it
    # gets sluggish, switch to pooled cards keyed by id.
    for c in grid.get_children(): c.queue_free()
    for c in Content.clades:
        var card := preload("res://scenes/widgets/codex_card.tscn").instantiate()
        card.populate_clade(c, c.id == String(character.clade_id))
        card.selected.connect(func(): _pick(c.id))
        grid.add_child(card)

func _pick(id: String) -> void:
    var first_species := Content.species.filter(
        func(s): return s.clade_id == id)
    character.patch({
        "clade_id": StringName(id),
        "species_id": first_species[0].id if first_species else &"",
    })
```

`CodexCard` is the unit you'll build twice: a `PanelContainer` styled like a parchment card with selection state. Make it a single reusable scene with a `populate_*` method per kind (clade, species, class, background) — that keeps the data → view mapping explicit.

---

## 6. Hover popovers — the trickiest port

The prototype's `TraitName` (in `trait-hint.jsx`) does three real things:

1. Spawns a popover into the document body so it escapes overflow clipping.
2. Measures and clamps to viewport, flipping above/below as needed.
3. Stays open while the cursor is over either the trigger *or* the popover (with an 80ms grace window).

In Godot, do this:

### 6.1 Architecture

- A single `PopoverLayer` (CanvasLayer) is added once at the top of `wizard.tscn`. It owns one reusable `TraitPopover` scene.
- `TraitChip` (the trigger) is a small `Button` (or `Label` + `Control` with mouse filter `STOP`). On `mouse_entered`, it asks the `PopoverLayer` to show the popover for its trait at its global rect.

### 6.2 Trigger + popover protocol

```gdscript
# scenes/widgets/trait_chip.gd
extends Button

@export var trait_data: Dictionary  # {id, name, description, tag?}
@export var detriment: bool = false

func _ready() -> void:
    flat = true
    mouse_entered.connect(_on_enter)
    mouse_exited.connect(_on_exit)

func _on_enter() -> void:
    PopoverLayer.show_for(self, trait_data, detriment)

func _on_exit() -> void:
    PopoverLayer.schedule_close_from_trigger()
```

```gdscript
# autoload/popover_layer.gd  (or attach to the PopoverLayer node in wizard.tscn)
extends CanvasLayer

@onready var popup: PopupPanel = $TraitPopover
var _close_timer: SceneTreeTimer

func show_for(trigger: Control, data: Dictionary, detriment: bool) -> void:
    _cancel_close()
    popup.populate(data, detriment)
    popup.reset_size()                          # measure
    var trig_rect := trigger.get_global_rect()
    var popup_size := popup.size
    var vp := trigger.get_viewport_rect().size
    var pad := 8.0

    # default: below
    var pos := Vector2(trig_rect.position.x, trig_rect.end.y + 6)
    if pos.y + popup_size.y + pad > vp.y \
       and trig_rect.position.y - 6 - popup_size.y >= pad:
        pos.y = trig_rect.position.y - 6 - popup_size.y     # flip above

    pos.x = clamp(pos.x, pad, vp.x - popup_size.x - pad)
    pos.y = clamp(pos.y, pad, vp.y - popup_size.y - pad)
    popup.position = pos
    popup.show()

func schedule_close_from_trigger() -> void:
    _close_timer = get_tree().create_timer(0.08)
    _close_timer.timeout.connect(_close_if_idle)

func _close_if_idle() -> void:
    if not popup.get_global_rect().has_point(popup.get_viewport().get_mouse_position()):
        popup.hide()

func _cancel_close() -> void:
    _close_timer = null  # timer fires regardless; _close_if_idle re-checks state
```

The `TraitPopover` scene itself:

```gdscript
# scenes/widgets/trait_popover.gd
extends PopupPanel

@onready var name_lbl: Label = %Name
@onready var tag_lbl: Label = %Tag
@onready var desc_lbl: RichTextLabel = %Desc
@onready var reading_lbl: Label = %Reading

func populate(d: Dictionary, detriment: bool) -> void:
    name_lbl.text = d.get("name", "")
    tag_lbl.text = d.get("tag", "detriment" if detriment else "")
    tag_lbl.visible = not tag_lbl.text.is_empty()
    desc_lbl.text = d.get("description", "")
    var reading: String = Rules.TRAIT_READING.get(d.get("id", ""), "")
    reading_lbl.text = reading
    reading_lbl.visible = not reading.is_empty()
    mouse_entered.connect(_cancel_close, CONNECT_ONE_SHOT_PERSIST)  # re-arm each show
    mouse_exited.connect(_request_close, CONNECT_ONE_SHOT_PERSIST)

func _cancel_close() -> void: PopoverLayer._cancel_close()
func _request_close() -> void: PopoverLayer.schedule_close_from_trigger()
```

> **Caveats.** `PopupPanel` is *not* always a perfect fit for hover-driven popovers — by default they auto-close on outside click, which is what you want here. If you need them to overlay other modal popups or coexist with focus, switch to a plain `Control` parented to the CanvasLayer with `mouse_filter = PASS` and your own click-outside detection.

---

## 7. Drag-and-drop on the Abilities step

This is actually *cleaner* in Godot than in HTML. Replace `dataTransfer` JSON strings with structured Dictionaries. The three cases the prototype handles (pool→slot, slot→slot, slot→pool) all fit into the same protocol.

### 7.1 The draggable token (replaces `.die` / filled `.slot`)

```gdscript
# scenes/widgets/ability_token.gd
extends Control

@export var value: int
@export var origin: StringName            # &"pool" or &"slot"
@export var origin_ability: StringName    # only used when origin == &"slot"
@export var origin_pool_idx: int = -1     # only used when origin == &"pool"

func _get_drag_data(_at_position: Vector2) -> Variant:
    var preview := duplicate()
    preview.modulate.a = 0.85
    set_drag_preview(preview)
    return {
        "kind": "ability_value",
        "value": value,
        "from": origin,
        "ability": origin_ability,
        "idx": origin_pool_idx,
    }
```

### 7.2 The slot drop target

```gdscript
# scenes/widgets/ability_slot.gd
extends PanelContainer

@export var ability: StringName            # &"STR" etc.
signal dropped(payload: Dictionary)

func _can_drop_data(_p, data) -> bool:
    return typeof(data) == TYPE_DICTIONARY and data.get("kind") == "ability_value"

func _drop_data(_p, data) -> void:
    dropped.emit(data)
```

### 7.3 The pool drop target — same shape, different signal handler

```gdscript
# scenes/widgets/ability_pool.gd
extends HBoxContainer

signal dropped_to_pool(payload: Dictionary)

func _can_drop_data(_p, data) -> bool:
    # Only accept tokens currently in a slot — pool→pool moves are a no-op.
    return typeof(data) == TYPE_DICTIONARY \
        and data.get("kind") == "ability_value" \
        and data.get("from") == &"slot"

func _drop_data(_p, data) -> void:
    dropped_to_pool.emit(data)
```

### 7.4 The step orchestrates state changes

This is the direct port of the JS `handleDrop` / `dropToPool` in `steps.jsx`:

```gdscript
# scenes/steps/step_stats.gd  (excerpt)
func _on_slot_dropped(dest_ability: StringName, p: Dictionary) -> void:
    var pool: Array = character.stat_pool.duplicate()
    var assign: Dictionary = character.stat_assign.duplicate()

    if p.from == &"pool":
        if assign.has(String(dest_ability)):
            pool.append(assign[String(dest_ability)])  # bumped value back to pool
        pool.remove_at(p.idx)
        assign[String(dest_ability)] = p.value
    elif p.from == &"slot":
        var src := String(p.ability)
        var dst := String(dest_ability)
        if src == dst: return
        var src_v = assign.get(src)
        var dst_v = assign.get(dst)
        assign[dst] = src_v
        if dst_v != null: assign[src] = dst_v
        else: assign.erase(src)

    character.patch({"stat_pool": pool, "stat_assign": assign})

func _on_pool_dropped(p: Dictionary) -> void:
    if p.from != &"slot": return
    var assign: Dictionary = character.stat_assign.duplicate()
    var v = assign.get(String(p.ability))
    if v == null: return
    assign.erase(String(p.ability))
    var pool: Array = character.stat_pool.duplicate()
    pool.append(v)
    character.patch({"stat_pool": pool, "stat_assign": assign})
```

Behavior parity with the prototype: clicking a filled slot to return its value to the pool is just a `gui_input` handler on the slot that synthesizes a `slot→pool` payload.

---

## 8. The parchment Theme

The single most important piece of art-direction fidelity. Approach:

### 8.1 Decide where the look lives

The prototype carries three themes (parchment / dark / blood) via CSS variable overrides. **Pick one and ship it.** If you really need runtime theming, build it as a Theme swap, not as parameterized StyleBoxes.

### 8.2 Build a Theme resource

Create `theme/parchment.tres`. Inside the editor, configure these *theme types* (Godot's mechanism for variant-styled controls):

| Theme type           | What it styles                          |
|----------------------|------------------------------------------|
| (default)            | Base: ink color, body font.              |
| `CodexFrame`         | The outer 1px-rule + double-inset border. Use a `StyleBoxFlat` with `border_color = #8a6f48`, `border_width_*`, then a second StyleBox via a child `Panel` for the inner rule. |
| `CodexCard`          | The selectable cards. Border + tinted background; selected state via `theme_type_variation = "CodexCardSelected"`. |
| `CodexCardSelected`  | Variant: seal-red border + drop shadow.  |
| `Stepper`            | Bottom-divided header.                   |
| `Sigil`              | Round 56px frame with a gradient.        |
| `TraitChip`          | Italic chip (uses display font).         |
| `BonusPillPos` / `BonusPillNeg` | The seal vs. dim pill.        |

Wire each step's nodes to those types via `theme_type_variation`. That replaces the role of CSS class names like `.card.selected`.

### 8.3 Fonts

Drop the TTFs into `res://fonts/`. In Theme:
- Default font → `CrimsonPro-Regular.ttf` at 15px.
- `Label/font` for `theme_type_variation = "Display"` → `CormorantGaramond-Medium.ttf`.
- `Label/font` for `Mono` → `JetBrainsMono-Regular.ttf` at 11px, with letter-spacing (Godot 4 supports `theme_override_constants/extra_spacing_glyph` on `Label`).

> Warn: small italic display text (the eyebrow + folio markings) will not be as crisp as it is in the browser. Bump the size up by ~1px or commit to non-italic eyebrows.

### 8.4 The paper grain

Two viable approaches:

1. **Bake it.** Render the prototype's `--paper-grain` once at 1920×1200 to a PNG; use as a `TextureRect` background or a `StyleBoxTexture` on the AppFrame.
2. **Procedural.** A `Sprite2D` with a `NoiseTexture2D` (FastNoiseLite) modulated low-alpha over the parchment color. More flexible; less authored-feeling.

Start with (1). Paper grain is mostly a static asset.

### 8.5 The wax seal mark on selected cards

The prototype draws this with two pseudo-elements (a radial-gradient circle plus an offset glyph). In Godot, the cleanest port is a small `wax_seal.tscn` node placed at the card's top-right corner, visible only when the card is selected. A 64×64 PNG with a slight emboss is fine; if you want it crisper, use `Polygon2D` with a radial vertex-color gradient and a glyph `Label` on top.

---

## 9. The Tweaks panel — drop it

The Tweaks panel is a *prototype-time affordance*. It uses a host-rewrite mechanism (`postMessage(__edit_mode_set_keys, ...)`) that is specific to this prototyping environment and has no equivalent in a shipped game. Delete it. If your game wants player-facing theme/density toggles, build them as part of your settings menu, persisted via `ConfigFile` to `user://settings.cfg`.

---

## 10. Aside (right-rail summary)

`Aside` is the most state-dependent component — it re-derives 5 sections from the character. In Godot, give it a single `refresh()` that rebuilds each section, and connect it to `Character.changed`. Don't try to be clever about partial updates; the panel is small enough that full rebuild is cheap.

The `stat-strip` (six cells with ability / score / mod) is worth extracting as `widgets/stat_strip.tscn` — both Aside and Step 7 use it.

---

## 11. The handoff contract

The prototype's `StepReview` ends at:

```js
onClick={() => alert(`${state.name} steps into Theriapolis. (Confirmed.)`)}
```

In Godot, the Confirm button should:

1. Compose a final `Character` instance.
2. Compute derived stats (final ability scores + mods) — this is *not* stored, since modifiers are inputs from clade/species and can be re-derived; the canonical record is the user's choices.
3. Emit a signal or call into your game's character-creation pipeline.

```gdscript
# scenes/steps/step_review.gd
signal character_confirmed(ch: Character)

func _on_confirm_pressed() -> void:
    if not _all_steps_valid(): return
    character_confirmed.emit(character)
```

A reasonable consumer somewhere up the tree:

```gdscript
func _on_character_confirmed(ch: Character) -> void:
    ResourceSaver.save(ch, "user://character.tres")
    SceneManager.change_scene("res://scenes/world/intro.tscn")
```

Final ability score for ability `ab`:

```gdscript
var base: int = ch.stat_assign.get(ab, 0)
var c_mod: int = clade.ability_mods.get(ab, 0)
var s_mod: int = species.ability_mods.get(ab, 0)
var final_score := base + c_mod + s_mod
var final_mod := Rules.ability_mod(final_score)
```

Skill proficiencies (combined): `bg.skill_proficiencies + ch.chosen_skills` — same as the JS.

---

## 12. Suggested build order

Keep yourself honest by porting in this order. Each step adds visible progress, and you can stop at any time and ship a smaller wizard.

1. **Autoloads + Character resource.** No UI. `_ready()` prints the loaded data. Verify counts match the JSON.
2. **Wizard shell + stepper + nav bar.** Plain default theme. Step content is just `Label.text = "Step N"`. Validation gating works.
3. **One step end to end — pick `StepClade`.** Real cards, click selects, Aside reflects the choice. Default theme still.
4. **`StepStats`.** Drag-and-drop is the highest-risk piece — getting it working early de-risks the rest.
5. **The popover system.** Land `TraitChip` + `TraitPopover`. Hook it up everywhere chips appear.
6. **Remaining steps.** Species, Class, Background, Skills, Review. They're all variations on the StepClade pattern.
7. **Aside.** Real summary, all sections.
8. **Theme.** Save until last; theming a half-built UI is wasted work.
9. **Polish.** Drop-cap on intros, sigils per clade, wax-seal selected mark, scroll behavior on long card grids.

---

## 13. Known gotchas to plan for

- **`StringName` vs `String`.** Pick one for IDs and stick to it. The above code uses `StringName` for IDs in `Character` exports and `String` everywhere they cross into JSON-shaped dicts. Mismatches silently fail dictionary lookups.
- **`Dictionary.duplicate()` is shallow.** When patching `stat_assign`, that's fine. If you ever nest mutable dicts inside `Character`, switch to `duplicate(true)`.
- **`Resource` mutation does not auto-trigger UI updates.** Always go through `Character.patch()` so `changed` fires. A direct `character.name_text = "..."` bypasses the signal.
- **Hover popovers and focus.** A `PopupPanel` will steal focus by default on some platforms. Set `popup.unfocusable = true` (Godot 4.6 has `Window.unfocusable`) for hover popovers so they don't disrupt keyboard nav.
- **JSON loader is synchronous.** All four files are tiny; this is fine. Don't overengineer with `WorkerThreadPool` unless you have measurements saying you need to.
- **Card grid responsiveness.** The prototype uses `grid-template-columns: repeat(auto-fill, minmax(240px, 1fr))`. Godot's `GridContainer` requires a fixed column count. Either pick a fixed 3 (good at 1280+) or write a tiny `_on_resized` that picks 2/3/4 columns based on available width.

---

## 14. What this guide does NOT cover

- Localization. The prototype is English-only; the SKILL_DESC and TRAIT_READING strings are content, not just labels.
- Save / load mid-creation. Add via `ResourceSaver.save(character, "user://wip_character.tres")` on each `Character.changed`.
- Audio (page turns, ink scratches, seal stamps) — the prototype is silent; the parchment aesthetic begs for it. Out of scope here.
- Subclasses, equipment, and tool/weapon proficiencies — the prototype's README documents these as known caveats; they remain caveats.

---

*End of guide. Read this alongside `README.md` for the source-side architecture; together they should give you everything needed to reimplement the wizard in Godot 4.6 without re-deriving the design decisions.*
