
Koota
- 122 installs
- 708 repo stars
- Updated August 4, 2026
- pmndrs/koota
Use koota for development tasks
About
koota: A skill for development. This provides functionality for development workflows.
- koota
Koota by the numbers
- 122 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,810 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pmndrs/koota --skill kootaAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 122 |
|---|---|
| repo stars | ★ 708 |
| Last updated | August 4, 2026 |
| Repository | pmndrs/koota ↗ |
What it does
Use koota for development tasks
Files
Koota ECS
Koota manages state using entities with composable traits.
Glossary
- Entity - A unique identifier pointing to data defined by traits. Spawned from a world.
- Trait - A reusable data definition. Can be schema-based (SoA), callback-based (AoS), or a tag.
- Relation - A directional connection between entities to build graphs.
- World - The context for all entities and their data (traits).
- Archetype - A unique combination of traits that entities share.
- Query - Fetches entities matching an archetype. The primary way to batch update state.
- Action - A discrete, synchronous data mutation (create, update, destroy). Reusable from any call site.
- System - A reactive orchestrator that observes state changes and coordinates work, including async workflows. Runs in the frame loop or event callbacks.
Design Principles
Data-oriented
Behavior is separated from data. Data is defined as traits, entities compose traits, and systems mutate data on traits via queries. See Basic usage for a complete example.
Composable systems
Design systems as small, single-purpose units rather than monolithic functions that do everything in sequence. Each system should handle one concern so that behaviors can be toggled on/off independently.
// Good: Composable systems - each can be enabled/disabled independently
function applyVelocity(world: World) {}
function applyGravity(world: World) {}
function applyFriction(world: World) {}
function syncToDOM(world: World) {}
// Bad: Monolithic system - can't disable gravity without disabling everything
function updatePhysicsAndRender(world: World) {
// velocity, gravity, friction, DOM sync all in one function
}This enables feature flags, debugging (disable one system to isolate issues), and flexible runtime configurations.
Decouple view from logic
Separate core state and logic (the "core") from the view ("app"):
- Run logic independent of rendering
- Swap views while keeping state (2D ↔ 3D)
- Run logic in a worker or on a server
Prefer traits + actions over classes
Prefer not to use classes to encapsulate data and behavior. Use traits for data and actions for behavior. Only use classes when required by external libraries (e.g., THREE.js objects) or the user prefers it.
Directory structure
If the user has a preferred structure, follow it. Otherwise, use this guidance: the directory structure should mirror how the app's data model is organized. Separate core state/logic from the view layer:
- Core - Pure TypeScript. Traits, systems, actions, world. No view imports.
- View - Reads from world, mutates via actions. Organized by domain/feature.
src/
├── core/ # Pure TypeScript, no view imports
│ ├── traits/
│ ├── systems/
│ ├── actions/
│ └── world.ts
└── features/ # View layer, organized by domainFiles are organized by role, not by feature slice. Traits and systems are composable and don't map cleanly to features.
For detailed patterns and monorepo structures, see references/architecture.md.
Trait types
| Type | Syntax | Use when | Examples |
|---|---|---|---|
| SoA (Schema) | trait({ x: 0 }) | Simple primitive data | Position, Velocity, Health |
| AoS (Callback) | trait(() => new Thing()) | Complex objects/instances | Ref (DOM), Keyboard (Set) |
| Tag | trait() | No data, just a flag | IsPlayer, IsEnemy, IsDead |
Trait naming conventions
| Type | Pattern | Examples |
|---|---|---|
| Tags | Start with Is | IsPlayer, IsEnemy, IsDead |
| Relations | Prepositional | ChildOf, HeldBy, Contains |
| Trait | Noun | Position, Velocity, Health |
Relations
Relations build graphs between entities such as hierarchies, inventories, targeting.
import { relation, trait } from 'koota'
const ChildOf = relation({ autoDestroy: 'orphan' }) // Hierarchy
const Contains = relation({ store: { amount: 0 } }) // With data
const Targeting = relation({ exclusive: true }) // One target only
// Build graph
const parent = world.spawn()
const child = world.spawn(ChildOf(parent))
const gold = world.spawn()
const silver = world.spawn()
const inventory = world.spawn(Contains(gold), Contains(silver))
// Query children of parent
const children = world.query(ChildOf(parent))
// Query all entities with any ChildOf relation
const allChildren = world.query(ChildOf('*'))
// Query relation targets
const targets = inventory.targetsFor(Contains)
// Filter by traits on the target entity
const IsRare = trait()
silver.add(IsRare)
// Target filters can use any legal query, not just a single trait
const rareInventories = world.query(Contains(IsRare))
// Get targets from entity
const items = inventory.targetsFor(Contains) // Entity[]
const target = child.targetFor(ChildOf) // Entity | undefinedFor detailed patterns, traversal, ordered relations, and anti-patterns, see references/relations.md.
Basic usage
import { trait, createWorld } from 'koota'
// 1. Define traits
const Position = trait({ x: 0, y: 0 })
const Velocity = trait({ x: 0, y: 0 })
const IsPlayer = trait()
// 2. Create world and spawn entities
const world = createWorld()
const player = world.spawn(Position({ x: 100, y: 50 }), Velocity, IsPlayer)
// 3. Query and update
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x
pos.y += vel.y
})Entities
Entities are unique identifiers that compose traits. Spawned from a world.
// Spawn
const entity = world.spawn(Position, Velocity)
// Read/write traits
entity.get(Position) // Read trait data
entity.set(Position, { x: 10 }) // Write (triggers change events)
entity.add(IsPlayer) // Add trait
entity.remove(Velocity) // Remove trait
entity.has(Position) // Check if has trait
// Destroy
entity.destroy()Entity IDs
An entity is internally a number packed with entity ID, generation ID (for recycling), and world ID. Safe to store directly for persistence or networking.
entity.id() // Just the entity ID (reused after destroy)
entity // Full packed number (unique forever)Typing
Use TraitRecord to get the type that entity.get() returns
type PositionRecord = TraitRecord<typeof Position>Queries
Queries fetch entities matching an archetype and are the primary way to batch update state.
// Query and update
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x
pos.y += vel.y
})
// Read-only iteration (no write-back)
const data: Array<{ x: number; y: number }> = []
world.query(Position, Velocity).readEach(([pos, vel]) => {
data.push({ x: pos.x, y: pos.y })
})
// Get first match
const player = world.queryFirst(IsPlayer, Position)
// Filter with modifiers
world.query(Position, Not(Velocity)) // Has Position but not Velocity
world.query(Or(IsPlayer, IsEnemy)) // Has either traitPrefer updateEach/readEach over for...of + entity.get() for data-bearing queries. readEach still gives you the entity as the second argument.
Note: updateEach/readEach only return data-bearing traits (SoA/AoS). Tags, Not(), and relation filters are excluded:
world.query(IsPlayer, Position, Velocity).updateEach(([pos, vel]) => {
// Array has 2 elements - IsPlayer (tag) excluded
})For tracking changes, caching queries, and advanced patterns, see references/queries.md.
React integration
Imports: Core types (World, Entity) from 'koota'. React hooks from 'koota/react'.
Change detection: entity.set() and world.set() trigger change events that cause hooks like useTrait to rerender. For AoS traits where you mutate objects directly, manually signal with entity.changed(Trait).
For React hooks and actions, see references/react-hooks.md.
For component patterns (App, Startup, Renderer, view sync, input), see references/react-patterns.md.
Runtime
Systems query the world and update entities. Run them via frameloop (continuous) or event handlers (discrete).
For systems, frameloop, event-driven patterns, and time management, see references/runtime.md.
Architecture and File Structure
Core Principle
Decompose classes into traits (data) and actions (behavior) unless there is a specific reason to use a class.
Conventions Override
Examples below use common conventions. Always follow the user's stated preferences or existing codebase conventions (file naming, casing, structure) over these examples.
Standard Structure
Separate core (pure TypeScript) from view (React/framework code):
src/
├── core/ # Pure TypeScript, ECS with Koota
│ ├── traits/
│ ├── systems/
│ ├── actions/
│ └── world.ts
│
├── features/ # View layer, organized by domain
│ ├── enemies/
│ ├── terrain/
│ └── ui/
│
├── utils/ # Generic, reusable
│
├── App.tsx
└── main.tsx # Entry pointOrganize by Role, Not Feature
Organize core/ by role (traits, systems, actions), not by feature slice. Traits and systems are composable across features.
Data modeling
Prefer multiple entities over array traits. Instead of one entity with a flat array of objects, spawn many entities with shared traits.
// ❌ Singleton with array — harder to query, compose, and extend
const Inventory = trait(() => ({ items: [] as { id: string; count: number }[] }))
const inventory = world.spawn(Inventory)
// ✅ Multiple entities — queryable, composable, per-item traits
const Item = trait({ id: '', count: 0 })
const IsInInventory = trait()
world.spawn(Item({ id: 'sword', count: 1 }), IsInInventory)
world.spawn(Item({ id: 'potion', count: 5 }), IsInInventory)
// Query all items
world.query(Item, IsInInventory)Why multiple entities:
- Queryable — filter, sort, iterate with
query() - Composable — add traits per-item (e.g.,
IsEquipped,IsDamaged) - Extensible — new behaviors without changing existing traits
- Reactive — React hooks work per-entity, not per-array-element
- Graphs — use relations to connect entities (e.g.,
ChildOf,Contains,DependsOn)
Detailed Example
src/
├── core/
│ ├── traits/
│ │ ├── position.ts
│ │ ├── health.ts
│ │ ├── velocity.ts
│ │ ├── terrain.ts
│ │ └── index.ts
│ │
│ ├── systems/
│ │ ├── updatePhysics.ts
│ │ ├── updateDamage.ts
│ │ └── index.ts
│ │
│ ├── actions/
│ │ ├── sceneActions.ts
│ │ ├── combatActions.ts
│ │ └── index.ts
│ │
│ └── world.ts
│
├── features/
│ ├── enemies/
│ │ ├── EnemyRenderer.tsx
│ │ └── EnemyView.tsx
│ │
│ ├── terrain/
│ │ ├── TerrainRenderer.tsx
│ │ └── TerrainTile.tsx
│ │
│ └── player/
│ ├── PlayerRenderer.tsx
│ └── PlayerView.tsx
│
├── utils/
│
├── App.tsx
└── main.tsxMonorepo Structure
Use when core needs to run independently (workers, servers, CLI) or with multiple views:
my-app/
├── packages/
│ ├── core/
│ │ ├── src/
│ │ │ ├── traits/
│ │ │ ├── systems/
│ │ │ ├── actions/
│ │ │ └── world.ts
│ │ └── package.json → @my-app/core
│ │
│ └── react/
│ ├── src/
│ │ ├── hooks/
│ │ └── index.ts
│ └── package.json → @my-app/react
│
├── apps/
│ ├── editor/ → imports @my-app/core, @my-app/react
│ ├── cli/ → imports @my-app/core only
│ └── agent/ → imports @my-app/core only
│
└── pnpm-workspace.yamlQueries
Complete guide to querying entities in Koota.
Contents
- Basic queries
- Query modifiers - Not, Or
- Tracking modifiers - Added, Removed, Changed
- Caching queries - createQuery for performance
- Change detection - updateEach options
- Query + select - Select subset of traits for updates
- Direct store access - useStores for performance
Basic queries
Queries fetch entities that share specific traits (archetypes).
// Returns QueryResult (Entity[] with extra methods)
const entities = world.query(Position, Velocity)
// Relation filters can target entities that match a query
const playerChildren = world.query(ChildOf(IsPlayer))
// Batch update with updateEach
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x
pos.y += vel.y
})
// Batch read with readEach
world.query(Position).readEach(([pos], entity) => {
// ...
})
// Get first match only
const player = world.queryFirst(IsPlayer, Position)
// Query all entities (excludes system entities)
const allEntities = world.query()Query modifiers
Filter queries with logical modifiers.
import { Not, Or } from 'koota'
// Has Position but NOT Velocity
world.query(Position, Not(Velocity))
// Has IsPlayer OR IsEnemy
world.query(Or(IsPlayer, IsEnemy))
// Combine modifiers
world.query(Position, Not(Velocity), Or(IsPlayer, IsEnemy))Tracking modifiers
Track structural and data changes. Each tracking modifier must be created as a unique instance.
import { createAdded, createRemoved, createChanged } from 'koota'
// Create unique instances (typically at module scope)
const Added = createAdded()
const Removed = createRemoved()
const Changed = createChanged()Added - Entities that added a trait since last query:
const newPositions = world.query(Added(Position))
// Track relation additions
const newChildren = world.query(Added(ChildOf))Removed - Entities that removed a trait since last query (includes destroyed entities):
const stoppedEntities = world.query(Removed(Velocity))
// Track orphaned entities
const orphaned = world.query(Removed(ChildOf))Changed - Entities whose trait data changed since last query:
const movedEntities = world.query(Changed(Position))
// Track relation data changes
const updatedChildren = world.query(Changed(ChildOf))Logical AND (default):
When multiple traits are passed to a tracking modifier, it uses logical AND. Only entities where all specified traits match the condition are returned:
// Entities where BOTH Position AND Velocity were added
const fullyAdded = world.query(Added(Position, Velocity))
// Entities where BOTH Position AND Velocity were removed
const fullyRemoved = world.query(Removed(Position, Velocity))
// Entities where BOTH Position AND Velocity have changed
const fullyUpdated = world.query(Changed(Position, Velocity))Logical OR:
To track entities where any of the specified traits match, wrap individual tracking modifiers in Or():
import { Or } from 'koota'
// Entities where EITHER Position OR Velocity was added
const eitherAdded = world.query(Or(Added(Position), Added(Velocity)))
// Entities where EITHER Position OR Velocity was removed
const eitherRemoved = world.query(Or(Removed(Position), Removed(Velocity)))
// Entities where EITHER Position OR Velocity has changed
const eitherChanged = world.query(Or(Changed(Position), Changed(Velocity)))Key points:
- Create instances at module scope, not inside functions
- Tracking resets after each query execution
- Changed only tracks
set()calls andentity.changed()signals
Caching queries
Inline queries hash parameters each call. For hot paths, cache with createQuery.
import { createQuery } from 'koota'
// Define once at module scope
const movementQuery = createQuery(Position, Velocity)
function updateMovement(world: World) {
// Fast array-based lookup
world.query(movementQuery).updateEach(([pos, vel]) => {
pos.x += vel.x
pos.y += vel.y
})
}When to use:
- Systems called every frame
- Queries in tight loops
- Performance-critical code
When inline is fine:
- Event handlers
- Startup/cleanup code
- Infrequent operations
Change detection
updateEach automatically detects changes for traits tracked via onChange or Changed modifier.
// Default: selective detection (only tracked traits)
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x
})
// Never trigger change events (silent updates)
world.query(Position).updateEach(
([pos]) => {
pos.x += 1
},
{ changeDetection: 'never' }
)
// Always trigger change events for all mutated traits (DEFAULT)
world.query(Position).updateEach(
([pos]) => {
pos.x += 1
},
{ changeDetection: 'always' }
)Shallow comparison:
Change detection uses shallow comparison like React. Objects and arrays only detect changes if replaced:
// ❌ Mutation not detected (same array reference)
world.query(Inventory).updateEach(([inv]) => {
inv.items.push(item)
})
// ✅ New array detected
world.query(Inventory).updateEach(([inv]) => {
inv.items = [...inv.items, item]
})
// ✅ Mutate and manually signal
world.query(Inventory).updateEach(([inv], entity) => {
inv.items.push(item)
entity.changed(Inventory)
})Query + select
Use select() when query filter is wider than traits needed for update:
// Query filters by Position + Velocity + Mass
// But only Mass is needed in the update
world
.query(Position, Velocity, Mass)
.select(Mass)
.updateEach(([mass]) => {
mass.value += 1
})Direct store access
For maximum performance, access SoA stores directly with useStores:
world.query(Position, Velocity).useStores(([position, velocity], entities) => {
for (let i = 0; i < entities.length; i++) {
const eid = entities[i].id()
position.x[eid] += velocity.x[eid] * delta
position.y[eid] += velocity.y[eid] * delta
}
})When to use:
- Updating thousands of entities per frame
- SIMD-style operations
- When profiling shows
updateEachas bottleneck
Tradeoffs:
- Bypasses safety checks
- No automatic change detection
- More verbose code
React Hooks
React hooks for integrating Koota with React applications.
Contents
- WorldProvider setup
- useQuery
- useQueryFirst
- useTrait
- useTag
- useHas
- useTarget / useTargets
- useTraitEffect
- Actions
- Change detection
WorldProvider setup
Wrap your app to make the world available to all React hooks.
`main.tsx`:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { WorldProvider } from 'koota/react'
import { App } from './app/app'
import { world } from './sim/world'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WorldProvider world={world}>
<App />
</WorldProvider>
</React.StrictMode>
)`sim/world.ts`:
import { createWorld } from 'koota'
import { Time, Pointer, Viewport } from './traits'
// Pass singleton traits to createWorld for global data
export const world = createWorld(Time, Pointer, Viewport)useQuery
Reactively updates when entities matching the query change (added/removed).
import { useQuery } from 'koota/react'
function RocketList() {
const rockets = useQuery(Position, Velocity)
return rockets.map((entity) => <RocketView key={entity} entity={entity} />)
}Supports all query modifiers:
const staticEntities = useQuery(Position, Not(Velocity))
const characters = useQuery(Or(IsPlayer, IsEnemy))useQueryFirst
Returns the first matching entity or undefined. Reactively updates.
import { useQueryFirst } from 'koota/react'
function PlayerHUD() {
const player = useQueryFirst(IsPlayer, Position)
if (!player) return null
return <PlayerStats entity={player} />
}Prefer `useQueryFirst` for single-entity lookups. Avoid useQuery(...)[0] so intent is clear and you don't subscribe to the full list.
// ✅ Single entity
const player = useQueryFirst(IsPlayer)
// ❌ Avoid
const player = useQuery(IsPlayer)[0]useTrait
Observes an entity's trait and rerenders when it changes. Returns undefined if trait is removed. Also accepts relation pairs like ChildOf(parent) to observe a specific relation's store data.
import { useTrait } from 'koota/react'
function RocketView({ entity }: { entity: Entity }) {
const position = useTrait(entity, Position)
if (!position) return null
return <div style={{ left: position.x, top: position.y }}>🚀</div>
}
// Observe a specific relation pair's store data
function ChildPriority({ entity, parent }: Props) {
const data = useTrait(entity, ChildOf(parent))
return <span>{data?.priority}</span>
}Works with world traits too:
function GameStatus() {
const world = useWorld()
const gameState = useTrait(world, GameState)
return <div>{gameState?.paused ? 'Paused' : 'Running'}</div>
}useTag
Observes a tag trait. Returns true if present, false if absent.
import { useTag } from 'koota/react'
function ActiveIndicator({ entity }: { entity: Entity }) {
const isActive = useTag(entity, IsActive)
if (!isActive) return null
return <span>🟢</span>
}useHas
Like useTag but for any trait. Returns true/false based on presence. Also accepts relation pairs like ChildOf(parent) or ChildOf('*').
import { useHas } from 'koota/react'
function HealthIndicator({ entity }: { entity: Entity }) {
const hasHealth = useHas(entity, Health)
return hasHealth ? <span>❤️</span> : null
}
// Track a specific relation pair or any target
const isChild = useHas(entity, ChildOf(parent))
const hasAnyParent = useHas(entity, ChildOf('*'))useTarget / useTargets
Observe relation targets on an entity.
import { useTarget, useTargets } from 'koota/react'
// Single target (exclusive relations)
function TargetDisplay({ entity }: { entity: Entity }) {
const target = useTarget(entity, Targeting)
return target ? <span>Targeting: {target.id()}</span> : null
}
// Multiple targets
function InventoryDisplay({ entity }: { entity: Entity }) {
const items = useTargets(entity, Contains)
return <ul>{items.map((item) => <li key={item}>{item.id()}</li>)}</ul>
}useTraitEffect
Subscribe to trait changes without causing rerenders. Runs as an effect. Also accepts relation pairs.
import { useTraitEffect } from 'koota/react'
function SyncMesh({ entity, meshRef }: Props) {
useTraitEffect(entity, Position, (position) => {
if (!position) return
meshRef.current.position.set(position.x, position.y, 0)
})
return null
}
// Subscribe to a specific relation pair
useTraitEffect(entity, ChildOf(parent), (data) => {
console.log('ChildOf data changed:', data)
})Actions
Actions are functions that spawn or modify entities. Use createActions to get the world automatically.
`sim/actions.ts`:
import { createActions, type Entity } from 'koota'
import { Position, Velocity, Health, IsPlayer, IsEnemy, IsDead } from './traits'
export const actions = createActions((world) => ({
spawnPlayer: () => {
return world.spawn(Position({ x: 0, y: 0 }), Velocity, Health({ value: 100 }), IsPlayer)
},
spawnEnemy: (x: number, y: number) => {
return world.spawn(Position({ x, y }), Velocity, Health({ value: 50 }), IsEnemy)
},
damageEntity: (entity: Entity, amount: number) => {
const health = entity.get(Health)
if (health) {
entity.set(Health, { value: Math.max(0, health.value - amount) })
if (health.value <= 0) entity.add(IsDead)
}
},
}))Using actions:
- In React:
useActions(actions) - In vanilla/systems:
actions(world)
Change detection
Hooks like useTrait rerender when change events fire.
Automatic: set() triggers change events automatically.
entity.set(Position, { x: 10, y: 20 }) // Triggers change, useTrait rerenders
world.set(GameState, { paused: true }) // Works for world traits tooManual (for AoS traits): When mutating objects directly, signal the change:
// ❌ Won't trigger React updates
const history = entity.get(History)!
history.undoStack.push(batch)// ✅ Mutate then signal
const history = entity.get(History)!
history.undoStack.push(batch)
entity.changed(History)When to use `changed()`:
- Mutating AoS trait objects (Sets, Maps, arrays, class instances)
- After direct property mutation on complex objects
- When
set()isn't used but React needs to update
React Patterns
Component patterns for Koota + React applications.
Contents
- App component
- Startup component
- Entity lifetime in React
- Frameloop component
- Renderer pattern
- View sync - Ref pattern, handleInit, sync systems
- Three.js interop
- Input patterns - Dragging, pointer capture, scoped events
App component
Compose renderers, frameloop, and startup. Use a fragment.
`app/app.tsx`:
import { EnemyRenderer } from './renderers/enemy-renderer'
import { PlayerRenderer } from './renderers/player-renderer'
import { Frameloop } from './frameloop'
import { Startup } from './startup'
export function App() {
return (
<>
<PlayerRenderer />
<EnemyRenderer />
<Frameloop />
<Startup />
</>
)
}Startup component
Spawn initial entities on mount. Clean up in useEffect return.
`app/startup.ts`:
import { useActions } from 'koota/react'
import { useEffect } from 'react'
import { actions } from '../core/actions'
export function Startup() {
const { spawnPlayer, spawnEnemies } = useActions(actions)
useEffect(() => {
const player = spawnPlayer()
const enemies = spawnEnemies(5)
return () => {
player.destroy()
enemies.forEach((e) => e.destroy())
}
}, [spawnPlayer, spawnEnemies])
return null
}Entity lifetime in React
Entities can be destroyed outside React at any time. React does not own the source of truth.
Never store entities in `useState` or `useRef`:
// ❌ Entity may be destroyed while ref holds stale reference
const entityRef = useRef<Entity | null>(null)
entityRef.current = world.spawn(Foo)
// ❌ Same problem with useState
const [entity, setEntity] = useState<Entity | null>(null)Spawn in effects with cleanup, or in Startup:
// ✅ Effect with cleanup
useEffect(() => {
const entity = world.spawn(Foo)
return () => entity.destroy()
}, [world])
// ✅ Startup component (see above)To access an entity in a component, query for it or pass it as a prop:
// ✅ Query
const player = useQueryFirst(IsPlayer)
// ✅ Prop from parent renderer
function PlayerView({ entity }: { entity: Entity }) { ... }Frameloop component
Run systems in requestAnimationFrame loop. See runtime.md for details.
import { useWorld } from 'koota/react'
import { useAnimationFrame } from './utils/use-animation-frame'
export function Frameloop() {
const world = useWorld()
useAnimationFrame(() => {
updateTime(world)
updateMovement(world)
syncToDOM(world)
})
return null
}Renderer pattern
Renderers query entities and map to View components.
Naming:
{Domain}Renderer— Queries and maps to views{Domain}View— Renders single entity
Example:
export function EnemyRenderer() {
const enemies = useQuery(IsEnemy, Position, Health)
return enemies.map((enemy) => <EnemyView key={enemy.id()} entity={enemy} />)
}
function EnemyView({ entity }: { entity: Entity }) {
const position = useTrait(entity, Position)
const health = useTrait(entity, Health)
if (!position || !health) return null
return (
<div
className="enemy"
style={{ left: position.x, top: position.y }}
>
<div className="health-bar" style={{ width: `${health.value}%` }} />
</div>
)
}View sync
React controls when a view element connects to an entity. Systems only query and mutate; they never add or remove view refs.
Lifecycle:
1. Component mounts → add Ref trait via handleInit 2. Systems mutate traits; sync system writes to view 3. Component unmounts → remove Ref trait
Ref trait
// For DOM
export const Ref = trait(() => null! as HTMLDivElement)
// For React Three Fiber
export const Ref = trait(() => null! as THREE.Object3D)handleInit pattern
function CardView({ entity }: { entity: Entity }) {
const card = useTrait(entity, Card)
const handleInit = useCallback(
(div: HTMLDivElement | null) => {
if (!div || !entity.isAlive()) return
entity.add(Ref(div))
return () => entity.remove(Ref)
},
[entity]
)
return (
<div ref={handleInit} className="card">
{card?.name}
</div>
)
}Sync system
export function syncToDOM(world: World) {
world.query(Position, Ref, ZIndex).updateEach(([pos, ref, zIndex]) => {
if (!ref) return
ref.style.transform = `translate(${pos.x}px, ${pos.y}px)`
ref.style.zIndex = zIndex.value.toString()
})
}React Three Fiber
function EnemyView({ entity }: { entity: Entity }) {
const handleInit = useCallback(
(group: THREE.Group | null) => {
if (!group || !entity.isAlive()) return
entity.add(Ref(group))
return () => entity.remove(Ref)
},
[entity]
)
return (
<group ref={handleInit}>
<mesh>
<boxGeometry />
<meshStandardMaterial color="red" />
</mesh>
</group>
)
}
export function syncThreeObjects(world: World) {
world.query(Position, Rotation, Ref).updateEach(([pos, rot, ref]) => {
if (!ref) return
ref.position.set(pos.x, pos.y, pos.z)
ref.rotation.set(rot.x, rot.y, rot.z)
})
}Why this pattern:
- Performance — Batch view updates in single system vs individual React renders
- Separation — React creates view elements, systems animate them
- Control — Run sync at precise points in frameloop
Three.js interop
Choose based on how much third-party Three code needs to touch transforms.
Ref-owned transforms (max interop)
Three.js objects stored in trait; systems mutate them directly. External libs (controls, physics) can also mutate transforms. No sync system needed.
Important: Use if the user is relying on third party Three libraries. This is likely common.
const Transform = trait({
position: () => new Vector3(),
rotation: () => new Euler(),
quaternion: () => new Quaternion(),
})
const handleInit = useCallback(
(group: THREE.Group | null) => {
if (!group || !entity.isAlive()) return
entity.set(Transform, (prev) => ({
position: group.position.copy(prev.position),
rotation: group.rotation.copy(prev.rotation),
quaternion: group.quaternion.copy(prev.quaternion),
scale: group.scale.copy(prev.scale),
}))
},
[entity]
)
// Systems mutate trait directly - Three sees changes immediately
world.query(Transform, Movement).updateEach(([transform, movement]) => {
transform.position.add(movement.velocity)
})Trait-owned transforms (balanced)
Scalar traits for transforms; Ref holds Object3D. Systems mutate traits; a sync system writes to Three. External libs should not mutate transforms directly.
Important: Only use if the user is not relying on third party Three libraries.
const Position = trait({ x: 0, y: 0, z: 0 })
const Rotation = trait({ x: 0, y: 0, z: 0 })
const Ref = trait(() => null! as THREE.Object3D)
function syncToThree(world: World) {
world.query(Position, Rotation, Ref).updateEach(([pos, rot, ref]) => {
ref.position.set(pos.x, pos.y, pos.z)
ref.rotation.set(rot.x, rot.y, rot.z)
})
}Input patterns
React components respond to user input by adding/removing traits. Systems process these traits.
Dragging pattern
Traits:
export const Dragging = trait({
offset: () => ({ x: 0, y: 0 }),
})
export const Pointer = trait({ x: 0, y: 0 }) // SingletonSystem:
export function updateDragging(world: World) {
const pointer = world.get(Pointer)
if (!pointer) return
const { delta } = world.get(Time)!
world.query(Position, Velocity, Dragging).updateEach(([pos, vel, dragging]) => {
const oldX = pos.x
const oldY = pos.y
pos.x = pointer.x - dragging.offset.x
pos.y = pointer.y - dragging.offset.y
const invDelta = delta > 0 ? 1 / delta : 0
vel.x = (pos.x - oldX) * invDelta
vel.y = (pos.y - oldY) * invDelta
})
}Component:
function CardView({ entity }: { entity: Entity }) {
const isDragging = useHas(entity, Dragging)
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const offset = {
x: event.clientX - centerX,
y: event.clientY - centerY,
}
entity.set(Position, { x: centerX, y: centerY })
entity.set(Velocity, { x: 0, y: 0 })
entity.add(Dragging({ offset }))
event.currentTarget.setPointerCapture(event.pointerId)
},
[entity]
)
const handlePointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
entity.remove(Dragging)
event.currentTarget.releasePointerCapture(event.pointerId)
},
[entity]
)
const handleLostPointerCapture = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (e.buttons === 0) entity.remove(Dragging)
},
[entity]
)
return (
<div
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onLostPointerCapture={handleLostPointerCapture}
className={`card ${isDragging ? 'dragging' : ''}`}
/>
)
}Key points:
- React handles input, adds traits
- System updates position
- Use pointer capture to track outside element
- Check
buttonson lost capture (React can trigger during re-renders)
Scoped events
Store input on the world for global scope, or on a scoped entity for element scope.
Global scope — Store on world singleton via world.set(Pointer, { x, y }) in a window event listener.
Entity scope — Store on a dedicated entity with identifier tag:
// Traits
export const IsCanvas = trait()
export const IsHovering = trait()
export const Pointer = trait({ x: 0, y: 0 })
// Spawn scoped entity
const canvas = world.spawn(IsCanvas, Pointer)
// Capture scoped pointer
const handlePointerMove = (e: React.PointerEvent) => {
const canvas = world.queryFirst(IsCanvas)
if (!canvas) return
canvas.set(Pointer, { x: e.clientX, y: e.clientY })
if (!canvas.has(IsHovering)) canvas.add(IsHovering)
}
const handlePointerLeave = () => {
const canvas = world.queryFirst(IsCanvas)
if (canvas) canvas.remove(IsHovering)
}
// Consume
world.query(IsCanvas, IsHovering, Pointer).readEach(([pointer]) => {
// Only runs when hovering canvas
pointer.x
})When to use:
- Global: Global input you would listen to on window
- Scoped: Element-scoped input (hover, focus, pointer capture)
Key points:
- Same traits (
Pointer,IsHovering), different entities (world vs canvas entity) - Identifier tag (
IsCanvas) finds the scoped entity
Relations
Relations build graphs between entities. Use for hierarchies, inventories, targeting, neighbor networks, and any entity-to-entity connection.
Contents
- Core Concepts
- Basic Syntax
- Building Graphs - Hierarchies, inventories, targeting, neighbors
- Querying Relations - Specific targets, wildcards, combined queries
- Traversing Graphs - Recursive traversal, building trees, finding ancestors
- Ordered Relations - Maintaining order (experimental)
- Removing Relations
- Relation Options
- React Hooks
- Anti-Patterns - Common mistakes to avoid
Core Concepts
A relation connects a source entity to a target entity. The source owns the relation.
┌─────────┐ ChildOf(Parent) ┌─────────┐
│ Child │ ─────────────────▶│ Parent │
│ (source)│ │ (target)│
└─────────┘ └─────────┘The child expresses the relationship, not the parent. This enables efficient batch queries for all children of a parent.
Basic Syntax
import { relation } from 'koota'
// Basic relation (no data)
const ChildOf = relation()
// Relation with data
const Contains = relation({ store: { amount: 0 } })
// Auto cleanup when target destroyed
const ChildOf = relation({ autoDestroy: 'orphan' })
// Only one target allowed per entity
const Targeting = relation({ exclusive: true })Building Graphs
Hierarchies (parent-child)
const ChildOf = relation({ autoDestroy: 'orphan' })
const parent = world.spawn()
const child = world.spawn(ChildOf(parent))
const grandchild = world.spawn(ChildOf(child))
// Destroying parent destroys entire subtree
parent.destroy() // child and grandchild also destroyedInventories (contains)
const Contains = relation({ store: { amount: 0 } })
const inventory = world.spawn()
const gold = world.spawn()
const sword = world.spawn()
inventory.add(Contains(gold, { amount: 100 }))
inventory.add(Contains(sword, { amount: 1 }))
// Update amount
inventory.set(Contains(gold), { amount: 50 })
// Read amount
const data = inventory.get(Contains(gold)) // { amount: 50 }Targeting/Following
const Targeting = relation({ exclusive: true })
const enemy = world.spawn()
const player = world.spawn()
const otherPlayer = world.spawn()
enemy.add(Targeting(player))
enemy.add(Targeting(otherPlayer)) // Replaces previous target
enemy.has(Targeting(player)) // false
enemy.has(Targeting(otherPlayer)) // trueNeighbor Networks
const NeighborOf = relation()
// Build bidirectional connections
entityA.add(NeighborOf(entityB))
entityB.add(NeighborOf(entityA))Querying Relations
Query children of specific parent
const children = world.query(ChildOf(parent))
for (const child of children) {
// Process each child
}Query all entities with any relation (wildcard)
// All entities that are children of something
const allChildren = world.query(ChildOf('*'))
// All entities that contain something
const allContainers = world.query(Contains('*'))Query relation targets by query
import { createQuery } from 'koota'
// Children whose parent matches IsPlayer
const playerChildren = world.query(ChildOf(IsPlayer))
// Children whose parent matches multiple traits
const activePlayerChildren = world.query(ChildOf(IsPlayer, IsActive))
// Reuse a cached target query in hot paths
const activePlayers = createQuery(IsPlayer, IsActive)
const cachedChildren = world.query(ChildOf(activePlayers))These target-query filters stay live. If a target gains or loses the traits required by the filter, cached queries and onQueryAdd/onQueryRemove subscriptions update automatically.
Get targets from an entity
// Get all targets
const items = entity.targetsFor(Contains) // Entity[]
// Get first target
const target = entity.targetFor(Targeting) // Entity | undefinedCombined queries
// Enemies targeting the player
const threats = world.query(IsEnemy, Targeting(player))
// Children of parent that also have Position
const positionedChildren = world.query(ChildOf(parent), Position)Traversing Graphs
Recursive traversal
function traverseFromNode(world: World, node: Entity, depth = 0) {
console.log(' '.repeat(depth) + `Node ${node.id()}`)
const children = world.query(ChildOf(node))
for (const child of children) {
traverseFromNode(world, child, depth + 1)
}
}
// Start from root
traverseFromNode(world, root)Building a tree
function buildTree(world: World, parent: Entity, depth: number, maxDepth: number) {
if (depth >= maxDepth) return
for (let i = 0; i < 3; i++) {
const child = world.spawn(ChildOf(parent))
buildTree(world, child, depth + 1, maxDepth)
}
}
const root = world.spawn()
buildTree(world, root, 0, 4)Finding ancestors
function getAncestors(entity: Entity): Entity[] {
const ancestors: Entity[] = []
let current = entity.targetFor(ChildOf)
while (current) {
ancestors.push(current)
current = current.targetFor(ChildOf)
}
return ancestors
}Ordered Relations
Ordered relations maintain a list of related entities with bidirectional sync. Use when order matters (UI layers, rendering order, execution order).
Why ordered relations?
A regular query returns a flat unordered list:
const children = world.query(ChildOf(parent)) // Order not guaranteedWithout ordered relations, you'd need to store an order field and sort every time you query. Ordered relations solve this by caching the order on the target.
Basic usage
import { relation, ordered } from 'koota'
const ChildOf = relation()
const OrderedChildren = ordered(ChildOf)
const parent = world.spawn(OrderedChildren)
const children = parent.get(OrderedChildren)
// Array-like interface
children.push(child1) // Adds ChildOf(parent) to child1
children.unshift(child2) // Adds to front
children.splice(0, 1) // Removes first child
// Bidirectional sync
child3.add(ChildOf(parent)) // child3 automatically added to listSupported methods
Standard array methods:
push(entity)- Add to endpop()- Remove from endshift()- Remove from frontunshift(entity)- Add to frontsplice(start, deleteCount, ...items)- Remove/insert
Special methods:
moveTo(entity, index)- Move entity to specific positioninsert(entity, index)- Insert at specific position
Example: UI layer ordering
const ChildOf = relation({ autoDestroy: 'orphan' })
const OrderedChildren = ordered(ChildOf)
const scene = world.spawn(OrderedChildren)
const layers = scene.get(OrderedChildren)
const background = world.spawn(ChildOf(scene))
const gameplay = world.spawn(ChildOf(scene))
const ui = world.spawn(ChildOf(scene))
// Render in order (background first, UI last)
function render(world: World) {
for (const layer of layers) {
renderLayer(layer)
}
}
// Reorder dynamically
layers.moveTo(ui, 0) // Move UI to backExample: Execution order
const ChildOf = relation()
const OrderedSystems = ordered(ChildOf)
const pipeline = world.spawn(OrderedSystems)
const systems = pipeline.get(OrderedSystems)
// Define system execution order
systems.push(inputSystem)
systems.push(physicsSystem)
systems.push(renderSystem)
// Run in order
function tick(world: World) {
for (const system of systems) {
executeSystem(system)
}
}Performance notes
Ordered relations add bookkeeping overhead:
- Cost is paid during structural changes (add, remove, move)
- NOT during query/iteration time
- Use only when order is essential
When to use:
- UI layer/z-index management
- System execution order
- Render order
- Any time iteration order matters
When NOT to use:
- Order doesn't matter
- Can sort at query time
- Performance-critical hot paths
Removing Relations
Remove specific relation
entity.add(Likes(apple))
entity.add(Likes(banana))
entity.remove(Likes(apple))
entity.has(Likes(apple)) // false
entity.has(Likes(banana)) // trueRemove all relations of a kind (wildcard)
entity.add(Likes(apple))
entity.add(Likes(banana))
entity.remove(Likes('*'))
entity.has(Likes(apple)) // false
entity.has(Likes(banana)) // falseRelation Options
| Option | Value | Effect |
|---|---|---|
store | { field: default } | Attach data to the relation |
autoDestroy | 'orphan' or 'source' | Destroy sources when target destroyed |
autoDestroy | 'target' | Destroy targets when source destroyed |
exclusive | true | Entity can only have one target |
React Hooks
import { useTarget, useTargets } from 'koota/react'
// Get first target (reactive)
const parent = useTarget(entity, ChildOf)
// Get all targets (reactive)
const items = useTargets(inventory, Contains)Anti-Patterns
❌ Storing the parent reference manually
// Don't do this - duplicates what relations provide
const Transform = trait({
x: 0,
y: 0,
parent: null as Entity | null, // ❌ Bad
})// Do this instead
const ChildOf = relation()
const child = world.spawn(Transform, ChildOf(parent))
const parent = child.targetFor(ChildOf) // ✅ Good❌ Using arrays to track children on the parent
// Don't do this - manual bookkeeping, error-prone
const Parent = trait({
children: () => [] as Entity[], // ❌ Bad
})// Do this instead - query for children
const ChildOf = relation()
const children = world.query(ChildOf(parent)) // ✅ Good❌ Forgetting autoDestroy for hierarchies
// Dangerous - orphans left behind when parent destroyed
const ChildOf = relation() // ❌ Missing autoDestroy// Safe - children cleaned up automatically
const ChildOf = relation({ autoDestroy: 'orphan' }) // ✅ Good❌ Multiple relations when exclusive is needed
// Bug-prone - entity can target multiple
const Targeting = relation()
enemy.add(Targeting(playerA))
enemy.add(Targeting(playerB)) // Now targeting both! ❌// Correct - only one target allowed
const Targeting = relation({ exclusive: true })
enemy.add(Targeting(playerA))
enemy.add(Targeting(playerB)) // Replaces playerA ✅❌ Querying without wildcard when you want all
// This finds nothing - no specific target provided
const allChildren = world.query(ChildOf) // ❌ Wrong// Use wildcard to query all entities with any target
const allChildren = world.query(ChildOf('*')) // ✅ CorrectRuntime Patterns
How and when to run logic in a Koota application.
Contents
Systems
Systems query the world and update entities. Always take world: World as first parameter.
`core/systems/update-movement.ts`:
import type { World } from 'koota'
import { Position, Velocity, Time } from '../traits'
export function updateMovement(world: World) {
const { delta } = world.get(Time)!
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x * delta
pos.y += vel.y * delta
})
}Key points:
- One file per system
- Name files:
update-{thing}.tsor{verb}-{thing}.ts - No React imports — systems are pure TypeScript
- Called from frameloop or event handlers
Actions vs systems
Actions are discrete, synchronous data mutations — create, read, update, destroy. Reusable from any call site (systems, UI handlers, tests, imports).
// Good actions: direct mutations
createEnemy: (pos) => world.spawn(Position(pos), IsEnemy)
applyDamage: (entity, amount) => entity.set(Health, { hp: entity.get(Health).hp - amount })Systems are reactive orchestrators. They observe state changes (createAdded, createChanged) in the frame loop and coordinate work, including async workflows. They may call actions for mutations, or mutate directly — whichever is clearer.
// Good system: reacts to state, orchestrates behavior
export function applyPoison(world: World) {
world.query(Changed(Poisoned)).readEach(([poison], entity) => {
entity.set(Health, { hp: entity.get(Health).hp - poison.dps * delta })
})
}Litmus test: if it's a direct mutation callable from multiple contexts, it's an action. If it's "when X happens, do Y" — observation plus orchestration — it's a system.
Common patterns:
// Query and update each
export function updatePhysics(world: World) {
world.query(Position, Velocity).updateEach(([position, velocity]) => {
position.x += velocity.x
})
}
// If you need both queried data and the entity, prefer readEach
export function processCompletedImages(world: World) {
world.query(Position).readEach(([position], entity) => {
// Read position
})
}
// Read singleton traits
export function updateAI(world: World) {
const { delta } = world.get(Time)!
const pointer = world.get(Pointer)!
// ... use delta and pointer
}Frameloop
Run systems continuously via requestAnimationFrame.
`app/frameloop.ts`:
import { useWorld } from 'koota/react'
import { useAnimationFrame } from './utils/use-animation-frame'
import { updateTime } from '../core/systems/update-time'
import { updateMovement } from '../core/systems/update-movement'
import { updateCollisions } from '../core/systems/update-collisions'
export function Frameloop() {
const world = useWorld()
useAnimationFrame(() => {
updateTime(world)
updateMovement(world)
updateCollisions(world)
})
return null
}`app/utils/use-animation-frame.ts`:
import { useEffect, useRef } from 'react'
export function useAnimationFrame(callback: () => void) {
const callbackRef = useRef(callback)
callbackRef.current = callback
useEffect(() => {
let rafId: number
const loop = () => {
callbackRef.current?.()
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(rafId)
}, [])
}Event-driven systems
Two strategies for handling events:
1. Capture for frameloop: Store event data in traits so frameloop systems can read it. Use for continuous input (pointer, keyboard, viewport).
useEffect(() => {
const handler = (e: PointerEvent) => {
world.set(Pointer, { x: e.clientX, y: e.clientY })
}
window.addEventListener('pointermove', handler)
return () => window.removeEventListener('pointermove', handler)
}, [world])2. Run on transition: Execute system logic immediately when an event fires. Use for discrete events (state machine transitions, network messages, entity lifecycle).
// XState transition
useEffect(() => {
const sub = actor.subscribe((snapshot) => {
handleStateTransition(world, snapshot)
})
return () => sub.unsubscribe()
}, [world, actor])
// System runs on transition
function handleStateTransition(world: World, snapshot: StateSnapshot) {
if (snapshot.matches('playing')) world.add(IsPlaying)
else world.remove(IsPlaying)
}Entity lifecycle events (onAdd, onRemove, onChange) are also transition-based:
useEffect(() => {
return world.onAdd(Position, (entity) => {
// Runs immediately when entity gains Position
})
}, [world])Time management
Track delta time using a Time trait and updateTime system. Run first in frameloop.
`core/traits/index.ts`:
export const Time = trait({ last: 0, delta: 0 })`core/systems/update-time.ts`:
import type { World } from 'koota'
import { Time } from '../traits'
export function updateTime(world: World) {
const now = performance.now()
const time = world.get(Time)!
const delta = Math.min((now - time.last) / 1000, 0.1)
world.set(Time, { last: now, delta })
}Key points:
Timeis a singleton trait passed tocreateWorld(Time, ...)deltais in seconds (divided by 1000)deltacapped at 0.1s to prevent large jumps- Call
updateTime(world)first in frameloop
Usage:
export function updateMovement(world: World) {
const { delta } = world.get(Time)!
world.query(Position, Velocity).updateEach(([pos, vel]) => {
pos.x += vel.x * delta
pos.y += vel.y * delta
})
}