ludic/docs/CONTROLLERS.md
Orkuncakilkaya dbb4ca6403
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Successful in 2m13s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 28s
docs(controllers): overview of the six-lever contract + the five packages (#57)
docs/CONTROLLERS.md documents the mechanism-vs-policy thesis, the six extension
levers, the ludic.gameplay/platformer/shooter/npcai/rpg packages, and how to
build a game against them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 17:27:01 +03:00

5.8 KiB
Raw Blame History

Builtin gameplay controllers

Ludic ships a layer of ready-made gameplay controllers — the "drop this in and you have a platformer / shooter / RPG / NPC" starting point — as external, versioned source packages (see PACKAGES.md). Because Ludic is AOT, a source package compiles straight into your game's compile-time ECS: there is no ABI seam and the whole thing stays deterministic, so lockstep, input replay and world_save snapshots hold across every controller.

This is the direction decided in issue #57 and implemented in #58–#61.

The design thesis: mechanism vs. policy

The engine ships mechanism; the developer owns policy. A controller is a thin spine of mechanism (integrate velocity, resolve a tile collision, advance a cooldown) with named decision points where policy is injected. Every controller ships a sensible default so it works out of the box, and exposes every decision so the default can be observed, tuned, vetoed or replaced.

Every builtin controller is a base, not a cage. If a part of a controller can't be reached by at least one of the six levers below, that is a design bug in the controller.

The six extension levers

  1. Data override — every controller property is POD with sane defaults, so behaviour is configured by spawning with overrides, no subclassing:
    spawn Hero { Platformer { jump_height: 5, apex_frames: 10, air_jumps: 1 } }
    
  2. Composition — controllers are small property bundles, never god-objects. A game adds its own property/handler to the same model; engine systems resolve fields by name through the reflection ABI and no-op when a field is absent.
  3. Phase ordering — engine systems run at a defined point in their phase. A handler in Update runs before the movement sweep; a handler in LateUpdate runs after collision resolved. That is pre/post-processing around any engine tick, deterministic and same-order every frame.
  4. Event hooks — every meaningful decision emits an event, and the consequential ones are cancellable. @On(JumpRequested) { cancel } vetoes; a mutable channel (e.g. Combat.set_amount) rewrites a value. Open to in-language games and foreign mods over the same EV0–EV7 bus.
  5. Sub-system disable + replace — each controller is decomposed into independently disable-able engine systems. disable system esys_platformer_move drops exactly one tick so a game can carry the component but drive it with its own handler, keeping the rest of the controller intact.
  6. Policy selection — where a behaviour is a formula (gravity curve, aim mode, AI decision model, damage calc), the controller exposes a policy/mode/model enum field the system switches on, plus a cancellable/mutable event for the fully custom case. Ludic has no closures; the enum-field-plus-event pair is the honest, deterministic substitute.

The packages

Package Issue What it gives you
ludic.gameplay #57 The shared foundation: Cooldown timer, Stats + a timed modifier stack, a Faction friend/enemy/neutral table, and a Combat damage pipeline (cancellable DamageAboutToApply with a mutable amount, Damaged/Died/Healed).
ludic.platformer #58 Jump-feel Platformer (apex/coyote/buffer/variable-height/multi-jump), decomposed input/move/gravity/jump/anim sub-systems, JumpRequested/Landed/StateChanged events, a gravity policy enum, and opt-in scaffolding (moving/crumble platforms with rider carry, pickups+Score, springs, hazards+Life, checkpoints/goal).
ludic.shooter #60 TopDown decoupled move + aim (mouse / stick / move-dir / nearest-enemy auto-aim), a name-keyed Weapon registry (fire-rate/spread/pellets/pattern + data-driven pierce/homing), a deterministic Projectile pool (faction-filtered hits through Combat, pierce, ring/spiral patterns, homing), and a budgeted wave Spawner.
ludic.npcai #61 Perception → decision → action: Vision/Memory (throttled, faction + optional LOS), three decision models (FSM, utility, behaviour tree) writing one Brain intent with a cancellable DecisionMade hook, Reynolds flocking steering, and a Follower companion. The AI writes the same intent fields the player controllers read, so an enemy reuses the shooter's weapon and a companion reuses the mover.
ludic.rpg #59 Seven independently-usable modules: movement (grid/free/tween), inventory + equipment, crafting, event-driven quests + flags, an Ink/Yarn dialog graph, Sokoban puzzles + a switch/plate/gate signal graph, and over-time status effects. Content is name-keyed data registries — a mod adds items/recipes/quests/dialog with zero code.

Using a controller

The genre packages build on the engine's Body/Collider + esys_move (swept-AABB tile collision, in runtime/native/systems_move.ludic) and on ludic.gameplay. In a real project you x add them; in this repo the packages live under packages/ and a game compiles against them with LUDIC_MODULES:

LUDIC_MODULES=packages ludicc examples/games/platformer_demo.ludic -o platformer

Worked, self-checking examples for every controller live in examples/games/ (platformer_demo, platformer_scaffolding, shooter_demo, npcai_demo, rpg_demo) and examples/library/gameplay_foundation.ludic; each is a deterministic regression case in x test.

What we deliberately do NOT do

  • No inheritance / virtual overrides. Extension is composition + events + policy fields, not subclassing — determinism-safe and replayable.
  • No hidden global singletons. Controller state lives in components (per-entity) or explicit module registries the game owns, so save/load and rollback capture it.
  • No float. Everything is Q16.16 fixed / integer, so lockstep and replay hold.