blog

Serverless in 20 Minutes: Gaming Leaderboard

The Kalix Team at Lightbend
  • 28 July 2022,
  • 14 minute read

In this tutorial, we will create a simple gaming leaderboard simulator app based on Spring Boot. We will deploy the code on a serverless platform, so we only have to write the logic without paying much attention to how the data is stored.

But first, let’s introduce our serverless deployment platform: Kalix.

Kalix is a Platform-as-a-Service (PaaS) from Lightbend. It lets developers focus on business logic and boosts their productivity as a result. The platform provides quick deployment and robust resources, both of which boost scalability. Additionally, its pay-as-you-go payment plan grows alongside the business. This is especially advantageous because user requests can spike in some seasons and decline in others.

Now that you know the Kalix basics, let’s get started.

Prerequisites

  • An understanding of Java and Java 11 or higher installed on your system.
  • Familiarity with gRPC and protobufs. If you do not have this, review the documentation.
  • Knowledge of the Maven dependency manager and Maven 3.x or higher on your system. You may use tools such as IntelliJ IDEA, which already includes Maven. It’s easier to run Maven commands from an integrated development environment (IDE) (for example, IntelliJ) than from the terminal.
  • Knowledge of Spring.
  • Docker 20.10.8 or higher installed on your system.
  • The Kalix CLI.
  • A Kalix account so you can explore its free features.

You can also view the complete project code at the following public repositories:

Building the App

The Spring app communicates with the Kalix service, which performs storage and retrieval logic. As mentioned, the storage technology details are abstracted—we only have to write the code.

There are several ways of persisting data, one of which is event sourcing. We chose event sourcing for this tutorial because it stores all entity changes in a journal, so you can view the change history, which is important for tracking player progress in a game.

First, let’s create the service.

Creating the Kalix Service

The following Maven archetype command downloads the pom.xml file that we require for adding dependencies and other configurations for our app. In your working directory, open your favorite terminal and run it. As always, be sure to check for the latest Kalix version before starting:

mvn -N -B archetype:generate
-DarchetypeGroupId=io.kalix
-DarchetypeArtifactId=kalix-maven-archetype
-DarchetypeVersion=1.0.2
-DgroupId=com.example
-DartifactId=leaderboard-kalix-services
-Dversion=1.0-SNAPSHOT
-Dpackage=com.example

Make any appropriate changes to the dockerImage, mainClass, name, and artifactId tags. You can rename them, but to avoid errors, we suggest that you keep the names used here.

Next, let’s create two protobuf files. One is for defining our data entities (domain), and the other is for creating services and their invocation commands (API). Run these two commands if you’re using Linux:

mkdir -p ./src/main/proto/spelling/game/api
mkdir -p ./src/main/proto/spelling/game/domain

Or, on Windows, run:

mkdir src/main/proto/spelling/game/api
mkdir src/main/proto/spelling/game/domain

Removing the Sample Files

Kalix applications expose the API as gRPC services, with the API and entities defined in protocol buffer descriptors. The Kalix sample application defines a simple counter API with two descriptor files called src/main/proto/org/example/domain/counter_domain.proto and src/main/proto/org/example/counter_api.proto.

We don’t use these sample files, so delete them. We’ll replace these files with our own.

Modifying the API protobuf Definitions

Create a file called spelling_game_api.proto and add the following:

syntax = "proto3";

package spelling.game.api;

option java_outer_classname = "SpellingGameApi";

import "kalix/annotations.proto";
import "google/api/annotations.proto";
import "google/protobuf/empty.proto";

service LeaderboardDemo {
option (kalix.codegen) = {
  event_sourced_entity: {
    name: "spelling.game.api.GamingLeaderboardEntity"
    entity_type: "leaderboard_entity"
    state: "spelling.game.domain.LeaderBoardState"
    events: "spelling.game.domain.PlayerAdded"
  }
};

rpc NewPlayer(AddPlayer) returns (google.protobuf.Empty) {
  option (google.api.http) = {
    post: "/player/{leaderboard_id}/add"
    body: "*"
  };
}

rpc FetchScores(GetScores) returns (LeaderBoard) {
  option (google.api.http) = {
    get: "/{leaderboard_id}/list"
  };
}
}
message LeaderBoard {
repeated PlayerDetails scores = 1;
}

message PlayerDetails {
string player_id = 1;
string player_name = 2;
int32 player_score = 3;
}

message AddPlayer {
string leaderboard_id = 1 [(kalix.field).entity_key = true];
string player_id = 2;
string player_name = 3;
int32 player_score = 4;
}

message GetScores{
string leaderboard_id = 1 [(kalix.field).entity_key = true];
}

Here, we start by setting the correct protobuf syntax version, package name, the class that will be auto-generated after compilation, and the required modules.

Next, we create our service and name it LeaderboardDemo. In the descriptor, we set the event-sourcing entity’s details: its name, unique state storage name (entity_type), persistence state name, and the events that will be emitted. A class with the entity’s name will be auto-generated where we write our logic.

The rest of the code consists of:

  • RPC commands that will be fired to add new players and fetch player scores for our leaderboard
  • Their transcoded respective REST API endpoints
  • The messages that represent entities that will be passed to the commands

Modifying the Domain protobuf Definitions

Create a new file called spelling_game_domain.proto and add the following code:

syntax = "proto3";

package spelling.game.domain;

option java_outer_classname = "SpellingGameDomain";

message LeaderBoardState {
repeated PlayerDetails scores = 1;
}

message PlayerDetails {
string player_id = 1;
string player_name = 2;
string player_score = 3;
}

message PlayerAdded {
PlayerDetails player = 1;
}

This consists of messages representing the data entities, the event-sourced entity state, and the event.

Run mvn compile from your terminal to compile the code.

Writing the Code for the Commands to Our Service

After the compilation, a class called GamingLeaderboardEntity in thesrc/main/java/spelling/game/api folder is generated. There are several modifications that need to be made to it.

First, replace the emptyState method body with the following code, which defines the default state of the event-sourcing entity:

return SpellingGameDomain.LeaderBoardState.getDefaultInstance();

Next, modify the body of the newPlayer method by adding the following, which builds the message received and then emits the PlayerAdded event:

SpellingGameDomain.PlayerAdded playerAddedEvent = 
SpellingGameDomain.PlayerAdded.newBuilder()
  .setPlayer(SpellingGameDomain.PlayerDetails.newBuilder()
    .setPlayerId(addCommand.getPlayerId())
    .setPlayerName(addCommand.getPlayerName())
    .setPlayerScore(addCommand.getPlayerScore())
    .build())
  .build();
return effects().emitEvent(playerAddedEvent).thenReply(__ ->
  Empty.getDefaultInstance());

For the fetchScores method, we retrieve all scores from the entity’s current state, convert them into API form, and then return the scores in an Effect. There is a helper method called convert, which helps with the domain to API conversion. Once the fetchScores method’s body is replaced and the helper method has been added, we get this snippet:

@Override
public Effect<SpellingGameApi.LeaderBoard> fetchScores(SpellingGameDomain.LeaderBoardState currentState, SpellingGameApi.GetScores getScores) {
  List<SpellingGameApi.PlayerDetails> apiScores = currentState.getScoresList().stream()
    .sorted()
    .map(this::convert)
    .collect(Collectors.toList());
  SpellingGameApi.LeaderBoard apiLeaderboard = SpellingGameApi.LeaderBoard.newBuilder()
    .addAllScores(apiScores).build();
return effects().reply(apiLeaderboard);
}

private SpellingGameApi.PlayerDetails convert(SpellingGameDomain.PlayerDetails playerDetails) {
return SpellingGameApi.PlayerDetails.newBuilder()
  .setPlayerId(playerDetails.getPlayerId())
  .setPlayerScore(playerDetails.getPlayerScore())
  .setPlayerName(playerDetails.getPlayerName())
  .build();
}

Next, we modify the playerAdded method. This method is used for persisting the entity’s changes to Kalix. It has four helper methods that aid in the API-to-domain and domain-to-API conversions for the correct handling of the states.

This is its final code after modifications:

@Override
public SpellingGameDomain.LeaderBoardState playerAdded(SpellingGameDomain.LeaderBoardState currentState, SpellingGameDomain.PlayerAdded playerAdded) {
Map<String, SpellingGameApi.PlayerDetails> playerDetailsMap = domainPlayerToMap(currentState);
SpellingGameDomain.PlayerDetails playerDetails = playerAdded.getPlayer();
SpellingGameApi.PlayerDetails createdPlayer = domainPlayerToApi(playerDetails);
playerDetailsMap.put(playerDetails.getPlayerId(), createdPlayer);
return mapPlayerToDomain(playerDetailsMap);
}

These are the helper methods for the playerAdded method:

private SpellingGameApi.PlayerDetails domainPlayerToApi(SpellingGameDomain.PlayerDetails playerDetails) {
return SpellingGameApi.PlayerDetails.newBuilder()
  .setPlayerId(playerDetails.getPlayerId())
  .setPlayerName(playerDetails.getPlayerName())
  .setPlayerScore(playerDetails.getPlayerScore())
  .build();
}

private Map<String, SpellingGameApi.PlayerDetails> domainPlayerToMap(SpellingGameDomain.LeaderBoardState leaderBoardState) {
return leaderBoardState.getScoresList().stream().collect(Collectors
  .toMap(SpellingGameDomain.PlayerDetails::getPlayerId, this::domainPlayerToApi));
}

private SpellingGameDomain.LeaderBoardState mapPlayerToDomain(Map<String, SpellingGameApi.PlayerDetails> playerDetailsMap) {
return SpellingGameDomain.LeaderBoardState.newBuilder()
  .addAllScores(playerDetailsMap.values().stream().map(this::apiPlayerToDomain).collect(Collectors.toList()))
  .build();
}

private SpellingGameDomain.PlayerDetails apiPlayerToDomain(SpellingGameApi.PlayerDetails playerDetails) {
return SpellingGameDomain.PlayerDetails.newBuilder()
  .setPlayerId(playerDetails.getPlayerId())
  .setPlayerName(playerDetails.getPlayerName())
  .setPlayerScore(playerDetails.getPlayerScore())
  .build();
}

Compile the code and deploy it using mvn compile and mvn deploy commands, respectively.

Note: To successfully carry out the deployment, remember to sign up for a free Kalix account.

Once the service is ready, run this command and try testing with the default UI rendered in your browser.

kalix service proxy <service-name> --grpcui

Creating the Consumer Spring Boot App

Initializing the App

First, go to Spring Initializr and create a Spring initializer. Add Spring Web and Thymeleaf as dependencies, then click the GENERATE button. Extract the contents of the Zip file that’s downloaded. Using your IDE, open the project and add these dependencies:

<!--JSON converter -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.13.0</version>
</dependency>

<!-- OkHttp client-->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.9.1</version>
</dependency>

<!--json simple-->
<dependency>
<groupId>com.googlecode.json-simple</groupId>
<artifactId>json-simple</artifactId>
<version>1.1</version>
</dependency>

The first one is for Jackson JSON converter, the second is for OKHttp Client, and the last is for JSON decoding and encoding. Rebuild the Maven file and then create these Plain Old Java Object (POJO) files in a module called pojo(src/main/java/com/kalix/leaderboard/demo/pojo). These will come in handy for sending requests to our service and capturing its responses.

Player.java

package com.kalix.leaderboard.demo.pojo;

public class Player {
private String playerId;
private String leaderBoardId;
private String playerName;
private String playerScore;

public String getPlayerId() {
  return playerId;
}
public void setPlayerId(String playerId) {
  this.playerId = playerId;
}

public String getPlayerName() {
  return playerName;
}
public void setPlayerName(String playerName) {
  this.playerName = playerName;
}

public String getPlayerScore() {
  return playerScore;
}
public void setPlayerScore(String playerScore) {
  this.playerScore = playerScore;
}

public String getLeaderBoardId() {
  return leaderBoardId;
}
public void setLeaderBoardId(String leaderBoardId) {
  this.leaderBoardId = leaderBoardId;
}
}

PlayerScore.java

package com.kalix.leaderboard.demo.pojo;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
"playerId",
"playerName",
"playerScore"
})
public class PlayerScore {
@JsonProperty("playerId")
private String playerId;
@JsonProperty("playerName")
private String playerName;
@JsonProperty("playerScore")
private String playerScore;

@JsonProperty("playerId")
public String getPlayerId() {
  return playerId;
}
@JsonProperty("playerId")
public void setPlayerId(String playerId) {
  this.playerId = playerId;
}

@JsonProperty("playerName")
public String getPlayerName() {
  return playerName;
}
@JsonProperty("playerName")
public void setPlayerName(String playerName) {
  this.playerName = playerName;
}

@JsonProperty("playerScore")
public String getPlayerScore() {
  return playerScore;
}
@JsonProperty("playerScore")
public void setPlayerScore(String playerScore) {
  this.playerScore = playerScore;
}
}

Scores.java

package com.kalix.leaderboard.demo.pojo;

import java.util.List;

import com.kalix.leaderboard.demo.pojo.PlayerScore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
"scores"
})
public class Scores {
@JsonProperty("scores")
private List<PlayerScore> scores = null;

@JsonProperty("scores")
public List<PlayerScore> getScores() {
  return scores;
}

@JsonProperty("scores")
public void setScores(List<PlayerScore> scores) {
  this.scores = scores;
}
}

Adding the Controller Code

Next, add a module called controller (src/main/java/com/kalix/leaderboard/demo/controller). This is where we write our controller code.

In the folder, create a file called ScoreController.java.

To add a new player, we invoke an endpoint, triggering code that adds the playerDetails object state to a form, which is then displayed.

Note: that the Player.java pojo file is used. We’ll create the HTML files later.

@GetMapping("/addScore")
public String showForm(Model model) {
Player newPlayer = new Player();
model.addAttribute("playerDetails", newPlayer);
return "addScore.html";
}

From the form’s action attribute, we invoice another endpoint for saving our data.

@PostMapping("/save")
public String saveDetails(@ModelAttribute("playerDetails") Player ourPlayerDetails) throws IOException {
OkHttpClient client = new OkHttpClient().newBuilder()
  .build();
String leaderBoardID = ourPlayerDetails.getLeaderBoardId();
String playerID = ourPlayerDetails.getPlayerId();
String playerName = ourPlayerDetails.getPlayerName();
String playerScore = ourPlayerDetails.getPlayerScore();

JSONObject jsonObject = new JSONObject();
jsonObject.put("player_id", playerID);
jsonObject.put("player_name", playerName);
jsonObject.put("player_score", playerScore);
String jsonData = String.valueOf(jsonObject);

MediaType mediaType = MediaType.parse("application/json");
RequestBody body = RequestBody.create(jsonData, mediaType);
Request request = new Request.Builder()
  .url("http://localhost:8080/player/" + leaderBoardID + "/add")
  .method("POST", body)
  .addHeader("Content-Type", "application/json")
  .build();
client.newCall(request).execute();
return "redirect:/leaderboard";
}

First, OKHttpClient is initialized. Next, we create a JSON object, as gPRC only allows raw JSON data. We set the media type to JSON and create a request body from the JSON object string. We convert the JSON object to a string because RequestBody.create does not accept JSON object data types as parameters.

We then set the required parameters for the request, such as headers and methods, using the Request class helper methods.

Finally, we execute the request and display the leaderboard page.

The code for displaying the leaderboard is simple. We create a list from the responses retrieved from the leaderboard URL. This list is then passed to our model, which we fire to our leaderboard HTML file:

@GetMapping("/leaderboard")
public String showScores(Model model) {
RestTemplate restTemplate = new RestTemplate();
//the leaderboard id to be changed dynamically in your code
Scores responseList = restTemplate.getForObject("http://localhost:8080/<leaderboard_id>/list", Scores.class);
model.addAttribute("scoreList", responseList.getScores());
return "leaderboard.html";
}

Adding the UI Files

In the templates (src/main/resources/templates) folder, create the following HTML files that will add and retrieve the score data. We used Bootstrap for styling, but you can use any styling framework.

Form (addScore.html)

<!DOCTYPE html>
<html xmlns:th="https://thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
  <title>Spring Kalix Leaderboard</title>
</head>
<body>
  <div class="d-flex justify-content-center">
    <div class="border-dark border-secondary card" style=" margin-top: 5%; min-width: 40%">
      <div class="card-header text-center">
        Add Player and Score
      </div>
      <form th:action="@{/save}" method="post" th:object="${playerDetails}">
        <div class="form-group" style="margin-left: 10%; margin-top: 5%; margin-right: 10%">
          <input type="text" class="form-control" th:field="*{leaderBoardId}" placeholder="Leaderboard ID">
        </div>
        <div class="form-group" style="margin-left: 10%; margin-right: 10%">
          <input type="text" class="form-control" th:field="*{playerId}" placeholder="Player ID">
        </div>
        <div class="form-group" style="margin-left: 10%; margin-right: 10%">
          <input type="text" class="form-control" th:field="*{playerName}" placeholder="Player Name">
        </div>
        <div class="form-group" style="margin-left: 10%; margin-right: 10%">
          <input type="text" class="form-control" th:field="*{playerScore}" placeholder="Player Score">
        </div>
        <div class="form-group" style="margin-left: 10%; margin-right: 10%">
          <button type="submit" class="btn btn-danger">Submit</button>
        </div>
      </form>
    </div>
  </div>
</body>
</html>

Leaderboard (leaderboard.html)

<!DOCTYPE html>
<html xmlns:th="https://thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
  <title>Spring Kalix Leaderboard</title>
</head>
<body>
  <div class="d-flex justify-content-center">
    <div class="card" style="width: 18rem; margin-top: 5%; max-width: 40%">
      <div class="card-header text-center">
          Leaderboard
      </div>
      <table class="table table-striped">
        <thead>
        <tr>
          <th class="text-danger">Player</th>
          <th class="text-danger">Score</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="score : ${scoreList}">
          <td th:text="${score.getPlayerName()}"></td>
          <td th:text="${score.getPlayerScore()}"></td>
        </tr>
        </tbody>
      </table>
    </div>
  </div>
</body>
</html>

To display the form, go to localhost:8085/addScore. For the leaderboard, go to localhost:8085/leaderboard.

The leaderboard and the form appear as follows:

Leaderboard
Leaderboard

This has been a quick overview of how you can use Kalix services to build an app. There is a lot more to explore. To go deeper, take a look at the Kalix documentation.

Author Section will go here