The engine already auto-ticks SpriteAnim/Motion; it now auto-DRAWS too. Declare a Sprite component (id/offx/offy/scale/flip/tint/hidden, shipped from ludic.core) on an entity with a Position and esys_sprite draws it each Render frame — no hand-written Render handler, no hand animation (adds the SpriteAnim frame when present). Registered on the engine-system registry (Render) and spliced only when the game declares Sprite, so a game that never declares it compiles byte-identically; opt out by omitting Sprite or `disable system esys_sprite`. Deprecates the bare draw_sprite/draw_sprite_scaled globals in favour of Screen.sprite/Screen.sprite_scaled: a direct bare call emits a one-time compile-time deprecation note (the bare form still lowers, since Screen.sprite uses it); migrates the chronorift demo to the namespaced calls (golden render byte-identical). Example sprite_render (pixel-readback: engine draws the sprite, respects hidden). Full suite 109/0, goldens byte-identical, fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6.2 KiB
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
- Data override — every controller
propertyis 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 } } - Composition — controllers are small property bundles, never god-objects. A game
adds its own
property/handlerto the same model; engine systems resolve fields by name through the reflection ABI and no-op when a field is absent. - Phase ordering — engine systems run at a defined point in their phase. A handler
in
Updateruns before the movement sweep; a handler inLateUpdateruns after collision resolved. That is pre/post-processing around any engine tick, deterministic and same-order every frame. - 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. - Sub-system disable + replace — each controller is decomposed into independently
disable-able engine systems.
disable system esys_platformer_movedrops exactly one tick so a game can carry the component but drive it with its own handler, keeping the rest of the controller intact. - Policy selection — where a behaviour is a formula (gravity curve, aim mode,
AI decision model, damage calc), the controller exposes a
policy/mode/modelenum 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.core | #77, #85 | The canonical engine-ABI components the engine systems read by name — Position, Body, Collider, Solids, and Sprite — so a game imports them instead of hand-declaring the bundles. Declaring Sprite (id/offset/scale/flip/tint/hidden) makes the engine sprite-render system auto-draw the entity each Render frame (adding the SpriteAnim frame when present). Extend by composition (attach your own components alongside). |
| 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.