One of the biggest problems in tactics games is that it’s sometimes hard for a player to know exactly what’s going to happen. You execute an attack but forget that it will expose you to another enemy. Or, even worse, you accidentally click on the wrong tile. In Rads & Relics, we wanted to mitigate these frustrating moments by building an undo system that allows players to take back some of their actions. However, implementing this required significant changes to our core game logic.

Direct Attacking

Here’s an example of what our code originally looked like. Each class had an attack method that applied different effects depending on the unit type. A soldier deals high direct damage, while a brawler deals less direct damage but also knocks enemies back.

# brawler.gd

func attack(target: Character):
    target.health -= 3
    target.knockback(1, self)

# soldier.gd

func attack(target: Character):
    target.health -= 5

Initially, this worked great. Both the player and the AI could just call attack and it would do exactly what was needed. But when we wanted to undo, this structure was a problem. Do we build an undo_attack on each character? And if we call attack directly from the AI or from a button press, how do we know what to undo?

Migrating to Commands

To address these concerns, we transitioned to a system based on the Command Pattern. Instead of executing actions immediately, we encapsulate them in command objects.

Here’s an example of a DamageCommand, which applies and reverses damage:

class_name DamageCommand

func _init(target, damage):
    _target = target
    _damage = damage

func forward():
    _target.health -= _damage

func backward():
    _target.health += _damage

Now, instead of executing an attack immediately, the attack function returns commands that describe what should happen.

# brawler.gd
func attack(target: Character):
    return [DamageCommand.new(target, 3), KnockbackCommand.new(self, target, 1)]

# soldier.gd

func attack(target: Character):
    return [DamageCommand.new(target, 5)]

All commands are then passed to a centralized GameExecutor, which processes them. Since every command is logged, undoing an action is as simple as calling backward() on the most recent command.

Benefits of the New System

Undo/Redo demo This new approach provides several major advantages as we continue to develop Rads & Relics:

  • Undo/redo: While we need to figure out how this plays into the end-user gameplay, it’s a real boon for playtesting. Try an attack, if it doesn’t feel right, undo, tweak some values and try again.
  • Instant replays: By saving all of the commands in a game, we can get a step-by-step playback of that game. Whenever we encounter a bug, we can save a replay and see exactly where the issue occurred.
  • Networked play: We’re still a ways off from this, but the command structure gives us the ability to serialize everything so you can play online with friends.

The command system is not a panacea. It means that we can’t make easy changes outside of the command system, otherwise all of the state will be messed up (for example, we forgot that the “end turn” actions reset action points directly). We need to decide which commands should be grouped together (e.g. if you undo an attack you should undo both the damage, and the knockback). But overall, we think that this new system is going to let us build a better game and that’s really what matters in the end.

State of the Game

  • Progress this week
    • New command-based action system
    • Added new graphics for terrains and characters
    • Improved path finding and range detection: Previously there was no pathfinding, now we’re using a simplified A*.
    • Reactive UI: UI now listens for specific values to change and updates itself, instead of massive sync operations. Additionally, allows for tweaking specific values at run time.
  • Upcoming
    • New character sprites, specifically drawn for the isometric perspective
    • Character upgrade progression
    • Enemy AI Improvements (deferred from this week)
    • Additional Enemy types (deferred from this week)