The way you handle state functionally is by continuing to transform the old state into the new, repeatedly. This is often done through the use of recursion, for something like a game, where you don’t know when you’ll be done.
So in your case, you have a
board, which you said is modeled as a immutable map. That’s a great start.
Now, you would want to do:
- Create initial board
- Take input for player 1 move
- Perform the transformation from initial board to board+player1move = next-board-state
- Take input for player 2 (could be the computer)
- Perform the transformation from next-board-state+player2move = next-next-board-state
- Repeat until someone wins
Here’s an example in code:
(loop [board init-board next-player first-player]
(println "You win!")
(println "You lose!")
(= :player next-player)
(recur (next-board board (player-move board))
(= :computer next-player)
(recur (next-board board (computer-move board))
So what is happening here is that your long living game state is maintained inside the recursive game loop. You walk through the game state with the
cond block, perform the current state actions, apply the transformations from it to the board, and then
recur where the board is now bound to the transformed board.
player-move fn takes a board, and returns the player1move. You can imagine in that method that some IO will be performed, waiting for the player to enter his move, and the IO will be parsed into the
move, and the board will be accordingly transformed. This is handled here by the
next-board fn, which takes the board and a move and return the board with the move applied to it. This whole thing could itself be a
loop, since you might need to validate the move is legal, and if not, return the player an error and
recur until the player provides you a valid move.
player-move takes a board and ask the user for their move, returning the move only once it is valid. After which,
next-board takes the board and returns a new copy with the result of applying the given
move to it.
computer-move fn is similar, but you would take the board, and use AI to decide what the computer move should be.
Now this might look very similar to an OOP/imperative approach, and it does look very similar. The difference is that nothing is being mutated. You pass in old-board, and get the new board as a copy. The old board is left intact.
In this way, you can think of it as the state is being carried over from the last iteration to the next.
This is the way to do it functionally. Almost all of your functions will be taking board as the first argument, and return a new board. And the “latest”
board will be maintained in the
Now, you can have more state if needed. You could have players, and rooms, and what not. Though a common pattern in Clojure is to take the whole set of the world and put it into one big map. So if you had more then just a
board to keep track of, you could either add more bindings to
loop, or put all of them under one big map called
world. The two approaches are somewhat similar, so its a bit of a matter of preference.
Now, if you wanted to be real fancy, you could
tap> the board and the move at every recursion, and have it sent to REBL, or pretty-printed, or logged to a file, etc. So now you could easily inspect each change to the board and it would be a nice way to debug and inspect what is happening inside your game loop at every move.
With that, you could even imagine being able to
reload a game. You’d just save the board and the next-player, and then to restore it, you’d call into the loop but initialize the board and next player to the last saved ones. Similarly, you could recreate the game state, say you had a bug, you could go to the tapped log file, grab the state before the bug, and initialize a loop from it, and debug what is happening. Sky is the limit here!