All-Seeing Wizards is a fun little game project I’ve been coming back to occasionally for some years now. I was originally inspired by Jackbox Games and thought I’d have a go at making that style of party game, with one big screen to show the board and players joining on their phones. I've struggled to get into some of Jackbox's games in the same way as a group board game though, so I was aiming to create something in that Jackbox style but with more traditional card-based and turn-based gameplay.
The overall idea
The core concept of the game is that the players play action cards to move around the map, attack each other, defend, and so on. The catch is that each turn the players secretly choose 4 action cards per turn, but then everyone's first card activates together, followed by everyone's second card, etc. This means that in order to win you need to predict where your opponents will move this turn, when they will attack, and so on.
(there's a bit more about how to play on GitHub)
This also means that it's important to be able to "preview" a set of actions in the player view while people are choosing cards, then gradually apply those actions to the game state and animate them in the host view so everyone understands what just happened.
The issue with this is that game programming traditionally uses mutable state and imperative programming, meaning the game state is updated in place each time it changes and there's no easy way to "preview" and "revert" updates. There are of course ways around this, but I like functional programming and this seemed like an interesting place to try a non-traditional approach with immutable state handling.
Immutable basics
Enter Immutable.js, a library for creating and updating immutable state in JavaScript. With usual mutable JavaScript objects you might write something like:
const gameState = {
players: [{ character: 'darkLord', hp: 5 }]
}
gameState.players[0].hp = 3
With Immutable.js you might write it more like:
import { fromJS } from 'immutable'
const gameState = fromJS({
players: [{ character: 'darkLord', hp: 5 }]
})
const newGameState = gameState.setIn(['players', 0, 'hp'], 3)
Notice that in the first example the original game state was lost, but in the second example we now have both the original and modified game state.
This approach is more performant than it looks; Immutable.js will reuse existing objects wherever possible, so rather than creating a clone of the entire state, it only clones the objects needed to express the new properties. If you have 4 players and modify one of them, the same object references will be returned for the other three.
The Immutable.js README has a much more complete description of immutability and why you might want to use the library. Also worth mentioning that Immer is an alternative which is a bit easier to get started with.
In the game
The way this works in practice is a bit more complex, but follows the same idea. The core piece of logic is a function called performTurn
, which takes a game state including players who have each picked cards to play that turn, and returns the new game state after the turn is complete (plus a list of what actions happened during the turn).
In vastly simplified terms the function looks like this:
game_state = original_game_state
for i in 1 to 4:
game_state = apply cards placed in slot i
game_state = discard picked cards and draw next hands
return game_state
The important thing about this is that it's a pure function; it doesn't change the original game state and just returns a new altered state. This means I can easily do all the following
- Display the current game state on the player and host screens
- Overlay the expected movements and attacks on the player screen when they pick cards, by calling
performTurn
with the cards they picked - Remove that overlay when they deselect cards
- Immediately compute and persist the new game state on the server once all players have picked cards (to maintain data consistency - the server could crash at any point and the game would always be in a state where you can continue playing)
- Gradually compute and display the new game state on the host display once the server has updated
Because all the functions involved are pure and idempotent (the same input will always result in the same output), the server and client can trust that the same actions applied to the same initial state will definitely get the same results, so each client can safely apply the actions without needing constant confirmation from the server.
Side note: Shared logic
This setup is doubly convenient because the core game logic is actually shared between the server and client. Since the whole project is built in TypeScript, I realised that rather than having the server and client constantly communicate about game states (increasing latency and network usage) I could just put the game logic in a shared folder and import it into both the server and client code.
Obviously it's still important to make sure the server has the "master" copy of the data and prevent clients from making arbitrary modifications, but with a clearly defined websocket interface I think overall this has the best of both worlds.
Inspiration
This project setup was inspired by a screencast by Gary Bernhardt (of "Wat" fame) called "Functional Core, Imperative Shell". This sounded hard to implement in most production applications but a great idea in concept, so I wanted to try it in a smaller project which already has a conveniently clear boundary between the core (game logic) and shell (client, network and database handling).
By the way, I can highly recommend his Destroy All Software screencasts in general - as well as specific techniques they have a ton of valuable insights for how to think about code.
And finally
My general point here is that there's always more than one way to approach a problem in any field, and sometimes you can get surprising advantages from going against whatever approach is standard in that field. Imperative and functional programming each have their tradeoffs, and each sometimes finds value in unlikely places.
Top comments (0)