Revisiting Turn Based Battle Systems

Posted on
game-design rpg battle-system

Introduction

Originally started in To Battle!, I went over the basic flow for a turn-based battle system based on a wait-time (or counter) system.

This time around I’ve decided to break it down a little more with more commentary.

Recap

The my original article, I covered the basic variable/parameters each entity/actor/object would have in the system.

Attributes

Name Human Readable Description
ap Action Points These are a none negative integer which represents how many points are available to perform actions with.
hp Health Points A none negative integer representing an entity’s ‘life’, once this value reaches zero they are considered ‘dead’ unless some other effect is preventing it.
mp Mana Points A none negative integer representing an entity’s mana (or magic), this value determines their energy for using magic.
wt Wait Time A borrowed concept from Tactics Ogre, represents how long an entity needs to ‘wait’ before being able to perform an action.
mwt Max Wait Time If using a fixed wait time per entity, this will be added to the entities wait time on turn_end
battle_wt Battle Wait Time Represents the total accumulated wait time for the battle.
round_wt Round Wait Time An alloted amount of time given to a single round
rounds Round count How many rounds have been completed since the beginning of the battle.
actions Actions A list of actions that an entity will perform when it’s wait time is zero, for players they need to fill this list before an entity can ‘act’.

One aspect I disregarded in my original document was what a “Round” is.

Depending on the implementation what makes up a Round varies, however it’s main definition is a “Series of Turns”.

Implementation wise, if the Round is implemented as a fixed Wait Time, then the round can have a variable number of turns in it depending on the progression of the battle.

However if the round is implemented as a fixed number of turns, then the wait time becomes variable, it also produces cases where a single entity could end up acting the entire round without any other entites performing an action (it’s possible).

More on this in the next section.

Terms

  • entity - an entity is any object present in the battle, which may or may not be able to act.
  • action - an action is any step or interaction that can be performed during the battle.
  • turn - a turn is allotted to an entity to perform a series of actions, an entity can have several turns.
  • round - a round is a series of turns, either fixed by a number of turns, or a total wait time.
  • battle - a battle includes entities who perform actions during their turns during each round.
  • judge - a step in the battle which involves determining victory or losing conditions.
  • victory condition - a requirement to determine if the battle was won
  • losing condition - a requirement to determine if the battle was lost
  • phase - a state of the battle system flow

Battle Phases

The entire battle flow can be broken down into a series of “phases”.

Start > Round > Turn > Action > End > Judge

This is a vastly simplified version and excludes something crucial, the Round to Action parts are loops.

  • battle_start - the starting phase, all setup is done from this phase, as well as telling the player what the victory and losing conditions are, this phase happens exactly once per battle.
  • round_next - this starts the battle loop, and advances the round counter by 1 (the system should be initialized with 0)
  • round_start - this is the actual round starting, and lets everyone know the round has started
  • round_start_judge - judges the round at the start
  • tick_next - this advances the the global ticker and decrements every entities wt until one reaches 0.
  • turn_start - the first entity to reach 0 pushes the system into this phase
  • turn_start_judge - judges the turn at start
  • actions_next - this phase determines if the entity can make an action this turn, if not, the system should switch to the turn_end phase.
  • actions_make - ai or player determines what action to perform
  • action_start - any initial preparation for the action is handled here (paying costs, decrementing some values etc..)
  • action_execute - the action is executed, or applied.
  • action_end - the action completed, give feedback from the action’s result, or apply some post effects.
  • action_judge - since the action was executed, judge the current state of the battle
  • turn_end - once all actions have been completed, the turn comes to an end
  • turn_judge - judge the current state of the battle after the turn ends
  • round_end - once the turn ends (by reaching it’s limit), this phase should be entered
  • round_end_judge - judge the current state of the battle after the round ends
  • battle_end - all other judge stages should jump to this when a condition has been fulfilled
  • battle_end_judge - the last judge of the victory or losing condition
  • finalize - not mandatory but is the final phase, use this to display the battle’s result to the player.

Example

function judge(state, phase_ref, next_phase) {
  for (let i = 0; i < state.conditions.length; ++i) {
    const condition = state.conditions[i];
    if (condition.is_met(state, phase_ref)) {
      state.next = next_phase;
      break;
    };
  }
  return state;
}

const phase_steps = {
  battle_start(state) {
    state.turns = 0;
    state.wt = 0;
    state.rounds = 0;
    state.round_wt = 0;
    state.round_mwt = 500; // choose a value of your liking or calculation
    state.entities.forEach((entity) => {
      // Notice that we 'set' the wt for battle start, instead of adding
      entity.wt = entity.mwt;
    });
    state.next = "round_next";
    return state;
  },
  round_next(state) {
    state.rounds += 1;
    state.next = "round_start";
    return state;
  },
  round_start(state) {
    state.entities.forEach((entity) => {
      entity.on_round_start();
    });
    state.next = "round_start_judge";
    return state;
  },
  round_start_judge(state) {
    // by default judging should immediately
    state.next = "tick_next";
    return judge(state, "round_start", "tick_next");
  },
  tick_next(state) {
    // be sure to unset the entity
    state.entity = null;
    // Instead of looping just to change the wt on the entities and state
    // Find the smallest wt in the entities and apply that to everything else
    // However the round_wt must not exceed it's maximum
    const smallest = state.entities.reduce((smallest_entity, entity) => {
      if (smallest_entity) {
        if (smallest_entity.wt > entity.wt) {
          return entity;
        } else {
          return smallest_entity;
        }
      } else {
        return entity;
      }
    }, null);
    if (smallest) {
      // set the applied to the smallest wt
      let applied_wt = smallest.wt;
      // add it to the round_wt to create expected round_wt
      let new_round_wt = state.round_wt + applied_wt;
      // check if the new round wt would exceed the maximum
      if (new_round_wt > state.round_mwt) {
        // if it did, applied must be changed to hit exactly the maximum
        applied_wt = state.round_mwt - state.round_wt;
        new_round_wt = state.round_mwt;
        state.next = "round_end"
      }
      state.entities.forEach((entity) => {
        entity.wt -= applied_wt;
      });
      state.round_wt = new_round_wt;
      if (smallest.wt === 0) {
        state.entity = smallest;
        state.next = "turn_start";
      }
    } else {
      // if it didn't find ANY entities, it simply means there was none to begin with, the battle should exit.
      // This could be quickly achieved by checking if there were any entities to begin with.
      state.next = "battle_end";
    }
    return state;
  },
  turn_start(state) {
    // here you can alert all entities that an entity's turn is starting
    // or you could just alert the entity alone, depends on if you need to alert everyone or not.
    state.entities.forEach((entity) => {
      // let the entity know that the specified entity is starting it's turn
      // if the entity being alerted matches the parameter, you may apply some special effects.
      entity.on_turn_start(state.entity);
    });
    // reset the entity's action index on turn start
    state.entity.action_index = 0;
    state.next = "turn_start_judge";
    return state;
  },
  turn_start_judge(state) {
    state.next = "action_next";
    return judge(state, "turn_start", "battle_end");
  },
  actions_next(state) {
    // determine if there are any available actions left
    if (state.entity.action_index < state.entity.action_max) {
      state.next = "actions_make";
    } else {
      state.next = "turn_end";
    }
    return state;
  },
  actions_make(state) {
    // this flag is set immediately for AIs but usually has to be set externally for players
    // Once they've made their action.
    if (state.entity.auto_action) {
      // make action should increment the action_index as needed
      state.entity.make_action();
      state.entity.action_ready = true;
    }
    if (state.entity.action_ready) {
      state.next = "action_start";
    }
    return state;
  },
  action_start(state) {
    if (state.entity.actions.length > 0) {
      state.entity.action = state.entity.actions.shift();
      state.next = "action_execute";
    } else {
      state.next = "actions_next";
    }
    return state;
  },
  action_execute(state) {
    if (state.entity.action) {
      // this can be executed outside of the state machine
      // only the action_completed flag needs to be set
    }

    if (state.entity.action.completed) {
      state.next = "action_end";
    }
    return state;
  },
  action_end(state) {
    // feel free to do anything here

    state.next = "action_judge";
    return state;
  },
  action_judge(state) {
    state.next = "actions_next";
    return judge(state, "action", "battle_end");
  },
  turn_end(state) {
    state.entities.forEach((entity) => {
      // let the entity know that the specified entity is starting it's turn
      // if the entity being alerted matches the parameter, you may apply some special effects.
      entity.on_turn_end(state.entity);
    });
    state.next = "turn_judge";
    return state;
  },
  turn_judge(state) {
    state.next = "tick_next";
    return judge(state, "turn", "battle_end");
  },
  round_end(state) {
    state.entities.forEach((entity) => {
      // let the entity know that the specified entity is starting it's turn
      // if the entity being alerted matches the parameter, you may apply some special effects.
      entity.on_round_end();
    });
    state.next = "round_end_judge";
    return state;
  },
  round_end_judge(state) {
    state.next = "round_next";
    return judge(state, "round", "battle_end");
  },
  battle_end(state) {
    // do whatever the hell you want to
    return state;
  },
  battle_end_judge(state) {
    // regardless of what happens, it should always end on finalize
    state.next = "finalize";
    return judge(state, "battle", "finalize");
  },
  finalize(state) {
    // do whatever the hell you want to
    return state;
  }
};

function run_phase(phase, state) {
  return phase_steps[phase](state);
}