ludic/packages/ludic.things
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
..
tests 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 2026-09-27 15:52:51 +03:00
dispatch.ludic 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 2026-09-27 15:52:51 +03:00
index.ludic 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 2026-09-27 15:52:51 +03:00
kinds.ludic 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 2026-09-27 15:52:51 +03:00
near.ludic 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 2026-09-27 15:52:51 +03:00
package.ludic feat(packages): ludic.things - placed things in a world: a Thing record, an open registry of kinds whose behaviour is function values, spatial queries, spawn / put / remove / move verbs, facts and a save section by kind key 2026-09-25 06:14:32 +03:00
port.ludic feat(packages): ludic.things - placed things in a world: a Thing record, an open registry of kinds whose behaviour is function values, spatial queries, spawn / put / remove / move verbs, facts and a save section by kind key 2026-09-25 06:14:32 +03:00
queries.ludic 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 2026-09-27 15:52:51 +03:00
README.md feat(packages): ludic.things - placed things in a world: a Thing record, an open registry of kinds whose behaviour is function values, spatial queries, spawn / put / remove / move verbs, facts and a save section by kind key 2026-09-25 06:14:32 +03:00
store.ludic 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 2026-09-27 15:52:51 +03:00
system.ludic refactor(packages): 0.R5 - a question no longer starts its state - 64 lazy starts ('if st.x == null { st.x = ... }' inside a getter) are the state's defaults, gear__ensure is gone, and ludic migrate state --tighten took mut off 208 package parameters: things_all, thing_find, gear_charge, gear_level, jobs_state and the rest read 2026-09-27 00:51:03 +03:00
verbs.ludic 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 2026-09-27 15:52:51 +03:00

ludic.things

The things placed in a world - a tent, a fire ring, a branch on the ground, a snare - and the kinds they are. The game names the kinds and draws them; the package keeps every Thing, answers where they are, asks a kind what it does, reports what happened, and saves the ones worth keeping. Uses ludic.base and nothing else.

import "ludic.things"

A Thing

property Thing {
  uid, kind, id          # unique for the run; its place in ThingKinds; what the kind says it means
  look                   # how the game draws it ("" is not drawn)
  x, y, z, yaw
  owner                  # whoever put it down, -1 the world's own
  active                 # false: taken, hidden, used up for now
  reach                  # how close it is used from (0: its kind's)
  timer, used, n         # state the kind decides the meaning of
  sx, sz, syaw           # where using it puts the user, and which way that faces
  shared                 # the game copies it to others (a party)
}

The kinds

ThingKinds is an open registry, filled by the game - usually one def per kind in one file, so TH_<KEY> is the order they are written in. A kind's behaviour is function values; a null one is a question the kind ignores.

def ThingKinds fire { name: "The campfire", prompt: fn fire_prompt, use: fn fire_use }
def ThingKinds lure { name_of: fn bait_name, tick: fn bait_wear, put_down: true, saved: true }
field
name, name_of: fn(Thing) -> string what it is called (name_of answering "" falls back to name)
prompt: fn(Thing) -> string, use: fn(Thing) what using it would do, and doing it
tick: fn(Thing, float) hours of the world's clock, for every active one
reach, reach_of: fn(Thing) -> float how close it is used from
personal one belongs to whoever put it down
saved the save section keeps a placed one (the port has the last word)
put_down set down by someone rather than grown by the world: a restock leaves it (the port has the last word)
keeps keeps its used through a restock

The port

Every member has a default, so any may be left out of the bind:

export port ThingsWorld {
  ground: fn(float, float) -> float   # the height a Thing stands at (unbound: 0)
  placed: fn(Thing)                   # it exists now: draw it
  removed: fn(Thing)                  # gone for good: stop drawing it
  shown: fn(Thing)                    # its `active` changed
  moved: fn(Thing)                    # it stands somewhere else
  keeps: fn(Thing) -> bool            # the save carries it (unbound: its kind's `saved`)
  put_down: fn(Thing) -> bool         # a restock leaves it be (unbound: its kind's `put_down`)
  restored: fn(Thing)                 # a load put it back, after `placed`
}

placed, removed, shown and moved are called at once, inside the verb, so the game can make the actor the moment the Thing exists (a caller often scales or tints it on the next line).

API

thing_spawn(kind, look, x, y, z, yaw, id) -> Thing, thing_put(kind, look, x, z, yaw, sink, reach, id) -> Thing a new Thing, at a height or on the ground set into it by sink
thing_remove(t), things_drop(gone: fn(Thing) -> bool), things_clear() gone for good (told to the port); a new world, quietly
thing_move(t, x, z, yaw), thing_move_to(t, x, y, z, yaw) on the ground there, or at a height
thing_hide(t), thing_show(t), thing_set_active(t, on) active, told to the port
things_all(), things_count(), things_ready(), thing_by_uid(uid) every Thing, in the order placed; whether a world was ever set out
thing_find(kind, id), thing_nearest(kind, x, z), things_within(x, z, r), things_each(kind), things_each_all(kind), thing_dist2(t, x, z) where they are: only active ones answer except the _all; a kind below 0 is any; within is nearest first
thing_name(t), thing_prompt(t), thing_use(t), thing_can_use(t), thing_reach(t), thing_kind(t), thing_is_personal(t), thing_is_put_down(t) the dispatcher
things_tick(hours), things_restock() every active kind that ticks; every Thing the world grew back and unused
things_facts() -> Queue<ThingFact> { what, uid, kind, id, owner, x, z }: THING_PLACED, THING_REMOVED, THING_USED
thing_kind_ok(k), thing_kind_by_key(key) the kinds
things_system() -> System "things", PH_COMMIT: its save section - every active Thing the port keeps, its kind by KEY, so the game may reorder its kinds - and a reset that removes the kept ones for the load to put back

Tests

ludic test packages/ludic.things