Skip to content

Refactor: Migrate entities in core/entity to Ashley ECS #49

@fredboy

Description

@fredboy

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":

  1. 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.
  2. Brittle inheritance. MobWalkingMobSheepMob, 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. 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:

  • MobControllerMobPhysicsSyncSystem, 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.
  • DropControllerDropPhysicsSystem, DropTtlSystem, DropPickupSystem, DropQueueDrainSystem, plus the existing OnBlockDestroyedListener hook moved into a thin adapter that calls engine.addEntity(...).
  • ProjectileControllerProjectileMovementSystem, 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

  • Ashley is on the classpath and works under desktop, android, ios and web builds.
  • All four entity families (mob, drop, projectile, container) are represented as Ashley entities + components.
  • All per-tick game-loop logic that used to live in *.update(...) on the entity classes is now in EntitySystems registered with the engine.
  • MobController / DropController / ProjectileController are either deleted or reduced to thin Dagger-friendly wrappers over the engine.
  • Save files written by the previous version still load.
  • At least one unit test per new system, demonstrating that systems are testable in pure JVM without libGDX initialisation.
  • ./gradlew ktlintCheck test desktop:run html:buildJs android:assembleDebug all pass.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions