An energy scheduler for turn-based games

Move the player, then move every monster once: that loop works until the first bat. Angband's energy system fixes it in forty lines, and the browser loop around it stops at the player and waits for a key.

GRD 019 #249 Filed gamedev roguelike typescript

Contents
  1. Energy
  2. The loop that waits
  3. What it doesn't do

The first game loop every turn based game gets is this: read a key, move the player, then loop over the monsters and move each of them once. It's fine until you want a bat.

A bat should move twice for every step you take. A zombie should move every other turn. The original Rogue handled exactly these two cases with two flags, hasted monsters moved twice and slowed ones skipped every second turn, and if that's all you need, that's all you need. The moment you want speed potions, slow spells, and a monster that's a bit faster rather than twice as fast, the flags run out.

Energy

Angband's answer, and it's the one most of the genre settled on, is energy. Every actor has a speed and an energy pool. Each game tick, every actor gains energy equal to its speed. When the pool reaches a threshold the actor may act, and acting costs the threshold. A normal speed of 10 with a cost of 100 means an action every ten ticks. Speed 20, every five. Speed 5, every twenty. Any ratio you like, from one number per actor.

The player is just another actor in that list. There's no "player turn" and "monster turn" any more, there's a scheduler that hands out turns to whoever has the energy.

ts
// src/turns/scheduler.ts
import type { Entity } from '../ecs/world';

export const ACTION_COST = 100;
export const NORMAL_SPEED = 10;

type Actor = { entity: Entity; speed: number; energy: number };

export class Scheduler {
  private actors: Actor[] = [];
  private current = 0;

  add(entity: Entity, speed = NORMAL_SPEED): void {
    this.actors.push({ entity, speed, energy: 0 });
  }

  remove(entity: Entity): void {
    const i = this.actors.findIndex((actor) => actor.entity === entity);
    if (i < 0) return;
    this.actors.splice(i, 1);
    // Keep the ring pointing at the same actor as before
    if (i < this.current) this.current--;
    if (this.current >= this.actors.length) this.current = 0;
  }

  setSpeed(entity: Entity, speed: number): void {
    const actor = this.actors.find((candidate) => candidate.entity === entity);
    if (actor) actor.speed = speed;
  }

  // Walk the ring, topping everyone up, and return the first actor that can act.
  // Nothing is charged yet: call spend() once the action really happened.
  // The player is always in the ring, so this always finds someone.
  next(): Entity {
    for (;;) {
      const actor = this.actors[this.current];
      if (actor.energy < ACTION_COST) actor.energy += actor.speed;
      if (actor.energy >= ACTION_COST) return actor.entity;
      this.current = (this.current + 1) % this.actors.length;
    }
  }

  spend(): void {
    this.actors[this.current].energy -= ACTION_COST;
    this.current = (this.current + 1) % this.actors.length;
  }
}

next walks the ring of actors, topping up each one until it finds one that can act, and returns it without charging. spend charges and moves on. That split matters: the player pressing a key that does nothing, bumping a wall say, shouldn't cost a turn, so the caller spends only after an action really happened. Call next again without spending and you get the same actor back, untouched.

Keep speeds below the cost. An actor with speed 100 acts every tick, and speed 200 can't act more often than that, because a visit hands out at most one action. That limit is on purpose: nothing moves more than once between two of the player's turns unless the player is the slow one.

The loop that waits

In a browser there's no blocking read for a key. The loop has to run until it reaches the player, return to the event loop, and resume when a key arrives. Bob Nystrom's post "A Turn-Based Game Loop" from 2014 describes the shape, and this is my version of it.

ts
// src/game.ts
import type { Action } from './actions';

let pending: Action | null = null;

// Drain the scheduler until it reaches the player with no key to act on
function run(): void {
  for (;;) {
    const entity = scheduler.next();
    const action = world.has(entity, 'player') ? pending : decide(world, entity);
    if (!action) return; // the player's turn, and no key yet
    pending = null;

    // perform returns whether time passed. A wall bump returns false and costs nothing.
    if (!action.perform(world, entity)) continue;
    scheduler.spend();
  }
}

window.addEventListener('keydown', (event) => {
  pending = actionForKey(event.key);
  if (!pending) return;
  run();
  render();
});

run drains the scheduler until it reaches the player with nothing to do, then returns. A key press stores an action and calls run again. The player's action goes first, then every monster with enough energy, then it stops at the player again and the screen redraws once.

perform returns whether time passed. A wall bump returns false and the loop comes straight back to the player, no energy spent and no monster moved. That one boolean is the difference between a game that feels fair and one where you lose a turn to a typo.

What it doesn't do

Energy is coarse. Every action costs the same, so a quick stab and a slow heavy swing take the same time. The alternative is a time ordered queue: each actor is scheduled at a timestamp, acting puts them back at now plus the cost of that action, and different actions can cost different amounts. That's what rot.js calls an action scheduler. It's more flexible and a bit more to get right, especially around removing a dead actor that's already queued. For my game, energy is enough and I like that I can read the whole scheduler on one screen.

Read more here: