Proposal: ECS spatial queries — Query.count/first/nearest/within (needs a runtime spatial index) #42

Closed
opened 2026-08-31 11:36:56 +02:00 by orkun · 1 comment
Owner

Follow-up to #24, which shipped the Grid.* half (tile geometry + A* pathfinding) in 07e5a20. This tracks the remaining, explicitly-blocked half: ECS spatial queries.

Proposed surface

  • Query.count([Prop]) -> int
  • Query.first([Prop]) -> entity
  • Query.nearest(from, [Prop]) -> entity
  • Query.within(center, radius, [Prop]) -> list

Why it's separate

count/first are straightforward iterations over the ECS query machinery, but nearest/within want a runtime spatial index (a grid/quadtree bucketing entity positions) to be efficient — that index is the real work here and is why #24 called this half blocked. Sequence it after the query/ECS plumbing exposes entity positions to a spatial structure.

Notes

  • Deterministic + fixed-point, matching the rest of the engine.
  • The grid-space pathfinding primitives (Grid.line/flood/line_of_sight/a_star) already landed in #24; this is the entity-space query side.
Follow-up to #24, which shipped the `Grid.*` half (tile geometry + A* pathfinding) in 07e5a20. This tracks the remaining, explicitly-blocked half: **ECS spatial queries**. ## Proposed surface - `Query.count([Prop]) -> int` - `Query.first([Prop]) -> entity` - `Query.nearest(from, [Prop]) -> entity` - `Query.within(center, radius, [Prop]) -> list` ## Why it's separate `count`/`first` are straightforward iterations over the ECS query machinery, but `nearest`/`within` want a **runtime spatial index** (a grid/quadtree bucketing entity positions) to be efficient — that index is the real work here and is why #24 called this half blocked. Sequence it after the query/ECS plumbing exposes entity positions to a spatial structure. ## Notes - Deterministic + fixed-point, matching the rest of the engine. - The grid-space pathfinding primitives (`Grid.line`/`flood`/`line_of_sight`/`a_star`) already landed in #24; this is the entity-space query side.
Author
Owner

Shipped in b25dc32 (pushed to main) — the entity-space half that #24 deferred as blocked.

Query.* over the EV2 reflection ABI (world_query_next / world_get):

  • Query.count(prop) -> int — how many live entities carry prop
  • Query.first(prop) -> int — the lowest-id bearer, or -1
  • Query.nearest(prop, pos, x_field, y_field, x, y) -> int — the bearer closest to (x, y), or -1
  • Query.within(prop, pos, x, y, radius, x_field, y_field) -> []int — every bearer within radius, ascending id order

prop is a property id (World.prop_id). The spatial forms read a position from a coordinate property pos at two int field ids (World.field_id), so prop can be a discriminating tag distinct from the position component — Query.nearest(Enemy, Position, cx, cy, px, py) reads "nearest Enemy" — or the same id to query the coordinate component itself.

On the "runtime spatial index" this issue called out as the real work: distances are exact squared integers (no sqrt), ties break to the lower entity id, and within returns ascending id order, so every answer is deterministic and replay-safe. The scan is linear over the entity table — ample for the entity counts Ludic targets, the same reasoning the grid pathfinder's open set uses. A bucketed grid / quadtree is a performance optimisation, not a correctness requirement, so it is left as a future refinement rather than a blocker; the surface here won't change when one is added.

The engine (runtime/native/query.ludic, ~55 lines of Ludic, C-free) is spliced on demand when the parser sees Query.*, which also force-emits the reflection ABI so a Query program needs no @events of its own (it used to require them).

Verified by examples/library/query.ludic (18 self-asserting cases over five entities at known positions: count/first with a component filter, nearest with a separate tag vs position property, within radii including r=0 and the empty-property case), wired into x test (now 63 passed). Docs: a Query section + 4 per-symbol pages; x check-impl / x check-docs green. Seed reseeded and the C-free bootstrap fixpoint holds. Closing.

Shipped in b25dc32 (pushed to `main`) — the entity-space half that #24 deferred as blocked. **`Query.*`** over the EV2 reflection ABI (`world_query_next` / `world_get`): - `Query.count(prop) -> int` — how many live entities carry `prop` - `Query.first(prop) -> int` — the lowest-id bearer, or `-1` - `Query.nearest(prop, pos, x_field, y_field, x, y) -> int` — the bearer closest to `(x, y)`, or `-1` - `Query.within(prop, pos, x, y, radius, x_field, y_field) -> []int` — every bearer within `radius`, ascending id order `prop` is a property id (`World.prop_id`). The spatial forms read a position from a coordinate property `pos` at two int field ids (`World.field_id`), so `prop` can be a discriminating tag distinct from the position component — `Query.nearest(Enemy, Position, cx, cy, px, py)` reads "nearest Enemy" — or the same id to query the coordinate component itself. **On the "runtime spatial index" this issue called out as the real work:** distances are exact squared integers (no sqrt), ties break to the lower entity id, and `within` returns ascending id order, so every answer is deterministic and replay-safe. The scan is linear over the entity table — ample for the entity counts Ludic targets, the same reasoning the grid pathfinder's open set uses. A bucketed grid / quadtree is a performance optimisation, not a correctness requirement, so it is left as a future refinement rather than a blocker; the surface here won't change when one is added. The engine (`runtime/native/query.ludic`, ~55 lines of Ludic, C-free) is spliced on demand when the parser sees `Query.*`, which also force-emits the reflection ABI so a Query program needs no `@events` of its own (it used to require them). Verified by `examples/library/query.ludic` (18 self-asserting cases over five entities at known positions: count/first with a component filter, nearest with a separate tag vs position property, within radii including `r=0` and the empty-property case), wired into `x test` (now 63 passed). Docs: a `Query` section + 4 per-symbol pages; `x check-impl` / `x check-docs` green. Seed reseeded and the C-free bootstrap fixpoint holds. Closing.
orkun closed this issue 2026-08-31 12:19:43 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#42
No description provided.