DEV Community

loading...
Cover image for Implementing a Rock-Paper-Scissors game using Event Sourcing

Implementing a Rock-Paper-Scissors game using Event Sourcing

m_holmqvist profile image Mattias Holmqvist Originally published at serialized.io ・10 min read

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:

Alt Text

We now need to find out API keys which are available under Settings.

Alt Text

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 the start-game command.
  • RoundStarted will be saved together with the GameStarted 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();
  }

}
Enter fullscreen mode Exit fullscreen mode

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;
  }

  ...

}

Enter fullscreen mode Exit fullscreen mode

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;
  }

}
Enter fullscreen mode Exit fullscreen mode

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();
  }

  ...

} 
Enter fullscreen mode Exit fullscreen mode

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());
  }

  ...

}

Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

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);
  }

}
Enter fullscreen mode Exit fullscreen mode

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();
  }

}
Enter fullscreen mode Exit fullscreen mode

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"
}
'
Enter fullscreen mode Exit fullscreen mode

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"
}
'
Enter fullscreen mode Exit fullscreen mode
curl -i http://localhost:8080/show-hand \
  --header "Content-Type: application/json" \
  --data '
{
    "gameId" : "dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d",
    "player": "Bob",
    "answer" : "PAPER"
}
'
Enter fullscreen mode Exit fullscreen mode

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.

Alt Text

To access this data we also have a query endpoint in the application:

curl -i http://localhost:8080/games/dfd2d7bb-8c67-4d4f-87c1-364d72dfd05d
Enter fullscreen mode Exit fullscreen mode

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"}]}
Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

Alt Text

To access this data we also have a query endpoint in the application:

curl -i http://localhost:8080/high-score
Enter fullscreen mode Exit fullscreen mode
{"highScores":[{"playerName":"Bob","wins":1}]}
Enter fullscreen mode Exit fullscreen mode

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}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)

pic
Editor guide