Proposal: builtin gameplay controllers — the extensibility contract (platformer / RPG / shooter / NPC AI) #57
Labels
No labels
area:ci
area:docs
area:input
area:net
area:rendering
area:repo
area:stdlib
area:tooling
area:types
cleanup
dx
priority:high
priority:low
priority:medium
proposal
status:in-progress
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: workshopsoft/ludic#57
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
Ludic has the low-level pieces to build games — ECS (
property/model/handler), engine-owned systems (the compiler ticksSpriteAnim/Motion/Light2Dfor 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
Data override (tuning is data, never code).
Every builtin
propertyis POD with sane defaults, so behavior is configured by spawning with overrides — no subclassing:Curves that can't be a single scalar are expressed as a small fixed-point profile field set, not hard-coded constants.
Composition (add siblings; extend the data footprint).
Controllers are small property bundles, never god-objects. A game adds its own
propertyto the samemodeland its ownhandlerin the same phase. Because engine-owned systems resolve fields by name through the reflection ABI and no-op when a field is absent (the existingesys_*contract), a game may add optional fields a builtin system will pick up (e.g. addwall_slide_speedto opt a body into wall-sliding) without us shipping every permutation.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.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.Sub-system disable + replace.
Each controller is decomposed into independently disableable sub-systems, not one handler.
disable Handler/disable Modelalready exist for user handlers; we add the parallel for engine-owned systems (a@Manual Propertymarker, ordisable 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.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 (mirrorsTween.ease(mode)), and (b) a cancellable/mutable event for the fully-custom case. Ludic has no lambdas, so we deliberately do not pretend to takefnvalues; the enum-field-plus-event pair is the honest, deterministic substitute and it is testable/replayable.What we are NOT doing (and why)
vars the game owns, so save/load, snapshots, and rollback (N1world_save) capture it for free.Cross-cutting building blocks these controllers will need
Filing these here so the family issues can reference them; some may become their own issues:
Body+Collider+esys_move). AABB sweep + tile-grid resolution, one-way platforms, triggers/sensors. Referenced by all four families. Ludic has aCollisionnamespace stub andRect/Vector/IVec2value types to build on; this is the biggest shared prerequisite and likely a dedicated issue.Statscomponent + a modifier-stack pattern (flat/percent, timed) shared by RPG, shooter, and NPC damage. Builds onDecimal/BigIntfor economy-grade numbers where wanted.Cooldown,esys_cooldown). Ubiquitous (jump buffer, fire rate, ability CD, AI re-plan interval). Deterministic frame countdowns.@Manual/disable systemmechanism (lever 5). Prerequisite for "replace just one sub-system." Probably lands first.@Before/@After(System)ordering annotations (lever 3).Phasing (of the whole layer)
Body/Collider+esys_move,Cooldown, lever-5 disable-engine-system, lever-3 ordering annotations, faction table, stats+modifiers. Nothing player-facing yet.References
CharacterBody2D+move_and_slide, the 2D platformer/topdown demos,Area2Dtriggers.leafwing-input-manager,bevy_ecs_ldtk,big-brainfor utility AI) — the "small composable systems you layer" ethos.esys_*).runtime/native/systems.ludic,emit_engine_systems_for_phase), events EV0–EV7, reflection ABI (World.*/Reflect.*),Query.*/Grid.*,Input.*action maps,Vector/IVec2/Rectvalue 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.
Family issue numbers (the
#PLATFORMER/#RPG/#SHOOTER/#NPCAIplaceholders in the body above resolve to these — filed after this umbrella so the numbers weren't known when writing it):Each conforms to the six-lever extensibility contract defined here; all four depend on the Foundation prerequisites (shared
Body/esys_movecollision,Cooldown, faction table, stats+modifiers, lever-5disable system, lever-3@Before/@Afterordering) listed above.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 theuses_engine_systems/emit_engine_systems_for_phaseregistry) 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.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(commited385ef):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 acancellable DamageAboutToApplyhook that can veto a hit or rewrite the amount (Combat.set_amount), plusDamaged/Died/Healed.The two prerequisites that needed the language, delivered:
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.Updateruns before the movement sweep, a handler inLateUpdateruns 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_saveall hold.x testis 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.