Java/Android Client Guide #

This client library guide will show you how to use the core Nakama features in Java by showing you how to develop the Nakama specific parts (without full game logic or UI) of an Among Us (external) inspired game called Sagi-shi (Japanese for “Imposter”).

Sagi-shi gameplay screen
Sagi-shi gameplay

Prerequisites #

Before proceeding ensure that you have:

  • Installed Nakama server
  • Downloaded the Nakama Java SDK. You can download “nakama-java-$version.jar” or “nakama-java-$version-all.jar” which includes a shadowed copy of all dependencies.

To work with the Java client you’ll need a build tool like Gradle and an editor/IDE like IntelliJ, Eclipse or Visual Studio Code.

Full API documentation #

For the full API documentation please visit the API docs.

Setup #

There are a few different options for installing the Nakama Java SDK in your project depending on whether you’re using Gradle, Maven or relying on the JAR package directly.

Installing with Gradle #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
repositories {
    maven {
        url 'https://jitpack.io'
    }
}

dependencies {
    implementation 'com.github.heroiclabs.nakama-java:nakama-java:<commit>'

 // or, depend on the fat Jar which bundles all of the Nakama Java dependencies into a single Jar.
 // implementation 'com.github.heroiclabs.nakama-java:nakama-java-all:<commit>'
}

Installing with Maven #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  <repositories>
    <repository>
      <id>jitpack.io</id>
      <url>https://jitpack.io</url>
    </repository>
  </repositories>

  ...
  <dependencies>
    <dependency>
      <groupId>com.github.heroiclabs.nakama-java</groupId>
      <artifactId>nakama-java</artifactId>
      <version>_commit_</version>
      <type>jar</type>
    </dependency>
  </dependencies>

Installing with JAR #

1
2
3
4
5
6
7
8
  <dependencies>
    <dependency>
      <groupId>com.github.heroiclabs.nakama-java</groupId>
      <artifactId>nakama-java-all</artifactId>
      <version>_commit_</version>
      <type>jar</type>
    </dependency>
  </dependencies>

To update to a newer version of the Nakama Java SDK, replace the version/commit reference in your Gradle/Maven configuration file.

Logging #

The Nakama Java SDK uses the SLF4J logging API. You can find more information on how to use this API and how to use different logging bindings by reading the SLF4J User Manual.

All examples in this guide use an SLF4J Logger which can be created as follows:

1
2
// Where App is the name of your class
Logger logger = LoggerFactory.getLogger(App.class);

For Android #

Android uses a permissions system which determines which platform services the application will request to use and ask permission for from the user. The client uses the network to communicate with the server so you must add the “INTERNET” permission.

1
<uses-permission android:name="android.permission.INTERNET"/>

Asynchronous programming #

Many of the Nakama APIs are asynchronous and non-blocking and are available in the Java SDK using ListenableFuture objects which are part of the Google Guava library.

Sagi-shi calls these async methods using callbacks to not block the calling thread so that the game is responsive and efficient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Create a single thread executor
ExecutorService executor = Executors.newSingleThreadExecutor();

// Get a ListenableFuture with a Session result
ListenableFuture<Session> authFuture = client.authenticateDevice(deviceId);

// Setup the success and failure callbacks, specifying which executor to use
Futures.addCallback(authFuture, new FutureCallback<Session>() {
    @Override
    public void onSuccess(@NullableDecl Session session) {
        logger.debug("Authenticated user id: " + session.getUserId());
        executor.shutdown();
    }

    @Override
    public void onFailure(Throwable throwable) {
        logger.error(throwable.getMessage());
        executor.shutdown();
    }
}, executor);

// Wait for the executor to finish all tasks
try {
    executor.awaitTermination(5, TimeUnit.SECONDS);
}
catch (InterruptedException e) {
    logger.error(e.getMessage());
}

If you wish to chain asynchronous calls together, you can do so using AsyncFunction<> objects and the Futures.transformAsync function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Get a ListenableFuture with a Session result
ListenableFuture<Session> authFuture = client.authenticateDevice(deviceId);

// Create an AsyncFunction to get the Account of a user using a Session object
AsyncFunction<Session, Account> accountFunction = session -> client.getAccount(session);

// Get a ListenableFuture from Futures.transformAsync by first passing the original Future, followed by the extended Future and finally an exector
ListenableFuture<Account> accountFuture = Futures.transformAsync(authFuture, accountFunction, executor);

// Setup the success and failture callbacks as usual
Futures.addCallback(accountFuture, new FutureCallback<>() {
    @Override
    public void onSuccess(@NullableDecl Account account) {
        logger.debug("Got account for user id: " + account.getUser().getId());
        executor.shutdown();
    }

    @Override
    public void onFailure(Throwable throwable) {
        logger.error(throwable.getMessage());
        executor.shutdown();
    }
}, executor);

For brevity, the code samples in this guide will use the simpler but thread blocking .get() function instead.

1
Account account = client.getAccount(session).get();

Handling exceptions #

Network programming requires additional safeguarding against connection and payload issues.

As shown above, API calls in Sagi-Shi use a callback pattern with both a success and a failure callback being provided. If an API call throws an exception it is handled in the onFailure callback and the exception details can be accessed in the throwable object.

Serializing and deserializing data #

When sending and receiving data across the network it will need to be serialized and deserialized appropriately. The two most common ways to do this are using JSON and Binary data.

Both examples will show how to serialize and deserialize the Map object below but can be used with any serializable object.

1
2
3
Map<String, String> data = new HashMap<>();
data.put("Key", "Value");
data.put("AnotherKey", "AnotherValue");

JSON #

Using the com.google.gson.Gson package.

1
2
3
4
5
// Serialize
String serialized = new Gson().toJson(data, data.getClass());

// Deserialize
Map<String, String> deserialized = new Gson().fromJson(serialized, new TypeToken<Map<String, String>>(){}.getType());

Binary #

Using the java.io.* package. Conversion to and from Base64 is only necessary if you wish to send and receive the serialized data as a String; otherwise you can serialize and deserialize using a byte[] array.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Serialize
String serialized = null;
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

try {
    ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
    objectOutputStream.writeObject(data);
    byte[] bytes = byteArrayOutputStream.toByteArray();
    serialized = Base64.getEncoder().encodeToString(bytes);
} catch (IOException e) {
    e.printStackTrace();
}


// Deserialize
byte[] bytes = Base64.getDecoder().decode(serialized);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);

try {
    ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
    Map<String, String> deserialized = (Map<String, String>) objectInputStream.readObject();
} catch (IOException e) {
    e.printStackTrace();
} catch (ClassNotFoundException e) {
    e.printStackTrace();
}

Getting started #

Learn how to get started using the Nakama Client and Socket objects to start building Sagi-shi and your own game.

Nakama Client #

The Nakama Client connects to a Nakama Server and is the entry point to access Nakama features. It is recommended to have one client per server per game.

To create a client for Sagi-shi pass in your server connection details:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
final String serverKey = "defaultkey";       // The key used to authenticate with the server without a session.
final string host = "127.0.0.1";             // The host address of the server.
final int port = 7349;                       // The port number of the server.
final boolean ssl = false;                   // Set connection strings to use the secure mode with the server.
final int deadlineAfterMs = 0;               // Timeout for the gRPC messages.
final long keepAliveTimeMs = Long.MAX_VALUE; // The time without read activity before sending a keepalive ping.
final long keepAliveTimeoutMs = 0L;          // The time waiting for read activity after sending a keepalive ping.
final boolean trace = false;                 // Trace all actions performed by the client.

// explicity passing all parameters
Client client = new DefaultClient(serverKey, host, port, ssl, deadlineAfterMs, keepAliveTimeMs, keepAliveTimeoutMs, trace);
// basic parameters
Client client = new DefaultClient(serverKey, host, port, ssl);
// minimum parameters
Client client = new DefaultClient(serverKey);

The Nakama Java SDK communicates with the Nakama server directly via gRPC so you will want to use the gRPC port number that you have configured for your Nakama server; by default this is 7349.

Configuring the Request Timeout Length #

Each request to Nakama from the client must complete in a certain period of time before it is considered to have timed out. You can configure how long this period is (in seconds) by setting the deadlineAfterMs value on the client before the constructor is invoked:

1
2
deadlineAfterMs = 10;
Client client = new DefaultClient(serverKey, host, port, ssl, deadlineAfterMs, keepAliveTimeMs, keepAliveTimeoutMs, trace);

Nakama Socket #

The Nakama Socket is used for gameplay and real-time latency-sensitive features such as chat, parties, matches and RPCs.

The socket is exposed on a different port on the server to the client. You’ll need to specify a different port here to ensure that connection is established successfully.

The client can create one or more sockets with the server. Each socket can have it’s own event listeners registered for responses received from the server.

From the client create a socket:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
SocketClient socket = client.createWebSocket();

SocketListener listener = new AbstractSocketListener() {
    @Override
    public void onDisconnect(final Throwable t) {
        logger.info("Socket disconnected.");
    }
};

socket.connect(session, listener).get();
logger.info("Socket connected.");

Authentication #

Nakama has many authentication methods and supports creating custom authentication on the server.

Sagi-shi will use device and Facebook authentication, linked to the same user account so that players can play from multiple devices.

Sagi-shi login screen
Login screen and Authentication options

Device authentication #

Nakama Device Authentication uses the physical device’s unique identifier to easily authenticate a user and create an account if one does not exist.

When using only device authentication, you don’t need a login UI as the player can automatically authenticate when the game launches.

Authentication is an example of a Nakama feature accessed from a Nakama Client instance.

1
2
3
String deviceId = UUID.randomUUID().toString();
Session session = client.authenticateDevice(deviceId).get();
logger.info("Session authToken: {}", session.getAuthToken());

Facebook authentication #

Nakama Facebook Authentication is an easy to use authentication method which lets you optionally import the player’s Facebook friends and add them to their Nakama Friends list.

1
2
3
String oauthToken = "...";
Session session = client.authenticateFacebook(oauthToken).get();
logger.info("Session authToken: {}", session.getAuthToken());

Custom authentication #

Nakama supports Custom Authentication methods to integrate with additional identity services.

See the Itch.io custom authentication recipe for an example.

Linking authentication #

Nakama allows players to Link Authentication methods to their account once they have authenticated.

Linking Device ID authentication

1
2
3
String deviceId = UUID.randomUUID().toString();
client.linkDevice(session, deviceId);
logger.info("Linked device id {} for user {}", deviceId, session.getUserId());

Linking Facebook authentication

1
2
3
4
String facebookAccessToken = "...";
boolean importFriends = true;
client.linkFacebook(session, facebookAccessToken, importFriends);
logger.info("Linked facebook authentication for user {}", deviceId, session.getUserId());

Session variables #

Nakama Session Variables can be stored when authenticating and will be available on the client and server as long as the session is active.

Sagi-shi uses session variables to implement analytics, referral and rewards programs and more.

Store session variables by passing them as an argument when authenticating:

1
2
3
4
5
6
Map<String, String> vars = new HashMap<>();
vars.put("DeviceOS", System.getProperty("os.name"));
vars.put("GameVersion", "VersionNumber");
vars.put("InviterUserId", "<SomeUserId>");

Session session = client.authenticateDevice(deviceId, vars).get();

To access session variables on the Client use the Vars property on the Session object:

1
Map<String, String> vars = session.getVars()

Session lifecycle #

Nakama Sessions expire after a time set in your server configuration. Expiring inactive sessions is a good security practice.

It is recommended to store the auth token from the session and check at startup if it has expired. If the token has expired you must reauthenticate. The expiry time of the token can be changed as a setting in the server.

1
2
3
4
5
6
7
logger.info("Session connected: {}", session.getAuthToken());

// Android
SharedPreferences pref = getActivity().getPreferences(Context.MODE_PRIVATE);
SharedPreferences.Editor editor = pref.edit();
editor.putString("nk.session", session.getAuthToken());
editor.commit();

Nakama provides ways to restore sessions without the need to re-authenticate, for example when Sagi-shi players re-launch the game.

1
session = DefaultSession.restore(authToken);

User accounts #

Nakama User Accounts store user information defined by Nakama and custom developer metadata.

Sagi-shi allows players to edit their accounts and stores metadata for things like game progression and in-game items.

Sagi-shi player profile screen
Player profile

Get the user account #

Many of Nakama’s features are accessible with an authenticated session, like fetching a user account.

Get a Sagi-shi player’s full user account with their basic user information and user id:

1
2
3
4
5
Account account = client.getAccount(session).get();
User user = account.getUser();
String username = user.getUsername();
String avatarUrl = user.getAvatarUrl();
String userId = user.getId();

Update the user account #

Nakama provides easy methods to update server stored resources like user accounts.

Sagi-shi players need to be able to update their public profiles:

1
2
3
4
5
6
7
String newUsername = "NotTheImp0ster";
String newDisplayName = "Innocent Dave";
String newAvatarUrl = "https://example.com/imposter.png";
String newLangTag = "en";
String newLocation = "Edinburgh";
String newTimezone = "BST";
client.updateAccount(session, newUsername, newDisplayName, newAvatarUrl, newLangTag, newLocation, newTimezone).get();

Getting users #

In addition to getting the current authenticated player’s user account, Nakama has a convenient way to get a list of other players’ public profiles from their ids, usernames or Facebook ids.

Sagi-shi uses this method to display player profiles when engaging with other Nakama features:

1
2
3
4
5
6
7
8
9
List<String> ids = Arrays.asList("userid1", "userid2");
List<String> usernames = Arrays.asList("username1", "username1");
String[] facebookIds = new String[] {"facebookid1"};

Users users = client.getUsers(session, ids, usernames, facebookIds).get();

for (User user : users.getUsersList()) {
  logger.info("User id {} username {}", user.getId(), user.getUsername());
}

Storing metadata #

Nakama User Metadata allows developers to extend user accounts with public user fields.

User metadata can only be updated on the server. See the updating user metadata recipe for an example.

Sagi-shi will use metadata to store what in-game items players have equipped:

Reading metadata #

Define a class that describes the metadata and parse the JSON metadata:

1
2
3
4
5
6
public class Metadata
{
    public String Title;
    public String Hat;
    public String Skin;
}
1
2
3
4
5
6
7
// Get the updated account object.
Account account = client.getAccount(session).get();

// Parse the account user metadata and log the result.
Gson gson = new Gson();
Metadata metadata = gson.fromJson(account.getUser().getMetadata(), Metadata.class);
logger.info("Title: {}, Hat: {}, Skin: {}", metadata.Title, metadata.Hat, metadata.Skin);

The above code uses the com.google.gson serialization/deserialization library.

Wallets #

Nakama User Wallets can store multiple digital currencies as key/value pairs of strings/integers.

Players in Sagi-shi can unlock or purchase titles, skins and hats with a virtual in-game currency.

Accessing wallets #

Parse the JSON wallet data from the user account:

1
2
3
4
5
6
Gson gson = new Gson();
Map<String, Double> wallet = new HashMap<>();
wallet = gson.fromJson(account.getWallet(), wallet.getClass());

logger.info("Wallet:");
wallet.forEach((k, v) -> logger.info("{}: {}", k, v));

Updating wallets #

Wallets can only be updated on the server. See the user account virtual wallet documentation for an example.

Validating in-app purchases #

Sagi-shi players can purchase the virtual in-game currency through in-app purchases that are authorized and validated to be legitimate on the server.

See the In-app Purchase Validation documentation for examples.

Storage Engine #

The Nakama Storage Engine is a distributed and scalable document-based storage solution for your game.

The Storage Engine gives you more control over how data can be accessed and structured in collections.

Collections are named, and store JSON data under a unique key and the user id.

By default, the player has full permission to create, read, update and delete their own storage objects.

Sagi-shi players can unlock or purchase many items, which are stored in the Storage Engine.

Sagi-shi player items screen
Player items

Reading storage objects #

Define a class that describes the storage object (or optionally use a HashMap<String, Object>) and create a new storage object id with the collection name, key and user id. Finally, read the storage objects and parse the JSON data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
StorageObjectId objectId = new StorageObjectId("favorites");
objectId.setKey("Hats");
objectId.setUserId(session.getUserId());

StorageObjects objects = client.readStorageObjects(session, objectId).get();

objects.getObjectsList().forEach((object) -> {
    Map<String, Object> parsedObj = new Gson().fromJson(object.getValue(), new TypeToken<Map<String, Object>>(){}.getType());
    logger.info("{}:", object.getKey());
    parsedObj.forEach((k, v) -> logger.info("{}: {}", k, v));
});

To read other players’ public storage object, use their UserId instead. Players can only read storage objects they own or that are public (PermissionRead value of 2).

Writing storage objects #

Nakama allows developers to write to the Storage Engine from the client and server.

Consider what adverse effects a malicious user can have on your game and economy when deciding where to put your write logic, for example data that should only be written authoritatively (i.e. game unlocks or progress).

Sagi-shi allows players to favorite items for easier access in the UI and it is safe to write this data from the client.

Create a write storage object with the collection name, key and JSON encoded data. Finally, write the storage objects to the Storage Engine:

1
2
3
4
5
6
7
8
// Serialize your object as JSON
Map<String, List<String>> favoriteHats = new HashMap<>();
favoriteHats.put("hats", Arrays.asList("cowboy", "alien"));
String favoriteHatsJson = new Gson().toJson(favoriteHats);

StorageObjectWrite writeObject = new StorageObjectWrite("favorites", "Hats", favoriteHatsJson, PermissionRead.OWNER_READ, PermissionWrite.OWNER_WRITE);
StorageObjectAcks acks = client.writeStorageObjects(session, writeObject).get();
logger.info("Stored objects {}", acks.getAcksList());

You can also pass multiple objects to the client.writeStorageObjects method:

1
2
3
4
5
6
7
8
String favoriteHatsJson = "...";
String myStatsJson = "...";

StorageObjectWrite writeObject = new StorageObjectWrite("favorites", "Hats", favoriteHats, PermissionRead.OWNER_READ, PermissionWrite.OWNER_WRITE);
StorageObjectWrite statsObject = new StorageObjectWrite("stats", "player", myStats, PermissionRead.OWNER_READ, PermissionWrite.OWNER_WRITE);

StorageObjectAcks acks = client.writeStorageObjects(session, saveGameObject, statsObject).get();
System.out.format("Stored objects %s", acks.getAcksList());

Conditional writes #

Storage Engine Conditional Writes ensure that write operations only happen if the object hasn’t changed since you accessed it.

This gives you protection from overwriting data, for example the Sagi-shi server could have updated an object since the player last accessed it.

To perform a conditional write, add a version to the write storage object with the most recent object version:

1
2
3
4
5
6
// Assuming we already got an object (`obj`)
// Create a new object with the same Collection and Key and use the previous object's Version
StorageObjectWrite writeObject = new StorageObjectWrite(obj.getCollection(), obj.getKey(), newJsonValue, PermissionRead.OWNER_READ, PermissionWrite.OWNER_WRITE);
writeObject.setVersion(obj.getVersion())

// ... then write it to the Storage Engine as shown above

Listing storage objects #

Instead of doing multiple read requests with separate keys you can list all the storage objects the player has access to in a collection.

Sagi-shi lists all the player’s unlocked or purchased titles, hats and skins:

1
2
3
4
5
6
int limit = 3;
String cursor = null;

StorageObjectList objects = client.listUsersStorageObjects(session, "Unlocks", limit, cursor);
logger.info("Object count: {}", objects.getObjectsCount());
objects.getObjectsList().forEach(object -> logger.info("Key: {}", object.getKey()));

Paginating results #

Nakama methods that list results return a cursor which can be passed to subsequent calls to Nakama to indicate where to start retrieving objects from in the collection.

For example:

  • If the cursor has a value of 5, you will get results from the fifth object.
  • If the cursor is null, you will get results from the first object.
1
StorageObjectList objectsPage2 = lient.listUsersStorageObjects(session, "Unlocks", limit, objectsPage1.cursor);

Protecting storage operations on the server #

Nakama Storage Engine operations can be protected on the server to protect data the player shouldn’t be able to modify (i.e. game unlocks or progress). See the writing to the Storage Engine authoritatively recipe.

Remote Procedure Calls #

The Nakama Server allows developers to write custom logic and expose it to the client as RPCs.

Sagi-shi contains various logic that needs to be protected on the server, like checking if the player owns equipment before equipping it.

Creating server logic #

See the handling player equipment authoritatively recipe for an example of creating a remote procedure to check if the player owns equipment before equipping it.

Client RPCs #

Nakama Remote Procedures can be called from the client and take optional JSON payloads.

The Sagi-shi client makes an RPC to securely equip a hat:

1
2
3
4
String payload = "{ \"item\", \"cowboy\" }";

Rpc result = client.rpc(session, "EquipHat", payload).get();
logger.info("New hat equipped successfully {}", result.getPayload());

Socket RPCs #

Nakama Remote Procedures can also be called from the socket when you need to interface with Nakama’s real-time functionality.

1
2
Rpc result = socket.rpc("EquipHat", payload).get();
logger.info("New hat equipped successfully {}", result.getPayload());

Friends #

Nakama Friends offers a complete social graph system to manage friendships amongst players.

Sagi-shi allows players to add friends, manage their relationships and play together.

Sagi-shi Friends screen
Friends screen

Adding friends #

Adding a friend in Nakama does not immediately add a mutual friend relationship. An outgoing friend request is created to each user, which they will need to accept.

Sagi-shi allows players to add friends by their usernames or user ids:

1
2
3
4
5
6
7
// Add friends by User ID.
List<String> ids = Arrays.asList("AlwaysTheImposter21", "SneakyBoi");

// Add friends by Username.
String[] usernames = new String[] { "<SomeUserId>", "<AnotherUserId>" };

client.addFriends(session, ids, usernames).get();

Friendship states #

Nakama friendships are categorized with the following states:

ValueState
0Mutual friends
1An outgoing friend request pending acceptance
2An incoming friend request pending acceptance
3Blocked by the user

Listing friends #

Nakama allows developers to list the player’s friends based on their friendship state.

Sagi-shi lists the 20 most recent mutual friends:

1
2
3
4
5
6
int friendshipState = 0;
int limit = 20; // Limit is capped at 1000
String cursor = null;

FriendList friends = client.listFriends(session, friendshipState, limit, cursor).get();
friends.getFriendsList().forEach(friend -> logger.info("Friend Id: {}", friend.getUser().getId()));

Accepting friend requests #

When accepting a friend request in Nakama the player adds a bi-directional friend relationship.

Nakama takes care of changing the state from pending to mutual for both.

In a complete game you would allow players to accept individual requests.

Sagi-shi just fetches and accepts all the incoming friend requests:

1
2
3
4
5
6
7
int friendshipState = 2;
FriendList friends = client.listFriends(session, friendshipState).get();

for (Friend friend : friends.getFriendsList()) {
    List<String> ids = Arrays.asList(friend.getUser().getId());
    client.addFriends(session, ids, null).get();
}

Deleting friends #

Sagi-shi players can remove friends by their username or user id:

1
2
3
4
5
6
7
// Delete friends by User ID.
List<String> ids = Arrays.asList("<SomeUserId>", "<AnotherUserId>");

// Delete friends by Username.
String[] usernames = new String[] { "<SomeUsername>", "<AnotherUsername>" };

client.deleteFriends(session, ids, usernames).get();

Blocking users #

Sagi-shi players can block others by their username or user id:

1
2
3
4
5
6
7
// Block friends by User ID.
List<String> ids = Arrays.asList("<SomeUserId>", "<AnotherUserId>");

// Block friends by Username.
String[] usernames = new String[] { "<SomeUsername>", "<AnotherUsername>" };

client.blockFriends(session, ids, usernames).get();

Blocked friends are represented by a friendship state of 3.

Status & Presence #

Nakama Status is a real-time status and presence service that allows users to set their online presence, update their status message and follow other user’s updates.

Players don’t have to be friends with others they want to follow.

Sagi-shi uses status messages and online presences to notify players when their friends are online and share matches.

Sagi-shi status update screen
Updating player status

Follow users #

The Nakama real-time APIs allow developers to subscribe to events on the socket, like a status presence change, and receive them in real-time.

The method to follow users also returns the current online users, known as presences, and their status.

Sagi-shi follows a player’s friends and notifies them when they are online:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// If one doesn't already exist, create a socket listener and handle status presence events
SocketListener socketListener = new SocketListener() {
    @Override
    public void onStatusPresence(StatusPresenceEvent e) {
        if (e.getJoins() != null) {
            e.getJoins().forEach(presence -> logger.info("{} is online with status {}", presence.getUsername(), presence.getStatus()));
        }

        if (e.getLeaves() != null) {
            e.getLeaves().forEach(presence -> logger.info("{} went offline", presence.getUsername()));
        }
    }

    // ... other required overrides (e.g. onChannelMessage etc)
}

// Then create and connect a socket connection
SocketClient socket = client.createSocket();
socket.connect(session, socketListener);

// Follow mutual friends and get the initial Status of any that are currently online
int friendshipState = 0; // Mutual friends
int limit = 20; // Limit is capped at 1000

FriendList friends = client.listFriends(session, friendshipState, limit, null).get();
List<String> friendIds = new ArrayList<>();
for (Friend friend : friends.getFriendsList()) {
    friendIds.add(friend.getUser().getId());
}

Status status = socket.followUsers(friendIds).get();
if (status.getPresences() != null) {
    status.getPresences().forEach(presence -> logger.info("{} is online with status {}", presence.getUsername(), presence.getStatus()));
}

Unfollow users #

Sagi-shi players can unfollow others:

1
socket.unfollowUsers("<UserId>", "<AnotherUserId>").get();

Updating player status #

Sagi-shi players can change and publish their status to their followers:

1
socket.updateStatus("Viewing the Main Menu").get();

Groups #

Nakama Groups is a group or clan system with public/private visibility, user memberships and permissions, metadata and group chat.

Sagi-shi allows players to form and join groups to socialize and compete.

Sagi-shi groups screen
Groups list screen

Creating groups #

Groups have a public or private “open” visibility. Anyone can join public groups, but they must request to join and be accepted by a superadmin/admin of a private group.

Sagi-shi players can create groups around common interests:

1
2
3
4
5
6
7
String name = "Imposters R Us";
String description = "A group for people who love playing the imposter.";
String avatarUrl = "";
String langTag = "";
Boolean open = true; // Public group
int maxSize = 100;
Group group = client.createGroup(session, name, description, avatarUrl, langTag, open, maxSize).get();

Update group visibility #

Nakama allows group superadmin or admin members to update some properties from the client, like the open visibility:

1
2
3
String groupId = "<GroupId>";
boolean open = false;
client.updateGroup(session, groupId, name, description, avatarUrl, langTag, open);

Update group size #

Other properties, like the group’s maximum member size, can only be changed on the server.

See the updating group size recipe for an example, and the Groups server function reference to learn more about updating groups on the server.

Sagi-shi group edit screen
Sagi-shi group edit

Listing and filtering groups #

Groups can be listed like other Nakama resources and also filtered with a wildcard group name.

Sagi-shi players use group listing and filtering to search for existing groups to join:

1
2
3
4
5
6
int limit = 20;
GroupList groupList = client.listGroups(session, "imposter%", limit).get();
groupList.getGroupsList().forEach(group -> logger.info("{} [{}]", group.getName(), group.hasOpen() && group.getOpen().getValue() ? "Public" : "Private"));

// Get the next page of results.
GroupList nextGroupListResults = client.listGroups(session, "imposter%", limit, groupList.getCursor()).get();

Deleting groups #

Nakama allows group superadmins to delete groups.

Developers can disable this feature entirely, see the Guarding APIs guide for an example on how to protect various Nakama APIs.

Sagi-shi players can delete groups which they are superadmins for:

1
client.deleteGroup(session, "<GroupId>").get();

Group metadata #

Like Users Accounts, Groups can have public metadata.

Sagi-shi uses group metadata to store the group’s interests, active player times and languages spoken.

Group metadata can only be updated on the server. See the updating group metadata recipe for an example.

The Sagi-shi client makes an RPC with the group metadata payload:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Map<String, Object> payload = new HashMap<>();
payload.put("GroupId", "<GroupId>");
payload.put("Interests", Arrays.asList("Deception", "Sabotage", "Cute Furry Bunnies"));
payload.put("ActiveTimes", Arrays.asList("9am-2pm Weekdays", "9am-10am Weekends"));
payload.put("Languages", Arrays.asList("English", "German"));

try {
    Rpc rpcResult = client.rpc(session, "UpdateGroupMetadata", new Gson().toJson(payload, payload.getClass())).get();
    logger.info("Successfully updated group metadata");
}
catch (ExecutionException ex) {
    logger.error(ex.getMessage());
}

Group membership states #

Nakama group memberships are categorized with the following states:

CodePurpose
0SuperadminThere must at least be 1 superadmin in any group. The superadmin has all the privileges of the admin and can additionally delete the group and promote admin members.
1AdminThere can be one of more admins. Admins can update groups as well as accept, kick, promote, demote, ban or add members.
2MemberRegular group member. They cannot accept join requests from new users.
3Join requestA new join request from a new user. This does not count towards the maximum group member count.

Joining a group #

If a player joins a public group they immediately become a member, but if they try and join a private group they must be accepted by a group admin.

Sagi-shi players can join a group:

1
client.joinGroup(session, "<GroupId>").get();

Listing the user’s groups #

Sagi-shi players can list groups they are a member of:

1
2
3
4
5
6
String userid = "<user id>";
UserGroupList userGroups = client.listUserGroups(session, userid).get();

for (UserGroupList.UserGroup userGroup : userGroups.getUserGroupsList()) {
    System.out.format("Group name %s role %d", userGroup.getGroup().getName(), userGroup.getState());
}

Listing members #

Sagi-shi players can list a group’s members:

1
2
GroupUserList groupUserList = client.listGroupUsers(session, "<GroupId>").get();
groupUserList.getGroupUsersList().forEach(groupUser -> logger.info("{}: {}", groupUser.getUser().getId(), groupUser.getState().getValue()));

Accepting join requests #

Private group admins or superadmins can accept join requests by re-adding the user to the group.

Sagi-shi first lists all the users with a join request state and then loops over and adds them to the group:

1
2
3
4
5
6
7
int state = 3;
int limit = 100;
String cursor = "";
GroupUserList groupUserList = client.listGroupUsers(session, groupId, state, limit, cursor).get();
groupUserList.getGroupUsersList().forEach(groupUser -> {
    client.addGroupUsers(session, groupId, groupUser.getUser().getId());
});

Promoting members #

Nakama group members can be promoted to admin or superadmin roles to help manage a growing group or take over if members leave.

Admins can promote other members to admins, and superadmins can promote other members up to superadmins.

The members will be promoted up one level. For example:

  • Promoting a member will make them an admin
  • Promoting an admin will make them a superadmin
1
client.promoteGroupUsers(session, "<GroupId>", "UserId", "AnotherUserId").get();

Demoting members #

Sagi-shi group admins and superadmins can demote members:

1
2
3
String groupid = "<group id>";
String[] userIds = new String[] {"<user id>"};
client.demoteGroupUsers(session, groupid, userIds).get();

Kicking members #

Sagi-shi group admins and superadmins can remove group members:

1
client.demoteGroupUsers(session, "<GroupId>", "UserId", "AnotherUserId").get();

Banning members #

Sagi-shi group admins and superadmins can ban a user when demoting or kicking is not severe enough:

1
client.banGroupUsers(session, "<GroupId>", "UserId", "AnotherUserId").get();

Leaving groups #

Sagi-shi players can leave a group:

1
client.leaveGroup(session, "<GroupId>").get();

Chat #

Nakama Chat is a real-time chat system for groups, private/direct messages and dynamic chat rooms.

Sagi-shi uses dynamic chat during matches, for players to mislead each other and discuss who the imposters are, group chat and private/direct messages.

Sagi-shi chat screen
Sagi-shi Chat

Joining dynamic rooms #

Sagi-shi matches have a non-persistent chat room for players to communicate in:

1
2
3
4
5
String roomName = "<MatchId>";
boolean persistence = false;
boolean hidden = false;
Channel channel = socket.joinChat(roomName, ChannelType.ROOM, persistence, hidden).get();
logger.info("Connected to dynamic room channel: {}", channel.getId());

Joining group chat #

Sagi-shi group members can have conversations that span play sessions in a persistent group chat channel:

1
2
3
4
5
String groupId = "<GroupId>";
boolean persistence = true;
boolean hidden = false;
Channel channel = socket.joinChat(groupId, ChannelType.GROUP, persistence, hidden).get();
logger.info("Connected to group channel: {}", channel.getId());

Joining direct chat #

Sagi-shi players can also chat privately one-to-one during or after matches and view past messages:

1
2
3
4
5
String userId = "<UserId>";
boolean persistence = true;
boolean hidden = false;
Channel channel = socket.joinChat(userId, ChannelType.DIRECT_MESSAGE, persistence, hidden).get();
logger.info("Connected to direct message channel: {}", channel.getId());

Sending messages #

Sending messages is the same for every type of chat channel. Messages contain chat text and emotes and are sent as JSON serialized data:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
String channelId = "<ChannelId>";

Map<String, String> messageContent = new HashMap<>();
messageContent.put("message", "I think Red is the imposter!");
ChannelMessageAck messageSendAck = socket.writeChatMessage(channelId, new Gson().toJson(messageContent, messageContent.getClass())).get();

Map<String, String> emoteContent = new HashMap<>();
emoteContent.put("emote", "point");
emoteContent.put("emoteTarget", "<RedPlayerUserId>");
ChannelMessageAck emoteSendAck = socket.writeChatMessage(channelId, new Gson().toJson(emoteContent, emoteContent.getClass())).get();

Listing message history #

Message listing takes a parameter which indicates if messages are received from oldest to newest (forward) or newest to oldest.

Sagi-shi players can list a group’s message history:

1
2
3
4
5
int limit = 100;
boolean forward = true;
String groupId = "<GroupId>";
ChannelMessageList channelMessageList = client.listChannelMessages(session, channelId, limit, null, forward).get();
channelMessageList.getMessagesList().forEach(message -> logger.info("{}:{}", message.getUsername(), message.getContent()));

Chat also has cacheable cursors to fetch the most recent messages, which you can store in whichever way you prefer.

1
2
3
4
5
// Store this in whichever way suits your application
String cacheableCursor = channelMessageList.getCacheableCursor();

// ...then use the cacheable cursor later to retrieve the next results
ChannelMessageList nextResults = client.listChannelMessages(session, groupId, limit, cacheableCursor, forward);

Updating messages #

Nakama also supports updating messages. It is up to you whether you want to use this feature, but in a game of deception like Sagi-shi it can add an extra element of deception.

For example a player sends the following message:

1
2
3
4
5
6
7
Map<String, String> messageContent = new HashMap<>();
messageContent.put("message", "I think Red is the imposter!");
ChannelMessageAck ack = socket.writeChatMessage(channelId, new Gson().toJson(messageContent, messageContent.getClass())).get();

Map<String, String> updatedMessageContent = new HashMap<>();
updatedMessageContent.put("message", "I think BLUE is the imposter!");
ChannelMessageAck updateAck = socket.updateChatMessage(channelId, ack.getMessageId(), new Gson().toJson(updatedMessageContent, updatedMessageContent.getClass())).get();

Matches #

Nakama supports Server Authoritative and Server Relayed multiplayer matches.

In server authoritative matches the server controls the gameplay loop and must keep all clients up to date with the current state of the game.

In server relayed matches the client is in control, with the server only relaying information to the other connected clients.

In a competitive game such as Sagi-shi, server authoritative matches would likely be used to prevent clients from interacting with your game in unauthorized ways.

For the simplicity of this guide, the server relayed model is used.

Creating matches #

Sagi-shi players can create their own matches and invite their online friends to join:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Match match = socket.createMatch().get();
FriendList friendList = client.listFriends(session, 0, 100, "").get();
for (Friend friend : friendList.getFriendsList()) {
    if (friend.getUser().getOnline()) {
        Map<String, String> messageContent = new HashMap<>();
        messageContent.put("message", String.format("Hey %s, join me for a match!", friend.getUser().getUsername()));
        messageContent.put("matchId", match.getMatchId());

        Channel channel = socket.joinChat(friend.getUser().getId(), ChannelType.DIRECT_MESSAGE).get();
        ChannelMessageAck ack = socket.writeChatMessage(channel.getId(), new Gson().toJson(messageContent, messageContent.getClass())).get();
    }
}

Joining matches #

Sagi-shi players can try to join existing matches if they know the id:

1
2
String matchId = "<MatchId>";
Match match = socket.joinMatch(matchId).get();

Or set up a real-time matchmaker listener and add themselves to the matchmaker:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// In the SocketListener, override the onMatchmakerMatched function
@Override
public void onMatchmakerMatched(MatchmakerMatched matchmakerMatched) {
    Match match = socket.joinMatch(matchmakerMatched.getMatchId()).get();
}

// ...then, elsewhere
int minPlayers = 2;
int maxPlayers = 10;
String query = "";
MatchmakerTicket matchmakingTicket = socket.addMatchmaker(minPlayers, maxPlayers, query).get();

Joining matches from player status

Sagi-shi players can update their status when they join a new match:

1
2
3
4
5
Map<String, String> status = new HashMap<>();
status.put("Status", "Playing a match");
status.put("MatchId", "<MatchId>");

socket.updateStatus(new Gson().toJson(status, status.getClass())).get();

When their followers receive the real-time status event they can try and join the match:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override
public void onStatusPresence(StatusPresenceEvent e) {
    if (e.getJoins() != null) {
        e.getJoins().forEach(presence -> {
            Map<String, String> status = new Gson().fromJson(presence.getStatus(), new TypeToken<Map<String, String>>(){}.getType());
            if (status.containsKey("MatchId")) {
                socket.joinMatch(status.get("MatchId")).get();
            }
        });
    }
}

Listing matches #

Match Listing takes a number of criteria to filter matches by including player count, a match label and an option to provide a more complex search query.

Sagi-shi matches start in a lobby state. The match exists on the server but the actual gameplay doesn’t start until enough players have joined.

Sagi-shi can then list matches that are waiting for more players:

1
2
3
4
5
6
7
int minPlayers = 2;
int maxPlayers = 10;
int limit = 10;
boolean authoritative = true;
String label = "";
MatchList matchList = client.listMatches(session, minPlayers, maxPlayers, limit, label, authoritative).get();
matchList.getMatchesList().forEach(match -> logger.info("{}: {}/{}", match.getMatchId(), match.getSize(), limit));

To find a match that has a label of "AnExactMatchLabel":

1
String label = "AnExactMatchLabel";

Spawning players #

The match object has a list of current online users, known as presences.

Sagi-shi uses the match presences to spawn players on the client (where a player is represented by a Player object):

1
2
3
4
5
6
7
8
Match match = socket.joinMatch(matchId).get();
Map<String, Player> players = new HashMap<>();

match.getPresences().forEach(presence -> {
    Player player = new Player();
    player.Spawn();
    players.put(presence.getSessionId(), player);
});

Sagi-shi keeps the spawned players up-to-date as they leave and join the match using the match presence received event as a Socket Listener override:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Override
public void onMatchPresence(MatchPresenceEvent e) {
    if (e.getJoins() != null) {
        e.getJoins().forEach(presence -> {
            Player player = new Player();
            player.Spawn();
            players.put(presence.getSessionId(), player);
        });
    }

    if (e.getLeaves() != null) {
        e.getLeaves().forEach(presence -> {
            if (players.containsKey(presence.getSessionId())) {
                Player player = players.get(presence.getSessionId());
                player.Despawn();
                players.remove(presence.getSessionId());
            }
        });
    }
}

Sending match state #

Nakama has real-time networking to send and receive match state as players move and interact with the game world.

During the match, each Sagi-shi client sends match state to the server to be relayed to the other clients.

Match state contains an op code that lets the receiver know what data is being received so they can deserialize it and update their view of the game.

Example op codes used in Sagi-shi:

  • 1: player position
  • 2: player calling vote

Sending player position

Define a class to represent Sagi-shi player position states:

1
2
3
4
5
public class PositionState {
    public float X;
    public float Y;
    public float Z;
}

Create an instance from the player’s transform, set the op code and send the JSON encoded state:

1
2
3
4
5
6
7
8
PositionState state = new PositionState();
state.X = 10;
state.Y = 5;
state.Z = 1;

int opCode = 1;
String json = new Gson().toJson(state, state.getClass());
socket.sendMatchData(matchId, opCode, json.getBytes());

Op Codes as static properties

Sagi-shi has many networked game actions. Using a static class of constants for op codes will keep your code easier to follow and maintain:

1
2
3
4
public class OpCodes {
    public static final long Position = 1;
    public static final long Vote = 2;
}

Receiving match state #

Sagi-shi players can receive match data from the other connected clients by subscribing to the match state received event:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Override
public void onMatchData(MatchData matchData) {
    if (matchData.getOpCode() == OpCodes.Position) {
        // Get the updated position data
        String json = new String(matchData.getData());
        PositionState position = new Gson().fromJson(json, new TypeToken<PositionState>(){}.getType());

        // Update the GameObject associated with that player
        if (players.containsKey(matchData.getPresence().getSessionId())) {
            Player player = players.get(matchData.getPresence().getSessionId());

            // Here we would normally do something like smoothly interpolate to the new position, but for this example let's just set the position directly.
            player.position = position;
        }
    }
}

Matchmaker #

Developers can find matches for players using Match Listing or the Nakama Matchmaker, which enables players join the real-time matchmaking pool and be notified when they are matched with other players that match their specified criteria.

Matchmaking helps players find each other, it does not create a match. This decoupling is by design, allowing you to use matchmaking for more than finding a game match. For example, if you were building a social experience you could use matchmaking to find others to chat with.

Add matchmaker #

Matchmaking criteria can be simple, find 2 players, or more complex, find 2-10 players with a minimum skill level interested in a specific game mode.

Sagi-shi allows players to join the matchmaking pool and have the server match them with other players:

1
2
3
4
5
6
7
8
int minPlayers = 2;
int maxPlayers = 10;
String query = "";
Map<String, String> stringProperties = new HashMap<>();
stringProperties.put("mode", "sabotage");
Map<String, Double> numericProperties = new HashMap<>();
numericProperties.put("skill", 125d);
MatchmakerTicket matchmakerTicket = socket.addMatchmaker(minPlayers, maxPlayers, query, stringProperties, numericProperties).get();

Once a match has been found, it can be joined directly from the onMatchmakerMatched socket handler.

1
2
3
4
@Override
public void onMatchmakerMatched(MatchmakerMatched matchmakerMatched) {
    Match match = socket.joinMatch(matchmakerMatched.getMatchId()).get();
}

Leaderboards #

Nakama Leaderboards introduce a competitive aspect to your game and increase player engagement and retention.

Sagi-shi has a leaderboard of weekly imposter wins, where player scores increase each time they win, and similarly a leaderboard for weekly crew member wins.

Sagi-shi leaderboard screen
Sagi-shi Leaderboard

Creating leaderboards #

Leaderboards have to be created on the server, see the leaderboard documentation for details on creating leaderboards.

Submitting scores #

When players submit scores, Nakama will increment the player’s existing score by the submitted score value.

Along with the score value, Nakama also has a subscore, which can be used for ordering when the scores are the same.

Sagi-shi players can submit scores to the leaderboard with contextual metadata, like the map the score was achieved on:

1
2
3
4
5
6
int score = 1;
int subscore = 0;
Map<String, String> metadata = new HashMap<>();
metadata.put("map", "space_station");

LeaderboardRecord record = client.writeLeaderboardRecord(session, "weekly_imposter_wins", score, subscore, new Gson().toJson(metadata, metadata.getClass())).get();

Listing the top records #

Sagi-shi players can list the top records of the leaderboard:

1
2
3
4
5
6
7
8
int limit = 20;
Iterable<String> ownerIds = null;
int expiry = 0;
String cursor = null;
String leaderboardName = "weekly_imposter_wins";

LeaderboardRecordList list = client.listLeaderboardRecords(session, leaderboardName, ownerIds, expiry, limit, cursor).get();
list.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

Listing records around the user

Nakama allows developers to list leaderboard records around a player.

Sagi-shi gives players a snapshot of how they are doing against players around them:

1
2
3
4
5
6
7
int limit = 20;
int expiry = 0;
String cursor = null;
String leaderboardName = "weekly_imposter_wins";

LeaderboardRecordList list = client.listLeaderboardRecordsAroundOwner(session, leaderboardName, session.getUserId(), expiry, limit).get();
list.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

Listing records for a list of users

Sagi-shi players can get their friends’ scores by supplying their user ids to the owner id parameter:

1
2
3
4
5
6
7
int limit = 20;
int expiry = 0;
String cursor = null;
String leaderboardName = "weekly_imposter_wins";

LeaderboardRecordList list = client.listLeaderboardRecords(session, leaderboardName, friendUserIds, expiry, limit, cursor).get();
list.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

The same approach can be used to get group member’s scores by supplying their user ids to the owner id parameter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
GroupUserList groupUserList = client.listGroupUsers(session, "<GroupId>", -1, 100, null).get();
List<String> groupUserIds = new ArrayList<>();
groupUserList.getGroupUsersList().forEach(user -> groupUserIds.add(user.getUser().getId()));

int limit = 20;
int expiry = 0;
String cursor = null;
String leaderboardName = "weekly_imposter_wins";

LeaderboardRecordList list = client.listLeaderboardRecords(session, leaderboardName, groupUserIds, expiry, limit, cursor).get();
list.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

Deleting records #

Sagi-shi players can delete their own leaderboard records:

1
client.deleteLeaderboardRecord(session, "<LeaderboardId>").get();

Tournaments #

Nakama Tournaments are short lived competitions where players compete for a prize.

Sagi-shi players can view, filter and join running tournaments.

Sagi-shi tournaments screen
Sagi-shi Tournaments

Creating tournaments #

Tournaments have to be created on the server, see the tournament documentation for details on how to create a tournament.

Sagi-shi has a weekly tournament which challenges players to get the most correct imposter votes. At the end of the week the top players receive a prize of in-game currency.

Joining tournaments #

By default in Nakama players don’t have to join tournaments before they can submit a score, but Sagi-shi makes this mandatory:

1
client.joinTournament(session, "<TournamentId>").get();

Listing tournaments #

Sagi-shi players can list and filter tournaments with various criteria:

1
2
3
4
5
6
7
int categoryStart = 1;
int categoryEnd = 2;
long startTime = -1;
long endTime = -1;
int limit = 100;
TournamentList tournamentList = client.listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, null).get();
tournamentList.getTournamentsList().forEach(tournament -> logger.info("{}:{}", tournament.getId(), tournament.getTitle()));

Categories are filtered using a range, not individual numbers, for performance reasons. Structure your categories to take advantage of this (e.g. all PvE tournaments in the 1XX range, all PvP tournaments in the 2XX range, etc.).

Listing records #

Sagi-shi players can list tournament records:

1
2
3
4
5
6
int limit = 20;
String tournamentName = "weekly_top_detective";
int expiry = -1;
String cursor = null;
TournamentRecordList recordList = client.listTournamentRecords(session, tournamentName, expiry, limit, cursor).get();
recordList.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

Listing records around a user

Similarly to leaderboards, Sagi-shi players can get other player scores around them:

1
2
3
4
5
int limit = 20;
String tournamentName = "weekly_top_detective";
int expiry = -1;
TournamentRecordList recordList = client.listTournamentRecordsAroundOwner(session, tournamentName, session.getUserId(), expiry, limit).get();
recordList.getRecordsList().forEach(record -> logger.info("{}:{}", record.getOwnerId(), record.getScore()));

Submitting scores #

Sagi-shi players can submit scores, subscores and metadata to the tournament:

1
2
3
4
5
6
int score = 1;
int subscore = 0;
Map<String, String> metadata = new HashMap<>();
metadata.put("map", "space_station");

client.writeLeaderboardRecord(session, "weekly_top_detective", score, subscore, new Gson().toJson(metadata, metadata.getClass())).get();

Notifications #

Nakama Notifications can be used for the game server to broadcast real-time messages to players.

Notifications can be either persistent (remaining until a player has viewed it) or transient (received only if the player is currently online).

Sagi-shi uses Notifications to notify tournament winners about their winnings.

Sagi-shi notification screen
Sagi-shi notifications

Receiving notifications #

Notifications have to be sent from the server.

Nakama uses a code to differentiate notifications. Codes of 0 and below are system reserved for Nakama internals.

Sagi-shi players can subscribe to the notification received event using the Socket Listener. Sagi-shi uses a code of 100 for tournament winnings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Override
public void onNotifications(NotificationList notificationList) {
    final int rewardCode = 100;
    for (Notification notification : notificationList.getNotificationsList()) {
        if (notification.getCode() == rewardCode) {
            logger.info("Congratulations, you won the tournament!\n{}\n{}", notification.getSubject(), notification.getContent());
        } else {
            logger.info("Other notification: {}:{}\n{}", notification.getCode(), notification.getSubject(), notification.getContent());
        }
    }
}

Listing notifications #

Sagi-shi players can list the notifications they received while offline:

1
2
3
int limit = 100;
NotificationList notificationList = client.listNotifications(session, limit).get();
notificationList.getNotificationsList().forEach(notification -> logger.info("Notification: {}:{}\n{}", notification.getCode(), notification.getSubject(), notification.getContent()));

Pagination and cacheable cursors

Like other listing methods, notification results can be paginated using a cursor or cacheable cursor from the result.

Assuming the cacheable has been saved, the next time the player logs in the cacheable cursor can be used to list unread notifications.

1
2
3
// Assuming this has been saved and loaded
String cacheableCursor = "";
NotificationList nextResults = client.listNotifications(session, limit, cacheableCursor);

Deleting notifications #

Sagi-shi players can delete notifications once they’ve read them:

1
client.deleteNotifications(session, "<NotificationId>", "<AnotherNotificationId>");