ludic/packages/ludic.base/ecs_table.ludic
Orkuncakilkaya f802bdd3ec feat(ecs): ludic.base Table<T> - dense rows of hot columns, generational handles, a spatial grid, kind indexes and an id map kept current by the setters; ludic.things on it
A mechanic that keeps many of something keeps them as rows of a Table<T> rather than a list it
scans. Removal swaps the last row in; a handle (22-bit slot, 9-bit generation) goes stale when its
entity is removed. tb_set_f / tb_set_xz / tb_set_i stamp a change tick and refile the row in every
index over that column in O(1): tb_grid (a doubly linked spatial hash, rings outward for nearest,
rehashing as it grows), tb_index (a cached query: the rows of each value of a kind column, gated by
an active column), tb_nearest_of / tb_within_of (a rare kind from its own list), tb_nearest_where
(a predicate on the record), tb_within_recs (into the caller's list), tb_changed_since /
tb_added_since, IntMap. No question allocates or writes: Ludic frees nothing, and the old lists'
per-call copies leaked every frame.

ludic.things keeps its Things as a Table<Thing> with x, z, kind and active as columns; every verb
writes the record and the row together (a Thing carries its handle and table, so thing_hide(t)
still needs no state), thing_set_on / thing_set_xz / thing_place_at are the silent forms the game
used to do by assignment, and things_verify holds the columns against the records.
things_near(_of) fill a caller's list, thing_of_kind walks a kind, things_count_of counts one, and
things_tick visits only the kinds that tick. thing_find answers the first-placed by uid.

Against a []Record scanned (M4 Pro): nearest 0.37 / 1.5 / 7.2 us at 10k / 100k / 1M (list 30 /
307 / 3075), by id 0.09 us at 100k (list 17), a move refiled in 11-44 ns. ecs_fuzz_test holds the
grid, the kind index and the record queries against a scan through 9000 random changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:52:51 +03:00

79 lines
2.9 KiB
Text

# ludic.base/ecs_table.ludic - a Table<T>: entities as dense rows of hot columns (floats, words)
# and one record each for what is cold. A handle is a slot and a generation, so a stale one misses.
export const ECS_SLOT: int = 4194303 # the low 22 bits of a handle: its slot
export const ECS_GEN: int = 511 # the next 9: how many times the slot was reused
export const ECS_NONE: int = -1
export property Table<T> {
n: int = 0
rec: []T = null
ent: words = null # row -> handle
f: []floats = null # hot float columns, a value per row each
i: []words = null # hot int columns
added: words = null # row -> the tick it was added at
changed: words = null # row -> the tick a setter last wrote it at
tick: int = 1
slot_gen: words = null # slot -> generation
slot_row: words = null # slot -> row, -1 while free
free: words = null
grids: []Grid = null
idx: []IntIndex = null
}
# a table of nf float and ni int columns, every value 0 in a new row
export function table_new<T>(nf: int, ni: int) -> Table<T> {
let tb = new Table<T>
tb.rec = new []T
tb.ent = words(0)
tb.f = new []floats
tb.i = new []words
for c in 0 .. nf { push(tb.f, floats(0)) }
for c in 0 .. ni { push(tb.i, words(0)) }
tb.added = words(0)
tb.changed = words(0)
tb.slot_gen = words(0)
tb.slot_row = words(0)
tb.free = words(0)
tb.grids = new []Grid
tb.idx = new []IntIndex
return tb
}
export function tb_len<T>(tb: Table<T>) -> int { return tb.n }
export function tb_rec<T>(tb: Table<T>, row: int) -> T { return tb.rec[row] }
export function tb_handle<T>(tb: Table<T>, row: int) -> int { return tb.ent[row] }
export function tb_tick<T>(tb: Table<T>) -> int { return tb.tick }
# the next tick: what is written from here on is newer than anything before
export function tb_advance<T>(tb: Table<T>) -> void { tb.tick += 1 }
# the row a handle names, or -1 when it was removed (or the slot has been reused since)
export function tb_row<T>(tb: Table<T>, h: int) -> int {
if h < 0 { return -1 }
let s = h & ECS_SLOT
if s >= len(tb.slot_gen) or tb.slot_gen[s] != ((h >> 22) & ECS_GEN) { return -1 }
return tb.slot_row[s]
}
export function tb_alive<T>(tb: Table<T>, h: int) -> bool { return tb_row(tb, h) >= 0 }
# a new row holding `r`, every column 0 and filed in no index until a setter says where it is
export function tb_add<T>(tb: Table<T>, r: T) -> int {
var s = 0
if len(tb.free) > 0 { s = List.pop(tb.free) } else {
s = len(tb.slot_gen)
push(tb.slot_gen, 0)
push(tb.slot_row, -1)
}
tb.slot_row[s] = tb.n
let h = s | (tb.slot_gen[s] << 22)
push(tb.rec, r)
push(tb.ent, h)
push(tb.added, tb.tick)
push(tb.changed, tb.tick)
for c in 0 .. len(tb.f) { push(tb.f[c], 0.0) }
for c in 0 .. len(tb.i) { push(tb.i[c], 0) }
tb.n += 1
for g in 0 .. len(tb.grids) { grid_grow(tb.grids[g]) }
for k in 0 .. len(tb.idx) { ix_grow(tb.idx[k]) }
return h
}