Skip to content

Undo/Redo in Card Games Without Writing Undo Logic

Most undo actions in games require writing precise and careful reverse actions for every move. In CentriFuGe I avoid all this by storing state snapshots of each move.

Hi world,

Having an undo feature in your card game is a very common and useful feature. It can help:

  • Replay a match
  • Save and load the game
  • As a player, undo a mistake you just made
  • As a developer, follow card interactions and debug different actions

An undo system sounds simple enough until you try to implement it.

The usual approach is to write a reverse action, which undoes everything the action performs. This can become brittle very quickly as more complexity is added to the game. This happens especially with the interaction-heavy style of more modern card games, which sport a wealth of different card effects and combinations. One missed interaction can completely break the game.

I didn’t want every move in CentriFuGe, my Card Game Framework, to require hand-written undo code. Instead, I’m choosing a system that is not only simpler, but also has further benefits.

The Issue with Traditional Undo Logic

Imagine you play a card from your hand onto the board

func play_card(card,player):

This could make the following changes:

  • The card is removed from the hand
  • The card is added to the board
  • Card effects are triggered
  • The user’s turn ends

Each of these actions needs to be accounted for to create the undo logic. The exact card needs to be taken back from the board, the triggers undone, and the card location restored to it’s original location in hand.

func undo_play_card(card, player)

While possible, the fragility of this approach quickly becomes apparent if you consider all the possible interacting extensions.

What if the end turn also triggered effects? What if the card triggered other cards to be played too?

The amount of edge cases quickly spirals in size. Adding new logic and effects means revisiting the undo function for each move to ensure it still works as expected.

I want to make card game development easier, so in designing my framework I opted for a different approach that avoids these issues.

Using Snapshots

Instead of implementing reversing moves, CentriFuGe does something simpler. It saves a snapshot of the game state before each move.

Imagine taking a photo of the game at a point in time. It shows where the card are located at that instant. Then, no matter what happens after that, you can always go back to the same point in time as the photo by putting everything back the way it is in the photo.

Now take a photo each time someone takes a move, plays a card, ends a turn. You’ve got a complete collection of every move. With this information, you’re able to view the before and after of each played card.

In code, the snapshot is stored as a read-only copy of the game state. Saving a snapshot before and after any move means that each action can be undone, and also redone. There is no need to write specific code on how the move undoes or redoes.

Different game states during a move

So undo() means “restore State 2”. While redo() means “restore State 3”.

What Gets Snapshotted

In CentriFuGe all the card game logic is stored within two objects, the “game sta” and the “context”. The game state is responsible for the location of the objects in the game, such as the cards and the deck composition. The context is the information as how the game should progress. It contains information not obviously visible, such as whose turn it is, and who should play next.

By having all the game information be saved in just two objects it becomes very easy to save and restore these objects. Anything the game needs to interact with will be stored in these variables.

Moves Become Pure State Transitions

Because moves use this snapshot approach, there’s no extra implementation. A move simply transforms the current state.

class PlayCardMove extends GameMove:
    func execute(state, context):
        game_state.hand.remove[card]
        game_state.board.add[card]
        game_state.triggers[card]

That’s all that is needed. No explicit undo() or redo() functions. It’s all built in to the transaction of executing a move. No worries about bugs arising from a miscreated undo function.

Why This Works Well for Card Games

Creating full snapshots of the game state is more cumbersome and slow than the traditional approach. If a game has a huge amount of state data, or if there are hundred of state changes a minute, storing snapshots would be unwieldy.

A game state for a card game is usually composed of only: the decks, player hands, and cards on the table. Furthermore, there are only so many moves made during a game. Compare that to a real time strategy game, where professional players can hit 300 actions in a minute. Storing all of these actions for every player would be unwieldy.

The smaller states and fewer moves for card games makes the snapshot approach a very attractive design choice. Board games are a similar genre in which this snapshot approach works for the architecture.

A Hidden Bonus: Time Travel Debugging

Once you take a snapshot of the game state, now keep it stored in a timeline. Each timeline entry is a chronologically ordered list of the moves made during the game. Then, if you want to go further back in time, all you do is continue undoing until you get to where you want.

Now, you’re able to go back to any move played this game. Now you can step through moves one at a time. Or replay an entire match.

This allows you to make branching histories, replaced any number of future moves with a different moves. As a developer it also allows you to trace a bug back through the moves to see where it originated from, and how other moves mutated it.

The Trade-off

Taking a full snapshot isn’t free. It costs computer memory. If a game has thousands of cards on the board it is going to take up too much memory and compute cycles to copy this information each time a move is made.

However, for a card game, where the number of moves are usually limited, and turns are taken without needing real time, the impact of making a snapshot is negligible. Card games rarely push this limit.

Plus, the added power of being able to undo moves, I feel, is worth some overhead.

Why CentriFuGe Uses This Design

CentriFuGe focuses on deterministic card game logic.

The goals are to have:

  • predictable rule execution
  • easy debugging
  • reliable save/load
  • clean move definitions

Snapshot-based undo supports all of these goals. Moves remain simple, and the framework handles history. Developers have access to all these powerful tools, and can even choose to integrate them into the game for uses to access too.

Final Thoughts

Undo functionality is really helpful in a lot of games. In games with complex interactions, writing manual undo logic becomes difficult. By using a snapshot approach, it entirely avoids any undo or redo code to be written.

By taking a snapshot before each move, CentriFuGe can support undo, redo, replay, debugging, saving/loading -all without writing a single line of undo logic.

As a final mention, a huge inspiration for CentriFuGe development is Boardgame.io, a framework for creating javascript board games. I want to mention it here as a codebase I have trawled through to look at how it functions. Boardgame.io, likewise, uses an immutable central state. It helped me wrap my head around how a Godot implementation could function.

Thanks for reading, and see you in the next one :)