Motivation
Today the entity layer in core/entity/{mob,drop,projectile,container} is built around heavyweight, stateful, deeply-inherited classes that bundle data, behaviour, physics, rendering and audio into single types. As the game grows (new mob types, new projectile variants, new interactions between systems), this design is increasingly painful to extend, test and reason about.
Ashley is the libGDX-blessed lightweight entity-component-system. Moving to ECS would let us:
- Compose entities from small
Components (Position, Velocity, Body, Sprite, Health, Hunger, Sight, Inventory, ProjectileTag, DropTag, ContainerRef, AIBehavior, …) instead of fattening a base class.
- Express game-loop logic as
EntitySystems with explicit Family queries, replacing the ad-hoc per-controller for (e in list) e.update(...) loops.
- Mix capabilities orthogonally (e.g. "a Drop that is also a projectile", "a falling block that takes damage") without inventing new subclasses of
Mob.
- Make the entity layer testable in isolation: a system that needs only
Position + Velocity doesn't drag a Box2D Body, a MobBehavior, animation state, or four *Adapter interfaces with it. This unblocks the test work the codebase is already trying to grow (see CLAUDE.md "Tests" section).
- Iterate over only the entities a system cares about, rather than running the same monolithic
update() over every mob regardless of which subsystems are relevant.
Existing problems
The current design has accumulated symptoms that point at the same root cause — "entity" is conflated with "GameObject base class":
- God classes.
core/entity/mob/.../model/Mob.kt is ~470 lines; Player.kt adds another ~610. A single Mob holds Box2D body, animation state, sound state, control vector, climb/swim flags, fly mode, damage-tint timer, bow charge, footContactCounter, autojump counters, cliff-edge counters, pending body transform, hit anim, drop logic. Player adds inventory, armor, hunger model, exhaustion, food tick accumulator, cursor state, sight light handle, hit animation, sprinting, bow shooting, raycast. None of this is composable.
- Brittle inheritance.
Mob → WalkingMob → SheepMob, plus FallingBlock, ArcherMob, Player — and BaseMobBehavior re-introduces a parallel hierarchy with a runtime mobType.isInstance(mob) check that throws if the wrong behaviour is paired with the wrong subclass (BaseMobBehavior.kt:18-22). Any cross-cutting trait (e.g. "can swim", "is on fire") becomes another flag on the base class.
- Three near-duplicate controllers.
MobController, DropController, ProjectileController each maintain their own LinkedList<T>, their own drain-queue pattern, their own removal loop, their own dispose() cascade. The only thing that differs is the per-entity tick.
- Render & audio are coupled to the entity.
MobsRenderer.draw(...) calls mob.draw(spriteBatch, ...) (core/gameplay/rendering/.../MobsRenderer.kt:38-40), and Mob/Player own the sprite-batch drawing code directly. Animation state lives on the entity (anim, animDelta, hitAnim). This violates the layer split the rest of the codebase already follows.
- Adapters as a workaround. Because
Mob.update needs access to the world, the player, projectiles and physics, we pass MobWorldAdapter, PlayerAdapter, ProjectileAdapter (plus MobPhysicsFactory for spawn). These adapters exist purely to keep the entity from depending on the controllers — exactly the kind of indirection ECS systems remove by letting a system query whatever entities it needs.
- Save/load mappers shadow the class hierarchy.
core/data/save/.../mapper/ has MobControllerMapper, PlayerMapper, FallingBlockMapper, DropMapper, ProjectileMapper, ChestMapper, FurnaceMapper, … each hand-rolled for one subclass. Adding a new entity type means another mapper.
- Hard to unit-test. Per CLAUDE.md, JVM tests can't instantiate libGDX/Box2D/RayHandler. Because logic lives inside
Mob/Player/Drop/Projectile and those classes own a Body, almost nothing in core/entity is unit-testable today — the only way forward is the documented "extract a pure helper" workaround. ECS naturally puts the data on components (testable) and the logic in systems (testable with mocked component mappers).
- Queues bolted on the side.
MobQueue / DropQueue are an explicit "pending entities, please spawn next tick" mechanism (entity/mob/MobQueue.kt). In Ashley this is engine.addEntity(...) and goes away.
Proposed migration
Ashley is small enough that this can be done incrementally, one entity family at a time, without a big-bang rewrite. Suggested plan:
1. Bring Ashley into the build
- Add
com.badlogicgames.ashley:ashley (and optionally io.github.libktx:ktx-ashley for ergonomic Kotlin DSL) to buildSrc/.../Dependencies.kt.
- Expose a
useAshley() helper in DependencyHandlerExtentions.kt mirroring useLibgdx() / useLibKtx().
- Verify the dependency works under the TeaVM-JS web build (Ashley is pure-JVM and should transpile cleanly, but it must be smoke-tested via
./gradlew html:buildJs before any production code depends on it). If Ashley uses any reflection that TeaVM rejects, document the workaround in html/NOTICE.md and emu/.
2. Introduce a shared Engine
- Create the Ashley
Engine once per game session inside GameComponent (@GameScope), provided via Dagger.
- Make it a peer of
MobController/DropController/ProjectileController initially — the controllers can keep working while we move entities under the engine one type at a time.
3. Define the component vocabulary in core/entity
Replace the four data classes with small Components. A non-exhaustive starting set (each is a plain Kotlin class implementing com.badlogic.ashley.core.Component):
- Spatial / physics:
TransformComponent (position, direction), VelocityComponent, Box2dBodyComponent (owns the Body, plus the dispose lifecycle currently in Mob.dispose), HitboxComponent, ControlVectorComponent.
- Mob-specific:
HealthComponent, BreathComponent, MobParamsComponent (wraps MobParams), AnimationStateComponent, DamageTintComponent, FootContactComponent, AutojumpCountersComponent, CliffEdgeCountersComponent, BowChargeComponent, ClimbStateComponent, FlyModeComponent, JumpCooldownComponent, PendingTransformComponent, MakingSoundComponent.
- Player-specific:
InventoryComponent, WearingArmorComponent, HungerComponent (food/saturation/exhaustion/timer), CursorComponent, SightComponent (the LightHandle), HitAnimComponent, SprintComponent, GameModeComponent, BedComponent.
- Drop:
DropComponent (the InventoryItem), MagnetComponent, BobbingComponent, DropTimestampComponent.
- Projectile:
ProjectileComponent (item, damage, dropOnGround), BulletPhysicsComponent.
- Container:
ContainerComponent (the inventory), ContainerKindComponent (chest/furnace).
- AI / behaviour: instead of a
MobBehavior field, use tag components (PassiveAiComponent, AggressiveAiComponent, ArcherAiComponent, FallingBlockAiComponent, PlayerControlledComponent) and let a dedicated system match the family.
Keep core/entity as the home for pure component classes plus shared component-mapper objects (com.badlogic.ashley.core.ComponentMapper).
4. Replace controllers with EntitySystems in core/game/controller/*
Each existing controller becomes one or more Ashley systems:
MobController → MobPhysicsSyncSystem, MobAnimationSystem, MobAiSystem (split by behaviour tag), MobHealthSystem (kill / death / drop), MobSoundSystem, MobSpawnSystem (replaces MobQueue), MobBoundaryWrapSystem (the horizontal world-wrap currently inside Mob.update).
Player-specific logic → PlayerHungerSystem, PlayerSightSystem, PlayerHitAnimSystem, PlayerCursorRaycastSystem, PlayerDeathSystem.
DropController → DropPhysicsSystem, DropTtlSystem, DropPickupSystem, DropQueueDrainSystem, plus the existing OnBlockDestroyedListener hook moved into a thin adapter that calls engine.addEntity(...).
ProjectileController → ProjectileMovementSystem, ProjectileDeathSystem.
ContainerController → component-backed lookup by ContainerCoordinates.
Systems should be @GameScope singletons, wired through Dagger and the existing automultibind KSP processor (i.e. introduce @BindEntitySystem analogous to the existing @BindWorldRenderer / contact-handler annotations, and let KSP generate the multibinding so Engine.addSystem(...) is wired automatically).
5. Move rendering out of the entity
Mob.draw / Player.draw → a MobRenderSystem (or just keep MobsRenderer but have it iterate a Family.all(SpriteComponent, TransformComponent, MobParamsComponent).get() instead of calling mob.draw).
- This finally lets us delete the
SpriteBatch import from core/entity.
6. Migrate save/load mappers
- Replace per-subclass mappers (
PlayerMapper, FallingBlockMapper, DropMapper, ProjectileMapper, ChestMapper, FurnaceMapper, …) with per-component mappers (HealthMapper, InventoryMapper, Box2dBodyStateMapper, MobParamsRefMapper, …) plus a single EngineSnapshotMapper that serialises Engine.entities as a list of (entityId, components[]).
- Keep the on-disk format (kotlinx-serialization protobuf) backward-compatible by writing a one-shot migration: read the legacy
MobControllerSave/DropControllerSave/ProjectileControllerSave payloads and translate them into component bundles on first load.
7. Replace contact handlers' entity dispatch
- Today,
Body.userData is the entity reference (Drop sets body.userData = this, etc.), and BindMobContactHandler / BindDropContactHandler / BindProjectileContactHandler modules wire one handler per pair. Switch userData to carry an Ashley Entity (or its id) so contact handlers can mutate components directly instead of calling methods on a typed entity.
8. Cleanup & deletion
Once all four entity families live under Ashley:
- Delete
core/entity/.../model/Mob.kt, Player.kt, WalkingMob.kt, SheepMob.kt, FallingBlock.kt, ArcherMob.kt, Drop.kt, Projectile.kt, Container.kt, Chest.kt, Furnace.kt — or reduce them to thin component-bundle factories.
- Delete
MobQueue / DropQueue (replaced by engine.addEntity).
- Delete the four
*Adapter interfaces (MobWorldAdapter, PlayerAdapter, ProjectileAdapter, DropWorldAdapter) once no system needs them. Their concerns become either component reads or direct GameWorld queries from inside a system.
- Delete or shrink
BaseMobBehavior + MobBehavior.
9. Tests
This is the payoff. For each new system, add a unit test (JUnit 5 + mockk + the test-stack already documented in CLAUDE.md) that:
- builds a throw-away
Engine,
- inserts hand-rolled component bundles,
- ticks the system,
- asserts the resulting component state.
No GL, no Box2D World, no RayHandler — exactly the testability the current design blocks.
Out of scope
- Block / world / chunk storage (those are not in
core/entity).
- Lighting backends (
lighting-box2d, lighting-tint) — they only consume entity positions, not the entity classes themselves.
- The menu MVVM stack in
core/gdx.
Acceptance criteria
Motivation
Today the entity layer in
core/entity/{mob,drop,projectile,container}is built around heavyweight, stateful, deeply-inherited classes that bundle data, behaviour, physics, rendering and audio into single types. As the game grows (new mob types, new projectile variants, new interactions between systems), this design is increasingly painful to extend, test and reason about.Ashley is the libGDX-blessed lightweight entity-component-system. Moving to ECS would let us:
Components (Position, Velocity, Body, Sprite, Health, Hunger, Sight, Inventory, ProjectileTag, DropTag, ContainerRef, AIBehavior, …) instead of fattening a base class.EntitySystems with explicitFamilyqueries, replacing the ad-hoc per-controllerfor (e in list) e.update(...)loops.Mob.Position+Velocitydoesn't drag a Box2DBody, aMobBehavior, animation state, or four*Adapterinterfaces with it. This unblocks the test work the codebase is already trying to grow (see CLAUDE.md "Tests" section).update()over every mob regardless of which subsystems are relevant.Existing problems
The current design has accumulated symptoms that point at the same root cause — "entity" is conflated with "GameObject base class":
core/entity/mob/.../model/Mob.ktis ~470 lines;Player.ktadds another ~610. A singleMobholds Box2D body, animation state, sound state, control vector, climb/swim flags, fly mode, damage-tint timer, bow charge, footContactCounter, autojump counters, cliff-edge counters, pending body transform, hit anim, drop logic.Playeradds inventory, armor, hunger model, exhaustion, food tick accumulator, cursor state, sight light handle, hit animation, sprinting, bow shooting, raycast. None of this is composable.Mob→WalkingMob→SheepMob, plusFallingBlock,ArcherMob,Player— andBaseMobBehaviorre-introduces a parallel hierarchy with a runtimemobType.isInstance(mob)check that throws if the wrong behaviour is paired with the wrong subclass (BaseMobBehavior.kt:18-22). Any cross-cutting trait (e.g. "can swim", "is on fire") becomes another flag on the base class.MobController,DropController,ProjectileControllereach maintain their ownLinkedList<T>, their own drain-queue pattern, their own removal loop, their owndispose()cascade. The only thing that differs is the per-entity tick.MobsRenderer.draw(...)callsmob.draw(spriteBatch, ...)(core/gameplay/rendering/.../MobsRenderer.kt:38-40), andMob/Playerown the sprite-batch drawing code directly. Animation state lives on the entity (anim,animDelta,hitAnim). This violates the layer split the rest of the codebase already follows.Mob.updateneeds access to the world, the player, projectiles and physics, we passMobWorldAdapter,PlayerAdapter,ProjectileAdapter(plusMobPhysicsFactoryfor spawn). These adapters exist purely to keep the entity from depending on the controllers — exactly the kind of indirection ECS systems remove by letting a system query whatever entities it needs.core/data/save/.../mapper/hasMobControllerMapper,PlayerMapper,FallingBlockMapper,DropMapper,ProjectileMapper,ChestMapper,FurnaceMapper, … each hand-rolled for one subclass. Adding a new entity type means another mapper.Mob/Player/Drop/Projectileand those classes own aBody, almost nothing incore/entityis unit-testable today — the only way forward is the documented "extract a pure helper" workaround. ECS naturally puts the data on components (testable) and the logic in systems (testable with mocked component mappers).MobQueue/DropQueueare an explicit "pending entities, please spawn next tick" mechanism (entity/mob/MobQueue.kt). In Ashley this isengine.addEntity(...)and goes away.Proposed migration
Ashley is small enough that this can be done incrementally, one entity family at a time, without a big-bang rewrite. Suggested plan:
1. Bring Ashley into the build
com.badlogicgames.ashley:ashley(and optionallyio.github.libktx:ktx-ashleyfor ergonomic Kotlin DSL) tobuildSrc/.../Dependencies.kt.useAshley()helper inDependencyHandlerExtentions.ktmirroringuseLibgdx()/useLibKtx()../gradlew html:buildJsbefore any production code depends on it). If Ashley uses any reflection that TeaVM rejects, document the workaround inhtml/NOTICE.mdandemu/.2. Introduce a shared
EngineEngineonce per game session insideGameComponent(@GameScope), provided via Dagger.MobController/DropController/ProjectileControllerinitially — the controllers can keep working while we move entities under the engine one type at a time.3. Define the component vocabulary in
core/entityReplace the four data classes with small
Components. A non-exhaustive starting set (each is a plain Kotlin class implementingcom.badlogic.ashley.core.Component):TransformComponent(position, direction),VelocityComponent,Box2dBodyComponent(owns theBody, plus thedisposelifecycle currently inMob.dispose),HitboxComponent,ControlVectorComponent.HealthComponent,BreathComponent,MobParamsComponent(wrapsMobParams),AnimationStateComponent,DamageTintComponent,FootContactComponent,AutojumpCountersComponent,CliffEdgeCountersComponent,BowChargeComponent,ClimbStateComponent,FlyModeComponent,JumpCooldownComponent,PendingTransformComponent,MakingSoundComponent.InventoryComponent,WearingArmorComponent,HungerComponent(food/saturation/exhaustion/timer),CursorComponent,SightComponent(theLightHandle),HitAnimComponent,SprintComponent,GameModeComponent,BedComponent.DropComponent(theInventoryItem),MagnetComponent,BobbingComponent,DropTimestampComponent.ProjectileComponent(item, damage, dropOnGround),BulletPhysicsComponent.ContainerComponent(the inventory),ContainerKindComponent(chest/furnace).MobBehaviorfield, use tag components (PassiveAiComponent,AggressiveAiComponent,ArcherAiComponent,FallingBlockAiComponent,PlayerControlledComponent) and let a dedicated system match the family.Keep
core/entityas the home for pure component classes plus shared component-mapper objects (com.badlogic.ashley.core.ComponentMapper).4. Replace controllers with
EntitySystems incore/game/controller/*Each existing controller becomes one or more Ashley systems:
MobController→MobPhysicsSyncSystem,MobAnimationSystem,MobAiSystem(split by behaviour tag),MobHealthSystem(kill / death / drop),MobSoundSystem,MobSpawnSystem(replacesMobQueue),MobBoundaryWrapSystem(the horizontal world-wrap currently insideMob.update).Player-specific logic →PlayerHungerSystem,PlayerSightSystem,PlayerHitAnimSystem,PlayerCursorRaycastSystem,PlayerDeathSystem.DropController→DropPhysicsSystem,DropTtlSystem,DropPickupSystem,DropQueueDrainSystem, plus the existingOnBlockDestroyedListenerhook moved into a thin adapter that callsengine.addEntity(...).ProjectileController→ProjectileMovementSystem,ProjectileDeathSystem.ContainerController→ component-backed lookup byContainerCoordinates.Systems should be
@GameScopesingletons, wired through Dagger and the existingautomultibindKSP processor (i.e. introduce@BindEntitySystemanalogous to the existing@BindWorldRenderer/ contact-handler annotations, and let KSP generate the multibinding soEngine.addSystem(...)is wired automatically).5. Move rendering out of the entity
Mob.draw/Player.draw→ aMobRenderSystem(or just keepMobsRendererbut have it iterate aFamily.all(SpriteComponent, TransformComponent, MobParamsComponent).get()instead of callingmob.draw).SpriteBatchimport fromcore/entity.6. Migrate save/load mappers
PlayerMapper,FallingBlockMapper,DropMapper,ProjectileMapper,ChestMapper,FurnaceMapper, …) with per-component mappers (HealthMapper,InventoryMapper,Box2dBodyStateMapper,MobParamsRefMapper, …) plus a singleEngineSnapshotMapperthat serialisesEngine.entitiesas a list of(entityId, components[]).MobControllerSave/DropControllerSave/ProjectileControllerSavepayloads and translate them into component bundles on first load.7. Replace contact handlers' entity dispatch
Body.userDatais the entity reference (Dropsetsbody.userData = this, etc.), andBindMobContactHandler/BindDropContactHandler/BindProjectileContactHandlermodules wire one handler per pair. SwitchuserDatato carry an AshleyEntity(or its id) so contact handlers can mutate components directly instead of calling methods on a typed entity.8. Cleanup & deletion
Once all four entity families live under Ashley:
core/entity/.../model/Mob.kt,Player.kt,WalkingMob.kt,SheepMob.kt,FallingBlock.kt,ArcherMob.kt,Drop.kt,Projectile.kt,Container.kt,Chest.kt,Furnace.kt— or reduce them to thin component-bundle factories.MobQueue/DropQueue(replaced byengine.addEntity).*Adapterinterfaces (MobWorldAdapter,PlayerAdapter,ProjectileAdapter,DropWorldAdapter) once no system needs them. Their concerns become either component reads or directGameWorldqueries from inside a system.BaseMobBehavior+MobBehavior.9. Tests
This is the payoff. For each new system, add a unit test (JUnit 5 + mockk + the test-stack already documented in CLAUDE.md) that:
Engine,No GL, no Box2D
World, noRayHandler— exactly the testability the current design blocks.Out of scope
core/entity).lighting-box2d,lighting-tint) — they only consume entity positions, not the entity classes themselves.core/gdx.Acceptance criteria
*.update(...)on the entity classes is now inEntitySystems registered with the engine.MobController/DropController/ProjectileControllerare either deleted or reduced to thin Dagger-friendly wrappers over the engine../gradlew ktlintCheck test desktop:run html:buildJs android:assembleDebugall pass.