Skip to content

How to Build: Movement

An idle RPG needs two movement modes that feel completely different to implement but share the same underlying system. In auto-battle mode, the player marches along the main road, stopping to fight enemies and resuming when combat ends. In active play, the player taps a location and walks there. Both modes need frame-rate independent movement, terrain height sampling, and smooth rotation toward the movement direction.

The trick is designing traits and systems that handle both modes without branching logic everywhere.

The traits

Three traits control movement behavior:

typescript
const MovementTarget = trait({
  x: 0,
  z: 0,
  speed: 5,
  hasTarget: false,
})

const WaypointQueue = trait(() => ({
  points: [] as Array<{ x: number; z: number }>,
}))

const AutoBattle = trait({
  enabled: true,
  roadPathIndex: 0,       // current position along the road path
  autoRetaliate: true,    // fight back when attacked in manual mode
})

MovementTarget is the immediate destination -- where the entity is walking right now. WaypointQueue holds a sequence of future targets for multi-point paths (from pathfinding). AutoBattle tracks whether the player is in idle mode and how far along the road they've progressed.

Notice that MovementTarget doesn't store a full Vector3. The y-coordinate (height) comes from terrain sampling, not from the target itself. You set an x/z destination and the movement system figures out the correct height each frame.

The road path data comes from a world-level trait set up during level loading:

typescript
function getLevelRoadPath(world: World): Array<{ worldX: number; worldZ: number }>

This returns the waypoints generated by Wave Function Collapse during level creation. Auto-battle mode simply walks these points in order.

The core movement system

This system runs every frame and moves any entity that has a Transform and a MovementTarget toward its destination:

typescript
function updateMovement(world: World): void

The algorithm is straightforward:

  1. Read Time.delta from the world for frame-rate independence
  2. Query all entities with Transform and MovementTarget
  3. For each entity where hasTarget is true:
    • Calculate the direction vector from current position to target (x/z only)
    • If the distance is less than a small threshold (say, 0.5 units), mark hasTarget = false and stop
    • Otherwise, move by speed * delta in the direction of the target
    • Rotate the entity to face the movement direction using Math.atan2(dx, dz)

The Math.min(step / dist, 1) ratio clamp is important -- it prevents the entity from overshooting the target on frames with large delta values. Without it, a lag spike could teleport the player past their destination.

Let's also handle the WaypointQueue here. When hasTarget becomes false (the entity reached its current target), check if the queue has more points. If so, pop the next one into MovementTarget. This means A* pathfinding just needs to fill the queue and the movement system handles the rest.

Auto-battle road following

A separate system handles the idle loop's road progression:

typescript
function updateAutoBattleMovement(world: World): void

This only runs for the player entity when AutoBattle.enabled is true. The logic:

  1. If the player is in combat, do nothing (pause movement)
  2. If the player already has a movement target, do nothing (still walking)
  3. Otherwise, read the next waypoint from the road path at roadPathIndex
  4. Set it as the new MovementTarget and increment roadPathIndex

We now have an idle loop: the player walks the road, encounters an enemy, stops to fight, kills it, then picks up the next waypoint and keeps walking. The combat system pauses movement by checking a CombatState.inCombat flag -- stub this as { inCombat: false } until Phase 3 is implemented, and movement will work on its own for testing.

Handling player taps

When the player taps the terrain during active play, we need to override auto-battle and send the player to the tapped location:

typescript
function handlePlayerTap(world: World): void

The flow:

  1. Check the PointerInput world trait for a tap event
  2. Raycast from the tap screen position to the terrain to get a world-space hit point
  3. Set the hit point as the player's MovementTarget
  4. Disable AutoBattle.enabled (but keep autoRetaliate true so the player still fights back)

This is where active play and idle play diverge. A tap switches the player out of auto-battle mode. You might add a user interface button that re-enables auto-battle, or automatically re-enable it after the player reaches their tapped destination and has been idle for a few seconds.

For pathfinding integration (covered in the next guide), the tap handler would route through A* instead of setting the movement target directly. The pathfinding system fills the WaypointQueue, and the movement system walks the path point by point.

Terrain height sampling

After updating the player's x/z position each frame, we need to set the correct y value so the player stays on the terrain surface. This is a terrain-specific concern that depends on your projection mode:

  • Flat hex grid: Sample the terrain height at the current x/z position. This might mean reading from a heightmap or raycasting downward against the terrain mesh.
  • Ribbon projection: Convert the flat position through projectToRibbon to get the correct 3D position on the helix surface. The ribbon transform handles both the height and the curvature.

Either way, the movement system writes x/z, and a follow-up step resolves y. Keep these concerns separate so flat-grid movement works correctly before you add ribbon complexity.

File organization

src/core/systems/update-movement.ts    # Core movement + waypoint queue consumption
src/core/systems/handle-player-tap.ts  # Tap input processing, raycast, target setting

The movement system is generic -- it moves any entity with the right traits, not just the player. This means when you add non-player characters or pets later, they get movement for free by attaching Transform and MovementTarget.

We now have a player that walks the road in idle mode and responds to taps in active play. But tapping a location on the other side of a wall should route around it, not walk through it. That's where pathfinding comes in.