Files
TheriapolisV3/GODOT_PORTING_GUIDE.md
T
Christopher Wiebe ee5439285c M6.1: Character creation wizard foundation (.tscn + Resource-based draft)
Pivots from M5's code-built UI to the editor-authorable .tscn pattern
recommended in GODOT_PORTING_GUIDE.md, after a session of fighting
Godot idioms with code-only layout. Default theme only; the parchment
Theme lands last per the guide's §12 build order so layout bugs surface
as layout bugs, not theming bugs.

GODOT_PORTING_GUIDE.md:
  Authored by Claude Design as the canonical port reference. Maps the
  React prototype's structure onto Godot 4.6 with concrete code sketches
  and a build-order recommendation. Drove the M6 architecture.

Fonts/:
  Cormorant Garamond (Medium + MediumItalic) and Crimson Pro (Regular +
  Italic + SemiBold) under OFL — the React prototype's serif-display
  and serif-body families. Not yet wired through CodexTheme.Build()
  because theming is deferred; CodexTheme.LoadFontFromFonts already
  picks them up automatically when the Theme pass lands.

Scenes/Wizard.tscn + Wizard.cs:
  Wizard shell per guide §4: codex-header (title + folio counter) +
  Stepper + Page (StepHost + Aside) + NavBar (Back / validation / Next).
  All node lookups via unique-name (%) syntax; layout authored as a
  scene file you can open in the editor. Step lifecycle drives the
  Aside via signal binding. Stepper logic mirrors app.jsx — locked
  iff some EARLIER step is unsatisfied; "type not yet implemented"
  doesn't lock.

Scenes/Aside.tscn + Aside.cs:
  Right-rail summary per guide §10. Single Refresh() rebuild on
  CharacterDraft.Changed; cheap enough not to bother with partial
  updates. Width 320 (was 380 before the layout overflow fix).

Scenes/Steps/IStep.cs + StepClade.cs:
  Per-step Bind(draft) + Validate() contract. StepClade renders the
  3-column clade card grid; click commits via CharacterDraft.Patch
  which triggers the Resource.Changed signal that Aside and Wizard
  both subscribe to.

UI/CharacterDraft.cs:
  Resource (not Node) per guide §2.1. Mirrors app.jsx's `state` shape
  exactly. Patch(dictionary) emits the inherited Resource.Changed
  signal — listeners use `draft.Changed += handler` regardless of
  which field changed. CodexContent provides lazy-loaded immutable
  content tables (Clades, Species, Classes, Subclasses, Backgrounds).

Main.{cs,tscn}: Node → Control
  When Main was a Node, Control children couldn't anchor to a real
  parent rect — they sat at (0,0) at intrinsic min size. With wide
  step content (3-column 200-px-card grid), the Wizard's min size
  pushed the navbar beyond the viewport's right edge, hiding the Next
  button on smaller windowed viewports. Making Main a full-rect-
  anchored Control gives child scenes a proper rect to lay out in.

UI/Widgets/CodexStepper.cs:
  Anchored the inner vbox to fill the button rect. Without this, the
  vbox sat at the button's top-left at intrinsic size and labels
  rendered in the corner — visible as the active-step label being
  off-center from the highlight bar.

Verified at 1152x720 windowed and (separately) at fullscreen:
  - 3-column card grid fits inside Wrap margins + Aside without
    horizontal overflow
  - Stepper labels centered under their highlight bars
  - Next button visible after clade selection; future steps switch
    to "coming soon" placeholder when clicked
  - Aside summary fills in CLADE block on selection

Closes M6.1.  Next per guide §12 build order: M6.2 — StepStats with
drag-drop (highest-risk piece, de-risk before easy steps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 19:35:03 -07:00

30 KiB
Raw Blame History

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: 12 days for scaffolding, ~1 day per step scene, 12 days for the popover and drag system, 23 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.

# 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()

# 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

# 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:

# 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:

# 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

# 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()
# 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:

# 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)

# 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

# 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

# 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:

# 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 MonoJetBrainsMono-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:

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.
# 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:

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:

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.