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 wonlosing condition
- a requirement to determine if the battle was lostphase
- 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 startedround_start_judge
- judges the round at the starttick_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 phaseturn_start_judge
- judges the turn at startactions_next
- this phase determines if the entity can make an action this turn, if not, the system should switch to theturn_end
phase.actions_make
- ai or player determines what action to performaction_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 battleturn_end
- once all actions have been completed, the turn comes to an endturn_judge
- judge the current state of the battle after the turn endsround_end
- once the turn ends (by reaching it’s limit), this phase should be enteredround_end_judge
- judge the current state of the battle after the round endsbattle_end
- all other judge stages should jump to this when a condition has been fulfilledbattle_end_judge
- the last judge of the victory or losing conditionfinalize
- 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);
}