When I decided to make a turn-based JRPG for Android in 2010, my initial thought was that it would be simple. After all, being turn-based, it wouldn't have complex physics or real-time issues and the simple art-style would make it a breeze.
Obviously, having never attempted to develop a complete game to release before, I had no idea what I was talking about, and indeed that game never went further than a moderately-successful demo.
Making games being fundamentally impossible aside, the key misunderstanding I want to highlight here today, is the disastrous assumption that turn-based games have simpler logic.
The Naïve Approach
AKA: How BladeQuest did it
So it's your party's turn to act in the game execution loop. You're looping, waiting for the player to make an input on their decision for the action they wish to perform. This involves UI: menus, clicks, confirms, cancels, etc. but the game state isn't fundamentally changing. Being turn-based (ignoring Active-Time-Battle shenanigans), the enemies aren't attacking, you're not taking damage, the player has as much time as they need to make their decision and confirm it.
In the code, at the basic level, once the confirmation of the decision is made, the game state is affected. Damage calculations are ran, defensive stats are considered, numbers are created and then they are applied to health bars through judicious addition and subtraction.
Of course, just updating these numbers and marking dead baddies as dead isn't very exciting so you need to do some animations. When an animation is playing, your game loop needs to understand that something is currently blocking further execution and do nothing, waiting for the animation to end.
So maybe you do something like this, for each character in the turn-order:
- First we want them to slide out of the party lineup to an acting position, so while this slide is happening, update their drawn position every frame based on a time step
- If they're at the acting position, change their sprite to an “acting pose” and yield every frame until an amount of delay time in that pose has passed
- If the posing delay is done, start the animation sequence for the selected action, creating particles, showing shapes, manipulating sprites
- During the animation, pegged to specific points or maybe just after it's done, apply some damage to a target
- Calculate the damage on the target enemy and add a “damage marker” to the draw state which will show that number bounding in front of the target
- Once the bouncing is done, actually apply the number to the enemy behind the scenes and see if they died
- If they died, start a death animation and wait each frame until that's complete
- If all the animations that have been started are complete on the current frame, slide the character back into the party line, waiting and updating position by timestep
- Now increase the turn index so the next character in the turn order goes
Why Doesn't This Work
It does work! It even worked in BladeQuest to make a successful demo!! But good god did we have trouble.
The biggest problem with this is state management between frames. An immense amount of state is needed for every part of this to know where particles are, where to draw the actors, what part of the turn they're on, etc. so that each frame your game knows whether to do something, draw something, or yield.
One of the hardest forms of this state tracking is timers. There could be timers for screen shaking, screen flashing, moving to poses, animation delays, marker bounces, all checking against their own internal clocks for when they're done. While nice and modular in theory, individual systems having their own internal timers requires them to sync with each other and communicate their status because they are often temporally blocking game state changes from taking place.
If actually taking the damage shouldn't happen until after an animation is finished, the line between render and update blurs, violating the golden rule of never allowing your game render to modify your game state.
If an attack critical-hits, you can pop off a quick screen-flash with a line of code, but what if that attack gets cancelled or blocked? Do you remove the flash you added? What if you want to play a special animation before the crit gets applied? You'll have to calculate if the crit will happen first, play animations with special state exceptions to wait for them to finish, and then calculate your damage and apply it. In the end, execution order winds up mattering a ton here.
In terms of scaling, special exceptions for new features start costing exponential dev efforts to glom onto this system. Want to add counter/parry/interrupt ability? Enjoy digging up every waiting-for-animations-to-finish call to see if it needs to handle a cancellation. We had an item called a Safety Ring which would prevent a fatal hit and the edge cases around a definitely-dead character not actually being dead were so numerous that we were still fixing Safety Ring bugs a day before the demo launched.
The Atomic Turn
Ok not actually technically atomic, but it sounds cooler.
I've been a hinting a little about a possible solution to the largely temporal issues with state management for a turn-based game. The biggest successes I've had with Chronicles development have been in identifying proper separations of concerns:
- Separate your data from your logic
- Separate your UI from your data
- Separate your update from your render
- Separate your device platform from your semantic inputs
The problem with the system describes above is that, display/animation/aesthetic/presentation is interleaved with execution logic.
What if we wrote a function that just executes the entire turn in a single function call, one frame, “atomically”. We can loop over the characters in the turn order, skip all presentation, determine the outcomes of all the actions and decisions, and apply them to the game state.
New features and exceptions can be written into this execution function much more easily, because they don't have to contend with timings and waiting. At every point in the execution of this function, the current game state is the exact correct state of all participating characters. character.health
is correct at the time you check it because you're still in the same frame and same function call you started executing the turn in!
Let's make this even more useful, by applying some of the functional programming concepts I talked about ages ago, and say that our turn execution function should take a const Game State and return a new, post-turn GameState. Now we're not even modifying the rendered state, we're just running the turn like we would call any other function. This means, we could actually execute the complete turn as a perfect simulation, and inspect the resulting state to derive what happened (or, what is about to happen).
But What About All My Animations??
Of course, just updating these numbers and marking dead baddies as dead isn't very exciting so you need to do some animations.
Rather than letting the presentation timings drive our state changes, we're going to use the state changes to set up our presentation timings.
After making a decision for your character in Chronicles, the turn plays out in front of you. Here is the total code in the game update that is happening for every frame of that execution:
void turnExecuteStep(GameState& g) {
auto& turn = g.turn;
assert(turn.stage == TurnStage_Executing); // not executing!!
if (g.step >= turn.startStep + turn.totalTurnLength) {
turn.stage = TurnStage_Finished;
}
}
The reason for this is that, while the atomic execution function is executing, in addition to the game state being updated, timed render logic is being added to a set of timelines to block out the turn.
Here is an example of the function that is called whenever applying any damage to another actor during the turn execution function. Play close attention to the second half where all of the functions take some form of a when
parameter:
void ActorExec::applyDamage(GameState& g, ActorHandle sender, ActorHandle receiver, WorldGridCoords3D receivePos, ActorDamage const& dmg, ActionApplyMode mode, StepCount when, ExecutionTimeBlocks& blocks, bool blockAnims) {
auto& cons = *getCurrentGameConstants();
auto a = receiver;
if (actorAlive(g, a) && dmg.dmg > 0) {
auto inflicted = actorApplyDamage(g, a, dmg);
int totalInflicted = inflicted.health + inflicted.armor + inflicted.stamina;
auto dmgCpy = dmg;
dmgCpy.dmg = totalInflicted;
turnRecordDamagedEvent(g, sender, receiver, dmgCpy);
bool killed = false;
if (auto act = g.save.actors.find(a)) {
if (act->health <= 0) {
killed = true;
}
}
// first we animate for hurtLen
// then show dmg number
// then we show death fade
StepCount msgEndStep = 0;
StepCount hurtLen = 0;
if (mode == ActionApplyMode_Execute) {
// dont show hurt animation if they took 0
if (totalInflicted > 0) {
hurtLen = cons.hurtPaletteLen;
gamePlaySound(g, CoreAsset_SFX_Damage, when);
actorAddDamagedAnimation(g, a, when, when + hurtLen);
if (a == g.save.player_controlled) {
gameShowDamagePaletteFlash(g, when, when + hurtLen);
}
actorSetDrawnStatus(g, a, actorCalcStatus(g, a), when + hurtLen);
}
}
The first thing we do is we actually apply damage numbers to the receiving actor, and record it in a log used for interrogating simulations like I described earlier.
mode
is a way to determine “Preview” vs “Execution” where the former can nicely skip all the presentation-related side-effects of the function.
The important presentation parts here are gamePlaySound
, actorAddDamagedAnimation
, gameShowDamagePaletteFlash
, and actorSetDrawnStatus
These functions all take begin
and end
frame counts because they don't execute immediately! All of these will happen at their requested step counts during that execution phase above because we're just waiting every frame until we hit turn.totalTurnLength
!
So you see, we execute the entire turn logic in a single function call, and it sets up a perfectly-synced, interleaving keyframe-style timeline of what the render function should show every frame during the execution.
TimeBlocks
A very handy tool for organizing frame timings is this simple TimeBlocks struct:
struct StepBlock {
StepCount begin, length;
};
struct ExecutionTimeBlocks {
sp::list<StepBlock> blocks;
operator bool() const { return !blocks.empty(); }
StepCount end() const {
StepCount out = 0;
for (auto&& b : blocks) {
out = std::max(out, b.begin + b.length);
}
return out;
}
};
With syncing animations, you often need to have a complicated balance of blocking and non-blocking animations, and you want child calls and dependent timings to not care about the parent. Maybe I want to fire 100 random arrow particles, all starting and ending at random times. I don't care about any indivudal arrow but I don't want to continue to the next step until the last arrow is done, so you can use these TimeBlocks!
What's nice there is you can then pass around these time blocks to any number of modular functional functions that just push their little blocking time gaps into the set and at the end the calling parent can easily determine the final frame count of the final item.
Here's the full function taht gets called if the actor is performing a move-attack:
static StepCount _executeMoveAttack(GameState& g, ActorHandle user, ActionToken token, ActionTokenSet const& set, ActionTokenMemory const& mem, ActionTokenIterationCache const& cache, ActionApplyMode mode, StepCount when) {
auto startStep = when;
ExecutionTimeBlocks timeBlocks;
for (auto&& result : actionMoveAttackCalculateResults(g, user, token, set, mem, cache)) {
auto dirVec = dirVecFromCoords(result.origin, result.targetTile);
auto& tState = g.turn.actors[user];
switch (result.result) {
case MoveAttackResult_ActivateDoor: {
ActorExec::toggleDoor(g, user, result.targetTile, mode, when, timeBlocks);
_pushFoVEventTorchEquipped(g, when);
} break;
case MoveAttackResult_Attack: {
_executeAttack(g, user, result, mode, startStep, timeBlocks);
} break;
case MoveAttackResult_Move: {
sdh_each(g.turn.actors[user].turnResults) { if (it->type == ActorTurnResult::MoveAttacked) sdh_mark_erased(); }
tState.turnResults.push_back({ ActorTurnResult::MoveAttacked, result.origin, dirVec, { result.targetTile }, false });
gamePlaySound(g, CoreAsset_SFX_Move, startStep);
if (!result.dodgeLocks.empty()) {
ActorExec::activateDodgeLocks(g, user, user, mode, startStep, result.dodgeLocks, timeBlocks);
}
auto dist = int2ManhattanDist(result.origin.xy(), result.targetTile.xy());
ActorExec::slide(g, user, user, dirVec, dist, true, mode, std::max(startStep, timeBlocks.end()), g.turn.turnLength, timeBlocks);
turnRecordMoveEvent(g, user, user, result.targetTile);
} break;
}
}
return std::max(startStep, timeBlocks.end());
}
The important takeaway here is this function returns a StepCount
meant to signify the “End Step” of this action. The turn execution function is going to use that end step as the start step of the next action in the queue.
So we use a local timeBlocks
here and pass it to any number of ActorExec::
functions similar to applyDamage
above. These Exec functions often call other Exec functions recursively as sliding and taking damage often causes more sliding and more taking damage. From the MoveAttack's perspective, we don't really care, because whatever happens, it's just filling up our timeBlocks
which we can just return the end()
of as our last step!
Finally, here is an excerpt from the function executeTurnAction
we've been talking about all this time:
if (!disabled) {
actorSpendStaminaForAbility(g, m, ab.ab);
auto drawnStatus = actorCalcStatus(g, m);
drawnStatus.stamina_recovery = 0;
actorSetDrawnStatus(g, m, drawnStatus, currentStep);
_beginAbilityCooldown(g, m, ab.ab);
size_t idx = 0;
ActionTokenIterationCache cache;
while (!actionTokensAtEnd(set, idx)) {
actionTokenLinkIterationCache(set, cache, idx);
//turn.activeActors.push_back({ m, g.turn.turnEndStep, (int)idx });
auto tok = set.tokens[idx];
gameStateCalculateActionTokenDecisionCache(g, m, tok, set, mem, cache);
if (mode == ActionApplyMode_Execute) {
currentStep = gameStateApplyActionTokenForExecution(g, m, tok, set, mem, cache, currentStep);
}
else {
gameStateApplyActionTokenForPreview(g, m, tok, set, mem, cache);
}
++idx;
}
}
auto actorEndStep = currentStep;
auto actorLen = actorEndStep - actorStartStep;
actorLen = std::max(actorLen, cons.turnMinimumLength);
turn.executingActorTimes.push_back({ m, actorStartStep, actorStartStep + actorLen });
auto nextActorStart = std::max(actorStartStep, actorStartStep + actorLen + turn.nextTurnDelay);
currentStep = nextActorStart;
actorBlocks.blocks.push_back({ actorStartStep, actorLen });
}
auto turnEnd = actorBlocks.end();
For a given actor, we execute their turn actions, starting with our currentStep
which, by the end of the action list, contains the final step in that actor's execution. Then we can do some simple logic to apply minimum lengths and determine the start step for the next Actor.
We have another TimeBlocks to keep track of one block per actor and after we're done we just query end()
to get our final turn step!
A Note on the Render Function
I require and recommend that render always take a const
GameState and draw the entire game in an immediate-mode fashion.
For the given step, we can determine what damage indicators to draw, what palette to use, where to draw the characters, what animation primitives to draw, and even do simple lerping and easing from the various begin
and end
s. The timeline you are building during exeuction must be completely deterministic such that all you need is your GameState and a current StepCount to draw everything about that frame perfectly!
In Conclusion
All of this is to say that, in Chronicles, the entire turn is executed atomically in a single frame which then sets up a complex timeline of render data to display the animated turn execution.
A great deal of thought and care went into this implementation and it required a lot of discipline to see it succeed. It is certainly a pain in the ass whenever a new system needs to create some new frame-delayed timeline system instead of just being able to happen instantly, but the result is an incredibly stable and scalable framework that I can add extremely complex combat logic to ad infinium.
I hope you enjoyed this write-up! If you have any questions or comments, you can DM me on Mastodon or just reply to the post about this!
Have a great day!
#chron4 #gamedev #longpost
| 🌐 | 🙋 | @britown@blog.brianna.town