Proposal: builtin RPG systems — movement (grid/free, 4/8-axis), inventory, crafting, quests, dialog, puzzles (base + extensible) #59

Closed
opened 2026-09-01 05:13:40 +02:00 by orkun · 2 comments
Owner

Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation. This is the largest family; each sub-module below can graduate to its own tracking issue once the shape is agreed.

Context

"RPG" is not one controller — it's a suite: movement (grid and free-form, 4- and 8-axis), inventory, crafting, quests, dialog, puzzles, plus the stats/damage backbone. The design goal is that each sub-module is independently usable (a puzzle game wants only the grid-movement + puzzle modules; a walking-sim wants dialog only) and each is extensible in every area via the six levers. Modeled on RPG Maker's event/database model, Godot RPG toolkits, and data-driven engines (LDtk/Tiled entity data, Ink/Yarn for dialog, flecs prefabs for item templates).

We reuse chronorift (the existing example JRPG) as the proving ground — its hand-written movement/battle/inventory is exactly what these builtins should be able to replace, then extend.


Module A — Movement (grid + free-form, 4/8-axis)

The headline "combos" the goal asked for. One component, a mode selector, so a game picks its movement feel as data.

property Mover {
  mode:   int = 0,   # 0 grid, 1 free-form, 2 grid-with-tween (smooth step between cells)
  axes:   int = 8,   # 4 = cardinal only, 8 = includes diagonals
  speed:  int = 4,
  step:   int = 1,   # grid: cells per move; free: subpixel accel handled by Body
  facing: int = 2,   # last-faced direction (for interaction raycasts / sprite)
  policy: int = 0
}
  • Grid mode (Grid.*): discrete cell moves, walkability via Grid/tilemap, optional tween between cells (mode 2) so it looks smooth but stays lockstep-discrete (RPG Maker / Pokémon feel).
  • Free-form mode: continuous Body velocity, 4- or 8-axis snapping or full analog (top-down Zelda feel), reusing #57 collision.
  • axes picks cardinal (4) vs. 8-way; a policy slot covers oddities (isometric, hex — hex via a Grid layout flag).
  • Sub-systems: esys_mover_input (Input actions → intent), esys_mover_grid / esys_mover_free (only the active one ticks), esys_mover_facing.
  • Events: TileEntered {e, x, y} (encounter checks, traps), cancellable MoveRequested {e, dx, dy} (locked doors, ice-sliding, cutscene lock), Interacted {e, target} (the "action button" raycast in facing).
  • Extend: ice/conveyor tiles = @On(MoveRequested); random encounters = @On(TileEntered) (exactly chronorift's encounter logic, now a listener); swap grid↔free per-scene = change mode.

Module B — Inventory

property Item      { id, stack_max, weight, tags }          # a template (heap record or entity)
property Inventory { slots, weight_max, gold }              # per owner
property Slot      { item_id, qty }                          # dynamic per-slot (Dict/Set-backed)
  • Backed by the existing Dict.*/Set.* (string→int) for item lookup by name, and Reflect/world storage per entity. Grid/slot inventories (Diablo/Resident-Evil bag) as a layout policy: flat, slots, weight-limited, grid-cells.
  • API: Inventory.add(e, "potion", 3), .remove, .count, .move(from,to), .has(e,"key"); equip slots via a companion Equipment { head, body, weapon, ... } component whose stat effects flow through #57 modifier stacks.
  • Events: cancellable ItemPickup, ItemAdded/Removed, cancellable ItemUse {e,item}, Equipped/Unequipped, InventoryFull.
  • Extend: cursed items that can't be dropped = @On(ItemRemoved)/cancel; weight-affects-speed = @On(InventoryChanged) writing a Mover.speed modifier; custom bag layout = layout policy + own draw handler.

Module C — Crafting

property Recipe { out_id, out_qty, station, time }   # + ingredient list (dynamic/Reflect)
property Station { kinds }                            # forge/loom/cook
  • A recipe registry (like Anim.clip's name table), data-driven so games/mods add recipes without code: Craft.recipe("sword", out:"iron_sword", in:[("iron",2),("wood",1)], station:"forge", time:60).
  • Craft.can(e, "sword"), Craft.make(e, "sword") (consumes from Inventory, respects time via Cooldown), queue support.
  • Events: cancellable CraftRequested, CraftStarted/Completed, RecipeDiscovered (for recipe-book unlock).
  • Extend: skill-gated recipes = @On(CraftRequested)/cancel; crit-crafting (bonus output) = @On(CraftCompleted); procedural/random recipes = register at runtime (EV7-style dynamic data). Mods add recipes over the ABI.

Module D — Quests

property Quest     { id, state, }         # state: 0 hidden,1 available,2 active,3 done,4 failed
property Objective { quest_id, kind, target, count, have }  # kind: kill/collect/reach/talk/flag
property Journal   { }                     # per-player active/completed sets
  • A quest graph: objectives + prerequisites, expressed as data (a quest is a record; steps are Objectives). Progress is driven entirely by events, which is the elegant part: esys_quest listens to gameplay events (ItemAdded, Death, TileEntered, DialogChose) and ticks matching objectives — so quests observe the same event bus everything else emits. No bespoke quest-tracking wiring per game.
  • API: Quest.start("q1"), .advance, .complete, .state("q1"), Quest.flag("met_king") (global flag store, Dict-backed, saved).
  • Events: QuestStarted/Completed/Failed, ObjectiveProgressed, cancellable QuestTurnIn.
  • Extend: branching/faction-locked quests = prerequisite predicates as @On(QuestStarted)/cancel; timed quests = Cooldown + QuestFailed; custom objective kinds = a kind policy value + a listener that ticks have.

Module E — Dialog

  • A dialog graph (Ink/Yarn/RPG-Maker style): nodes = lines + choices; edges = choice→node or condition→node. Data-driven registry so writers add trees without touching engine code; conditions/effects reference the quest-flag store and inventory.
Dialog.tree("king", ...)                     # register nodes/choices/conditions
Dialog.start(e, "king")                      # opens; drives a `Dialog` component state
Dialog.choose(e, i)
  • property Dialog { tree, node, } + esys_dialog advances state; a layer renders the box (reuses the retained-UI ui blocks + TrueType text).
  • Events: DialogStarted/Ended, DialogLine {e, node}, cancellable DialogChoice {e, choice}, DialogEffect {e, key} (fires quest flags/item grants declaratively).
  • Extend: voice/portrait/typewriter = @On(DialogLine); skill-check choices = @On(DialogChoice)/cancel when stat too low; localization = swap the tree registry; branching on world state = condition predicates evaluated against the flag/world store.

Module F — Puzzles

A small toolkit of the recurring 2D puzzle primitives, each a component + engine system + events, composable on the grid mover:

  • property Pushable (Sokoban blocks) — esys_push resolves push chains on grid moves; BlockPushed/cancellable PushBlocked.
  • property Pressure/property Switch/property Gate — a signal graph: switches emit SignalChanged, gates listen; wiring is data (Switch.link("s1","gate1")), so logic puzzles need no code.
  • property Pushable+Pressure = classic weighted-plate puzzle out of the box.
  • Optional Grid.a_star-backed "is this solvable" check for procedural puzzle gen.
  • Extend: ice-sliding blocks, one-way pushes, multi-plate AND/OR gates — all via the signal graph + @On(PushBlocked) policy.

Module G — Stats / combat backbone (shared)

Pulled from #57: Stats + modifier stacks, Damage/Heal/Death events, status effects (property Status { kind, stacks, ttl } + esys_status ticking durations via Cooldown). Turn-based and real-time battle are both just policies over this (chronorift's turn machine becomes one shipped policy). Damage formula is a policy enum + a cancellable DamageAboutToApply {e, src, amount*} (mutable) event — the single most-overridden hook in any RPG.


Extensibility summary (the whole family)

Every module is: a POD component bundle (lever 1 data) + engine systems each disable-able (lever 5) + a name-keyed data registry for content (items/recipes/quests/dialog — mods add content with zero code, EV7-style) + cancellable events at each decision (lever 4) + a policy field where behavior is a formula (lever 6) + free composition with the game's own components (lever 2). A game can adopt one module (just dialog) or all seven, and replace any sub-system without forking.

Ludic ties

  • Registries reuse the Anim.clip name-table pattern and Dict/Set; flags/journal/inventory persist via world_save (N1) → save games + rollback for free.
  • Quests-as-event-listeners is only possible because the whole engine already emits on one bus (EV0–EV7); this module is the payoff of that architecture.
  • chronorift.ludic is the migration target: re-implement it on these builtins to prove parity, then show a 20-line extension the hand-written version couldn't do cheaply.

Phasing

  1. Module A (movement) + Module G backbone (stats/damage/status) — unblocks everything, replaces chronorift overworld+battle spine.
  2. Module B (inventory) + C (crafting) — share item registry.
  3. Module D (quests) + E (dialog) — share the flag store + event-driven progress.
  4. Module F (puzzles) — signal graph + pushables.
  5. Turn-based & real-time battle policies over the backbone; examples/games/rpg.ludic; chronorift parity migration.

References

  • RPG Maker (database + event pages + variables/switches — the data-driven model), Godot RPG toolkits.
  • Ink / Yarn Spinner (dialog graphs with conditions/effects), LDtk/Tiled (entity data).
  • Diablo/Resident-Evil grid inventories; flecs prefabs (item/enemy templates as data).
_Part of the builtin-controllers layer (#57). Conforms to the six-lever extensibility contract. Planning only — no implementation. This is the largest family; each sub-module below can graduate to its own tracking issue once the shape is agreed._ ## Context "RPG" is not one controller — it's a suite: movement (grid **and** free-form, 4- and 8-axis), inventory, crafting, quests, dialog, puzzles, plus the stats/damage backbone. The design goal is that each sub-module is **independently usable** (a puzzle game wants only the grid-movement + puzzle modules; a walking-sim wants dialog only) and each is **extensible in every area** via the six levers. Modeled on RPG Maker's event/database model, Godot RPG toolkits, and data-driven engines (LDtk/Tiled entity data, Ink/Yarn for dialog, flecs prefabs for item templates). We reuse `chronorift` (the existing example JRPG) as the proving ground — its hand-written movement/battle/inventory is exactly what these builtins should be able to *replace, then extend*. --- ## Module A — Movement (grid + free-form, 4/8-axis) The headline "combos" the goal asked for. One component, a `mode` selector, so a game picks its movement feel as data. ``` property Mover { mode: int = 0, # 0 grid, 1 free-form, 2 grid-with-tween (smooth step between cells) axes: int = 8, # 4 = cardinal only, 8 = includes diagonals speed: int = 4, step: int = 1, # grid: cells per move; free: subpixel accel handled by Body facing: int = 2, # last-faced direction (for interaction raycasts / sprite) policy: int = 0 } ``` - **Grid mode** (`Grid.*`): discrete cell moves, walkability via `Grid`/tilemap, optional tween between cells (mode 2) so it *looks* smooth but stays lockstep-discrete (RPG Maker / Pokémon feel). - **Free-form mode**: continuous `Body` velocity, 4- or 8-axis snapping or full analog (top-down Zelda feel), reusing #57 collision. - `axes` picks cardinal (4) vs. 8-way; a `policy` slot covers oddities (isometric, hex — hex via a `Grid` layout flag). - Sub-systems: `esys_mover_input` (Input actions → intent), `esys_mover_grid` / `esys_mover_free` (only the active one ticks), `esys_mover_facing`. - Events: `TileEntered {e, x, y}` (encounter checks, traps), `cancellable MoveRequested {e, dx, dy}` (locked doors, ice-sliding, cutscene lock), `Interacted {e, target}` (the "action button" raycast in `facing`). - **Extend:** ice/conveyor tiles = `@On(MoveRequested)`; random encounters = `@On(TileEntered)` (exactly chronorift's `encounter` logic, now a listener); swap grid↔free per-scene = change `mode`. ## Module B — Inventory ``` property Item { id, stack_max, weight, tags } # a template (heap record or entity) property Inventory { slots, weight_max, gold } # per owner property Slot { item_id, qty } # dynamic per-slot (Dict/Set-backed) ``` - Backed by the existing `Dict.*`/`Set.*` (string→int) for item lookup by name, and `Reflect`/`world` storage per entity. Grid/slot inventories (Diablo/Resident-Evil bag) as a `layout` policy: flat, slots, weight-limited, grid-cells. - API: `Inventory.add(e, "potion", 3)`, `.remove`, `.count`, `.move(from,to)`, `.has(e,"key")`; equip slots via a companion `Equipment { head, body, weapon, ... }` component whose stat effects flow through #57 modifier stacks. - Events: `cancellable ItemPickup`, `ItemAdded/Removed`, `cancellable ItemUse {e,item}`, `Equipped/Unequipped`, `InventoryFull`. - **Extend:** cursed items that can't be dropped = `@On(ItemRemoved)`/`cancel`; weight-affects-speed = `@On(InventoryChanged)` writing a `Mover.speed` modifier; custom bag layout = `layout` policy + own draw handler. ## Module C — Crafting ``` property Recipe { out_id, out_qty, station, time } # + ingredient list (dynamic/Reflect) property Station { kinds } # forge/loom/cook ``` - A recipe **registry** (like `Anim.clip`'s name table), data-driven so games/mods add recipes without code: `Craft.recipe("sword", out:"iron_sword", in:[("iron",2),("wood",1)], station:"forge", time:60)`. - `Craft.can(e, "sword")`, `Craft.make(e, "sword")` (consumes from `Inventory`, respects `time` via `Cooldown`), queue support. - Events: `cancellable CraftRequested`, `CraftStarted/Completed`, `RecipeDiscovered` (for recipe-book unlock). - **Extend:** skill-gated recipes = `@On(CraftRequested)`/`cancel`; crit-crafting (bonus output) = `@On(CraftCompleted)`; procedural/random recipes = register at runtime (EV7-style dynamic data). Mods add recipes over the ABI. ## Module D — Quests ``` property Quest { id, state, } # state: 0 hidden,1 available,2 active,3 done,4 failed property Objective { quest_id, kind, target, count, have } # kind: kill/collect/reach/talk/flag property Journal { } # per-player active/completed sets ``` - A quest **graph**: objectives + prerequisites, expressed as data (a quest is a record; steps are `Objective`s). Progress is driven **entirely by events**, which is the elegant part: `esys_quest` listens to gameplay events (`ItemAdded`, `Death`, `TileEntered`, `DialogChose`) and ticks matching objectives — so quests observe the same event bus everything else emits. No bespoke quest-tracking wiring per game. - API: `Quest.start("q1")`, `.advance`, `.complete`, `.state("q1")`, `Quest.flag("met_king")` (global flag store, `Dict`-backed, saved). - Events: `QuestStarted/Completed/Failed`, `ObjectiveProgressed`, `cancellable QuestTurnIn`. - **Extend:** branching/faction-locked quests = prerequisite predicates as `@On(QuestStarted)`/`cancel`; timed quests = `Cooldown` + `QuestFailed`; custom objective kinds = a `kind` policy value + a listener that ticks `have`. ## Module E — Dialog - A **dialog graph** (Ink/Yarn/RPG-Maker style): nodes = lines + choices; edges = choice→node or condition→node. Data-driven registry so writers add trees without touching engine code; conditions/effects reference the quest-flag store and inventory. ``` Dialog.tree("king", ...) # register nodes/choices/conditions Dialog.start(e, "king") # opens; drives a `Dialog` component state Dialog.choose(e, i) ``` - `property Dialog { tree, node, }` + `esys_dialog` advances state; a `layer` renders the box (reuses the retained-UI `ui` blocks + TrueType text). - Events: `DialogStarted/Ended`, `DialogLine {e, node}`, `cancellable DialogChoice {e, choice}`, `DialogEffect {e, key}` (fires quest flags/item grants declaratively). - **Extend:** voice/portrait/typewriter = `@On(DialogLine)`; skill-check choices = `@On(DialogChoice)`/`cancel` when stat too low; localization = swap the tree registry; branching on world state = condition predicates evaluated against the flag/`world` store. ## Module F — Puzzles A small toolkit of the recurring 2D puzzle primitives, each a component + engine system + events, composable on the grid mover: - `property Pushable` (Sokoban blocks) — `esys_push` resolves push chains on grid moves; `BlockPushed`/`cancellable PushBlocked`. - `property Pressure`/`property Switch`/`property Gate` — a **signal graph**: switches emit `SignalChanged`, gates listen; wiring is data (`Switch.link("s1","gate1")`), so logic puzzles need no code. - `property Pushable`+`Pressure` = classic weighted-plate puzzle out of the box. - Optional `Grid.a_star`-backed "is this solvable" check for procedural puzzle gen. - **Extend:** ice-sliding blocks, one-way pushes, multi-plate AND/OR gates — all via the signal graph + `@On(PushBlocked)` policy. ## Module G — Stats / combat backbone (shared) Pulled from #57: `Stats` + modifier stacks, `Damage`/`Heal`/`Death` events, status effects (`property Status { kind, stacks, ttl }` + `esys_status` ticking durations via `Cooldown`). Turn-based **and** real-time battle are both just policies over this (chronorift's turn machine becomes one shipped policy). Damage formula is a `policy` enum + a `cancellable DamageAboutToApply {e, src, amount*}` (mutable) event — the single most-overridden hook in any RPG. --- ## Extensibility summary (the whole family) Every module is: a POD component bundle (lever 1 data) + engine systems each disable-able (lever 5) + a name-keyed **data registry** for content (items/recipes/quests/dialog — mods add content with zero code, EV7-style) + cancellable events at each decision (lever 4) + a `policy` field where behavior is a formula (lever 6) + free composition with the game's own components (lever 2). A game can adopt one module (just dialog) or all seven, and replace any sub-system without forking. ## Ludic ties - Registries reuse the `Anim.clip` name-table pattern and `Dict`/`Set`; flags/journal/inventory persist via `world_save` (N1) → save games + rollback for free. - Quests-as-event-listeners is only possible *because* the whole engine already emits on one bus (EV0–EV7); this module is the payoff of that architecture. - `chronorift.ludic` is the migration target: re-implement it on these builtins to prove parity, then show a 20-line extension the hand-written version couldn't do cheaply. ## Phasing 1. **Module A (movement)** + Module G backbone (stats/damage/status) — unblocks everything, replaces chronorift overworld+battle spine. 2. **Module B (inventory)** + **C (crafting)** — share item registry. 3. **Module D (quests)** + **E (dialog)** — share the flag store + event-driven progress. 4. **Module F (puzzles)** — signal graph + pushables. 5. Turn-based & real-time battle **policies** over the backbone; `examples/games/rpg.ludic`; chronorift parity migration. ## References - RPG Maker (database + event pages + variables/switches — the data-driven model), Godot RPG toolkits. - **Ink** / **Yarn Spinner** (dialog graphs with conditions/effects), LDtk/Tiled (entity data). - Diablo/Resident-Evil grid inventories; flecs prefabs (item/enemy templates as data).
Author
Owner

Amendment — ships as an external package

Per the decision on #57, this controller ships as an external, versioned source package (Ludic modules, compiled into the consumer's binary), not as compiler/runtime/native stdlib and not as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57.

Depends on #62 (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.

## Amendment — ships as an external package Per the decision on #57, this controller ships as an **external, versioned source package** (Ludic modules, compiled into the consumer's binary), **not** as compiler/`runtime/native` stdlib and **not** as a precompiled OS binary. Rationale (wasm target, determinism, compile-time ECS) and the binary-mod escape hatch are on #57. **Depends on #62** (package distribution + package-declarable namespaces/components/engine-systems) — the mechanism that lets this register its component schema and engine-owned systems without a compiler edit. The six-lever extensibility contract it conforms to stays in the language core.
Author
Owner

Shipped — ludic.rpg, all seven modules (commit 46c275c)

The RPG suite as a source package on the six-lever contract (#57). Each module is independently usable and content is name-keyed data registries, so a game or mod adds items/recipes/quests/dialog with zero code.

  • A — Movement: one Mover with a mode selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and cancellable MoveRequested (locked doors/ice) / TileEntered (encounters) / Interacted (the action-button raycast).
  • B — Inventory: a name-keyed item registry, per-owner counts, gold, ItemUse veto, and Equipment whose bonuses flow through the gameplay Stats modifier stack.
  • C — Crafting: a data-driven recipe + ingredient registry; Craft.can/Craft.make consume from the inventory.
  • D — Quests: quests + objectives whose progress is driven by Quest.notify (route any gameplay signal in), auto-completing when met, plus a global flag store for branching.
  • E — Dialog: an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker Dialog component and cancellable DialogChoice for skill-check gating.
  • F — Puzzles: Sokoban Pushable + a switch / pressure-plate / gate signal graph — logic puzzles with no code.
  • G — Status/combat backbone: over-time poison/regen effects routed through the shared Combat pipeline (armor, veto and Died all still apply); timed stat buffs are the gameplay modifier stack.

Everything is integer-deterministic, so save/load (world_save), replay and rollback hold. Example examples/games/rpg_demo.ludic exercises all seven modules with 21 self-checks (grid move + wall block + MoveRequested veto + interact, inventory + equipment stat bonus, crafting, event-driven quest auto-complete, a dialog graph walk, a pushable + a pressure-plate/gate signal, a poison status) — a regression case in x test (103/0). Docs: docs/CONTROLLERS.md.

Closing as done.

## Shipped — `ludic.rpg`, all seven modules (commit 46c275c) The RPG suite as a source package on the six-lever contract (#57). Each module is independently usable and content is name-keyed data registries, so a game or mod adds items/recipes/quests/dialog with zero code. - **A — Movement**: one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). - **B — Inventory**: a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. - **C — Crafting**: a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. - **D — Quests**: quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. - **E — Dialog**: an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. - **F — Puzzles**: Sokoban `Pushable` + a switch / pressure-plate / gate signal graph — logic puzzles with no code. - **G — Status/combat backbone**: over-time poison/regen effects routed through the shared `Combat` pipeline (armor, veto and `Died` all still apply); timed stat buffs are the gameplay modifier stack. Everything is integer-deterministic, so save/load (`world_save`), replay and rollback hold. Example `examples/games/rpg_demo.ludic` exercises all seven modules with 21 self-checks (grid move + wall block + MoveRequested veto + interact, inventory + equipment stat bonus, crafting, event-driven quest auto-complete, a dialog graph walk, a pushable + a pressure-plate/gate signal, a poison status) — a regression case in `x test` (103/0). Docs: `docs/CONTROLLERS.md`. Closing as done.
orkun closed this issue 2026-09-01 16:28:44 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#59
No description provided.