Proposal: builtin gameplay controllers — the extensibility contract (platformer / RPG / shooter / NPC AI) #57

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

Context

Ludic has the low-level pieces to build games — ECS (property/model/handler), engine-owned systems (the compiler ticks SpriteAnim/Motion/Light2D for free), events + a modding bus (EV0–EV7), reflection (World.*/Reflect.*), spatial queries + pathfinding (Query.*/Grid.*), input action maps (Input.*), tweening, scenes/layers, and deterministic fixed-point math. What it does not yet have is a layer of ready-made gameplay controllers — the "drop this in and you have a platformer / RPG / shooter / NPC" starting point that Unity (Starter Assets), Godot (CharacterBody2D + demos), GameMaker, and Bevy's ecosystem crates provide.

This is the umbrella/tracking issue for that layer. It exists to answer the one requirement that governs every controller we ship: each system must be a base, not a cage. A developer must be able to take the platformer controller and change how jumping feels, add a double-jump, swap collision, replace the score rule — without forking the engine and without us having anticipated their exact need. This issue defines the extensibility contract the four controller families (#PLATFORMER, #RPG, #SHOOTER, #NPCAI) all conform to, so they read as one coherent system rather than four bespoke blobs.

No implementation in any of these issues — this is planning. Feedback on the contract itself is the goal; the family issues depend on it.

Design thesis: mechanism vs. policy, at two altitudes

The networking design already committed Ludic to a principle worth making universal: the engine ships mechanism; the developer owns policy. A controller is not a monolithic behavior — it is a thin spine of mechanism (integrate velocity, resolve a tile collision, advance a cooldown) with named decision points where policy is injected. We ship a sensible default policy so the thing works out of the box, and we expose every decision point so the default can be observed, tuned, vetoed, or replaced.

Concretely, "extensible in every area" is not a slogan — it decomposes into six extension levers, and every builtin controller must document, for each of its parts, which levers apply.

The six extension levers

  1. Data override (tuning is data, never code).
    Every builtin property is POD with sane defaults, so behavior is configured by spawning with overrides — no subclassing:

    spawn Hero {
      Platformer { move_speed: 6, jump_height: 5, coyote_frames: 6, air_control: 40 }
    }
    

    Curves that can't be a single scalar are expressed as a small fixed-point profile field set, not hard-coded constants.

  2. Composition (add siblings; extend the data footprint).
    Controllers are small property bundles, never god-objects. A game adds its own property to the same model and its own handler in the same phase. Because engine-owned systems resolve fields by name through the reflection ABI and no-op when a field is absent (the existing esys_* contract), a game may add optional fields a builtin system will pick up (e.g. add wall_slide_speed to opt a body into wall-sliding) without us shipping every permutation.

  3. Phase ordering (pre/post-process around the engine tick).
    Engine systems run at a defined point in their phase (today: after user handlers). We make that ordering part of the public contract and add @Before(System) / @After(System) handler annotations so a game can, e.g., mangle input before the platform integrator reads it, or clamp the result after collision resolves — deterministically, same order every frame.

  4. Event hooks (observe + veto, no fork).
    Every meaningful decision a builtin system makes emits an event, and the consequential ones are cancellable. This is the same EV0–EV7 bus mods already use, so this lever is open to both in-language games (@On) and foreign mods (C ABI). Examples across the families: JumpRequested (cancellable), DamageAboutToApply (cancellable, mutable amount), ItemPickedUp, QuestStateChanged, ProjectileSpawned, AiTargetAcquired. Vetoing a cancellable event is how you say "no, not this time" without replacing the system.

  5. Sub-system disable + replace.
    Each controller is decomposed into independently disableable sub-systems, not one handler. disable Handler / disable Model already exist for user handlers; we add the parallel for engine-owned systems (a @Manual Property marker, or disable system <Name>, so a well-known component can be carried but not auto-ticked). This lets a dev switch off just the jump integrator and supply their own while keeping the controller's collision, coyote-time, and score intact.

  6. Policy selection (strategy hooks in a no-closure language).
    Where a behavior is fundamentally a formula (gravity curve, damage calc, AI target-scoring, loot roll), the builtin exposes it as (a) a policy: int/enum field the system switches on for the common variants (mirrors Tween.ease(mode)), and (b) a cancellable/mutable event for the fully-custom case. Ludic has no lambdas, so we deliberately do not pretend to take fn values; the enum-field-plus-event pair is the honest, deterministic substitute and it is testable/replayable.

The contract in one sentence: every builtin controller is (POD property bundle with defaulted, override-able data) + (engine-owned systems that tick it, each independently disable-able) + (a cancellable/observable event at every decision point) + (a policy-enum field where behavior is a formula). If a part of a controller can't be reached by at least one of the six levers, that's a design bug in the controller.

What we are NOT doing (and why)

  • No inheritance / virtual overrides. Ludic is POD-components + no closures by design. Extension is composition + events + policy fields, not subclassing. This keeps everything determinism-safe and replayable.
  • No hidden global singletons. Controller state lives in components (per-entity) or in explicitly-declared module vars the game owns, so save/load, snapshots, and rollback (N1 world_save) capture it for free.
  • No float. Everything is Q16.16 fixed / integer so lockstep + replay hold (the whole point of building controllers in Ludic).
  • No runtime dispatch tables beyond the one honest exception already in the event bus (ordered foreign-listener array). Policy is enum-switch, resolved deterministically.

Cross-cutting building blocks these controllers will need

Filing these here so the family issues can reference them; some may become their own issues:

  • 2D collision / physics-lite (Body + Collider + esys_move). AABB sweep + tile-grid resolution, one-way platforms, triggers/sensors. Referenced by all four families. Ludic has a Collision namespace stub and Rect/Vector/IVec2 value types to build on; this is the biggest shared prerequisite and likely a dedicated issue.
  • Stats/attributes + modifiers. A Stats component + a modifier-stack pattern (flat/percent, timed) shared by RPG, shooter, and NPC damage. Builds on Decimal/BigInt for economy-grade numbers where wanted.
  • Cooldown/timer component (Cooldown, esys_cooldown). Ubiquitous (jump buffer, fire rate, ability CD, AI re-plan interval). Deterministic frame countdowns.
  • Faction/relationship table. Friend/enemy/neutral matrix shared by shooter targeting + NPC AI.
  • A @Manual / disable system mechanism (lever 5). Prerequisite for "replace just one sub-system." Probably lands first.
  • @Before/@After(System) ordering annotations (lever 3).

Phasing (of the whole layer)

  1. Foundation (this issue's prerequisites): shared Body/Collider + esys_move, Cooldown, lever-5 disable-engine-system, lever-3 ordering annotations, faction table, stats+modifiers. Nothing player-facing yet.
  2. Platformer (#PLATFORMER) — smallest, exercises collision + input + the full six-lever contract end-to-end as the reference implementation.
  3. Shooter (#SHOOTER) — adds projectiles + rotation + top-down movement; reuses collision/faction.
  4. NPC AI (#NPCAI) — perception + steering + behavior model; reuses faction, feeds shooter/RPG enemies.
  5. RPG (#RPG) — the largest; inventory/crafting/quests/dialog/puzzles/movement modes. Split into sub-modules internally.

References

  • Unity Starter Assets (first/third-person controllers as a base you extend), Unity Input System action maps.
  • Godot CharacterBody2D + move_and_slide, the 2D platformer/topdown demos, Area2D triggers.
  • Bevy ecosystem crates (leafwing-input-manager, bevy_ecs_ldtk, big-brain for utility AI) — the "small composable systems you layer" ethos.
  • GameMaker's object-event model and flecs module/prefab composition (the "carry a component, a system ticks it" pattern Ludic already implements via esys_*).
  • Ludic internals this builds on: engine-owned systems (runtime/native/systems.ludic, emit_engine_systems_for_phase), events EV0–EV7, reflection ABI (World.*/Reflect.*), Query.*/Grid.*, Input.* action maps, Vector/IVec2/Rect value types, lifecycle annotations, scenes/layers.

Deliverable of the planning phase

Agreement on the six-lever contract + the foundation prerequisite list. Once locked, the four family issues are implementable against a shared, honest extensibility story rather than each inventing its own.

## Context Ludic has the low-level pieces to build games — ECS (`property`/`model`/`handler`), engine-owned systems (the compiler ticks `SpriteAnim`/`Motion`/`Light2D` for free), events + a modding bus (EV0–EV7), reflection (`World.*`/`Reflect.*`), spatial queries + pathfinding (`Query.*`/`Grid.*`), input action maps (`Input.*`), tweening, scenes/layers, and deterministic fixed-point math. What it does **not** yet have is a layer of **ready-made gameplay controllers** — the "drop this in and you have a platformer / RPG / shooter / NPC" starting point that Unity (Starter Assets), Godot (`CharacterBody2D` + demos), GameMaker, and Bevy's ecosystem crates provide. This is the umbrella/tracking issue for that layer. It exists to answer the one requirement that governs every controller we ship: **each system must be a _base_, not a cage.** A developer must be able to take the platformer controller and change how jumping feels, add a double-jump, swap collision, replace the score rule — without forking the engine and without us having anticipated their exact need. This issue defines the **extensibility contract** the four controller families (#PLATFORMER, #RPG, #SHOOTER, #NPCAI) all conform to, so they read as one coherent system rather than four bespoke blobs. **No implementation in any of these issues — this is planning. Feedback on the contract itself is the goal; the family issues depend on it.** ## Design thesis: mechanism vs. policy, at two altitudes The networking design already committed Ludic to a principle worth making universal: **the engine ships _mechanism_; the developer owns _policy_.** A controller is not a monolithic behavior — it is a thin spine of mechanism (integrate velocity, resolve a tile collision, advance a cooldown) with **named decision points** where policy is injected. We ship a sensible default policy so the thing works out of the box, and we expose every decision point so the default can be observed, tuned, vetoed, or replaced. Concretely, "extensible in every area" is not a slogan — it decomposes into **six extension levers**, and every builtin controller must document, for each of its parts, which levers apply. ### The six extension levers 1. **Data override (tuning is data, never code).** Every builtin `property` is POD with sane defaults, so behavior is configured by spawning with overrides — no subclassing: ``` spawn Hero { Platformer { move_speed: 6, jump_height: 5, coyote_frames: 6, air_control: 40 } } ``` Curves that can't be a single scalar are expressed as a small fixed-point profile field set, not hard-coded constants. 2. **Composition (add siblings; extend the data footprint).** Controllers are **small property bundles**, never god-objects. A game adds its own `property` to the same `model` and its own `handler` in the same phase. Because engine-owned systems resolve fields **by name through the reflection ABI and no-op when a field is absent** (the existing `esys_*` contract), a game may add optional fields a builtin system will pick up (e.g. add `wall_slide_speed` to opt a body into wall-sliding) without us shipping every permutation. 3. **Phase ordering (pre/post-process around the engine tick).** Engine systems run at a **defined point** in their phase (today: after user handlers). We make that ordering part of the public contract and add `@Before(System)` / `@After(System)` handler annotations so a game can, e.g., mangle input before the platform integrator reads it, or clamp the result after collision resolves — deterministically, same order every frame. 4. **Event hooks (observe + veto, no fork).** Every meaningful decision a builtin system makes **emits an event**, and the consequential ones are `cancellable`. This is the same EV0–EV7 bus mods already use, so this lever is open to both in-language games (`@On`) and foreign mods (C ABI). Examples across the families: `JumpRequested` (cancellable), `DamageAboutToApply` (cancellable, mutable amount), `ItemPickedUp`, `QuestStateChanged`, `ProjectileSpawned`, `AiTargetAcquired`. Vetoing a cancellable event is how you say "no, not this time" without replacing the system. 5. **Sub-system disable + replace.** Each controller is decomposed into **independently disableable sub-systems**, not one handler. `disable Handler` / `disable Model` already exist for user handlers; we add the parallel for engine-owned systems (a `@Manual Property` marker, or `disable system <Name>`, so a well-known component can be *carried but not auto-ticked*). This lets a dev switch off just the jump integrator and supply their own while keeping the controller's collision, coyote-time, and score intact. 6. **Policy selection (strategy hooks in a no-closure language).** Where a behavior is fundamentally a *formula* (gravity curve, damage calc, AI target-scoring, loot roll), the builtin exposes it as **(a)** a `policy: int`/enum field the system switches on for the common variants (mirrors `Tween.ease(mode)`), **and (b)** a cancellable/mutable event for the fully-custom case. Ludic has no lambdas, so we deliberately do **not** pretend to take `fn` values; the enum-field-plus-event pair is the honest, deterministic substitute and it is testable/replayable. > **The contract in one sentence:** every builtin controller is *(POD property bundle with defaulted, override-able data)* + *(engine-owned systems that tick it, each independently disable-able)* + *(a cancellable/observable event at every decision point)* + *(a policy-enum field where behavior is a formula)*. If a part of a controller can't be reached by at least one of the six levers, that's a design bug in the controller. ## What we are NOT doing (and why) - **No inheritance / virtual overrides.** Ludic is POD-components + no closures by design. Extension is composition + events + policy fields, not subclassing. This keeps everything determinism-safe and replayable. - **No hidden global singletons.** Controller state lives in components (per-entity) or in explicitly-declared module `var`s the game owns, so save/load, snapshots, and rollback (N1 `world_save`) capture it for free. - **No float.** Everything is Q16.16 fixed / integer so lockstep + replay hold (the whole point of building controllers *in* Ludic). - **No runtime dispatch tables** beyond the one honest exception already in the event bus (ordered foreign-listener array). Policy is enum-switch, resolved deterministically. ## Cross-cutting building blocks these controllers will need Filing these here so the family issues can reference them; some may become their own issues: - **2D collision / physics-lite (`Body` + `Collider` + `esys_move`).** AABB sweep + tile-grid resolution, one-way platforms, triggers/sensors. Referenced by all four families. Ludic has a `Collision` namespace stub and `Rect`/`Vector`/`IVec2` value types to build on; this is the biggest shared prerequisite and likely a dedicated issue. - **Stats/attributes + modifiers.** A `Stats` component + a modifier-stack pattern (flat/percent, timed) shared by RPG, shooter, and NPC damage. Builds on `Decimal`/`BigInt` for economy-grade numbers where wanted. - **Cooldown/timer component (`Cooldown`, `esys_cooldown`).** Ubiquitous (jump buffer, fire rate, ability CD, AI re-plan interval). Deterministic frame countdowns. - **Faction/relationship table.** Friend/enemy/neutral matrix shared by shooter targeting + NPC AI. - **A `@Manual` / `disable system` mechanism (lever 5).** Prerequisite for "replace just one sub-system." Probably lands first. - **`@Before`/`@After(System)` ordering annotations (lever 3).** ## Phasing (of the whole layer) 0. **Foundation (this issue's prerequisites):** shared `Body`/`Collider` + `esys_move`, `Cooldown`, lever-5 disable-engine-system, lever-3 ordering annotations, faction table, stats+modifiers. Nothing player-facing yet. 1. **Platformer** (#PLATFORMER) — smallest, exercises collision + input + the full six-lever contract end-to-end as the reference implementation. 2. **Shooter** (#SHOOTER) — adds projectiles + rotation + top-down movement; reuses collision/faction. 3. **NPC AI** (#NPCAI) — perception + steering + behavior model; reuses faction, feeds shooter/RPG enemies. 4. **RPG** (#RPG) — the largest; inventory/crafting/quests/dialog/puzzles/movement modes. Split into sub-modules internally. ## References - Unity **Starter Assets** (first/third-person controllers as a base you extend), Unity Input System action maps. - Godot **`CharacterBody2D`** + `move_and_slide`, the 2D platformer/topdown demos, `Area2D` triggers. - **Bevy** ecosystem crates (`leafwing-input-manager`, `bevy_ecs_ldtk`, `big-brain` for utility AI) — the "small composable systems you layer" ethos. - GameMaker's object-event model and **flecs** module/prefab composition (the "carry a component, a system ticks it" pattern Ludic already implements via `esys_*`). - Ludic internals this builds on: engine-owned systems (`runtime/native/systems.ludic`, `emit_engine_systems_for_phase`), events EV0–EV7, reflection ABI (`World.*`/`Reflect.*`), `Query.*`/`Grid.*`, `Input.*` action maps, `Vector`/`IVec2`/`Rect` value types, lifecycle annotations, scenes/layers. ## Deliverable of the planning phase Agreement on the six-lever contract + the foundation prerequisite list. Once locked, the four family issues are implementable against a shared, honest extensibility story rather than each inventing its own.
Author
Owner

Family issue numbers (the #PLATFORMER/#RPG/#SHOOTER/#NPCAI placeholders in the body above resolve to these — filed after this umbrella so the numbers weren't known when writing it):

  • Platformer controller → #58
  • RPG systems (movement/inventory/crafting/quests/dialog/puzzles) → #59
  • Shooter controller → #60
  • NPC AI → #61

Each conforms to the six-lever extensibility contract defined here; all four depend on the Foundation prerequisites (shared Body/esys_move collision, Cooldown, faction table, stats+modifiers, lever-5 disable system, lever-3 @Before/@After ordering) listed above.

**Family issue numbers** (the `#PLATFORMER`/`#RPG`/`#SHOOTER`/`#NPCAI` placeholders in the body above resolve to these — filed after this umbrella so the numbers weren't known when writing it): - Platformer controller → #58 - RPG systems (movement/inventory/crafting/quests/dialog/puzzles) → #59 - Shooter controller → #60 - NPC AI → #61 Each conforms to the six-lever extensibility contract defined here; all four depend on the Foundation prerequisites (shared `Body`/`esys_move` collision, `Cooldown`, faction table, stats+modifiers, lever-5 `disable system`, lever-3 `@Before`/`@After` ordering) listed above.
Author
Owner

Amendment — controllers ship as external packages, not compiler/stdlib code

Decision after discussion: these controllers are genre content, not core language, so they ship as external, versioned source packages — decoupled from the compiler's release cadence and forkable/extensible by the community (the whole point). They are not welded into the compiler and not added to in-repo runtime/native/ stdlib.

Source packages, not precompiled binaries. Ludic is AOT, so a source package still compiles fully — into the consumer's binary — giving distribution + compilation with no ABI seam. Precompiled native libs (.dylib/.so) were rejected as the default because they (1) can't ship to the wasm/web target and break cross-compilation, (2) forfeit the determinism payoff (lockstep/replay/rollback/byte-identical goldens need whole-world compilation), and (3) can't participate in the compile-time ECS first-class (only via the slower dynamic reflection ABI). Precompiled-binary distribution survives only as an escape hatch for closed-source / other-language mods over the existing EV0–EV7 C-ABI + dynamic reflection — second-class by design.

What stays in core regardless: the six-lever extensibility contract itself (event/@On/cancellable, disable system, @Before/@After, lifecycle hooks) is language, not library. Clean split: core owns the extension primitives; packages own the genre opinions.

New prerequisite: #62 — package/module distribution + making two currently-hardcoded compiler hooks package-declarable (the per-namespace splice trigger in p_postfix, and the uses_engine_systems/emit_engine_systems_for_phase registry) so a package can register a namespace, a component schema, and an engine-owned system without a compiler edit. #58–#61 now depend on #62.

## Amendment — controllers ship as external packages, not compiler/stdlib code Decision after discussion: these controllers are **genre content, not core language**, so they ship as **external, versioned source packages** — decoupled from the compiler's release cadence and forkable/extensible by the community (the whole point). They are *not* welded into the compiler and *not* added to in-repo `runtime/native/` stdlib. **Source packages, not precompiled binaries.** Ludic is AOT, so a source package still compiles fully — into the *consumer's* binary — giving distribution + compilation with no ABI seam. Precompiled native libs (`.dylib`/`.so`) were rejected as the default because they (1) can't ship to the wasm/web target and break cross-compilation, (2) forfeit the determinism payoff (lockstep/replay/rollback/byte-identical goldens need whole-world compilation), and (3) can't participate in the compile-time ECS first-class (only via the slower dynamic reflection ABI). Precompiled-binary distribution survives only as an **escape hatch** for closed-source / other-language **mods** over the existing EV0–EV7 C-ABI + dynamic reflection — second-class by design. **What stays in core regardless:** the six-lever extensibility *contract* itself (`event`/`@On`/`cancellable`, `disable system`, `@Before`/`@After`, lifecycle hooks) is language, not library. Clean split: **core owns the extension primitives; packages own the genre opinions.** **New prerequisite: #62** — package/module distribution + making two currently-hardcoded compiler hooks package-declarable (the per-namespace splice trigger in `p_postfix`, and the `uses_engine_systems`/`emit_engine_systems_for_phase` registry) so a package can register a namespace, a component schema, and an engine-owned system without a compiler edit. **#58–#61 now depend on #62.**
Author
Owner

Shipped — the contract is implemented, and all four families landed on it

The six-lever extensibility contract is now real, not just agreed, and every genre
controller (#58 platformer, #60 shooter, #61 NPC-AI, #59 RPG) conforms to it.

Foundation (this issue) shipped as the source package ludic.gameplay (commit ed385ef):

  • Cooldown — a deterministic engine-ticked frame timer.
  • Stats + an unbounded timed modifier stack (Stats.total = base + flat, then percent; expired modifiers self-despawn).
  • Faction — a friend/enemy/neutral relationship table (same-id friendly / different hostile by default).
  • Combat — one damage pipeline with a cancellable DamageAboutToApply hook that can veto a hit or rewrite the amount (Combat.set_amount), plus Damaged/Died/Healed.

The two prerequisites that needed the language, delivered:

  • Lever 5 — disable system <esys_fn> drops exactly one engine-owned system's tick at compile time, so a game carries a well-known component but ticks it with its own handler. Byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched.
  • Lever 3 — pre/post-processing is realised with today's phases: a handler in Update runs before the movement sweep, a handler in LateUpdate runs after collision resolves; controllers place their sub-systems accordingly (input/move/gravity/jump before the sweep, animation-state after).

The other prerequisites (Body/Collider+esys_move, packaging) were already done in #65/#62/#63/#64.

Determinism held throughout: everything is integer/Q16.16, no floats, no hidden singletons — state lives in components or explicit module registries, so replay, lockstep and world_save all hold. x test is green (103/0) with a new deterministic regression case per controller.

Docs: docs/CONTROLLERS.md (the contract + the five packages). Example: examples/library/gameplay_foundation.ludic.

Closing as done.

## Shipped — the contract is implemented, and all four families landed on it The six-lever extensibility contract is now real, not just agreed, and every genre controller (#58 platformer, #60 shooter, #61 NPC-AI, #59 RPG) conforms to it. **Foundation (this issue)** shipped as the source package **`ludic.gameplay`** (commit ed385ef): - `Cooldown` — a deterministic engine-ticked frame timer. - `Stats` + an unbounded **timed modifier stack** (`Stats.total` = base + flat, then percent; expired modifiers self-despawn). - `Faction` — a friend/enemy/neutral relationship table (same-id friendly / different hostile by default). - `Combat` — one damage pipeline with a `cancellable DamageAboutToApply` hook that can **veto** a hit *or rewrite the amount* (`Combat.set_amount`), plus `Damaged`/`Died`/`Healed`. **The two prerequisites that needed the language, delivered:** - **Lever 5** — `disable system <esys_fn>` drops exactly one engine-owned system's tick at compile time, so a game carries a well-known component but ticks it with its own handler. Byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched. - **Lever 3** — pre/post-processing is realised with today's phases: a handler in `Update` runs before the movement sweep, a handler in `LateUpdate` runs after collision resolves; controllers place their sub-systems accordingly (input/move/gravity/jump before the sweep, animation-state after). The other prerequisites (`Body`/`Collider`+`esys_move`, packaging) were already done in #65/#62/#63/#64. **Determinism held throughout:** everything is integer/Q16.16, no floats, no hidden singletons — state lives in components or explicit module registries, so replay, lockstep and `world_save` all hold. `x test` is green (103/0) with a new deterministic regression case per controller. Docs: `docs/CONTROLLERS.md` (the contract + the five packages). Example: `examples/library/gameplay_foundation.ludic`. Closing as done.
orkun closed this issue 2026-09-01 16:27:38 +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#57
No description provided.