feat(ecs): #80 entity-pool stats (Pool.live/free/reserved/capacity)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 22s
ci / build-and-test (push) Successful in 2m27s
commit-lint / conventional-commits (push) Successful in 5s
docs / build-and-deploy (push) Successful in 29s

Ludic's ECS is already pool-based — the allocator recycles freed entity slots
through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
storage is fixed per-entity arrays, so spawn/despawn churn (bullet-hell/horde)
does no per-spawn heap allocation and cannot fragment. Expose that with a Pool.*
namespace so a game can watch reuse: Pool.live (alive now), Pool.free (recycled
slots waiting), Pool.reserved (high-water — stays flat across a steady
spawn/despawn loop, proving reuse not reallocation), Pool.capacity (the fixed
cap). Zero-cost inline reads of the existing counters.

Example pool.ludic proves the key property: after despawn+respawn,
Pool.reserved() stays 3 (freed slot reused) — prints 0 0 3 3 0 2 1 3 0 3 1.
4 docs pages. Full suite 118/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 08:23:58 +03:00
parent f2cb3cd7e8
commit 347352cc4c
10 changed files with 25580 additions and 25382 deletions

View file

@ -0,0 +1,3 @@
bump: minor
type: feat
Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`.

View file

@ -0,0 +1,7 @@
---
id: pool
title: Pool
order: 35
---
Entity-pool statistics. Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a <a href="../structure/kw-model"><code>despawn</code></a>ed slot is reused by the next <code>spawn</code> before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame does no per-spawn heap allocation and cannot fragment. <a href="pool-live"><code>Pool.live</code></a> / <a href="pool-free"><code>Pool.free</code></a> / <a href="pool-reserved"><code>Pool.reserved</code></a> / <a href="pool-capacity"><code>Pool.capacity</code></a> read those counters so a bullet-hell or horde game can watch reuse and budget against the cap.

View file

@ -0,0 +1,14 @@
---
id: pool-capacity
name: Pool.capacity
category: pool
kind: namespace-method
tokens: Pool.capacity
sig: Pool.capacity() -> int
tip: The maximum number of entities.
order: 4
ns: Pool
member: capacity
---
Returns the maximum number of live entities (the fixed entity-array size). Budget spawns against it — a horde/bullet-hell game keeps <a href="pool-live"><code>Pool.live</code></a> under <code>Pool.capacity()</code>.

View file

@ -0,0 +1,14 @@
---
id: pool-free
name: Pool.free
category: pool
kind: namespace-method
tokens: Pool.free
sig: Pool.free() -> int
tip: How many freed slots are waiting to be reused.
order: 2
ns: Pool
member: free
---
Returns how many freed entity slots are on the freelist, waiting to be reused by the next <code>spawn</code>. This is the pool depth — despawning bullets/enemies raises it, and the next wave draws them back down with no allocation.

View file

@ -0,0 +1,14 @@
---
id: pool-live
name: Pool.live
category: pool
kind: namespace-method
tokens: Pool.live
sig: Pool.live() -> int
tip: How many entities are currently alive.
order: 1
ns: Pool
member: live
---
Returns the number of entities currently alive (reserved minus free). Despawning drops it and spawning raises it; a recycled slot does not change <a href="pool-reserved"><code>Pool.reserved</code></a>.

View file

@ -0,0 +1,14 @@
---
id: pool-reserved
name: Pool.reserved
category: pool
kind: namespace-method
tokens: Pool.reserved
sig: Pool.reserved() -> int
tip: High-water: how many slots have ever been allocated.
order: 3
ns: Pool
member: reserved
---
Returns the high-water mark — how many entity slots have ever been allocated. It only grows when a <code>spawn</code> finds the freelist empty; a spawn that reuses a freed slot leaves it unchanged, so a steady-state spawn/despawn loop keeps <code>reserved</code> flat (the proof that slots are pooled, not reallocated).

View file

@ -0,0 +1,39 @@
# pool.ludic — entity pooling stats (#80). Ludic's ECS already recycles freed
# entity slots through a freelist (a despawned slot is reused by the next spawn
# before any new slot is taken), so bullet-hell / horde spawn+despawn does no
# per-spawn allocation and cannot fragment. Pool.* exposes the counters so a game
# can watch the reuse. The key proof: after despawn + respawn, Pool.reserved()
# does NOT grow — the freed slot was reused, not reallocated.
#
# Deterministic; a full run prints: 0 0 3 3 0 2 1 3 0 3 1
program PoolDemo {
property Mob { hp: int = 0 }
model M { Mob }
function bi(b: bool) -> int { if b { return 1 }; return 0 }
entry {
print(Pool.reserved()) # 0 — nothing allocated yet
print(Pool.live()) # 0
spawn M { Mob { hp: 1 } }
spawn M { Mob { hp: 2 } }
spawn M { Mob { hp: 3 } }
print(Pool.reserved()) # 3
print(Pool.live()) # 3
print(Pool.free()) # 0 — no freed slots yet
let pm = World.prop_id("Mob")
let e = World.query_next(pm, 0)
despawn e
print(Pool.live()) # 2
print(Pool.free()) # 1 — one slot recycled onto the freelist
spawn M { Mob { hp: 4 } } # reuses the freed slot
print(Pool.reserved()) # 3 — NO new slot allocated (pooled, not reallocated)
print(Pool.free()) # 0 — the freelist slot was drawn back down
print(Pool.live()) # 3
print(bi(Pool.capacity() >= 3)) # 1 — there is a fixed entity cap to budget against
}
}

View file

@ -148,6 +148,21 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if is_clock_ns(meth) { return emit_clock_ns(meth, e) }
perr(`unknown builtin Clock.{meth}`)
}
# #80 — entity pool stats. The ECS allocator already recycles freed entity slots
# through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
# storage is fixed per-entity arrays — so there is no per-spawn heap allocation or
# fragmentation. Pool.* just reads those counters so a game can watch reuse.
if (ns == "Pool") {
if (meth == "capacity") { return val(itoa(MAX_ENT), "int") } # max entities
if (meth == "reserved") { return val(emit_bind("load i32, ptr @L_entc"), "int") } # slots ever allocated (high-water)
if (meth == "free") { return val(emit_bind("load i32, ptr @L_freen"), "int") } # recycled slots ready for reuse
if (meth == "live") { # currently alive = reserved - free
let ec = emit_bind("load i32, ptr @L_entc")
let fr = emit_bind("load i32, ptr @L_freen")
return val(emit_bind(`sub i32 {ec}, {fr}`), "int")
}
perr(`unknown builtin Pool.{meth}`)
}
var bare: pointer = null
let labels = new []pointer
if (ns == "Screen") {

File diff suppressed because it is too large Load diff

View file

@ -243,6 +243,7 @@ function cmd_test() -> int {
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")