Appearance
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): voidThe algorithm is straightforward:
- Read
Time.deltafrom the world for frame-rate independence - Query all entities with
TransformandMovementTarget - For each entity where
hasTargetis 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 = falseand stop - Otherwise, move by
speed * deltain 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): voidThis only runs for the player entity when AutoBattle.enabled is true. The logic:
- If the player is in combat, do nothing (pause movement)
- If the player already has a movement target, do nothing (still walking)
- Otherwise, read the next waypoint from the road path at
roadPathIndex - Set it as the new
MovementTargetand incrementroadPathIndex
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): voidThe flow:
- Check the
PointerInputworld trait for a tap event - Raycast from the tap screen position to the terrain to get a world-space hit point
- Set the hit point as the player's
MovementTarget - Disable
AutoBattle.enabled(but keepautoRetaliatetrue 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
projectToRibbonto 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 settingThe 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.