View as Markdown

Nakama Unreal Client Guide

In this guide, you’ll learn the core Nakama features for Unreal by building the Nakama-specific parts of Sagi-shi, a fictional multiplayer game used throughout as a working example. The examples focus on Nakama integration and don’t include full game logic or UI.

Sagi-shi gameplay screen
Sagi-shi gameplay
Console Support

The Unreal SDK includes console support for Sony, Microsoft, and Nintendo platforms.

PlayStation, Xbox, and Nintendo Switch logos showing Unreal SDK console platform support

Before you begin #

Ensure that you have:

Confirm that your Unreal project is a C++ project. If it’s Blueprint only, you can add a new C++ file to your project in Unreal Editor by going to File > New C++ Class. Set it private and name it whatever you like. Having this file in your project lets Unreal compile the plugin.

Installation #

The client is available from the Heroic Labs GitHub releases page.

To use Nakama in your Unreal project, you’ll need to copy the Nakama Client files you downloaded into the appropriate place:

  1. Open your Unreal project folder (for example, C:\\MyUnrealProject\\) in Explorer or Finder.
  2. If one doesn’t already exist, create a Plugins folder here.
  3. Copy the Nakama folder from the root of the downloaded release zip and put it in the Plugins folder.

Optional: Put the plugin inside your Unreal Engine plugin folder (for example, C:\Program Files\Epic Games\UE_5.3\Engine\Plugins\) to use the plugin across multiple projects.

Remember to add the module to the dependencies under your project’s Build.cs file. For example:

1
PrivateDependencyModuleNames.AddRange(new string[] { "Nakama" });

At this point, installation is complete. Restart Unreal. After it compiles everything, open Edit > Plugins and search for “Nakama”. You should see the Nakama plugin; make sure to enable it (this may prompt an editor restart).

Updates #

Track new versions and improvements in the Changelog.

Asynchronous programming #

Many of the Nakama APIs are asynchronous and non-blocking.

Sagi-shi calls these methods in a way which does not block the calling thread so that the game is responsive and efficient.

Callbacks run after the function that started the request has returned, so they must not capture local variables by reference. Keep state such as the session as members of a long-lived object, for example your actor, and capture a weak pointer to that object. See Callback threading.

1
2
3
4
5
6
// In the class declaration, for example ASagiShiActor.h
UPROPERTY()
FNakamaClientConfig ClientConfig;

UPROPERTY()
FNakamaSession Session;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const TWeakObjectPtr<ASagiShiActor> WeakThis(this);

const FString DeviceId = TEXT("<DeviceId>");
const FString Username = TEXT("NewUser");
constexpr bool bCreate = true;

Nakama::AuthenticateDevice(ClientConfig, bCreate, Username, DeviceId).Next(
    [WeakThis](const FNakamaSessionResult& AuthenticateResult)
    {
       if (AuthenticateResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Failed to log in: %s"), *AuthenticateResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Session = AuthenticateResult.Value;

       UE_LOG(LogTemp, Log, TEXT("Log in successful: %s"), *AuthenticateResult.Value.Username);
    });

Callback threading #

Nakama callbacks run on the game thread. The HTTP request is sent on a background thread, but Unreal invokes your callback on the next engine tick, on the game thread. You can read and modify actors, components, and other game state directly inside a callback, without using AsyncTask to get back to the game thread first.

A response can arrive many frames after the call that started it, by which point the object that set up the callback may have been destroyed. A delegate bound with AddDynamic holds a weak reference to the UObject and skips the call if that object is gone. A TFunction lambda has no such protection, so if it captures a raw pointer to an object that has since been garbage collected, using that pointer crashes.

When using a lambda, capture a TWeakObjectPtr and check it before use, as in the authentication example above.

The examples in this guide assume that:

  • ClientConfig, Session, RtConnection and any other state a callback updates, such as Cursor, CurrentMatch, CurrentParty, MessageId and Players, are members of the class. Members can’t be named directly in a lambda capture list, so capture them by value with an init-capture such as [ClientConfig = this->ClientConfig], or reach them through WeakThis.
  • WeakThis is a TWeakObjectPtr to that class, created from this before the request.

Values a callback only reads, such as ClientConfig or Session, can instead be captured by value.

Handling exceptions #

Network programming requires additional safeguarding against connection and payload issues.

API calls in Sagi-shi gracefully handle errors:

1
2
3
4
5
6
7
8
Nakama::GetAccount(ClientConfig, Session).Next([](const FNakamaAccountResult& AccountResult)
{
    if (AccountResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("An error occurred: %d:%s"), AccountResult.Error.Code,
              *AccountResult.Error.Message);
    }
});

Serializing and deserializing data #

This guide makes use of JSON serialization and deserialization using the built in JSON utilities inside Unreal Engine.

To use them you must ensure you have included them in your *.build.cs file as follows:

1
PublicDependencyModuleNames.AddRange(new string[] { "Json", "JsonUtilities" });

First define a struct for your data (You can do this in the .h file for your Actor):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
USTRUCT()
struct FPlayer
{
    GENERATED_BODY()

public:
    UPROPERTY()
    FString Username;

    UPROPERTY()
    uint8 Level;
};

To write the struct to an FString (be sure to #include "JsonObjectConverter.h"):

1
2
3
FPlayer Player { "PlayerOne", 99 };
FString Json;
FJsonObjectConverter::UStructToJsonObjectString(Player, Json);

To read from a JSON string into a struct:

1
2
3
4
5
6
FString Json = TEXT("{\"username\": \"PlayerTwo\", \"level\": 10}");
FPlayer PlayerTwo;
if (!FJsonObjectConverter::JsonObjectStringToUStruct(Json, &PlayerTwo))
{
    // Error deserializing
}

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
const FString ServerKey = TEXT("defaultkey");
const FString Host = TEXT("127.0.0.1");
constexpr int32 Port = 7350;
constexpr bool bUseSSL = false;

ClientConfig = FNakamaClientConfig{ServerKey, Host, Port, bUseSSL};

Configuring request retries #

To configure request retry logic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/** Maximum number of retry attempts (0 = no retries). */
constexpr int32 MaxRetries = 4;
/** Base delay in milliseconds before first retry. Actual delay uses exponential backoff with full jitter. */
constexpr int32 BaseDelayMs = 500;
/** Whether to automatically refresh expired sessions before making API calls. */
constexpr bool bAutoRefreshSession = true;
/** How many seconds before expiry a session is considered "about to expire" and gets refreshed. */
constexpr int64 AutoRefreshBufferSeconds = 300;
/** HTTP request timeout in seconds. */
constexpr float Timeout = 10.0f;

FNakamaRetryConfig RetryConfig = FNakamaRetryConfig{
    MaxRetries, BaseDelayMs, bAutoRefreshSession, AutoRefreshBufferSeconds, Timeout
};

This RetryConfig struct can be optionally passed into requests to override the default values.

To be notified when a session is automatically refreshed, for example to persist the new tokens, set the OnSessionRefreshed callback on the config:

1
2
3
4
RetryConfig.OnSessionRefreshed = [](const FNakamaSession& RefreshedSession)
{
    // Store RefreshedSession.Token and RefreshedSession.RefreshToken.
};

If the callback captures a UObject pointer, also set OnSessionRefreshedOwner so the callback is skipped if the object has been garbage-collected.

Nakama Realtime Client #

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

Keep the realtime connection as a member of a long-lived object, such as your actor, so it stays alive for as long as you need it:

1
2
// In the class declaration, for example ASagiShiActor.h
TSharedPtr<FNakamaRtConnection> RtConnection;

Create and connect the real time client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
RtConnection = MakeShared<FNakamaRtConnection>();

constexpr float PingIntervalSeconds = 2.0f;
const FNakamaWebSocketConnectionParams ConnectionParams = {Host, Session.Token, PingIntervalSeconds, Port, bUseSSL};

RtConnection->Connect(ConnectionParams).Next([](const FNakamaWebSocketConnectionResult& ConnectionResult)
{
    if (ConnectionResult.ErrorCode != ENakamaWebSocketError::None)
    {
       UE_LOG(LogTemp, Error, TEXT("Socket connection failed: %d"), (uint8)ConnectionResult.ErrorCode);
       return;
    }

    UE_LOG(LogTemp, Log, TEXT("Socket connection succeeded"));
});

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
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const FString DeviceId = TEXT("<DeviceId>");
const FString Username = TEXT("NewUser");
constexpr bool bCreate = true;

Nakama::AuthenticateDevice(ClientConfig, bCreate, Username, DeviceId).Next(
    [WeakThis](const FNakamaSessionResult& AuthenticateResult)
    {
       if (AuthenticateResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Failed to log in: %s"), *AuthenticateResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Session = AuthenticateResult.Value;

       UE_LOG(LogTemp, Log, TEXT("Log in successful: %s"), *AuthenticateResult.Value.Username);
    });

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
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Authenticate with the Nakama server using Facebook Authentication.
const FString AccountToken = TEXT("<Token>");
const FString Username = TEXT("Username");
constexpr bool bCreate = true;
constexpr bool bImportFriends = true;

Nakama::AuthenticateFacebook(ClientConfig, bCreate, Username, bImportFriends, AccountToken).Next(
    [WeakThis](const FNakamaSessionResult& AuthenticateResult)
    {
       if (AuthenticateResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Failed to log in: %s"), *AuthenticateResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Session = AuthenticateResult.Value;

       UE_LOG(LogTemp, Log, TEXT("Log in successful: %s"), *AuthenticateResult.Value.Username);
    });

Console authentication #

See the console authentication documentation for more information on how to authenticate players on consoles.

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
4
5
6
7
Nakama::LinkDevice(ClientConfig, Session, DeviceId, {}).Next([](const FNakamaVoidResult& LinkResult)
{
    if (LinkResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Error linking device: %s"), *LinkResult.Error.Message);
    }
});

Linking Facebook authentication

1
2
3
4
5
6
7
8
Nakama::LinkFacebook(ClientConfig, Session, bImportFriends, AccountToken).Next(
    [](const FNakamaVoidResult& LinkResult)
    {
       if (LinkResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error linking Facebook: %s"), *LinkResult.Error.Message);
       }
    });

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
 7
 8
 9
10
const TMap<FString, FString> Vars = {
    { "DeviceOS", "<OperatingSystem>" },
    { "DeviceModel", "<DeviceModel>" },
    { "GameVersion", "<GameVersion>" },
    { "InviterUserId", "<SomeUserId>" },
};

/// ...

Nakama::AuthenticateDevice(ClientConfig, bCreate, Username, DeviceId, Vars);

To access session variables:

1
FString DeviceOs = Session.Vars["DeviceOS"];

Session lifecycle #

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

Nakama provides ways to restore sessions, for example when Sagi-shi players re-launch the game, or refresh tokens to keep the session active while the game is being played.

Use the auth and refresh tokens on the session object to restore or refresh sessions.

Store the tokens for use later:

1
2
FString AuthToken = Session.Token;
FString RefreshToken = Session.RefreshToken;

Restore a session without having to re-authenticate:

1
Session.Update(AuthToken, RefreshToken);

Check if a session has expired or is close to expiring and refresh it to keep it alive:

 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
// Check whether a session has expired or is close to expiry.
if (constexpr int64 InOneDay = 24 * 60 * 60; !Session.IsExpired(InOneDay))
{
    return;
}

// Refresh the existing session
Nakama::SessionRefresh(ClientConfig, RefreshToken, {}).Next(
    [WeakThis](const FNakamaSessionResult& RefreshResult)
    {
       if (RefreshResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Refresh failed: %s"), *RefreshResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Session = RefreshResult.Value;

       UE_LOG(LogTemp, Log, TEXT("Refresh successful: %s"), *RefreshResult.Value.Username);
    });

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
 6
 7
 8
 9
10
11
12
Nakama::GetAccount(ClientConfig, Session).Next([](const FNakamaAccountResult& AccountResult)
{
    if (AccountResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Failed to get user account: %s"), *AccountResult.Error.Message);
       return;
    }

    const FString Username = AccountResult.Value.User.Username;
    const FString AvatarUrl = AccountResult.Value.User.AvatarUrl;
    const FString UserId = AccountResult.Value.User.Id;
});

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
 8
 9
10
11
12
13
14
15
16
17
18
const FString Username = TEXT("<NewUsername>");
const FString DisplayName = TEXT("<NewDisplayName>");
const FString AvatarUrl = TEXT("<NewAvatarUrl>");
const FString LanguageTag = TEXT("<NewLanguageTag>");
const FString Location = TEXT("<NewLocation>");
const FString Timezone = TEXT("<NewTimezone>");

Nakama::UpdateAccount(ClientConfig, Session, Username, DisplayName, AvatarUrl, LanguageTag, Location, Timezone).
    Next([](const FNakamaVoidResult& UpdateResult)
    {
       if (UpdateResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Failed to update user account: %s"), *UpdateResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Account successfully updated"));
    });

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 or usernames.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const TArray<FString> UserIds = { TEXT("<AnotherUserId>") };
const TArray<FString> Usernames = { TEXT("<AnotherUsername>") };
const TArray<FString> FacebookIds = { TEXT("<AnotherFacebookId>") };

Nakama::GetUsers(ClientConfig, Session, UserIds, Usernames, FacebookIds).Next(
    [](const FNakamaUsersResult& UsersResult)
    {
       if (UsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving users: %s"), *UsersResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully retrieved %d users"), UsersResult.Value.Users.Num());
    });

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
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
USTRUCT()
struct FUserMetadata
{
    GENERATED_BODY()

public:
    UPROPERTY()
    FString Title;

    UPROPERTY()
    FString Hat;

    UPROPERTY()
    FString Skin;
};

Nakama::GetAccount(ClientConfig, Session).Next([](const FNakamaAccountResult& AccountResult)
{
    if (AccountResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Failed to get user account: %s"), *AccountResult.Error.Message);
       return;
    }

    FUserMetadata UserMetadata;
    FJsonObjectConverter::JsonObjectStringToUStruct(AccountResult.Value.User.Metadata, &UserMetadata);
    UE_LOG(LogTemp, Log, TEXT("Title: %s"), *UserMetadata.Title);
    UE_LOG(LogTemp, Log, TEXT("Hat: %s"), *UserMetadata.Hat);
    UE_LOG(LogTemp, Log, TEXT("Skin: %s"), *UserMetadata.Skin);
});

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
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
USTRUCT()
struct FUserWallet
{
    GENERATED_BODY()

public:
    UPROPERTY()
    int32 Coins;
};

Nakama::GetAccount(ClientConfig, Session, RetryConfig).Next([](const FNakamaAccountResult& AccountResult)
{
    if (AccountResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Failed to get user account: %s"), *AccountResult.Error.Message);
       return;
    }

    FUserWallet UserWallet;
    FJsonObjectConverter::JsonObjectStringToUStruct(AccountResult.Value.Wallet, &UserWallet);
    UE_LOG(LogTemp, Log, TEXT("Coins: %d"), UserWallet.Coins);
});

Updating wallets #

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

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 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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
USTRUCT()
struct FUnlocksStorageObject
{
    GENERATED_BODY()

public:
    UPROPERTY()
    TArray<FString> Hats;
};

FNakamaReadStorageObjectId Id;
Id.Collection = TEXT("Unlocks");
Id.Key = TEXT("Hats");
Id.UserId = Session.UserId;
TArray<FNakamaReadStorageObjectId> StorageObjectIds = { Id };

Nakama::ReadStorageObjects(ClientConfig, Session, StorageObjectIds).Next(
    [](const FNakamaStorageObjectsResult& StorageReadResult)
    {
       if (StorageReadResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error reading storage objects: %s"), *StorageReadResult.Error.Message);
          return;
       }

       if (StorageReadResult.Value.Objects.Num() != 1)
       {
          UE_LOG(LogTemp, Warning, TEXT("Incorrect amount of storage objects: %d"),
                 StorageReadResult.Value.Objects.Num());
          return;
       }

       FUnlocksStorageObject HatsStorageObject;
       if (!FJsonObjectConverter::JsonObjectStringToUStruct(StorageReadResult.Value.Objects[0].Value,
                                                            &HatsStorageObject, 0, 0))
       {
          UE_LOG(LogTemp, Error, TEXT("Failed to deserialize storage object."));
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Unlocked Hats:"));
       for (FString Hat : HatsStorageObject.Hats)
       {
          UE_LOG(LogTemp, Log, TEXT("%s"), *Hat);
       }
    });

To read other players’ public storage objects use their UserId instead. Remember that 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
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
FUnlocksStorageObject HatsStorageObject;
HatsStorageObject.Hats.Add(TEXT("cowboy"));
HatsStorageObject.Hats.Add(TEXT("alien"));

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(HatsStorageObject, Json);

FNakamaWriteStorageObject WriteObject;
WriteObject.Collection = "Unlocks";
WriteObject.Key = "Hats";
WriteObject.PermissionWrite = static_cast<int32_t>(
    ENakamaStoragePermissionWrite::STORAGE_PERMISSION_WRITE_OWNER_WRITE);
WriteObject.PermissionRead = static_cast<int32_t>(
    ENakamaStoragePermissionRead::STORAGE_PERMISSION_READ_OWNER_READ);
WriteObject.Value = Json;

const TArray StorageObjectsData = {WriteObject};

Nakama::WriteStorageObjects(ClientConfig, Session, StorageObjectsData).Next(
    [](const FNakamaStorageObjectAcksResult& StorageWriteResult)
    {
       if (StorageWriteResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error writing storage objects: %s"), *StorageWriteResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Success writing storage objects"));
    });

You can also pass multiple write objects:

1
2
const TArray<FNakamaWriteStorageObject> StorageObjectsData = { WriteObject1, WriteObject2, WriteObject3 };
Nakama::WriteStorageObjects(ClientConfig, Session, StorageObjectsData);

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
 7
 8
 9
10
11
12
13
14
15
// Assuming we already have the acknowledgement (StorageObjectAck) from a previous write or the
// version of a storage object we read.
FNakamaWriteStorageObject WriteObject2;
WriteObject2.Collection = "Unlocks";
WriteObject2.Key = "Hats";
WriteObject2.PermissionWrite = static_cast<int32_t>(
    ENakamaStoragePermissionWrite::STORAGE_PERMISSION_WRITE_OWNER_WRITE);
WriteObject2.PermissionRead = static_cast<int32_t>(
    ENakamaStoragePermissionRead::STORAGE_PERMISSION_READ_OWNER_READ);
WriteObject2.Value = Json;
WriteObject2.Version = StorageObjectAck.Version;

const TArray StorageObjectsData = { WriteObject2 };

Nakama::WriteStorageObjects(ClientConfig, Session, StorageObjectsData);

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
 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
const FString Collection = TEXT("Unlocks");
constexpr int32 Limit = 3;

Nakama::ListStorageObjects(ClientConfig, Session, Session.UserId, Collection, Limit, Cursor).Next(
    [WeakThis](const FNakamaStorageObjectListResult& StorageListResult)
    {
       if (StorageListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error reading storage objects: %s"), *StorageListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = StorageListResult.Value.Cursor;

       for (const FNakamaStorageObject& StorageObject : StorageListResult.Value.Objects)
       {
          FUnlocksStorageObject ParsedStorageObject;
          FJsonObjectConverter::JsonObjectStringToUStruct(StorageObject.Value, &ParsedStorageObject);

          UE_LOG(LogTemp, Log, TEXT("%s Unlocks:"), *StorageObject.Key);
          for (FString Hat : ParsedStorageObject.Hats)
          {
             UE_LOG(LogTemp, Log, TEXT("%s"), *Hat);
          }
       }
    });

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 an empty string, you will get results from the first object.
1
2
// Assuming you have updated the cached cursor in the previous call to ListStorageObjects.
Nakama::ListStorageObjects(ClientConfig, Session, Session.UserId, Collection, Limit, 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
 5
 6
 7
 8
 9
10
11
12
13
const FString RpcId = TEXT("EquipHat");
const FString Payload = TEXT("<Payload>");

Nakama::RpcFunc(ClientConfig, Session, RpcId, Payload).Next([](const FNakamaRpcResult& Result)
{
    if (Result.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Error equipping hat: %s"), *Result.Error.Message);
       return;
    }

    UE_LOG(LogTemp, Log, TEXT("New hat successfully equipped"));
});

Like all client API calls, RPC callbacks run on the game thread. See Callback threading for details and for the object lifetime caveat when using lambdas.

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
 8
 9
10
11
12
13
14
15
const TArray<FString> Ids = {"<SomeUserId>", "<AnotherUserId>"};
const TArray<FString> Usernames = {"AlwaysTheImposter21", "SneakyBoi"};
const FString Metadata = TEXT("<Metadata>");

Nakama::AddFriends(ClientConfig, Session, Ids, Usernames, Metadata).Next(
    [](const FNakamaVoidResult& AddFriendResult)
    {
       if (AddFriendResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error adding friends: %s"), *AddFriendResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully added friends"));
    });

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
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
constexpr int32 Limit = 20;
constexpr int32 State = static_cast<int32_t>(ENakamaFriendState::FRIEND);

Nakama::ListFriends(ClientConfig, Session, Limit, State, Cursor).Next(
    [WeakThis](const FNakamaFriendListResult& FriendListResult)
    {
       if (FriendListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving friends: %s"), *FriendListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = FriendListResult.Value.Cursor;

       UE_LOG(LogTemp, Log, TEXT("Friends:"));
       for (const FNakamaFriend& Friend : FriendListResult.Value.Friends)
       {
          UE_LOG(LogTemp, Log, TEXT("ID: %s"), *Friend.User.Id);
       }
    });

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
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
constexpr int32 Limit = 1000;
constexpr int32 State = static_cast<int32_t>(ENakamaFriendState::INVITE_RECEIVED);

Nakama::ListFriends(ClientConfig, Session, Limit, State, Cursor).Next(
    [WeakThis, ClientConfig = this->ClientConfig, Session = this->Session](const FNakamaFriendListResult& FriendListResult)
    {
       if (FriendListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving friends: %s"), *FriendListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = FriendListResult.Value.Cursor;

       for (const FNakamaFriend& Friend : FriendListResult.Value.Friends)
       {
          Nakama::AddFriends(ClientConfig, Session, {Friend.User.Id}, {}, TEXT("<Metadata>"));
       }
    });

Deleting friends #

Sagi-shi players can decline friend requests and remove friends by their username or user id:

 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
// Delete friends by User Id
Nakama::DeleteFriends(ClientConfig, Session, {TEXT("<SomeUserId>"), TEXT("<AnotherUserId>")}, {}).Next(
    [](const FNakamaVoidResult& DeleteFriendResult)
    {
       if (DeleteFriendResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error deleting friends: %s"), *DeleteFriendResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully deleted friends"));
    });

// Delete friends by Username
Nakama::DeleteFriends(ClientConfig, Session, {}, {TEXT("<SomeUsername>"), TEXT("<AnotherUsername>")})
    .Next([](const FNakamaVoidResult& DeleteFriendResult)
    {
       if (DeleteFriendResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error deleting friends: %s"), *DeleteFriendResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully deleted friends"));
    });

Blocking users #

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

 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
// Block friends by User Id
Nakama::BlockFriends(ClientConfig, Session, {TEXT("<SomeUserId>"), TEXT("<AnotherUserId>")}, {}, RetryConfig).Next(
    [](const FNakamaVoidResult& BlockFriendResult)
    {
       if (BlockFriendResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error blocking friends: %s"), *BlockFriendResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully blocked friends"));
    });

// Block friends by Username
Nakama::BlockFriends(ClientConfig, Session, {}, {TEXT("<SomeUsername>"), TEXT("<AnotherUsername>")})
    .Next([](const FNakamaVoidResult& BlockFriendResult)
    {
       if (BlockFriendResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error blocking friends: %s"), *BlockFriendResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully blocked friends"));
    });

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
RtConnection->StatusPresenceEvent.AddLambda([](const FNakamaRtStatusPresenceEvent& StatusPresenceEvent)
{
    for (const FNakamaRtUserPresence& Leave : StatusPresenceEvent.Leaves)
    {
       UE_LOG(LogTemp, Log, TEXT("%s (%s) left status: %s"), *Leave.Username, *Leave.UserId, *Leave.Status);
    }

    for (const FNakamaRtUserPresence& Join : StatusPresenceEvent.Joins)
    {
       UE_LOG(LogTemp, Log, TEXT("%s (%s) new status: %s"), *Join.Username, *Join.UserId, *Join.Status);
    }
});

constexpr int32 Limit = 1000;
constexpr int32 State = static_cast<int32_t>(ENakamaFriendState::FRIEND);

Nakama::ListFriends(ClientConfig, Session, Limit, State, Cursor).Next(
    [WeakThis, RtConnection = this->RtConnection](const FNakamaFriendListResult& FriendListResult)
    {
       if (FriendListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving friends: %s"), *FriendListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = FriendListResult.Value.Cursor;

       for (const FNakamaFriend& Friend : FriendListResult.Value.Friends)
       {
          NakamaRt::StatusFollow(RtConnection, {Friend.User.Id}, {}).Next(
             [](const NakamaRt::FNakamaRtResult<FNakamaRtStatus>& StatusFollowResult)
             {
                if (StatusFollowResult.Error.IsSet())
                {
                   UE_LOG(LogTemp, Error, TEXT("Error following users: %s"),
                          *StatusFollowResult.Error.GetValue().Message);
                   return;
                }

                UE_LOG(LogTemp, Log, TEXT("Successfully followed users"));

                if (StatusFollowResult.Data.IsSet())
                {
                   for (const FNakamaRtUserPresence& Presence : StatusFollowResult.Data.GetValue().Presences)
                   {
                      UE_LOG(LogTemp, Log, TEXT("%s (%s) current status: %s"), *Presence.Username,
                             *Presence.UserId, *Presence.Status);
                   }
                }
             });
       }
    });

Unfollow users #

Sagi-shi players can unfollow others:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
NakamaRt::StatusUnfollow(RtConnection, {TEXT("<UserId>")}).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtEmptyResponse>& UnfollowResponse)
    {
       if (UnfollowResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error unfollowing users: %s"), *UnfollowResponse.Error.GetValue().Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully unfollowed users"));
    });

Updating player status #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
NakamaRt::StatusUpdate(RtConnection, {TEXT("Viewing the Main Menu")}).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtEmptyResponse>& UpdateResponse)
    {
       if (UpdateResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error updating status: %s"), *UpdateResponse.Error.GetValue().Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully updated status"));
    });

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
 8
 9
10
11
12
13
14
15
16
17
18
const FString Name = TEXT("Imposters R Us");
const FString Description = TEXT("A group for people who love playing the imposters.");
const FString LangTag = TEXT("");
const FString AvatarUrl = TEXT("");
constexpr bool bOpen = true;
constexpr int32 MaxCount = 100;

Nakama::CreateGroup(ClientConfig, Session, Name, Description, LangTag, AvatarUrl, bOpen, MaxCount)
    .Next([](const FNakamaGroupResult& CreateGroupResult)
    {
       if (CreateGroupResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error creating group: %s"), *CreateGroupResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully created group: %s"), *CreateGroupResult.Value.Id);
    });

Update group visibility #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const FString GroupId = TEXT("<GroupId>");
const FString Name = TEXT("Imposters R Us");
const FString Description = TEXT("A group for people who love playing the imposters.");
const FString LangTag = TEXT("");
const FString AvatarUrl = TEXT("");
constexpr bool bOpen = true;

Nakama::UpdateGroup(ClientConfig, Session, GroupId, Name, Description, LangTag, AvatarUrl, bOpen).Next(
    [](const FNakamaVoidResult& UpdateGroupResult)
    {
       if (UpdateGroupResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error updating group: %s"), *UpdateGroupResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully updated group"));
    });

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. The name filter supports a wildcard and is mutually exclusive with the language tag, member count and open filters, so a request can use a name filter or the other filters, but not both.

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

To search by name, pass the name filter and leave the other filters empty:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const FString NameFilter = TEXT("imposter%");
constexpr int32 Limit = 20;

Nakama::ListGroups(ClientConfig, Session, NameFilter, Cursor, Limit, TEXT(""), {}, {}).Next(
    [WeakThis](const FNakamaGroupListResult& GroupListResult)
    {
       if (GroupListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing groups: %s"), *GroupListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = GroupListResult.Value.Cursor;

       for (const FNakamaGroup& Group : GroupListResult.Value.Groups)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %s"), *Group.Name, Group.Open.GetValue() ? TEXT("Public") : TEXT("Private"));
       }
    });

To filter by language, member count and visibility instead, leave the name filter empty:

 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
constexpr int32 Limit = 20;
const FString LangTag = TEXT("en");
constexpr int32 Members = 10;
constexpr bool bOpen = true;

Nakama::ListGroups(ClientConfig, Session, TEXT(""), Cursor, Limit, LangTag, Members, bOpen).Next(
    [WeakThis](const FNakamaGroupListResult& GroupListResult)
    {
       if (GroupListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing groups: %s"), *GroupListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = GroupListResult.Value.Cursor;

       for (const FNakamaGroup& Group : GroupListResult.Value.Groups)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %s"), *Group.Name, Group.Open.GetValue() ? TEXT("Public") : TEXT("Private"));
       }
    });

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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const FString GroupId = TEXT("<GroupId>");

Nakama::DeleteGroup(ClientConfig, Session, GroupId).Next([](const FNakamaVoidResult& DeleteGroupResult)
{
    if (DeleteGroupResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Error deleting group: %s"), *DeleteGroupResult.Error.Message);
       return;
    }

    UE_LOG(LogTemp, Log, TEXT("Successfully deleted group"));
});

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
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/* Define a metadata struct */
USTRUCT()
struct FGroupMetadata
{
	GENERATED_BODY()

public:
	UPROPERTY()
	TArray<FString> Interests;

	UPROPERTY()
	TArray<FString> ActiveTimes;

	UPROPERTY()
	TArray<FString> Languages;
};

const FString GroupId = TEXT("<GroupId>");
FGroupMetadata GroupMetadata;
GroupMetadata.Interests = {"Deception", "Sabotage", "Cute Furry Bunnies"};
GroupMetadata.ActiveTimes = {"9am-2pm Weekdays", "9am-10am Weekends"};
GroupMetadata.Languages = {"English", "German"};

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(GroupMetadata, Json);

Nakama::RpcFunc(ClientConfig, Session, TEXT("UpdateGroupMetadata"), Json).Next(
    [](const FNakamaRpcResult& UpdateGroupMetadataResult)
    {
       if (UpdateGroupMetadataResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error updating group metadata: %s"),
                 *UpdateGroupMetadataResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully updated group metadata"));
    });

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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const FString GroupId = TEXT("<GroupId>");

Nakama::JoinGroup(ClientConfig, Session, GroupId).Next([](const FNakamaVoidResult& JoinGroupResult)
{
    if (JoinGroupResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Error joining group: %s"), *JoinGroupResult.Error.Message);
       return;
    }

    UE_LOG(LogTemp, Log, TEXT("Successfully joined group"));
});

Listing the user’s groups #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
constexpr int32 Limit = 100;
constexpr int32 State = static_cast<int32_t>(ENakamaUserGroupListUserGroupState::MEMBER);

Nakama::ListUserGroups(ClientConfig, Session, Session.UserId, Limit, State, Cursor).Next(
    [WeakThis](const FNakamaUserGroupListResult& UserGroupListResult)
    {
       if (UserGroupListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing user groups: %s"), *UserGroupListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = UserGroupListResult.Value.Cursor;

       for (const auto& [Group, State] : UserGroupListResult.Value.UserGroups)
       {
          UE_LOG(LogTemp, Log, TEXT("%s"), *Group.Name);
       }
    });

Listing members #

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

 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
const FString GroupId = TEXT("<GroupId>");
constexpr int32 Limit = 100;
constexpr int32 State = static_cast<int32_t>(ENakamaGroupUserListGroupUserState::MEMBER);

Nakama::ListGroupUsers(ClientConfig, Session, GroupId, Limit, State, Cursor).Next(
    [WeakThis](const FNakamaGroupUserListResult& ListGroupUsersResult)
    {
       if (ListGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing group users: %s"), *ListGroupUsersResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = ListGroupUsersResult.Value.Cursor;

       for (const auto& [User, State] : ListGroupUsersResult.Value.GroupUsers)
       {
          UE_LOG(LogTemp, Log, TEXT("%s"), *User.Username);
       }
    });

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
 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
35
36
37
38
const FString GroupId = TEXT("<GroupId>");
constexpr int32 Limit = 100;
constexpr int32 State = static_cast<int32_t>(ENakamaGroupUserListGroupUserState::JOIN_REQUEST);

Nakama::ListGroupUsers(ClientConfig, Session, GroupId, Limit, State, Cursor).Next(
    [WeakThis, Session = this->Session, ClientConfig = this->ClientConfig, GroupId](const FNakamaGroupUserListResult& ListGroupUsersResult)
    {
       if (ListGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing group users: %s"), *ListGroupUsersResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = ListGroupUsersResult.Value.Cursor;

       for (const auto& [User, State] : ListGroupUsersResult.Value.GroupUsers)
       {
          UE_LOG(LogTemp, Log, TEXT("%s"), *User.Username);

          Nakama::AddGroupUsers(ClientConfig, Session, GroupId, {User.Id}).Next(
             [](const FNakamaVoidResult& AddGroupUsersResult)
             {
                if (AddGroupUsersResult.bIsError)
                {
                   UE_LOG(LogTemp, Error, TEXT("Error adding group users: %s"),
                          *AddGroupUsersResult.Error.Message);
                   return;
                }

                UE_LOG(LogTemp, Log, TEXT("Successfully added group users"));
             });
       }
    });

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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const FString GroupId = TEXT("<GroupId>");

Nakama::PromoteGroupUsers(ClientConfig, Session, GroupId, {TEXT("<UserId>")}).Next(
    [](const FNakamaVoidResult& PromoteGroupUsersResult)
    {
       if (PromoteGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error promoting group users: %s"), *PromoteGroupUsersResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully promoted group users"));
    });

Demoting members #

Sagi-shi group admins and superadmins can demote members:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const FString GroupId = TEXT("<GroupId>");

Nakama::DemoteGroupUsers(ClientConfig, Session, GroupId, {TEXT("<UserId>")}).Next(
    [](const FNakamaVoidResult& DemoteGroupUsersResult)
    {
       if (DemoteGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error demoting group users: %s"), *DemoteGroupUsersResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully demoted group users"));
    });

Kicking members #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const FString GroupId = TEXT("<GroupId>");

Nakama::KickGroupUsers(ClientConfig, Session, GroupId, {TEXT("<UserId>")}).Next(
    [](const FNakamaVoidResult& KickGroupUsersResult)
    {
       if (KickGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error kicking group users: %s"), *KickGroupUsersResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully kicked group users"));
    });

Banning members #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const FString GroupId = TEXT("<GroupId>");

Nakama::BanGroupUsers(ClientConfig, Session, GroupId, {TEXT("<UserId>")}).Next(
    [](const FNakamaVoidResult& BanGroupUsersResult)
    {
       if (BanGroupUsersResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error banning group users: %s"), *BanGroupUsersResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully banned group users"));
    });

Leaving groups #

Sagi-shi players can leave a group:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const FString GroupId = TEXT("<GroupId>");

Nakama::LeaveGroup(ClientConfig, Session, GroupId).Next([](const FNakamaVoidResult& LeaveGroupResult)
{
    if (LeaveGroupResult.bIsError)
    {
       UE_LOG(LogTemp, Error, TEXT("Error leaving group: %s"), *LeaveGroupResult.Error.Message);
       return;
    }

    UE_LOG(LogTemp, Log, TEXT("Successfully left group"));
});

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
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString RoomName = TEXT("<MatchId>");
constexpr int32 ChannelType = static_cast<int32_t>(ENakamaRtChannelJoinType::ROOM);
constexpr bool bPersistence = false;
constexpr bool bHidden = false;

NakamaRt::ChannelJoin(RtConnection, RoomName, ChannelType, bPersistence, bHidden).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtChannel>& ChannelJoinResult)
    {
       if (ChannelJoinResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error connecting to channel: %s"),
                 *ChannelJoinResult.Error.GetValue().Message);
          return;
       }

       if (ChannelJoinResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Connected to channel: %s"), *ChannelJoinResult.Data.GetValue().Id);
       }
    });

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
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString UserId = TEXT("<UserId>");
constexpr int32 ChannelType = static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);
constexpr bool bPersistence = true;
constexpr bool bHidden = false;

NakamaRt::ChannelJoin(RtConnection, UserId, ChannelType, bPersistence, bHidden).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtChannel>& ChannelJoinResult)
    {
       if (ChannelJoinResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error connecting to channel: %s"),
                 *ChannelJoinResult.Error.GetValue().Message);
          return;
       }

       if (ChannelJoinResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Connected to channel: %s"), *ChannelJoinResult.Data.GetValue().Id);
       }
    });

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
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString GroupId = TEXT("<GroupId>");
constexpr int32 ChannelType = static_cast<int32_t>(ENakamaRtChannelJoinType::GROUP);
constexpr bool bPersistence = true;
constexpr bool bHidden = false;

NakamaRt::ChannelJoin(RtConnection, GroupId, ChannelType, bPersistence, bHidden).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtChannel>& ChannelJoinResult)
    {
       if (ChannelJoinResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error connecting to channel: %s"),
                 *ChannelJoinResult.Error.GetValue().Message);
          return;
       }

       if (ChannelJoinResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Connected to channel: %s"), *ChannelJoinResult.Data.GetValue().Id);
       }
    });

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
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
USTRUCT()
struct FChatMessage
{
	GENERATED_BODY()

public:
	UPROPERTY()
	FString Message;

	UPROPERTY()
	FString Emote;

	UPROPERTY()
	FString EmoteTarget;

  	UPROPERTY()
  	FString MatchId;

  	UPROPERTY()
  	FString PartyId;
};

const FString ChannelId = TEXT("<ChannelId>");

FChatMessage ChatMessage;
ChatMessage.Message = TEXT("I think Red is the imposter!");

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(ChatMessage, Json);

NakamaRt::ChannelMessageSend(RtConnection, ChannelId, Json).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtChannelMessageAck>& MessageSendResponse)
    {
       if (MessageSendResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error sending message: %s"),
                 *MessageSendResponse.Error.GetValue().Message);
          return;
       }

       if (MessageSendResponse.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Successfully sent message: %s"),
                 *MessageSendResponse.Data.GetValue().MessageId);
       }
    });

FChatMessage ChatMessage2;
ChatMessage2.Emote = TEXT("point");
ChatMessage2.EmoteTarget = TEXT("<RedPlayerUserId>");

FString Json2;
FJsonObjectConverter::UStructToJsonObjectString(ChatMessage2, Json2);

NakamaRt::ChannelMessageSend(RtConnection, ChannelId, Json2).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtChannelMessageAck>& MessageSendResponse)
    {
       if (MessageSendResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error sending message: %s"),
                 *MessageSendResponse.Error.GetValue().Message);
          return;
       }

       if (MessageSendResponse.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Successfully sent message: %s"),
                 *MessageSendResponse.Data.GetValue().MessageId);
       }
    });

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
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const FString ChannelId = TEXT("<ChannelId>");
constexpr int32 Limit = 100;
constexpr bool bForward = false;

Nakama::ListChannelMessages(ClientConfig, Session, ChannelId, Limit, bForward, Cursor).Next(
    [WeakThis](const FNakamaChannelMessageListResult& MessageListResult)
    {
       if (MessageListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving message: %s"), *MessageListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = MessageListResult.Value.NextCursor;

       for (const FNakamaChannelMessage& Message : MessageListResult.Value.Messages)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %s"), *Message.Username, *Message.Content);
       }
    });

Chat also has cacheable cursors to fetch the most recent messages. Read more about cacheable cursors in the listing notifications documentation.

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
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const FString ChannelId = TEXT("<ChannelId>");

FChatMessage ChatMessage;
ChatMessage.Message = TEXT("I think Red is the imposter!");

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(ChatMessage, Json);

NakamaRt::ChannelMessageSend(RtConnection, ChannelId, Json).Next(
    [WeakThis](const NakamaRt::FNakamaRtResult<FNakamaRtChannelMessageAck>& MessageSendResponse)
    {
       if (MessageSendResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error sending message: %s"),
                 *MessageSendResponse.Error.GetValue().Message);
          return;
       }

       if (MessageSendResponse.Data.IsSet())
       {
          if (!WeakThis.IsValid())
          {
             return;
          }

          WeakThis->MessageId = MessageSendResponse.Data.GetValue().MessageId;
          UE_LOG(LogTemp, Log, TEXT("Successfully sent message: %s"), *WeakThis->MessageId);
       }
    });

Once the send has been acknowledged and MessageId is set, they can quickly edit their message to confuse others. Message content is the JSON serialized chat message, so serialize the updated message the same way:

1
2
3
4
5
6
7
FChatMessage UpdatedChatMessage;
UpdatedChatMessage.Message = TEXT("I think BLUE is the imposter!");

FString UpdatedJson;
FJsonObjectConverter::UStructToJsonObjectString(UpdatedChatMessage, UpdatedJson);

NakamaRt::ChannelMessageUpdate(RtConnection, ChannelId, MessageId, UpdatedJson);

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
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const FString MatchName = TEXT("MyMatchName");

NakamaRt::MatchCreate(RtConnection, MatchName).Next(
    [WeakThis, Session = this->Session, RtConnection = this->RtConnection, ClientConfig = this->ClientConfig](
    const NakamaRt::FNakamaRtResult<FNakamaRtMatch>& MatchCreateResponse)
    {
       if (MatchCreateResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error creating match: %s"), *MatchCreateResponse.Error.GetValue().Message);
          return;
       }

       if (!MatchCreateResponse.Data.IsSet()) return;

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->CurrentMatch = MatchCreateResponse.Data.GetValue();

       UE_LOG(LogTemp, Log, TEXT("Created match: %s"), *WeakThis->CurrentMatch.MatchId);

       // Get the user's friends so we can send them invites to the match
       Nakama::ListFriends(ClientConfig, Session, 100, static_cast<int32_t>(ENakamaFriendState::FRIEND), TEXT("")).Next(
          [RtConnection, CurrentMatch = WeakThis->CurrentMatch](const FNakamaFriendListResult& FriendListResult)
          {
             if (FriendListResult.bIsError)
             {
                UE_LOG(LogTemp, Error, TEXT("Error retrieving friends: %s"), *FriendListResult.Error.Message);
                return;
             }

             for (const FNakamaFriend& Friend : FriendListResult.Value.Friends)
             {
                FChatMessage ChatMessage;
                ChatMessage.Message = FString::Printf(
                   TEXT("Hey %s, join me for a match!"), *Friend.User.Username);
                ChatMessage.MatchId = CurrentMatch.MatchId;

                FString Json;
                FJsonObjectConverter::UStructToJsonObjectString(ChatMessage, Json);

                // Join a direct message channel with the friend, then send the message to that channel
                constexpr int32 ChannelType = static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);
                constexpr bool bPersistence = true;
                constexpr bool bHidden = false;

                NakamaRt::ChannelJoin(RtConnection, Friend.User.Id, ChannelType, bPersistence, bHidden).Next(
                    [RtConnection, Json](const NakamaRt::FNakamaRtResult<FNakamaRtChannel>& ChannelJoinResult)
                    {
                       if (ChannelJoinResult.Error.IsSet())
                       {
                          UE_LOG(LogTemp, Error, TEXT("Error joining direct channel: %s"),
                                 *ChannelJoinResult.Error.GetValue().Message);
                          return;
                       }

                       if (ChannelJoinResult.Data.IsSet())
                       {
                          NakamaRt::ChannelMessageSend(RtConnection, ChannelJoinResult.Data.GetValue().Id, Json);
                       }
                    });
             }
          });
    });

Joining matches #

Sagi-shi players can try to join existing matches if they know the id, or the token. Whichever isn’t being used should be left blank:

 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
const FString MatchId = TEXT("<MatchId>"); // Used for server authoritative matches
const FString Token = TEXT(""); // Used for client relayed matches
const TMap<FString, FString> Metadata = {
    { TEXT("Region"), TEXT("EU") }
};

NakamaRt::MatchJoin(RtConnection, MatchId, Token, Metadata).Next(
    [WeakThis](const NakamaRt::FNakamaRtResult<FNakamaRtMatch>& MatchJoinResponse)
    {
       if (MatchJoinResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error joining match: %s"), *MatchJoinResponse.Error.GetValue().Message);
          return;
       }

       if (MatchJoinResponse.Data.IsSet())
       {
          if (!WeakThis.IsValid())
          {
             return;
          }

          WeakThis->CurrentMatch = MatchJoinResponse.Data.GetValue();
          UE_LOG(LogTemp, Log, TEXT("Successfully joined match: %s"), *WeakThis->CurrentMatch.MatchId);
       }
    });

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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
RtConnection->MatchmakerMatched.AddLambda([WeakThis](const FNakamaRtMatchmakerMatched& MatchedResult)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    const TMap<FString, FString> Metadata = {};
    // Relayed matches are joined with the token, authoritative matches with the match id.
    NakamaRt::MatchJoin(WeakThis->RtConnection, MatchedResult.MatchId, MatchedResult.Token, Metadata);
});

constexpr int32 MinPlayers = 2;
constexpr int32 MaxPlayers = 10;
const FString Query = TEXT("");
const TMap<FString, FString> StringProperties = {};
const TMap<FString, double> NumericProperties = {};
constexpr int32 CountMultiple = 0;

NakamaRt::MatchmakerAdd(RtConnection, MinPlayers, MaxPlayers, Query, CountMultiple, StringProperties,
                        NumericProperties).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtMatchmakerTicket>& MatchmakerAddResult)
    {
       if (MatchmakerAddResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error adding matchmaker: %s"),
                 *MatchmakerAddResult.Error.GetValue().Message);
          return;
       }

       if (MatchmakerAddResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Successfully joined matchmaker: %s"),
                 *MatchmakerAddResult.Data.GetValue().Ticket);
       }
    });

Joining matches from player status

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
USTRUCT()
struct FUserStatus
{
	GENERATED_BODY()

public:
	UPROPERTY()
	FString Status;
	
	UPROPERTY()
	FString MatchId;
};

FUserStatus UserStatus;
UserStatus.Status = TEXT("Playing a match");
UserStatus.MatchId = TEXT("<MatchId>");

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(UserStatus, Json);

NakamaRt::StatusUpdate(RtConnection, Json);

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
12
13
14
15
16
17
18
RtConnection->StatusPresenceEvent.AddLambda([WeakThis](const FNakamaRtStatusPresenceEvent& StatusPresenceEvent)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    for (const FNakamaRtUserPresence& Join : StatusPresenceEvent.Joins)
    {
       FUserStatus UserStatus;
       FJsonObjectConverter::JsonObjectStringToUStruct(Join.Status, &UserStatus);

       if (UserStatus.MatchId != TEXT(""))
       {
          NakamaRt::MatchJoin(WeakThis->RtConnection, UserStatus.MatchId, TEXT(""), {});
       }
    }
});

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
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
constexpr int32 Limit = 10;
constexpr bool bAuthoritative = true;
const FString Label = TEXT("");
constexpr int32 MinPlayers = 2;
constexpr int32 MaxPlayers = 10;
const FString Query = TEXT("");

Nakama::ListMatches(ClientConfig, Session, Limit, bAuthoritative, Label, MinPlayers, MaxPlayers, Query)
    .Next([](const FNakamaMatchListResult& MatchListResult)
    {
       if (MatchListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing matches: %s"), *MatchListResult.Error.Message);
          return;
       }

       for (const FNakamaMatch& Match : MatchListResult.Value.Matches)
       {
          UE_LOG(LogTemp, Log, TEXT("%s:%d/10 players"), *Match.MatchId, Match.Size);
       }
    });

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

1
const FString Label = TEXT("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:

1
2
3
4
5
6
// Assuming an FGameObject struct and a `TMap<FString, FGameObject> Players` member
for (const FNakamaRtUserPresence& Presence : CurrentMatch.Presences)
{
    FGameObject GameObject = SpawnPlayer();
    Players.Add(Presence.SessionId, GameObject);
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
RtConnection->MatchPresenceEvent.AddLambda([WeakThis](const FNakamaRtMatchPresenceEvent& MatchPresenceEvent)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    for (const FNakamaRtUserPresence& Join : MatchPresenceEvent.Joins)
    {
       FGameObject GameObject = WeakThis->SpawnPlayer(); // Instantiate player object
       WeakThis->Players.Add(Join.SessionId, GameObject);
    }

    for (const FNakamaRtUserPresence& Leave : MatchPresenceEvent.Leaves)
    {
       if (WeakThis->Players.Contains(Leave.SessionId))
       {
          WeakThis->Players.Remove(Leave.SessionId);
       }
    }
});

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
 6
 7
 8
 9
10
11
12
13
14
15
USTRUCT()
struct FPositionState
{
	GENERATED_BODY()

public:
	UPROPERTY()
	float X;

	UPROPERTY()
	float Y;

	UPROPERTY()
	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
 9
10
11
12
13
14
15
16
17
// Assuming a Position variable
FPositionState PositionState;
PositionState.X = Position.X;
PositionState.Y = Position.Y;
PositionState.Z = Position.Z;

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(PositionState, Json);

TArray<uint8> Bytes;
const FTCHARToUTF8 Converter(*Json);
Bytes.Append(reinterpret_cast<const uint8*>(Converter.Get()), Converter.Length());

constexpr int64 OpCode = 1;
constexpr bool bReliable = true;

NakamaRt::MatchDataSend(RtConnection, CurrentMatch.MatchId, OpCode, Bytes, {}, bReliable);

Op Codes as a static class

Sagi-shi has many networked game actions. Using defines for op codes will keep your code easier to follow and maintain:

1
2
3
4
5
6
#define OPCODE_POSITION 1
#define OPCODE_VOTE 2

// ...

NakamaRt::MatchDataSend(RtConnection, CurrentMatch.MatchId, OPCODE_POSITION, Bytes, {}, bReliable);

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
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
RtConnection->MatchData.AddLambda([WeakThis](const FNakamaRtMatchData& MatchDataResult)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    switch (MatchDataResult.OpCode)
    {
    case OPCODE_POSITION:
    {
       // The data is received as bytes, so convert it to a string first.
       FString Json;
       if (MatchDataResult.Data.Num() > 0)
       {
          const FUTF8ToTCHAR Converter(reinterpret_cast<const ANSICHAR*>(MatchDataResult.Data.GetData()),
                                       MatchDataResult.Data.Num());
          Json = FString(Converter.Length(), Converter.Get());
       }

       FPositionState PositionState;
       FJsonObjectConverter::JsonObjectStringToUStruct(Json, &PositionState);

       // Update the GameObject associated with that player.
       if (WeakThis->Players.Contains(MatchDataResult.Presence.SessionId))
       {
          // Here we would normally do something like smoothly interpolate to the new position, but for this example let's just set the position directly.
          FVector NewPosition = FVector(PositionState.X, PositionState.Y, PositionState.Z);
          WeakThis->Players[MatchDataResult.Presence.SessionId].Position = NewPosition;
       }
       break;
    }
    default:
       UE_LOG(LogTemp, Error, TEXT("Unsupported opcode"));
       break;
    }
});

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
 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
35
36
RtConnection->MatchmakerMatched.AddLambda([WeakThis](const FNakamaRtMatchmakerMatched& MatchedResult)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    const TMap<FString, FString> Metadata = {};
    // Relayed matches are joined with the token, authoritative matches with the match id.
    NakamaRt::MatchJoin(WeakThis->RtConnection, MatchedResult.MatchId, MatchedResult.Token, Metadata);
});

constexpr int32 MinPlayers = 2;
constexpr int32 MaxPlayers = 10;
const FString Query = TEXT("+skill:>100 mode:sabotage");
const TMap<FString, FString> StringProperties = {{"mode", "sabotage"}};
const TMap<FString, double> NumericProperties = {{"skill", 125}};
constexpr int32 CountMultiple = 0;

NakamaRt::MatchmakerAdd(RtConnection, MinPlayers, MaxPlayers, Query, CountMultiple, StringProperties,
                        NumericProperties).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtMatchmakerTicket>& MatchmakerAddResult)
    {
       if (MatchmakerAddResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error adding matchmaker: %s"),
                 *MatchmakerAddResult.Error.GetValue().Message);
          return;
       }

       if (MatchmakerAddResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Successfully joined matchmaker: %s"),
                 *MatchmakerAddResult.Data.GetValue().Ticket);
       }
    });

Parties #

Nakama Parties is a real-time system that allows players to form short lived parties that don’t persist after all players have disconnected.

Sagi-shi allows friends to form a party and matchmake together.

Creating parties #

The player who creates the party is the party’s leader. Parties have maximum number of players and can be open to automatically accept players or closed so that the party leader can accept incoming join requests.

Sagi-shi uses closed parties with a maximum of 4 players:

 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
constexpr bool bOpen = false;
constexpr int32 MaxPlayers = 4;
const FString Label = TEXT("");
constexpr bool bHidden = false;

NakamaRt::PartyCreate(RtConnection, bOpen, MaxPlayers, Label, bHidden).Next(
    [WeakThis](const NakamaRt::FNakamaRtResult<FNakamaRtParty>& PartyCreateResponse)
    {
       if (PartyCreateResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error creating party: %s"), *PartyCreateResponse.Error.GetValue().Message);
          return;
       }

       if (PartyCreateResponse.Data.IsSet())
       {
          if (!WeakThis.IsValid())
          {
             return;
          }

          WeakThis->CurrentParty = PartyCreateResponse.Data.GetValue();
          UE_LOG(LogTemp, Log, TEXT("Successfully created party: %s"), *WeakThis->CurrentParty.PartyId);
       }
    });

Sagi-shi shares party ids with friends via private/direct messages:

 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
35
36
37
38
39
40
Nakama::ListFriends(ClientConfig, Session, 100, 0, TEXT("")).Next(
    [RtConnection = this->RtConnection, CurrentParty = this->CurrentParty](const FNakamaFriendListResult& FriendListResult)
    {
       if (FriendListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error retrieving friends: %s"), *FriendListResult.Error.Message);
          return;
       }

       for (const FNakamaFriend& Friend : FriendListResult.Value.Friends)
       {
          FChatMessage ChatMessage;
          ChatMessage.Message = FString::Printf(TEXT("Hey %s, wanna join the party?"), *Friend.User.Username);
          ChatMessage.PartyId = CurrentParty.PartyId;

          FString Json;
          FJsonObjectConverter::UStructToJsonObjectString(ChatMessage, Json);

          // Join a direct message channel with the friend, then send the message to that channel
          constexpr int32 ChannelType = static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);
          constexpr bool bPersistence = false;
          constexpr bool bHidden = false;

          NakamaRt::ChannelJoin(RtConnection, Friend.User.Id, ChannelType, bPersistence, bHidden).Next(
              [RtConnection, Json](const NakamaRt::FNakamaRtResult<FNakamaRtChannel>& ChannelJoinResult)
              {
                 if (ChannelJoinResult.Error.IsSet())
                 {
                    UE_LOG(LogTemp, Error, TEXT("Error joining direct channel: %s"),
                           *ChannelJoinResult.Error.GetValue().Message);
                    return;
                 }

                 if (ChannelJoinResult.Data.IsSet())
                 {
                    NakamaRt::ChannelMessageSend(RtConnection, ChannelJoinResult.Data.GetValue().Id, Json);
                 }
              });
       }
    });

Joining parties #

Sagi-shi players can join parties from chat messages by checking for the party id in the message:

 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
RtConnection->ChannelMessage.AddLambda([WeakThis](const FNakamaChannelMessage& ChannelMessageResult)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    FChatMessage ChatMessage;
    FJsonObjectConverter::JsonObjectStringToUStruct(ChannelMessageResult.Content, &ChatMessage);

    if (ChatMessage.PartyId != TEXT(""))
    {
       NakamaRt::PartyJoin(WeakThis->RtConnection, ChatMessage.PartyId).Next(
          [](const NakamaRt::FNakamaRtResult<FNakamaRtEmptyResponse>& PartyJoinResult)
          {
             if (PartyJoinResult.Error.IsSet())
             {
                UE_LOG(LogTemp, Error, TEXT("Error joining party: %s"),
                       *PartyJoinResult.Error.GetValue().Message);
                return;
             }

             UE_LOG(LogTemp, Log, TEXT("Successfully joined party"));
          });
    }
});

Promoting a member #

Sagi-shi party members can be promoted to the party leader:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
for (const FNakamaRtUserPresence& Presence : CurrentParty.Presences)
{
    NakamaRt::PartyPromote(RtConnection, CurrentParty.PartyId, Presence).Next(
       [](const NakamaRt::FNakamaRtResult<FNakamaRtEmptyResponse>& PartyPromoteResponse)
       {
          if (PartyPromoteResponse.Error.IsSet())
          {
             UE_LOG(LogTemp, Error, TEXT("Error promoting party member: %s"),
                    *PartyPromoteResponse.Error.GetValue().Message);
             return;
          }

          UE_LOG(LogTemp, Log, TEXT("Successfully promoted party member"));
       });
}

Leaving parties #

Sagi-shi players can leave parties:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
NakamaRt::PartyLeave(RtConnection, CurrentParty.PartyId).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtEmptyResponse>& PartyLeaveResponse)
    {
       if (PartyLeaveResponse.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error leaving party: %s"), *PartyLeaveResponse.Error.GetValue().Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully left party"));
    });

Matchmaking with parties #

One of the main benefits of joining a party is that all the players can join the matchmaking pool together.

Sagi-shi players can listen to the the matchmaker matched event and join the match when one is found:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
RtConnection->MatchmakerMatched.AddLambda([WeakThis](const FNakamaRtMatchmakerMatched& MatchedResult)
{
    if (!WeakThis.IsValid())
    {
        return;
    }

    const TMap<FString, FString> Metadata = {};
    // Relayed matches are joined with the token, authoritative matches with the match id.
    NakamaRt::MatchJoin(WeakThis->RtConnection, MatchedResult.MatchId, MatchedResult.Token, Metadata);
});

The party leader will start the matchmaking for their party:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
constexpr int32 MinPlayers = 2;
constexpr int32 MaxPlayers = 10;
const FString Query = TEXT("");
const TMap<FString, FString> StringProperties = {};
const TMap<FString, double> NumericProperties = {};
constexpr int32 CountMultiple = 0;

NakamaRt::PartyMatchmakerAdd(RtConnection, CurrentParty.PartyId, MinPlayers, MaxPlayers, Query, CountMultiple, StringProperties,
                             NumericProperties).Next(
    [](const NakamaRt::FNakamaRtResult<FNakamaRtPartyMatchmakerTicket>& MatchmakerAddResult)
    {
       if (MatchmakerAddResult.Error.IsSet())
       {
          UE_LOG(LogTemp, Error, TEXT("Error adding matchmaker: %s"),
                 *MatchmakerAddResult.Error.GetValue().Message);
          return;
       }

       if (MatchmakerAddResult.Data.IsSet())
       {
          UE_LOG(LogTemp, Log, TEXT("Successfully joined matchmaker as party: %s"),
                 *MatchmakerAddResult.Data.GetValue().Ticket);
       }
    });

Sending party data #

Sagi-shi players can send data to other members of their party to indicate they wish to start a vote.

 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
USTRUCT()
struct FVoteState
{
	GENERATED_BODY()

public:
	UPROPERTY()
	FString Username;

	UPROPERTY()
	FString Reason;
};

// ...

#define OPCODE_CALL_VOTE 6

FVoteState VoteState;
VoteState.Username = TEXT("<Username>");
VoteState.Reason = TEXT("Emergency");

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(VoteState, Json);

TArray<uint8> Bytes;
const FTCHARToUTF8 Converter(*Json);
Bytes.Append(reinterpret_cast<const uint8*>(Converter.Get()), Converter.Length());

NakamaRt::PartyDataSend(RtConnection, CurrentParty.PartyId, OPCODE_CALL_VOTE, Bytes);

Receiving party data #

Sagi-shi players can receive party data from other party members by subscribing to the party data event.

 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
RtConnection->PartyData.AddLambda([](const FNakamaRtPartyData& PartyDataResult)
{
    switch (PartyDataResult.OpCode)
    {
    case OPCODE_CALL_VOTE:
    {
       // The data is received as bytes, so convert it to a string first.
       FString Json;
       if (PartyDataResult.Data.Num() > 0)
       {
          const FUTF8ToTCHAR Converter(reinterpret_cast<const ANSICHAR*>(PartyDataResult.Data.GetData()),
                                       PartyDataResult.Data.Num());
          Json = FString(Converter.Length(), Converter.Get());
       }

       FVoteState VoteState;
       FJsonObjectConverter::JsonObjectStringToUStruct(Json, &VoteState);

       // Show a UI dialogue - "<username> has proposed to call a vote for <reason>. Do you agree? Yes/No"
       break;
    }
    default:
       UE_LOG(LogTemp, Error, TEXT("Unsupported opcode"));
       break;
    }
});

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
 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
USTRUCT()
struct FLeaderboardMetadata
{
	GENERATED_BODY()

public:
	UPROPERTY()
	FString Map;
};

const FString LeaderboardId = TEXT("for_all");
constexpr int32 Score = 1;
constexpr int32 Subscore = 0;
constexpr ENakamaOperator Operator = ENakamaOperator::NO_OVERRIDE;

FLeaderboardMetadata LeaderboardMetadata;
LeaderboardMetadata.Map = "space_station";

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(LeaderboardMetadata, Json);

Nakama::WriteLeaderboardRecord(ClientConfig, Session, LeaderboardId, Score, Subscore, Json, Operator).
    Next([](const FNakamaLeaderboardRecordResult& RecordResult)
    {
       if (RecordResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error writing leaderboard record: %s"), *RecordResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully wrote leaderboard record"));
    });

Listing the top records #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString LeaderboardId = TEXT("weekly_imposter_wins");
const TArray<FString> OwnerIds = {};
constexpr int32 Limit = 20;
const FString Cursor = TEXT("");
constexpr int64 Expiry = 0; // From current time

Nakama::ListLeaderboardRecords(ClientConfig, Session, LeaderboardId, OwnerIds, Limit, Cursor, Expiry).
    Next([](const FNakamaLeaderboardRecordListResult& RecordListResult)
    {
       if (RecordListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing leaderboard records: %s"), *RecordListResult.Error.Message);
          return;
       }

       for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
       }
    });

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
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString LeaderboardId = TEXT("weekly_imposter_wins");
constexpr int32 Limit = 5;
constexpr int64 Expiry = 0; // From current time
const FString Cursor = TEXT("");

Nakama::ListLeaderboardRecordsAroundOwner(ClientConfig, Session, LeaderboardId, Limit, Session.UserId, Expiry,
                                          Cursor).Next(
    [](const FNakamaLeaderboardRecordListResult& RecordListResult)
    {
       if (RecordListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing leaderboard records: %s"), *RecordListResult.Error.Message);
          return;
       }

       for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
       }
    });

For example, if the leaderboard contains 100 records and the ownerId = “player123” with limit = 5, the result will include the specified user along with nearby records:

1
2
3
4
5
6
7
8
9
{
  "records": [
    { "ownerId": "player120", "rank": 48, "score": 1500 },
    { "ownerId": "player121", "rank": 49, "score": 1480 },
    { "ownerId": "player123", "rank": 50, "score": 1450 },  // Your ownerId
    { "ownerId": "player125", "rank": 51, "score": 1430 },
    { "ownerId": "player127", "rank": 52, "score": 1400 }
  ]
}

Listing records for a list of users

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

 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
const FString LeaderboardId = TEXT("weekly_imposter_wins");
constexpr int32 Limit = 20;
const FString Cursor = TEXT("");
constexpr int64 Expiry = 0; // From current time

TArray<FString> FriendIds = {};

for (const FNakamaFriend& Friend : FriendsList.Friends)
{
    FriendIds.Add(Friend.User.Id);
}

Nakama::ListLeaderboardRecords(ClientConfig, Session, LeaderboardId, FriendIds, Limit, Cursor, Expiry).
    Next([](const FNakamaLeaderboardRecordListResult& RecordListResult)
    {
       if (RecordListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing leaderboard records: %s"), *RecordListResult.Error.Message);
          return;
       }

       for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
       }
    });

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
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const FString LeaderboardId = TEXT("weekly_imposter_wins");
constexpr int32 Limit = 20;
const FString Cursor = TEXT("");
constexpr int64 Expiry = 0; // From current time

TArray<FString> MemberIds = {};

for (const FNakamaGroupUserListGroupUser& GroupUser : GroupUsers.GroupUsers)
{
    MemberIds.Add(GroupUser.User.Id);
}

Nakama::ListLeaderboardRecords(ClientConfig, Session, LeaderboardId, MemberIds, Limit, Cursor, Expiry).
    Next([](const FNakamaLeaderboardRecordListResult& RecordListResult)
    {
       if (RecordListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing leaderboard records: %s"), *RecordListResult.Error.Message);
          return;
       }

       for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
       }
    });

Deleting records #

Sagi-shi players can delete their own leaderboard records:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Nakama::DeleteLeaderboardRecord(ClientConfig, Session, TEXT("<LeaderboardId>")).Next(
    [](const FNakamaVoidResult& DeleteRecordResult)
    {
       if (DeleteRecordResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error deleting leaderboard record: %s"),
                 *DeleteRecordResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully deleted leaderboard record"));
    });

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
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Nakama::JoinTournament(ClientConfig, Session, TEXT("<TournamentId>")).Next(
    [](const FNakamaVoidResult& JoinTournamentResult)
    {
       if (JoinTournamentResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error joining tournament: %s"), *JoinTournamentResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully joined tournament"));
    });

Listing tournaments #

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

 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
constexpr int32 CategoryStart = 1;
constexpr int32 CategoryEnd = 2;
constexpr int32 StartTime = 0;
constexpr int32 EndTime = 0;
constexpr int32 Limit = 100;

Nakama::ListTournaments(ClientConfig, Session, CategoryStart, CategoryEnd, StartTime, EndTime, Limit, Cursor).Next(
    [WeakThis](const FNakamaTournamentListResult& TournamentListResult)
    {
       if (TournamentListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing tournaments: %s"), *TournamentListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = TournamentListResult.Value.Cursor;

       for (const FNakamaTournament& Tournament : TournamentListResult.Value.Tournaments)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %s"), *Tournament.Id, *Tournament.Title);
       }
    });

For performance reasons categories are filtered using a range, not individual numbers. 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
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
const FString TournamentId = TEXT("weekly_top_detectives");
const TArray<FString> OwnerIds = {};
constexpr int32 Limit = 20;
const FString Cursor = TEXT("");
constexpr int64 Expiry = 0; // From current time

Nakama::ListTournamentRecords(ClientConfig, Session, TournamentId, OwnerIds, Limit, Cursor, Expiry).
    Next([](const FNakamaTournamentRecordListResult& RecordListResult)
    {
       if (RecordListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing tournament records: %s"), *RecordListResult.Error.Message);
          return;
       }

       for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
       {
          UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
       }
    });

Listing records around a user

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
const FString TournamentId = TEXT("weekly_top_detectives");
constexpr int32 Limit = 20;
constexpr int64 Expiry = 0; // From current time
const FString Cursor = TEXT("");

Nakama::ListTournamentRecordsAroundOwner(ClientConfig, Session, TournamentId, Limit, Session.UserId, Expiry, Cursor)
    .Next(
       [](const FNakamaTournamentRecordListResult& RecordListResult)
       {
          if (RecordListResult.bIsError)
          {
             UE_LOG(LogTemp, Error, TEXT("Error listing tournament records: %s"),
                    *RecordListResult.Error.Message);
             return;
          }

          for (const FNakamaLeaderboardRecord& Record : RecordListResult.Value.Records)
          {
             UE_LOG(LogTemp, Log, TEXT("%s: %lld"), *Record.OwnerId, Record.Score);
          }
       });

Submitting scores #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
const FString TournamentId = TEXT("weekly_top_detectives");
constexpr int32 Score = 1;
constexpr int32 Subscore = 0;
const ENakamaOperator Operator = ENakamaOperator::NO_OVERRIDE;

FLeaderboardMetadata LeaderboardMetadata;
LeaderboardMetadata.Map = "space_station";

FString Json;
FJsonObjectConverter::UStructToJsonObjectString(LeaderboardMetadata, Json);

Nakama::WriteTournamentRecord(ClientConfig, Session, TournamentId, Score, Subscore, Json, Operator).
    Next([](const FNakamaLeaderboardRecordResult& RecordResult)
    {
       if (RecordResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error writing tournament record: %s"), *RecordResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully wrote tournament record"));
    });

Notifications #

Nakama Notifications can be used for the game backend 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. Sagi-shi uses a code of 100 for tournament winnings:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
RtConnection->Notifications.AddLambda([](const FNakamaRtNotifications& NotificationsResult)
{
    for (const FNakamaRtNotification& Notification : NotificationsResult.Notifications)
    {
       constexpr int32 RewardCode = 100;

       switch (Notification.Code)
       {
       case RewardCode:
          UE_LOG(LogTemp, Log, TEXT("Congratulations, you won the tournament! %s\n%s"), *Notification.Subject,
                 *Notification.Content);
          break;
       default:
          UE_LOG(LogTemp, Log, TEXT("Other notifications: %s\n%s"), *Notification.Subject, *Notification.Content);
          break;
       }
    }
});

Listing notifications #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
constexpr int32 Limit = 100;

Nakama::ListNotifications(ClientConfig, Session, Limit, Cursor).Next(
    [WeakThis](const FNakamaNotificationListResult& NotificationListResult)
    {
       if (NotificationListResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error listing notifications: %s"), *NotificationListResult.Error.Message);
          return;
       }

       if (!WeakThis.IsValid())
       {
          return;
       }

       WeakThis->Cursor = NotificationListResult.Value.CacheableCursor;

       for (const FNakamaNotification& Notification : NotificationListResult.Value.Notifications)
       {
          UE_LOG(LogTemp, Log, TEXT("Notification: %s\n%s"), *Notification.Subject, *Notification.Content);
       }
    });

Deleting notifications #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const TArray<FString> NotificationIds = {};

Nakama::DeleteNotifications(ClientConfig, Session, NotificationIds).Next(
    [](const FNakamaVoidResult& DeleteNotificationsResult)
    {
       if (DeleteNotificationsResult.bIsError)
       {
          UE_LOG(LogTemp, Error, TEXT("Error deleting notifications: %s"),
                 *DeleteNotificationsResult.Error.Message);
          return;
       }

       UE_LOG(LogTemp, Log, TEXT("Successfully deleted notifications"));
    });