Implementing a Rock-Paper-Scissors game using Event Sourcing
Mattias Holmqvist
Posted on February 8, 2021
In this tutorial we will look at how we can design the game flow for a rock-paper-scissors game using Serialized APIs for Event Sourcing and CQRS.
Our favorite runtime environment for applications is usually Dropwizard but since many out there prefer Spring Boot I decided to use it instead for this article.
Our Java client works with any runtime environment or platform you use
on the JVM.
Configure the Serialized project
To develop our game we will use Serialized aggregates and projections. The aggregates will store the events for each game and the projections will provide a view of each game as well as a high score list of the top winners (in the case of multiple games being run).
If you have not yet signed up to Serialized you will need to [sign up for a free developer account (https://app.serialized.io). Once you've signed up and created your first project you will have an empty view of Aggregates, like this:
We now need to find out API keys which are available under Settings.
Copy the access key and secret access key to a safe location. We will need these to access Serialized APIs from our backend application.
Great job! We now have an empty Serialized project. We're now ready to start developing our game!
Setup and outline of the game
In this section we will describe the basic functionality of the game and the pieces that we need to implement to develop the whole game flow.
Game rules
The core of the game are the game rules. If you are interested in the history of the game you can read more about it on Wikipedia.
This is a brief summary of the game rules:
- The game is played by two players.
- A round consists of both players showing their hand (rock/paper/scissors).
- Each round ends when both players have shown their hands and there is a winner.
- If a round is tied (same hand is shown), the round will be run again.
- Paper beats rock.
- Rock beats scissors.
- Scissors beats paper.
- A game is best of 3 rounds - it is finished when one player has 2 round wins.
Game model
We will design our game using commands and events. The commands are actions that the game supports and the events are the Domain Events that are emitted as results from these commands.
Commands
Given our simple game rules there are only two actions we can perform:
-
StartGame
will be sent when we decide to start a game with two players. -
ShowHand
will be sent whenever a player shows their hand.
Note: since the game ends automatically when the last hand is shown, and we have a winner, this is not modeled as a command, but rather as an event instead.
An interesting consequence of designing the game with commands and events is that we get a clear picture of how there is a discrepancy between the number of different commands and events.
Events
Below is a description of the events that our Game
aggregate emits as a result for successfully processing commands:
-
GameStarted
will be saved as a consequence of thestart-game
command. -
RoundStarted
will be saved together with theGameStarted
for the first round and implicitly for upcoming rounds when both players have answered. -
PlayerAnswered
will be saved when a hand is shown by a player. -
RoundTied
will be saved when both players have shown hands, and they show the same sign. -
RoundFinished
will be saved when both players have shown hands -
GameFinished
will be saved when we have a winner (2 or more rounds won for a player).
Queries
We will also implement a number of queries to show the Projection support in Serialized. Projections help us calculate queryable models that are the result of a number of events that have been saved in the aggregates. The queries that we will support for the game are the following:
- Game status - the current status of the game (including the rounds that have been played).
- High score (wins per player in a list).
- Total number of games.
Implementing the game
Let's dive in to the implementation of our game!
App configuration
If you are not familiar with our Java client, you can read more here about the basics.
First we must configure the Serialized client. In our AppConfig
class we create an injectable bean for the aggregate client that we will use to store events into Serialized. We use a GameState
class to manage the materialization of the state from any previously stored events and register the handler methods in this class for each event type that we designed in our modeling session, respectively.
@Configuration
public class AppConfig {
@Autowired
public AppConfig(Environment env) {
this.env = env;
}
...
@Bean
public AggregateClient<GameState> gameClient() {
return AggregateClient.aggregateClient(GAME_AGGREGATE_TYPE, GameState.class, getConfig())
.registerHandler(GameStarted.class, GameState::handleGameStarted)
.registerHandler(PlayerWonRound.class, GameState::handlePlayerWonRound)
.registerHandler(GameFinished.class, GameState::handleGameFinished)
.registerHandler(PlayerAnswered.class, GameState::handlePlayerAnswered)
.registerHandler(RoundStarted.class, GameState::handleRoundStarted)
.registerHandler(RoundFinished.class, GameState::handleRoundFinished)
.registerHandler(RoundTied.class, GameState::handleRoundTied)
.build();
}
private SerializedClientConfig getConfig() {
return SerializedClientConfig.serializedConfig()
.accessKey(env.getProperty("SERIALIZED_ACCESS_KEY"))
.secretAccessKey(env.getProperty("SERIALIZED_SECRET_ACCESS_KEY"))
.build();
}
}
The game logic
The GameState
class contains the state for our Game
aggregate. When a new command (ShowHand
) is sent to the Game
aggregate, we will first load an instance of GameState
based on all previously stored events that was saved on the game.
/**
* The transient state of a game, built up from events
*/
public class GameState {
private final Set<Player> registeredPlayers = new LinkedHashSet<>();
private final Set<PlayerHand> shownHands = new HashSet<>();
private final Map<Player, Long> wins = new HashMap<>();
private GameStatus gameStatus = GameStatus.NEW;
public static GameState newGame() {
return new GameState();
}
public GameState handleGameStarted(Event<GameStarted> event) {
gameStatus = GameStatus.STARTED;
registeredPlayers.addAll(event.data().players.stream().map(Player::fromString).collect(toSet()));
return this;
}
public GameState handlePlayerWonRound(Event<PlayerWonRound> event) {
Player winner = Player.fromString(event.data().winner);
long numberOfWins = wins.getOrDefault(winner, 0L);
wins.put(winner, numberOfWins + 1);
return this;
}
public GameState handleRoundStarted(Event<RoundStarted> event) {
return this;
}
public GameState handlePlayerAnswered(Event<PlayerAnswered> event) {
Player player = Player.fromString(event.data().player);
shownHands.add(new PlayerHand(player, event.data().answer));
return this;
}
public GameState handleRoundTied(Event<RoundTied> event) {
shownHands.clear();
return this;
}
public GameState handleRoundFinished(Event<RoundFinished> event) {
shownHands.clear();
return this;
}
public GameState handleGameFinished(Event<GameFinished> event) {
this.gameStatus = GameStatus.FINISHED;
return this;
}
...
}
We will use a single aggregate Game
to implement the rules for the game and to emit the proper events for the correct situations. Games are a good showcase for Event Sourcing since they require strong consistency. The preserved history that we get out of the box is also useful to build fun side-features such as high score and statistics. By reacting to events we can also easily build notifications and reminders for players to act on.
The Game
class is our aggregate root that contains the implementation of the game rules and logic.
public class Game {
private final GameState gameState;
...
public List<Event<?>> startGame(Player player1, Player player2) {
if (player1.equals(player2)) {
throw new IllegalArgumentException("Cannot play against yourself");
}
Set<Player> players = Stream.of(player1, player2).collect(toCollection(LinkedHashSet::new));
return singletonList(gameStarted(players));
}
Player calculateWinner(PlayerHand hand1, PlayerHand hand2) {
if (hand1.answer.equals(ROCK)) {
return hand2.answer.equals(SCISSORS) ?
hand1.player : hand2.player;
} else if (hand1.answer.equals(PAPER)) {
return hand2.answer.equals(ROCK) ?
hand1.player : hand2.player;
} else
return hand2.answer.equals(PAPER) ?
hand1.player : hand2.player;
}
Player calculateLoser(PlayerHand player1, PlayerHand player2) {
return calculateWinner(player1, player2).equals(player1.player) ? player2.player : player1.player;
}
}
Generating the high score leaderboard
The high score in our application is a Projection that is built up from all the GameFinished
events that contain the id of the winner/loser for each game.
We can build this projection by simply telling Serialized to transform our events into a queryable high score projection.
In our AppConfig
we add a projection client that is used to initialize our projection definition when our application starts.
@Configuration
public class AppConfig {
...
@Bean
public ProjectionClient projectionApiClient() {
return ProjectionClient.projectionClient(getConfig()).build();
}
...
}
We create a ProjectionInitializer
service that will initialize all our projections (in this case the high score)
If the definition code is changed between app starts it will re-create the projection by re-reading all GameFinished
events again.
@Service
public class ProjectionInitializer {
private final ProjectionClient projectionClient;
@Autowired
public ProjectionInitializer(ProjectionClient projectionClient) {
this.projectionClient = projectionClient;
}
public void createGameProjection() {
projectionClient.createOrUpdate(
singleProjection("games")
.feed("game")
.addHandler(GameStarted.class.getSimpleName(),
merge().build(),
set().with(targetSelector("status")).with(rawData("IN_PROGRESS")).build())
.addHandler(RoundFinished.class.getSimpleName(),
append()
.with(targetSelector("rounds"))
.build())
.addHandler(GameFinished.class.getSimpleName(),
merge().build(),
set().with(targetSelector("status")).with(rawData("FINISHED")).build())
.build());
}
public void createHighScoreProjection() {
projectionClient.createOrUpdate(
singleProjection("high-score")
.feed("game")
.withIdField("winner")
.addHandler("GameFinished",
inc().with(targetSelector("wins")).build(),
set().with(targetSelector("playerName")).with(eventSelector("winner")).build(),
setref().with(targetSelector("wins")).build())
.build());
}
...
}
For the games projection we append rounds to a rounds array and modify the status of the game when GameStarted
and GameFinished
event are received.
The high-score projection is slightly different. In this case we use the idField
feature of Serialized to create projections that are identifiable using the playerId instead of the aggregateId
(which is the gameId in this case). This makes the high-score projections queryable by playerId.
The application class
Our main application class is the entry point for our Spring Boot application. It will start the web container and also initialize/update our projection definitions during boot. Here's how it looks:
@Configuration
@EnableAutoConfiguration
@ComponentScan
public class GameApplication implements CommandLineRunner {
private final ProjectionInitializer configurer;
@Autowired
public GameApplication(ProjectionInitializer configurer) {
this.configurer = configurer;
}
@Override
public void run(String... strings) {
configurer.createHighScoreProjection();
configurer.createGameProjection();
configurer.totalStatsProjection();
}
public static void main(String[] args) {
SpringApplication.run(GameApplication.class, args);
}
}
Before starting the application you need to provide your Serialized API keys as system properties/environment variables to the Java process so that our AppConfig
class can pick them up and initialize our client properly.
After starting the application, you can go to Projections in the Serialized console and you should be able to see the initialized projection definitions there.
Putting the pieces together
To expose the game logic to our client (Web/Mobile/other) we will create a @Controller
that receives HTTP POST
requests (Commands) from the client and executes our domain logic of the gameId
provided in the request:
@Controller
public class GameCommandController {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final AggregateClient<GameState> gameClient;
@Autowired
public GameCommandController(AggregateClient<GameState> gameClient) {
this.gameClient = gameClient;
}
@RequestMapping(value = "/start-game", method = POST, consumes = "application/json")
@ResponseStatus(value = HttpStatus.OK)
public void startGame(@RequestBody StartGameCommand command) {
Player player1 = Player.fromString(command.player1);
Player player2 = Player.fromString(command.player2);
GameState state = GameState.newGame();
Game game = Game.fromState(state);
gameClient.save(saveRequest().withAggregateId(command.gameId).withEvents(game.startGame(player1, player2)).build());
logger.info("Game [{}] started with players [{}, {}]", command.gameId, command.player1, command.player2);
}
@RequestMapping(value = "/show-hand", method = POST, consumes = "application/json")
@ResponseStatus(value = HttpStatus.OK)
public void showHand(@RequestBody ShowHandCommand command) {
// Load the aggregate state from all events, execute domain logic and store the result
gameClient.update(command.gameId, gameState -> {
Game game = Game.fromState(gameState);
return game.showHand(Player.fromString(command.player), command.answer);
});
logger.info("Player [{}] answered [{}] in game [{}]", command.player, command.answer, command.gameId);
}
}
To expose the projections we create a @Controller
that receives HTTP calls from the client and queries our projections and returns the results:
@Controller
public class GameQueryController {
private final ProjectionClient projectionClient;
@Autowired
public GameQueryController(ProjectionClient projectionClient) {
this.projectionClient = projectionClient;
}
@RequestMapping(value = "/high-score", method = GET, produces = "application/json")
@ResponseBody
public HighScoreProjection highScore() {
return HighScoreProjection.fromProjections(projectionClient.query(list("high-score").sort("-wins").build(HighScore.class)));
}
@RequestMapping(value = "/stats", method = GET, produces = "application/json")
@ResponseBody
public TotalGameStats gameStats() {
ProjectionResponse<TotalGameStats> projection = projectionClient.query(aggregated("total-game-stats").build(TotalGameStats.class));
return projection.data();
}
@RequestMapping(value = "/games/{gameId}", method = GET, produces = "application/json")
@ResponseBody
public GameProjection game(@PathVariable UUID gameId) {
ProjectionResponse<GameProjection> game = projectionClient.query(single("games")
.id(gameId)
.build(GameProjection.class));
return game.data();
}
}
Testing the application
To test the application you can send HTTP POST requests to the endpoints /start-game
and /show-hand
.
An example request to start a new game can look like this:
curl -i http://localhost:8080/start-game \
--header "Content-Type: application/json" \
--data '
{
"gameId" : "dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d",
"player1": "Lisa",
"player2": "Bob"
}
'
To play a round we will send two show-hand
requests. One for Lisa and one for Bob:
curl -i http://localhost:8080/show-hand \
--header "Content-Type: application/json" \
--data '
{
"gameId" : "dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d",
"player": "Lisa",
"answer" : "ROCK"
}
'
curl -i http://localhost:8080/show-hand \
--header "Content-Type: application/json" \
--data '
{
"gameId" : "dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d",
"player": "Bob",
"answer" : "PAPER"
}
'
Round results
Since our projection was already set up to show the results for each round we can now navigate to Projections/games/dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d
in the Serialized console and see the finished round.
To access this data we also have a query endpoint in the application:
curl -i http://localhost:8080/games/dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d
Which calls the Serialized games
projection for the given game id and returns the (already) calculated projection data:
{"players":["Bob","Lisa"],"status":"IN_PROGRESS","rounds":[{"winner":"Bob","loser":"Lisa"}]}
Showing the high-score
If we play one more identical round where Bob wins the round (and hence also the game), we can see the high-score projection being updated with his win of the game:
To access this data we also have a query endpoint in the application:
curl -i http://localhost:8080/high-score
{"highScores":[{"playerName":"Bob","wins":1}]}
This query shows the projection data from the high-score
projection and uses the sort
and limit
feature of the Projection API to show the 10 players with the most number of wins. After running a few more games with more players the response can look like this:
{
"highScores":
[
{"playerName":"Bob","wins":11},
{"playerName":"Lisa","wins":6},
{"playerName":"John","wins":5},
{"playerName":"Dan","wins":4},
{"playerName":"Anna","wins":1}
]
}
Summary
That wraps up the overview of this tutorial showing the basic techniques for Event Sourcing and CQRS using Serialized. Hope you find this tutorial useful and that it can inspire you to build your own application (or perhaps a game) using Serialized.
The complete sample code
Check out the complete sample code for this tutorial in our Github repository. Feel free to clone the project and modify it however you want. Have fun!
Posted on February 8, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.