Koota
Koota is an ECS-based state management library optimized for real-time apps, games, and XR experiences. Use as much or as little as you need.
npm i koota
👉 Try the starter template
First, define traits
Traits are the building blocks of your state. They represent slices of data with specific meanings.
import { trait } from 'koota';
const Position = trait({ x: 0, y: 0 });
const Velocity = trait({ x: 0, y: 0 });
const Mesh = trait(() => THREE.Mesh());
const IsActive = trait();
Spawn entities
Entities are spawned in a world. By adding traits to an entity they gain content.
import { createWorld } from 'koota';
const world = createWorld();
const player = world.spawn(Position, Velocity);
const goblin = world.spawn(Position({ x: 10, y: 10 }), Velocity, Mesh);
Query and update data
Queries fetch entities sharing traits (archetypes). Use them to batch update entities efficiently.
world.query(Position, Velocity).updateEach(([position, velocity]) => {
position.x += velocity.x * delta;
position.y += velocity.y * delta;
});
Use in your React components
Traits can be used reactively inside of React components.
import { WorldProvider, useQuery, useTrait } from 'koota/react'
createRoot(document.getElementById('root')!).render(
<WorldProvider world={world}>
<App />
</WorldProvider>
);
function RocketRenderer() {
const rockets = useQuery(Position, Velocity)
return (
<>
{rockets.map((entity) => <Rocket key={entity} entity={entity} />)}
</>
)
}
function Rocket({ entity }) {
const position = useTrait(entity, Position)
return (
<div style={{ position: 'absolute', left: position.x ?? 0, top: position.y ?? 0 }}>
🚀
</div>
)
}
Modify Koota state safely with actions
Use actions to safely modify Koota from inside of React in either effects or events.
import { createActions } from 'koota'
import { useActions } from 'koota/react';
const actions = createActions((world) => ({
spawnShip: (position) => world.spawn(Position(position), Velocity),
destroyAllShips: (world) => {
world.query(Position, Velocity).forEach((entity) => {
entity.destroy();
});
},
}));
function DoomButton() {
const { spawnShip, destroyAllShips } = useActions(actions);
useEffect(() => {
spawnShip({ x: 0, y: 1 });
spawnShip({ x: 1, y: 0 });
spawnShip({ x: 1, y: 1 });
return () => drestroyAllShips();
}, []);
return <button onClick={destroyAllShips}>Boom!</button>;
}
Or access world directly and use it.
const world = useWorld();
useEffect(() => {
const entity = world.spawn(Velocity, Position);
return () => entity.destroy();
});
Advanced
Relationships
Koota supports relationships between entities using the relation
function. Relationships allow you to create connections between entities and query them efficiently.
const ChildOf = relation();
const parent = world.spawn();
const child = world.spawn(ChildOf(parent));
const entity = world.queryFirst(ChildOf(parent));
With data
Relationships can contain data like any trait.
const Contains = relation({ store: { amount: 0 } });
const inventory = world.spawn();
const gold = world.spawn();
inventory.add(Contains(gold));
inventory.set(Contains(gold), { amount: 10 });
Auto remove target
Relations can automatically remove target entities and their descendants.
const ChildOf = relation({ autoRemoveTarget: true });
const parent = world.spawn();
const child = world.spawn(ChildOf(parent));
const grandchild = world.spawn(ChildOf(child));
parent.destroy();
world.has(child);
Exclusive Relationships
Exclusive relationships ensure each entity can only have one target.
const Targeting = relation({ exclusive: true });
const hero = world.spawn();
const rat = world.spawn();
const goblin = world.spawn();
hero.add(Targeting(rat));
hero.add(Targeting(goblin));
hero.has(Targeting(rat));
hero.has(Targeting(goblin));
Querying relationships
Relationships can be queried with specific targets, wildcard targets using *
and even inverted wildcard searches with Wildcard
to get all entities with a relationship targeting another entity.
const gold = world.spawn();
const silver = world.spawn();
const inventory = world.spawn(Contains(gold), Contains(silver));
const targets = inventory.targetsFor(Contains);
const chest = world.spawn(Contains(gold));
const dwarf = world.spawn(Desires(gold));
const constainsSilver = world.query(Contains(silver));
const containsAnything = world.query(Contains('*'));
const relatesToGold = world.query(Wildcard(gold));
Query modifiers
Modifiers are used to filter query results enabling powerful patterns. All modifiers can be mixed together.
Not
The Not
modifier excludes entities that have specific traits from the query results.
import { Not } from 'koota';
const staticEntities = world.query(Position, Not(Velocity));
Or
By default all query parameters are combined with logical AND. The Or
modifier enables using logical OR instead.
import { Or } from 'koota';
const movingOrVisible = world.query(Or(Velocity, Renderable));
Added
The Added
modifier tracks all entities that have added the specified traits since the last time the query was run. A new instance of the modifier must be created for tracking to be unique.
import { createAdded } from 'koota';
const Added = createAdded();
const newPositions = world.query(Added(Position));
Removed
The Removed
modifier tracks all entities that have removed the specified traits since the last time the query was run. This includes entities that have been destroyed. A new instance of the modifier must be created for tracking to be unique.
import { createRemoved } from 'koota';
const Removed = createRemoved();
const stoppedEntities = world.query(Removed(Velocity));
Changed
The Changed
modifier tracks all entities that have had the specified traits values change since the last time the query was run. A new instance of the modifier must be created for tracking to be unique.
import { createChanged } from 'koota';
const Changed = createChanged();
const movedEntities = world.query(Changed(Position));
Add, remove and change events
Koota allows you to subscribe to add, remove, and change events for specific traits.
const unsub = world.onChange(Position, (entity) => {
console.log(`Entity ${entity} changed position`);
});
const unsub = world.onAdd(Position, (entity) => {
console.log(`Entity ${entity} added position`);
});
const unsub = world.onRemove(Position, (entity) => {
console.log(`Entity ${entity} removed position`);
});
const entity = world.spawn(Position);
entity.set(Position, { x: 10, y: 20 });
entity.remove(Position);
World traits
For global data like time, these can be traits added to the world. World traits do not appear in queries.
const Time = trait({ delta: 0, current: 0 });
world.add(Time);
const time = world.get(Time);
world.set(Time, { current: performance.now() });
Select traits on queries for updates
Query filters entity results and select
is used to choose what traits are fetched for updateEach
and useStore
.
Modifying trait stores direclty
For performance-critical operations, you can modify trait stores directly using the useStore hook. This approach bypasses some of the safety checks and event triggers, so use it with caution. All stores are structure of arrays for performance purposes.
world.query(Position, Velocity).useStore(([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;
}
});
APIs in detail until I make docs
These are more like notes for docs. Take a look around, ask questions. Eventually this will become proper docs.
World
This is where all data is stored. We have methods on entities but this is a bit of a trick, entities don't actually store any data and instead it is operating on the connected world. Each world has its own set of entities that do not overlap with another. Typically you only need one world.
Worlds can have traits, which is our version of a singleton. Use these for global resources like a clock. Each world gets its own entity used for world traits. This entity is no queryable but will show up in the list of active entities making the only way to retrieve a world trait with its API.
const entity = world.spawn()
const result = world.has(entity)
const entities = world.query(Position)
const entity = world.queryFirst(Position)
world.add(Time)
world.remove(Time)
const result = world.has(Time)
const time = world.get(Time)
world.set(Time, { current: performance.now() })
const unsub = world.onAdd([Position], (entity) => {})
const unsub = world.onRemove([Position], (entity) => {})
const unsub = world.onChange([Position], (entity) => {})
world.entities
const id = world.id()
world.reset()
world.destroy()
Entity
An entity is a number encoded with a world, generation and ID. Every entity is unique even if they have the same ID since they will have different generations. This makes automatic-recycling possible without reference errors. Because of this, the number of an entity won't give you its ID but will have to instead be decoded with entity.id()
.
entity.add(Position)
entity.remove(Position)
const result = enttiy.has(Position)
const position = entity.get(Position)
entity.set(Position, { x: 10, y: 10 })
const targets = entity.targetsFor(Contains)
const target = entity.targetFor(Contains)
const id = entity.id()
entity.destroy()
Trait
A trait defines a kind of data. If you are familiar with ECS, it is our version of a component. We call it a trait instead to not get confused with React or web components. From a high level, you just create traits either with a schema or with a callback returning an object.
const Position = trait({ x: 0, y: 0, z: 0 })
const Velocity = trait(() => THREE.Vector3())
Both schema-based and callback-based traits are used similarly, but they have different performance implications due to how their data is stored internally:
- Schema-based traits use a Structure of Arrays (SoA) storage.
- Callback-based traits use an Array of Structures (AoS) storage.
Learn more about AoS and SoA here.
Structure of Arrays (SoA) - Schema-based traits
When using a schema, each property is stored in its own array. This can lead to better cache locality when accessing a single property across many entities. This is always the fastest option for data that has intensive operations.
const Position = trait({ x: 0, y: 0, z: 0 });
const store = {
x: [0, 0, 0, ...],
y: [0, 0, 0, ...],
z: [0, 0, 0, ...],
};
Array of Structures (AoS) - Callback-based traits
When using a callback, each entity's trait data is stored as an object in an array. This is best used for compatibiilty with third party libraries like Three, or class instnaces in general.
const Velocity = trait(() => ({ x: 0, y: 0, z: 0 }));
const store = [
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
{ x: 0, y: 0, z: 0 },
];
const Mesh = trait(() => THREE.Mesh())
React
useQuery
Reactively updates when entities matching the query changes. Returns a QueryResult
, which is like an array of entities.
const entities = useQuery(Position, Velocity);
return (
<>
{entities.map(entity => <Renderer key={entity.id()} entity={entity} />)}
</>
);
usQueryFirst
Works like useQuery
but only returns the first result. Can either be an entity of undefined.
const player = useQueryFirst(Player, Position);
return player ? (
<Renderer entity={player} />
) : null;
useWorld
Returns the default world. If a world is passed in via WorldProvider
then this is returned instead. The default world can be gotten at any time with getDefaultWorld
.
const world = useWorld();
useEffect(() => {
const entity = world.spawn()
return => entity.destroy()
}, [])
WorldProvider
The provider for the world context. A world must be created and passed in, which then overrides the default world.
const world = createWorld();
function App() {
return (
<WorldProvider world={world}>
<Game />
</WorldProvider>
);
}
useTrait
Observes an entity, or world, for a given trait and reactively updates when it is added, removed or changes value.
const position = useTrait(entity, Position);
if (!position) return null
return (
<div>
Position: {position.x}, {position.y}
</div>
);
useTraitEffect
Subscribes a callback to a trait on an entity. This callback fires as an effect whenenver it is added, removed or changes value without rerendering.
useTraitEffect(entity, Position, (position) => {
if (!position) return;
meshRef.current.position.copy(position);
});
useTraitEffect(world, GameState, (state) => {
if (!state) return;
console.log('Game state changed:', state);
});
useActions
Returns actions bound to the world that is context. Use actions created by createActions
.
const actions = createActions((world) => ({
spawnPlayer: () => world.spawn(IsPlayer).
destroyAllPlayers: () => {
world.query(IsPlayer).forEach((player) => {
player.destroy()
})
}
}))
const { spawnPlayer, destroyAllPlayers } = useActions();
useEffect(() => {
spawnPlayer()
return () => destroyAllPlayers()
}, [])