How answers work
What ANSWER compiles to, how the core calls it, and the optional members that let a pack beat the generic default.
Skippable. Nothing here changes what you write; it is what ANSWER compiles
to, and it matters once you want a pack to be fast.
Every answer is an interface
ANSWER(entities) generates an implementation of the interface behind entity.query@1. The core
never assumes a layout: it asks for the frame's entity set and your code gets there however the game
demands.
// ---- required for CORRECTNESS struct EntityProvider { Status snapshot(const Query& q, SnapshotSink& sink); bool alive(EntityId id); Status resolve(EntityId id, EntityRef& out); // ---- required for PERFORMANCE above ~10k entities virtual u32 count() { return UseDefault; } virtual Status within(Vec3 c, f32 r, Sink& s) { return UseDefault; } virtual Status by_kind(Str kind, Sink& s) { return UseDefault; } // ---- tells the core how to cache you virtual Hints hints() { return {}; } };
Where the per-game reality goes
snapshot is the whole trick. Seven games, seven different bodies, one API on the mod's side.
| Game shape | What snapshot does |
|---|---|
| Flat pool, fixed stride | One bulk read, stride the buffer |
| Chunked pool, free list | Walk chunk headers, skip free slots by bitmap |
| ECS archetype tables | Iterate archetypes matching the component mask |
| Scene graph | Depth-first from the root, flatten as it goes |
| Handle table behind a vtable | Read the table, call the virtual getter per live slot |
| Managed heap (JVM, CLR) | Enumerate through the backend object API, no pointer math |
| Linked list of actors | Chase next until null, bounded by a sanity cap |
From the mod's side all seven are f.entities().alive(). That is the only
thing the surface has to guarantee.
A worked example
A chunked pool with a free-list bitmap, which no declarative binding could express. Two crossings regardless of entity count.
Status snapshot(SnapshotSink& sink) override { auto base = sym("entity_pool"); if (base.failed()) return base.status(); auto hdr = read<PoolHeader>(\*base); if (hdr.failed()) return hdr.status(); // one crossing for every chunk std::vector<Span> spans; for (u32 c = 0; c < hdr->chunk_count; ++c) spans.push_back({ hdr->chunks[c], hdr->chunk_bytes }); auto blobs = read_many(spans); for (auto& blob : blobs) { if (blob.failed()) continue; auto bits = bitmap_of(blob); for (u32 i = 0; i < hdr->per_chunk; ++i) { if (!bits.test(i)) continue; // free slot auto\* e = entity_at(blob, i); sink.add({ e->index, e->generation }); } } return Status::Ok; }
snapshot is called at most once per frame and the result is shared, so
alive() stays one crossing however many mods call it.
Three functions is true only for small pools
snapshot is both the required primitive and the most expensive operation, while the operations that
are cheap per shape are the optional ones. That inversion has a cost that scales.
| Entities | Full transform snapshot | Effect |
|---|---|---|
| 400 | ~26 KB | Negligible |
| 10,000 | ~640 KB | Approaching the frame budget |
| 100,000 | ~6.4 MB | At or over the whole budget, before any mod runs |
| 500,000 | ~32 MB | Not viable at any frame rate |
The real interface is six functions, not three. count() on a free-list pool
is a popcount; by_kind() on an archetype game is one table walk. Falling
back to a filtered snapshot for either pays O(total) for an O(matching)
answer.
snapshot takes a Query so the core can push the filter down to you. Linked lists and scene graphs
cannot narrow cheaply and will walk anyway; they declare that through hints() rather than silently
costing more. Conformance records which members you implement natively, so the registry can show that
a pack is correct but slow rather than letting a user find out in a stutter.
Going faster
The optionals exist so a pack can beat the core's generic default. UseDefault is not an error, it
means "use your default", so you implement only what your game can genuinely do better.
| Optional | Default | Override when |
|---|---|---|
count() | Size of a snapshot | The game keeps a live count you can read |
within() | Filter the snapshot host-side | The game has a spatial index, octree or grid |
by_kind() | Filter the snapshot host-side | Kinds are separate pools or archetypes |
hints() | Conservative assumptions | The snapshot is cheap, unstable, or bounded |
Adding a required member is a major bump of the question. Adding an optional with a core default is a minor, and every existing pack keeps working untouched. The interface grows without invalidating packs.
Sandboxing
An answer usually needs memory.read, symbols and sometimes memory.scan, which is short enough to
compile to sandboxed WebAssembly: capability-gated, architecture-portable, and a bad answer traps
instead of corrupting the host. That is the default, so most packs install with no prompt despite
being code.
Native is for the rare case that must be inside the frame holding a game lock or calling a game function directly.
The worst a sandboxed pack does is hand back garbage entities or a Failed.
That is exactly why conformance testing is not optional.