feat(render): #85 engine sprite-render system + deprecate bare draw_sprite
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 23s
ci / build-and-test (push) Successful in 2m20s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 28s

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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-02 07:20:13 +03:00
parent d9287d6b1d
commit 57b8747c31
11 changed files with 18366 additions and 18002 deletions

3
changes/sprite-render.md Normal file
View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Engine sprite-render system (#85) — the engine already auto-*ticks* SpriteAnim and Motion; it now auto-*draws* too. Declare a `Sprite` component (id + optional offx/offy/scale/flip/tint/hidden, shipped from **ludic.core**) on an entity with a `Position` and the engine draws it each Render frame — no hand-written Render handler querying positions and calling `draw_sprite` per entity, and no hand animation (when the entity also carries `SpriteAnim`, the current frame is added to the base id). Registered on the compile-time engine-system registry for the Render phase and spliced only when a game declares `Sprite`, so a game that never declares it compiles byte-identically; a game wanting a custom draw omits `Sprite` (or `disable system esys_sprite`). Also **deprecates the bare `draw_sprite` / `draw_sprite_scaled` globals** in favour of the namespaced `Screen.sprite` / `Screen.sprite_scaled`: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, since `Screen.sprite` uses it), and the in-repo `chronorift` demo is migrated to the namespaced calls. Example: `examples/library/sprite_render.ludic`.

View file

@ -53,7 +53,7 @@ controller.
| Package | Issue | What it gives you | | Package | Issue | What it gives you |
| --- | --- | --- | | --- | --- | --- |
| **ludic.core** | #77 | The canonical engine-ABI components the movement system reads by name — `Position`, `Body`, `Collider`, `Solids` — so a game imports them instead of hand-declaring the bundles. Extend by composition (attach your own components alongside). | | **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.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.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.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`. |

View file

@ -24,19 +24,19 @@ handler Draw phase Render {
',', 'S', 'B' => base = SPR_FLOOR ',', 'S', 'B' => base = SPR_FLOOR
'=' => base = SPR_WALL '=' => base = SPR_WALL
} }
draw_sprite(base, sx, sy) Screen.sprite(base, sx, sy)
if c == 'T' { draw_sprite(SPR_TREE, sx, sy) } if c == 'T' { Screen.sprite(SPR_TREE, sx, sy) }
if c == '#' { draw_sprite(SPR_TREE, sx, sy) } if c == '#' { Screen.sprite(SPR_TREE, sx, sy) }
if c == 'H' { draw_sprite(SPR_HOUSE, sx, sy) } if c == 'H' { Screen.sprite(SPR_HOUSE, sx, sy) }
if c == 'D' { draw_sprite(SPR_DOOR, sx, sy) } if c == 'D' { Screen.sprite(SPR_DOOR, sx, sy) }
if c == 'S' { draw_sprite(SPR_DOOR, sx, sy) } if c == 'S' { Screen.sprite(SPR_DOOR, sx, sy) }
if c == 'B' { if c == 'B' {
if boss_down == 0 { draw_sprite(SPR_BOSS, sx, sy) } if boss_down == 0 { Screen.sprite(SPR_BOSS, sx, sy) }
} }
} }
} }
for (p, a) in query [Pos, Actor] { for (p, a) in query [Pos, Actor] {
draw_sprite(a.kind, p.x * 16 - cx, p.y * 16 - cy) Screen.sprite(a.kind, p.x * 16 - cx, p.y * 16 - cy)
} }
for (h, pp) in query [Stats, Party] { for (h, pp) in query [Stats, Party] {
if pp.slot == 0 { if pp.slot == 0 {
@ -59,7 +59,7 @@ handler Draw phase Render {
for (e, a) in query [Stats, Actor, {Enemy}] { for (e, a) in query [Stats, Actor, {Enemy}] {
var bs = 5 var bs = 5
if enemy_type == 2 { bs = 7 } if enemy_type == 2 { bs = 7 }
draw_sprite_scaled(a.kind, 160 - bs * 8, 44, bs) Screen.sprite_scaled(a.kind, 160 - bs * 8, 44, bs)
Screen.draw_text(100, 20, enemy_name(enemy_type), 0xffffff, 1) Screen.draw_text(100, 20, enemy_name(enemy_type), 0xffffff, 1)
Screen.fill_rectangle(100, 30, 120, 8, 0x401018) Screen.fill_rectangle(100, 30, 120, 8, 0x401018)
Screen.fill_rectangle(100, 30, e.hp * 120 / e.maxhp, 8, 0xe04040) Screen.fill_rectangle(100, 30, e.hp * 120 / e.maxhp, 8, 0xe04040)
@ -75,7 +75,7 @@ handler Draw phase Render {
var spr = 5 var spr = 5
if pt.slot == 1 { spr = 9 } if pt.slot == 1 { spr = 9 }
if pt.slot == act { Screen.draw_rectangle(hx - 2, 180, 88, 44, 0xffff00) } if pt.slot == act { Screen.draw_rectangle(hx - 2, 180, 88, 44, 0xffff00) }
draw_sprite_scaled(spr, hx, 184, 2) Screen.sprite_scaled(spr, hx, 184, 2)
if pt.slot == 0 { Screen.draw_text(hx + 36, 182, "KNIGHT", 0xffe08a, 1) } if pt.slot == 0 { Screen.draw_text(hx + 36, 182, "KNIGHT", 0xffe08a, 1) }
if pt.slot == 1 { Screen.draw_text(hx + 36, 182, "MAGE", 0xc9a0ff, 1) } if pt.slot == 1 { Screen.draw_text(hx + 36, 182, "MAGE", 0xc9a0ff, 1) }
Screen.draw_text(hx + 36, 195, "HP", 0x88ff88, 1) Screen.draw_text(hx + 36, 195, "HP", 0x88ff88, 1)

View file

@ -0,0 +1,32 @@
# sprite_render.ludic — the engine-owned sprite-render system (#85). Declare a
# Sprite component (from ludic.core) on an entity with a Position and the engine
# draws it each Render frame — no hand-written Render handler querying positions
# and calling draw_sprite per entity. A `hidden` sprite is skipped. Verified by
# pixel readback after one frame.
#
# Driven headless by one stdin key ('q' quits after the frame renders).
# printf 'q' | LUDIC_MODULES=packages bin/ludic examples/library/sprite_render.ludic -> 1 0
program SpriteRender {
import "ludic.core/components.ludic"
var HERO: int = 0
@OnStart handler Boot {
HERO = png_load("assets/kenney/tiny-dungeon/Tiles/tile_0096.png")
spawn Shown { Position { x: 40, y: 40 }, Sprite { id: HERO } }
spawn Hidden { Position { x: 100, y: 40 }, Sprite { id: HERO, hidden: 1 } }
}
# An empty Render handler: the engine sprite system draws the Sprite entities.
handler Draw phase Render { }
@OnQuit handler Report {
var drew = 0
for yy in 40 .. 56 { for xx in 40 .. 56 { if Screen.pixel(xx, yy) != 0 { drew = 1 } } }
print(drew) # 1 — the engine drew the shown sprite
var hid = 0
for yy in 40 .. 56 { for xx in 100 .. 116 { if Screen.pixel(xx, yy) != 0 { hid = 1 } } }
print(hid) # 0 — the hidden sprite was skipped
}
}

View file

@ -41,6 +41,15 @@ property Collider {
hit: int = 0, entered: int = 0, exited: int = 0 hit: int = 0, entered: int = 0, exited: int = 0
} }
# A drawable sprite (#85): declare it and the engine-owned sprite-render system
# draws the entity each Render frame from its Position — no hand-written Render
# handler. `id` is a sprite id (png_load / atlas cell); when the entity also carries
# SpriteAnim, the current animation frame is added to it. offx/offy offset from the
# Position anchor, scale is an integer zoom (0/1 = 1:1), flip mirrors horizontally,
# tint (non-zero) draws every opaque pixel in one colour (hit flash), hidden = 1
# skips it. Draw order is spawn order; a game wanting custom draw omits Sprite.
property Sprite { id: int = 0, offx: int = 0, offy: int = 0, scale: int = 1, flip: int = 0, tint: int = 0, hidden: int = 0 }
# Optional single config entity that turns the tile-grid broadphase on: solids are # Optional single config entity that turns the tile-grid broadphase on: solids are
# read from the Map.* tilemap. tile > 0 sets the tile size in px; wall is the solid # read from the Map.* tilemap. tile > 0 sets the tile size in px; wall is the solid
# glyph; oneway is an optional one-way-platform glyph (0 = none). # glyph; oneway is an optional one-way-platform glyph (0 = none).

View file

@ -20,3 +20,4 @@ provides "Position"
provides "Body" provides "Body"
provides "Collider" provides "Collider"
provides "Solids" provides "Solids"
provides "Sprite"

View file

@ -367,6 +367,32 @@ function rt_draw_sprite_scaled(id: int, px: int, py: int, sc: int) -> void {
} }
} }
# The full sprite blit the engine sprite-render system (#85) uses: an integer
# scale (>= 1), a horizontal flip, and an optional solid tint (0 = keep the
# sprite's own colours; non-zero = draw every opaque pixel in the tint colour, for
# a hit-flash / team-colour silhouette). It funnels through the same put_px /
# fill_rect chokepoints, so camera / zoom / clip / blend all apply.
function rt_draw_sprite_ex(id: int, px: int, py: int, sc: int, flip: int, tint: int) -> void {
if id < 0 { return }
if id >= spr_n { return }
var s = sc
if s < 1 { s = 1 }
let base = id * SPR_SZ * SPR_SZ
for y in 0 .. SPR_SZ {
for x in 0 .. SPR_SZ {
var rx = x
if flip != 0 { rx = SPR_SZ - 1 - x } # sample mirrored, draw upright
let p = spr_px[base + y * SPR_SZ + rx]
if ((p >> 24) & 255) != 0 {
var col = (p & 16777215)
if tint != 0 { col = (tint & 16777215) }
if s == 1 { rt_put_px(px + x, py + y, col) }
else { rt_fill_rect(px + x * s, py + y * s, s, s, col) }
}
}
}
}
# ---- .lspr sprite sheets (palette + ASCII rows) --------------------------- # ---- .lspr sprite sheets (palette + ASCII rows) ---------------------------
function rt_hexval(c: int) -> int { function rt_hexval(c: int) -> int {
if c >= 48 { if c <= 57 { return c - 48 } } if c >= 48 { if c <= 57 { return c - 48 } }

View file

@ -0,0 +1,84 @@
# ============================================================================
# systems_sprite.ludic — the engine-owned sprite-render system (#85).
#
# The engine already auto-*ticks* SpriteAnim (advance the frame) and Motion; this
# adds the missing half: it auto-*draws* an entity's sprite. Declare a `Sprite`
# component (id + optional offset / scale / flip / tint / hidden) and the engine
# draws it each Render frame from the entity's `Position` — no hand-written Render
# handler querying positions and calling draw_sprite per entity, and no hand
# animation: when the entity also carries SpriteAnim, the current animation frame
# is added to the base id automatically.
#
# It is registered on the compile-time engine-system registry (parse_program) for
# the Render phase and spliced only when a game declares `Sprite`, so a game that
# never declares it compiles byte-identically. A game that wants a custom draw
# simply doesn't declare Sprite (or `disable system esys_sprite`) and draws itself.
#
# Component contract (POD, every field defaulted; ship it from ludic.core):
# property Sprite {
# id : int # sprite id (png_load / atlas cell); base frame for SpriteAnim
# offx : int = 0 # draw offset from the Position anchor, px
# offy : int = 0
# scale : int = 1 # integer scale (0/1 = 1:1)
# flip : int = 0 # 1 = mirror horizontally
# tint : int = 0 # 0 = sprite's own colours; non-zero = solid tint (hit flash)
# hidden : int = 0 # 1 = skip drawing this entity
# }
#
# Draw order is entity/query order (spawn order). Everything integer + through the
# camera/zoom/clip chokepoints, so it stays deterministic and headless-identical.
# ============================================================================
# read entity `e`'s world coordinate from a Position component (x when axis==0,
# y when axis==1), or 0 if it has none.
function spr_pos(e: int, axis: int) -> int {
let pp = World.prop_id("Position")
if pp < 0 { return 0 }
if World.has(e, pp) == 0 { return 0 }
var f = World.field_id(pp, "x")
if axis == 1 { f = World.field_id(pp, "y") }
if f < 0 { return 0 }
return World.get(e, pp, f)
}
function esys_sprite() -> void {
let P = World.prop_id("Sprite")
if P < 0 { return }
let f_id = World.field_id(P, "id")
if f_id < 0 { return }
let f_offx = World.field_id(P, "offx")
let f_offy = World.field_id(P, "offy")
let f_scale = World.field_id(P, "scale")
let f_flip = World.field_id(P, "flip")
let f_tint = World.field_id(P, "tint")
let f_hidden = World.field_id(P, "hidden")
let SA = World.prop_id("SpriteAnim")
var f_frame = 0 - 1
if SA >= 0 { f_frame = World.field_id(SA, "frame") }
var e = World.query_next(P, 0)
while e >= 0 {
var hidden = 0
if f_hidden >= 0 { hidden = World.get(e, P, f_hidden) }
if hidden == 0 {
var id = World.get(e, P, f_id)
# add the current animation frame when the entity also carries SpriteAnim.
if (SA >= 0) and (f_frame >= 0) {
if World.has(e, SA) != 0 { id = id + World.get(e, SA, f_frame) }
}
var x = spr_pos(e, 0)
var y = spr_pos(e, 1)
if f_offx >= 0 { x = x + World.get(e, P, f_offx) }
if f_offy >= 0 { y = y + World.get(e, P, f_offy) }
var sc = 1
if f_scale >= 0 { let sv = World.get(e, P, f_scale); if sv > 1 { sc = sv } }
var flip = 0
if f_flip >= 0 { flip = World.get(e, P, f_flip) }
var tint = 0
if f_tint >= 0 { tint = World.get(e, P, f_tint) }
rt_draw_sprite_ex(id, x, y, sc, flip, tint)
}
e = World.query_next(P, e + 1)
}
}

View file

@ -210,7 +210,9 @@ function p_postfix() -> Node {
else { if is_op("[") { pi = pi + 1; let lo = expr() else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } } else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } }
else { if is_op("(") { let c = node(E_CALL); c.a = e; c.line = toks[pi].line; args_call(c); e = c } else { break } } } else { if is_op("(") { let c = node(E_CALL); c.a = e; c.line = toks[pi].line; args_call(c); e = c
if (c.a.kind == E_ID) and ((c.a.s == "draw_sprite") or (c.a.s == "draw_sprite_scaled")) { warn_draw_sprite(c.a.s) } # #85 deprecate the bare aliases
} else { break } } }
} }
return e return e
} }
@ -445,6 +447,17 @@ var g_uses_esys: bool = false # an engine-owned system component (SpriteAnim
var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic var g_uses_input: bool = false # a program used Input.bind/down/poll/… (action maps + record/replay) -> splice input.ludic
var g_has_clear_color: bool = false # @ClearColor(N) declared -> the Render phase auto-clears + auto-presents (#86) var g_has_clear_color: bool = false # @ClearColor(N) declared -> the Render phase auto-clears + auto-presents (#86)
var g_clear_color: int = 0 # the declared clear colour (0x00RRGGBB) var g_clear_color: int = 0 # the declared clear colour (0x00RRGGBB)
var g_warned_draw_sprite: bool = false # emit the bare-draw_sprite deprecation note once (#85)
# #85 — steer a direct bare draw_sprite / draw_sprite_scaled call to the namespaced
# Screen.sprite (or the engine sprite-render system). Warns once, non-fatally — the
# bare form still compiles (Screen.sprite lowers to it), it is just deprecated.
function warn_draw_sprite(name: pointer) -> void {
if g_warned_draw_sprite { return }
g_warned_draw_sprite = true
let m = `ludicc: warning: bare {name}(...) is deprecated (#85) — use Screen.sprite / Screen.sprite_scaled, or declare a Sprite component for the engine sprite-render system\n`
file_write(file_stderr(), m, len(m))
}
var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (#48) -> splice systems.ludic + force the reflection ABI var g_uses_anim_rt: bool = false # Anim.play/clip/on_frame/fired or Motion.to (#48) -> splice systems.ludic + force the reflection ABI
var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update var g_uses_tween_rt: bool = false # Tween.to/chain/delay/… (#48) -> splice tween.ludic + run esys_tween each Update
var g_uses_audio: bool = false # Audio.* (#22) -> splice audio.ludic; a windowed build also links audio.ll + AVFoundation var g_uses_audio: bool = false # Audio.* (#22) -> splice audio.ludic; a windowed build also links audio.ll + AVFoundation
@ -844,6 +857,13 @@ function maybe_splice_runtime() -> void {
do_import("runtime/native/core.ludic") do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_move.ludic") do_import("runtime/native/systems_move.ludic")
} }
# Sprite -> the engine sprite-render system (#85). It draws through
# rt_draw_sprite_ex (image.ludic, part of core), so it links against core.ludic
# (do_import dedupes when a game already pulled core in).
if find_comp("Sprite") != null {
do_import("runtime/native/core.ludic")
do_import("runtime/native/systems_sprite.ludic")
}
# Light2D / Occluder -> the lighting render system, which links against the # Light2D / Occluder -> the lighting render system, which links against the
# 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the # 2D light pass (light.ludic). do_import dedupes, so this is a no-op when the
# game also uses Light.* directly (g_uses_light already pulled it in). # game also uses Light.* directly (g_uses_light already pulled it in).
@ -882,6 +902,7 @@ function parse_program() -> void {
g_uses_input = false g_uses_input = false
g_has_clear_color = false g_has_clear_color = false
g_clear_color = 0 g_clear_color = 0
g_warned_draw_sprite = false
g_uses_light = false g_uses_light = false
g_uses_value = false g_uses_value = false
g_uses_xml = false g_uses_xml = false
@ -899,6 +920,7 @@ function parse_program() -> void {
push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update") push(g_esys_comp, "SpriteAnim"); push(g_esys_fn, "esys_spriteanim"); push(g_esys_phase, "Update")
push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update") push(g_esys_comp, "Motion"); push(g_esys_fn, "esys_motion"); push(g_esys_phase, "Update")
push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update") push(g_esys_comp, "Body"); push(g_esys_fn, "esys_move"); push(g_esys_phase, "Update")
push(g_esys_comp, "Sprite"); push(g_esys_fn, "esys_sprite"); push(g_esys_phase, "Render")
push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render") push(g_esys_comp, "Light2D"); push(g_esys_fn, "esys_light2d"); push(g_esys_phase, "Render")
g_namespaces = new []pointer g_namespaces = new []pointer
loaded_paths = new []pointer loaded_paths = new []pointer

File diff suppressed because it is too large Load diff

View file

@ -243,6 +243,7 @@ function cmd_test() -> int {
feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)") feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)")
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61) # builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)") controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)") controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)") controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)") controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")