tb_on_add / tb_on_remove take a fn(int) (its states supplied, like a system's) told each handle as
its row is made and before it goes (ecs_hooks.ludic), so what follows a table - a drawing, a
message - does so without a scan. Every row's tick now also stamps its 64-row block, and
tb_changed_since / tb_added_since skip the blocks nobody wrote since: a delta over a large table
costs the blocks that moved. A row moved into a removal's gap keeps its own tick (it was not
written); a removal is told by the hook. ecs_test holds both. The ludic.wildlife table
(
|
||
|---|---|---|
| .. | ||
| tests | ||
| ecs_grid.ludic | ||
| ecs_grid_file.ludic | ||
| ecs_grid_query.ludic | ||
| ecs_grid_where.ludic | ||
| ecs_grid_within.ludic | ||
| ecs_hooks.ludic | ||
| ecs_index.ludic | ||
| ecs_map.ludic | ||
| ecs_plan.ludic | ||
| ecs_table.ludic | ||
| ecs_table_remove.ludic | ||
| ecs_table_set.ludic | ||
| index.ludic | ||
| package.ludic | ||
| queue.ludic | ||
| README.md | ||
| rng.ludic | ||
| save.ludic | ||
| save_fields.ludic | ||
| system.ludic | ||
| tick.ludic | ||
ludic.base
The vocabulary a game made of mechanic packages shares. A mechanic (fishing, needs, a shop, the
weather) depends on ludic.base and on nothing else - never on another mechanic. The game is the
only code that knows two mechanics, and it wires them together.
(ludic.core is a different, older package: the engine's canonical ECS components, used by the
examples under examples/library/.)
import "ludic.base"
The rules
- A mechanic uses only this. Its module line says so -
module ludic_clock uses ludic_base- and the compiler refuses a reference into any other module, a package's included. Its tests are one program:
ludic.base, the mechanic, and a fake for each port.
- and the compiler refuses a reference into any other module, a package's included. Its tests are one program:
- Ports for questions. What a mechanic needs to ASK the world is a
portit owns (export port FishingWorld { is_water: fn(float, float) -> bool }), in primitive andludic.basetypes only. The game binds it once, as a declaration (bind FishingWorld { is_water: fn lake }), and the compiler refuses a program that uses a port with a required member and never binds it. A member with a default may be left out. - Queues for facts. What HAPPENED is pushed onto a
Queue<T>the mechanic owns (caught) and drained by the game in a later phase. A mechanic never acts on another's behalf. - Verbs for changes. A mechanic's data is its
state(ToyFishingState), handed to its functions as a parameter, and it changes only through its exported functions (fishing_cast,pack_add). Nothing writes another module's state; a function value (fn fishing_tick) is called with its states supplied, so a system never passes them itself. - Phases for order. A system names its phase; within a phase the game's list is the order. No system says "after fishing".
- Its own save section. Each system saves under its own key with its own version and
migrates its own old versions in
load. A missing section is a reset. - Its own dice. A mechanic is handed an
Rng, never draws fromRandom.*, so moving it cannot shift anyone else's rolls.
The API
Tick { dt, frame, hours }, tick_new(dt, frame, hours) |
what a system's tick receives |
PH_INPUT, PH_SENSE, PH_SIMULATE, PH_RESOLVE, PH_COMMIT, PH_PRESENT, PH_COUNT |
the phases, in the order they run |
Queue<T>, queue_new<T>(name), q_push(q, v), q_drain(q) -> []T, q_len(q), q_clear(q) |
facts, first in first out (queue_new, not q_new: that name is render3d's quaternion). A queue keeps its own count, so its verbs take only the queue: a mechanic's verbs take only the mechanic's own state |
q_tag(q) -> QueueTag, core_undrained(tags) -> []string |
the names of the given queues still holding facts; ask it at the end of a frame |
Rng, rng_new(seed), rng_seed(r, seed), rng_next(r), rng_float(r), rng_between(r, lo, hi), rng_span(r, lo, hi) |
a xorshift32 stream of its own (rng_between is inclusive; rng_range is taken by Random.range) |
SaveNode { found, version, data }, save_tree(), save_section(root, key, version, v), load_section(root, key) -> SaveNode, save_encode, save_decode |
the save tree: {"fishing": {"v": 3, ...}}; v is reserved in a section, and a non-object value rides under data |
sv_int, sv_float, sv_bool, sv_str (key, fallback), sv_ints |
typed reads with a fallback; a float is kept as thousandths |
sv_put_int, sv_put_float, sv_put_bool, sv_put_str, sv_put_ints |
the matching writes |
System { key, phase, version, init, reset, tick, save, load }, system_new(key, phase) |
a system; a null function is a verb it does not have |
core_add(s), core_clear(), core_count() |
the game's system list (a key may appear once) |
def Systems key { ... }, SYS_<KEY>, SYS_COUNT |
a system declared from any module; the list starts from these |
core_init_all(), core_reset_all(), core_tick_all(t), core_save_all() -> Val, core_load_all(v) |
the runner: in the order added, tick phase by phase |
Entities: Table<T> and its indexes
A mechanic that keeps many of something (Things on the ground, animals, drops) keeps them as rows
of a Table<T> in its state rather than as a list it scans. The table is data-oriented and
allocates nothing per query:
- Rows are dense. Hot data is columns -
tb.f[c](floats) andtb.i[c](ints), one value per row - andtb.rec[row]is a recordTfor everything cold. A removal moves the last row into the gap (tb_remove), so a sweep over0 .. tb_len(tb)touches contiguous memory. - Handles go stale.
tb_addreturns a handle: a 22-bit slot and a 9-bit generation.tb_row(tb, h)is -1 once the entity is removed, even after the slot is reused. Keep handles, never rows. - Indexes are kept by the setters.
tb_set_f,tb_set_xz,tb_set_i(or writingtb.f/tb.idirectly and thentb_refile) stamp the row's change tick and refile it in every index that reads that column, in O(1). An index is never a frame behind.tb_grid(tb, cx, cz, gate, cell)- a spatial hash over two float columns, gated by an int column (a row is filed while it is non-zero: "active").tb_nearest(tb, g, x, z, maxr, mc, mv)searches rings outward and stops at the first ring that cannot hold anything nearer;tb_within(..., out)fills a caller'swords. Buckets double as the rows grow.tb_index(tb, col, gate)- a cached query: the rows holding each value of an int column (a kind).ix_rows(ix, v),ix_count(ix, v),ix_first(ix, v).tb_nearest_of/tb_within_ofplan between the two: a rare kind is scanned from its own list, a common one searched by rings.
- Change detection.
tb_advance(tb)moves the table to its next tick;tb_changed_since(tb, tick, out)andtb_added_sincename the rows written since - what a save or a message needs to send a delta instead of everything. - Stable ids.
IntMap(imap_new,imap_put,imap_get(m, k, none),imap_del) maps an id kept in a save or a message to a handle without a scan.
Ludic frees nothing a safe program allocates, so a query that built a list per call leaked every
frame. Every question here writes into a buffer the caller keeps. What each costs, against a
[]Record list scanned (M4 Pro, one thread):
| 10 000 | 100 000 | 1 000 000 | |
|---|---|---|---|
| nearest, any | 0.37 us (list 30) | 1.5 us (list 307) | 7.2 us (list 3075) |
| nearest of a kind (1 in 40) | 1.4 us (list 5.8) | 3.3 us (list 56) | 12 us (list 864) |
| by id | 0.1 us (list 1.7) | 0.09 us (list 17) | 1.5 us (list 324) |
| within 30 m | 0.9 us | 1.5 us | 6.9 us |
| a move, refiled | 11 ns | 14 ns | 44 ns |
tests/ecs_fuzz_test.ludic holds the grid and the kind index against a scan through thousands of
random adds, removes, moves, kind changes and gate flips.
A toy mechanic
module toy_fishing uses ludic_base
numbers float
import "ludic.base"
export property Caught { species: int = 0, weight: float = 0.0 }
export port FishingWorld { is_water: fn(float, float) -> bool } # its port
export state ToyFishingState {
dice: Rng = null
casts: int = 0
caught: Queue<Caught> = null # its facts
}
export function fishing_cast(toy_fishing_st: mut ToyFishingState, x: float, z: float) -> bool { # its verb
if not FishingWorld.is_water(x, z) { return false }
toy_fishing_st.casts += 1
return true
}
function fishing_reset(toy_fishing_st: mut ToyFishingState) -> void {
toy_fishing_st.casts = 0
toy_fishing_st.dice = rng_new(7)
toy_fishing_st.caught = queue_new("fishing.caught")
}
function fishing_tick(toy_fishing_st: mut ToyFishingState, t: Tick) -> void {
while toy_fishing_st.casts > 0 {
let c = new Caught
c.species = rng_between(toy_fishing_st.dice, 0, 2)
c.weight = 0.5 + rng_float(toy_fishing_st.dice)
q_push(toy_fishing_st.caught, c)
toy_fishing_st.casts -= 1
}
}
export function fishing_system() -> System {
let s = system_new("fishing", PH_SIMULATE)
s.reset = fn fishing_reset
s.tick = fn fishing_tick
return s
}
A game wiring two of them
toy_pack is the same shape: verbs pack_add(kind, n) / pack_count(kind) and a system in
PH_COMMIT that saves its counts. Neither knows the other; the game binds fishing's port, writes
the route, and orders the three.
import "toys/fishing"
import "toys/pack"
import "ludic.base"
program Game {
numbers float
function lake(x: float, z: float) -> bool { return x < 100.0 }
bind FishingWorld { is_water: fn lake }
# the route: a landed fish goes into the pack
function route_fishing_pack(fishing: ToyFishingState, pack: mut ToyPackState, t: Tick) -> void {
let fish = q_drain(fishing.caught)
for i in 0 .. len(fish) { pack_add(pack, fish[i].species, 1) }
}
function game_start(base_st: mut BaseState) -> void {
core_add(base_st, fishing_system())
let r = system_new("route.fishing_pack", PH_RESOLVE)
r.tick = fn route_fishing_pack # a fn(Tick) -> void: its states are supplied
core_add(base_st, r)
core_add(base_st, pack_system())
core_reset_all(base_st)
}
}
Both are compiled and run by tests/route_test.ludic (the mechanics are tests/toys/).
Tests
Each piece has a program under tests/, and ludic test runs them all, every test block in a
process of its own:
ludic test packages/ludic.base
queue_test, rng_test, save_test, system_test, registry_test and route_test. They were
written before a generic call inside a test body resolved and before each test had a fresh
state, so the queue cases are functions a test calls and the runner's cases start with
core_clear(); neither is needed any more.
Declared systems
Systemsis anopen registry, so a system can be declared instead of added:def Systems fishing { phase: PH_SIMULATE, tick: fn fishing_tick }from any module. The runner starts from the declared systems - the order the compiler gives an open registry: ludic.base's own (none), then each other module's by module name, each module's in the order it is read - andcore_addappends after them. A game that wants to decide the order itself writes thedefs in its own files, or keeps callingcore_addin one place.