Queries
Master ECS queries: the mechanism for finding and filtering entities. Learn about defineQuery, enterQuery, exitQuery, query caching, and complex query patterns.
Queries are the primary way to find entities with specific components. Web Engine uses bitECS queries, which are automatically cached for O(1) performance after the first execution.
Basic Queries#
A query is defined once and reused throughout the system lifecycle. Calling a query returns an array of entity IDs matching the component set.
import { defineQuery } from 'bitecs';import { Transform, RigidBody } from '@web-engine/core'; // Define a query for entities with Transform AND RigidBodyconst physicsQuery = defineQuery([Transform, RigidBody]); // Execute the query (returns entity ID array)export function MySystem(world: IWorld) { const entities = physicsQuery(world); console.log(`Found ${entities.length} physics entities`); // Iterate over results for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // Access components const x = Transform.position.x[eid]; const mass = RigidBody.mass[eid]; } return world;}Query Caching
bitECS automatically caches query results. After the first call, subsequent calls to the same query are O(1) — just a pointer lookup. The cache is invalidated only when entities are added/removed or components are added/removed.
Enter and Exit Queries#
bitECS provides enterQuery and exitQuery to detect when entities enter or exit a query. This is useful for initialization and cleanup logic.
enterQuery: Entities That Just Joined#
import { defineQuery, enterQuery } from 'bitecs';import { Mesh, Material } from '@web-engine/core'; // Define base queryconst meshQuery = defineQuery([Mesh, Material]); // Get entities that just got Mesh + Materialconst meshEnterQuery = enterQuery(meshQuery); export function MeshInitSystem(world: IWorld) { // Only entities that JUST got both components this frame const newMeshes = meshEnterQuery(world); for (let i = 0; i < newMeshes.length; i++) { const eid = newMeshes[i]; console.log(`New mesh entity: ${eid}`); // Initialize Three.js mesh, upload to GPU, etc. initializeMesh(eid); } return world;}exitQuery: Entities That Just Left#
import { defineQuery, exitQuery } from 'bitecs';import { AudioSource } from '@web-engine/core'; const audioQuery = defineQuery([AudioSource]);const audioExitQuery = exitQuery(audioQuery); export function AudioCleanupSystem(world: IWorld) { // Entities that just lost AudioSource component const removedAudio = audioExitQuery(world); for (let i = 0; i < removedAudio.length; i++) { const eid = removedAudio[i]; console.log(`Cleaning up audio for entity ${eid}`); // Stop audio, release resources stopAndReleaseAudio(eid); } return world;}When Enter/Exit Triggers
enterQuery triggers when an entity gains the LAST component needed to match the query.
exitQuery triggers when an entity loses ANY component in the query.
Query Modifiers#
bitECS supports query modifiers to create more complex filters.
Not() Modifier: Exclude Components#
import { defineQuery, Not } from 'bitecs';import { Transform, RigidBody, Static } from '@web-engine/core'; // Find entities with Transform and RigidBody, but WITHOUT Staticconst dynamicPhysicsQuery = defineQuery([ Transform, RigidBody, Not(Static), // Exclude static objects]); export function DynamicPhysicsSystem(world: IWorld) { const entities = dynamicPhysicsQuery(world); // Only dynamic (non-static) physics entities for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // Process dynamic objects only } return world;}Common Use Cases for Not()
Not() is useful for excluding disabled entities, filtering out specific states, or processing only active objects. For example: Not(Disabled), Not(Hidden), Not(Dead).
Typed Queries#
Web Engine provides typed query helpers that preserve component type information for better TypeScript support.
import { createTypedQuery, createTypedEnterQuery, createTypedExitQuery } from '@web-engine/core';import { Transform, Velocity, Health } from '@web-engine/core'; // Create typed queryconst movingQuery = createTypedQuery([Transform, Velocity]); // Create typed enter queryconst healthEnter = createTypedEnterQuery( createTypedQuery([Health])); // Create typed exit queryconst healthExit = createTypedExitQuery( createTypedQuery([Health])); // TypeScript now knows which components are availableexport function MovementSystem(world: IWorld) { const entities = movingQuery(world); for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // TypeScript knows Transform and Velocity are available Transform.position.x[eid] += Velocity.linear.x[eid]; } return world;}Complex Query Patterns#
Multiple Queries in One System#
import { defineQuery, Not } from 'bitecs';import { Transform, Player, Enemy, Health, Dead } from '@web-engine/core'; // Separate queries for different entity typesconst playerQuery = defineQuery([Transform, Player, Health, Not(Dead)]);const enemyQuery = defineQuery([Transform, Enemy, Health, Not(Dead)]); export function CombatSystem(world: IWorld) { const players = playerQuery(world); const enemies = enemyQuery(world); // Process players for (let i = 0; i < players.length; i++) { const playerEid = players[i]; // Player logic } // Process enemies for (let j = 0; j < enemies.length; j++) { const enemyEid = enemies[j]; // Enemy logic } return world;}Hierarchical Queries#
import { defineQuery } from 'bitecs';import { Transform, Parent, Mesh } from '@web-engine/core'; // Query for all entities with parents (children)const childQuery = defineQuery([Transform, Parent]); // Query for all root entities (no parent)const rootQuery = defineQuery([Transform, Not(Parent)]); export function HierarchySystem(world: IWorld) { // Process root entities first const roots = rootQuery(world); for (let i = 0; i < roots.length; i++) { const eid = roots[i]; // Process root } // Then process children const children = childQuery(world); for (let i = 0; i < children.length; i++) { const eid = children[i]; const parentEid = Parent.eid[eid]; // Process child, using parent's transform } return world;}State Machine Queries#
import { defineQuery } from 'bitecs';import { Character, Idle, Walking, Running, Jumping } from '@web-engine/core'; // Define queries for each stateconst idleQuery = defineQuery([Character, Idle]);const walkingQuery = defineQuery([Character, Walking]);const runningQuery = defineQuery([Character, Running]);const jumpingQuery = defineQuery([Character, Jumping]); export function CharacterStateSystem(world: IWorld) { // Process each state separately const idleChars = idleQuery(world); for (let i = 0; i < idleChars.length; i++) { // Idle behavior } const walkingChars = walkingQuery(world); for (let i = 0; i < walkingChars.length; i++) { // Walking behavior } // ... and so on return world;}Query Performance#
bitECS queries are highly optimized and use multiple strategies to maximize performance:
Automatic Caching
Query results are cached after first execution. Subsequent calls are O(1) pointer lookups.
Bitset Intersection
Queries use bitwise operations on dense bitsets for ultra-fast component matching.
Lazy Evaluation
Query results are only computed when needed and invalidated only on structural changes.
Minimal Allocation
Query arrays are reused across frames. No garbage collection from query execution.
Performance Tips#
- Define queries once — Create queries at module scope, not inside systems
- Reuse query results — Store query results in a variable if needed multiple times
- Check length first — Use
if (entities.length === 0)to skip empty iterations - Use skipWhenEmpty — Register systems with
skipWhenEmpty: truefor conditional systems - Minimize query complexity — Simpler queries (fewer components) are faster
- Profile query hot paths — Use Chrome DevTools to identify slow queries
Query Benchmarks#
Real-world query performance in Web Engine (measured on M1 Pro):
Query Performance (1 million entities, 100,000 matches): First call (uncached): ~2.5msSubsequent calls (cached): ~0.001ms (1 microsecond!)enterQuery (100 new): ~0.05msexitQuery (100 removed): ~0.05ms With Not() modifier: ~3ms (first), ~0.001ms (cached)3-component query: ~3.5ms (first), ~0.001ms (cached)5-component query: ~5ms (first), ~0.001ms (cached) Iteration over 100k matches: ~0.5ms (simple operations) Conclusion: Query overhead is negligible after first call.Focus optimization efforts on iteration logic, not queries.Real-World Examples from Web Engine#
Physics System Query#
// Query for entities that need physics bodies createdconst physicsQuery = createTypedQuery([ Transform, RigidBody, Collider, Not(PhysicsBodyAdded), // Only entities without bodies yet]); // Query for entities with physics bodiesconst activePhysicsQuery = createTypedQuery([ Transform, RigidBody, PhysicsBodyAdded,]); // Query for entities to remove from physicsconst physicsExitQuery = createTypedExitQuery(physicsQuery);Render System Query#
// Query for visible meshes (not culled)const visibleMeshQuery = createTypedQuery([ Transform, Mesh, Not(Culled), Not(Hidden),]); // Query for instanced meshesconst instancedMeshQuery = createTypedQuery([ Transform, InstancedMesh, Not(Culled),]); // Query for transparent meshes (for separate render pass)const transparentQuery = createTypedQuery([ Transform, Mesh, TransparentMaterial,]);Best Practices#
- Define queries at module scope — Not inside functions
- Use descriptive names —
visibleMeshQueryvsq1 - Document query intent — Explain what the query finds and why
- Use enterQuery for initialization — Perfect for creating resources
- Use exitQuery for cleanup — Ensure resources are released
- Keep queries simple — Split complex logic into multiple queries
- Profile before optimizing — Measure actual performance before adding complexity
- Use typed queries — Better TypeScript support and IDE autocomplete