Awake ECS
A small, dependency-free sparse-set Entity Component System for Kotlin Multiplatform (Android, iOS, JVM/desktop, Web/wasmJs). Built in-house for the Awake engine instead of adopting an existing library (Fleks, Artemis-odb, Ashley) — see docs/ecs-benchmark-scorecard.md for the real, same-JVM benchmark comparison that justifies this, and awake-engine-core-engineer.md for the architecture rationale.
The core tradeoff: family membership is maintained on every structural change, so iteration walks a packed array with no matching work. That buys roughly 5-6x faster query iteration than the alternatives, paid for with slower bulk component churn. Games iterate constantly and mutate structure rarely, so the trade is deliberate.
Not thread-safe by design — this ECS is meant to be driven from a single game-update thread, matching this project's Vulkan threading model.
Installation
Not on Maven Central. build-and-publish.yml publishes the vulkan-kmp artifacts only, so this
module has never been released — build it locally with ./gradlew publishToMavenLocal and take it
from there:
repositories {
mavenLocal()
}
dependencies {
implementation("com.awakekt.awake:ecs:<version>")
}
The version is derived from git describe, so it follows your checkout — print it with
./gradlew :awake:ecs:properties | grep version.
Core concepts
Entity— a value class wrapping a packedid+generation. The generation exists so a recycled id can't alias a stale handle held elsewhere.World— owns entity allocation/recycling and one component store per component type. Everything below is a method onWorld.- Components — plain data classes/objects. No base interface or registration step
required; any
Anytype can be a component. System— afun interfacewith a singleupdate(world: World, delta: Float). Systems are just plain classes you call yourself each frame — there's no built-in scheduler.
Quick start
import com.awakekt.awake.ecs.World
data class Position(var x: Float, var y: Float)
data class Velocity(var dx: Float, var dy: Float)
val world = World()
val player = world.create()
world.add(player, Position(0f, 0f))
world.add(player, Velocity(1f, 0f))
// Iterate every entity that has both components:
world.queryEach<Position, Velocity> { entity, position, velocity ->
position.x += velocity.dx
position.y += velocity.dy
}
world.remove<Velocity>(player) // stops moving
world.destroy(player) // recycles the id; old `player` handle is now stale
world.isAlive(player) // false
Entities
val entity = world.create()
world.isAlive(entity) // true
world.destroy(entity) // true; id becomes eligible for reuse with a bumped generation
world.isAlive(entity) // false -- this exact handle can never come back alive
A destroyed id can be handed out again by a later create(), but the new Entity will
have a different generation, so any old handle you were still holding safely reads as
not-alive rather than aliasing the new entity.
Components
world.add(entity, Position(1f, 2f)) // add or replace
world.get<Position>(entity) // Position? -- null if absent or entity is dead
world.has<Position>(entity) // Boolean
world.remove<Position>(entity) // Position? -- the removed value, or null
To ask what an entity is made of rather than whether it has one specific component:
world.componentTypes(entity) // List<KClass<out Any>>, registration order
// empty for a dead entity
Types, not values — a heterogeneous set of components has no type-safe representation, so read
each one back with get. This is what an inspector, a debug overlay or a serializer wants;
inspectStorage(entity) returns the same set with each type's storage kind and count attached,
which is a question about performance rather than about the entity.
Payload-free marker components should be singleton objects implementing EcsTag:
data object IsSelected : EcsTag
world.add(entity, IsSelected)
world.has<IsSelected>(entity) // true
Tag columns store one canonical singleton instead of a repeated reference per entity, so
prefer family iteration on hot paths — components()/componentsA()/componentsB() still
work for tags, but materialize the repeated array that direct iteration avoids. The tag must
be a Kotlin object; a class producing multiple instances is rejected.
Each of these has three overloads. Pick by call frequency:
| Overload | Use for | Cost it avoids |
|---|---|---|
world.add<Position>(entity, c) |
one-off calls | — |
world.add(entity, type, c) |
loops, type hoisted |
re-deriving the reified type token |
world.add(entity, typeId, c) |
hot loops | the above, plus the KClass map lookup |
// Hot loop: hoist the ComponentTypeId once, outside the loop
val positionTypeId = world.typeId(Position::class)
for (entity in manyEntities) {
world.add(entity, positionTypeId, Position(0f, 0f))
}
Component pooling
Register a factory once per component type, then obtain (and automatically recycle)
pooled instances instead of allocating fresh ones on every add/remove:
world.registerPool(Position::class) { Position(0f, 0f) }
val entity =
world.spawn<Position> { it.x = 1f; it.y = 2f } // create() + pooled add<T>() + init block
world.destroy(entity) // Position instance returns to the pool
If a component implements Poolable, reset() runs automatically when it's returned to
the pool (on remove/destroy), so the next obtain() doesn't hand back stale state:
data class Position(var x: Float = 0f, var y: Float = 0f) : Poolable {
override fun reset() {
x = 0f; y = 0f
}
}
Without a registered factory, world.add<T>(entity) (the no-component-argument overload)
falls back to reflection for zero-arg-constructor components on JVM/Android. iOS has no
reflection-based instantiation — register an explicit factory via registerPool for any
type you construct this way if the code needs to run there.
A hard limit: 64 component types per World
Entity-component membership is tracked as a single Long bitmask per entity (one bit per
component type), which is how has()/family-matching stay cheap. That caps this ECS at
64 distinct component types per World — registering a 65th type throws a clear
IllegalArgumentException from world.typeId(...)/add(...) rather than silently
overflowing. This is a deliberate tradeoff, not an oversight; if your game genuinely needs
more than 64 component types in one World, that's worth raising as a design question
before working around it.
Queries and families
For iterating "every entity with components X (and Y)", use queryEach for a one-shot pass
or family for a cache you keep and reuse (e.g. as a System's field):
// One-shot iteration
world.queryEach<Position> { entity, position -> /* ... */ }
world.queryEach<Position, Velocity> { entity, position, velocity -> /* ... */ }
// A maintained, reusable handle -- membership stays incrementally up to date as
// components are added/removed, no rescan needed on each access
val movers = world.family<Position, Velocity>() // Family2<Position, Velocity>
movers.forEach { entity, position, velocity -> /* ... */ }
movers.forEachComponents { position, velocity -> /* ... */ } // skip the Entity if you don't need it
movers.size
// Direct array access, for callers that want bulk/indexed access instead of a callback.
// For EcsTag columns this lazily materializes repeated singleton references.
val positions: Array<Position> = movers.componentsA()
val velocity = movers.componentB(0)
Family1/Family2 cover the common 1- and 2-component case and hand you typed components
directly (no extra lookup per entity). For 3+ component types, or one/exclude
semantics, use the general family { } builder instead — it only hands back matched
Entity handles (Kotlin can't express an arbitrary-arity typed tuple), so read components
back via world.get<T>(entity):
val renderable = world.family {
all(Position::class, MeshRenderer::class)
exclude(Hidden::class)
}
renderable.forEach { entity ->
val position = world.get<Position>(entity)!!
// ...
}
world.query(vararg types) / world.queryEach(vararg types) { entity -> } are also
available when you only need the matching Entity list/callback and don't care about
typed component access at all.
Systems
class MovementSystem : System {
override fun update(world: World, delta: Float) {
world.queryEach<Position, Velocity> { _, position, velocity ->
position.x += velocity.dx * delta
position.y += velocity.dy * delta
}
}
}
val systems = listOf(MovementSystem())
// Your own game loop drives this -- there's no scheduler built into the ECS itself
fun gameLoop(world: World, delta: Float) {
systems.forEach { it.update(world, delta) }
}
Design credit: Awake's system model is Ashley-like in spirit, but with a
Bevy/Unity/Flecs-style separation. Like libGDX Ashley, systems are behavior objects
that run against an Engine/World. Like Bevy schedules, Unity Entities system
groups, and Flecs pipelines, the decision about when a system runs belongs to the
runtime/schedule layer, not the component data model. In Awake that means awake-ecs
keeps System small and scheduler-free, while higher layers such as awake-scene
can register systems into explicit phases such as fixed simulation steps or per-frame
render/update passes.
If a system keeps its own per-frame scratch state (buffers, visited-sets), reuse instance
fields across update() calls instead of allocating fresh collections every frame — see
awake-scene's TransformSystem for a worked example (entity-id-indexed arrays instead of
a Map/Set keyed by Entity, to avoid boxing the value class on every frame).
What this ECS deliberately doesn't do
- No archetype/table storage — sparse-set per component type plus maintained dense family
caches instead. The measured decision lives in
docs/tasks/archive/2026-08-18-ecs-hybrid-archetype-sparse-set.md. - No bulk/batch structural mutation API —
add/removeapply immediately. A benchmarked deferred-rebuild prototype only beat the immediate path when a batch touched roughly the entire world at once, which is not a workload this engine runs. Seedocs/tasks/2026-08-21-ecs-adaptive-bulk-mutation-plan.md. - No built-in scheduler, job system, or parallelism — single-threaded by design.
- No serialization at this layer — component types are plain data classes; use whatever
serialization approach fits your game (
awake-scene's scene runtime useskotlinx.serializationon top of this, entirely outsideawake-ecsitself). - No mandatory component registration — any
Anyworks as a component the moment youaddone, no base interface or upfront registration required. Reflection is used, but only opt-in: pooled zero-arg component instantiation on JVM/Android falls back to it if you don't register a factory (see "Component pooling" above); iOS has no reflection fallback and requires an explicit factory for that path.
Benchmarking
awake-ecs-benchmark (a separate, JVM-only module) benchmarks this ECS against Fleks,
Artemis-odb, and Ashley on the same JVM/hardware. See
docs/ecs-benchmark-scorecard.md for the numbers,
methodology, and an honest account of where this ECS currently wins and where it doesn't.