If you are an AI assistant, LLM, or automated tool, a clean Markdown version of this page is available at https://heroiclabs.com/docs/nakama/client-libraries/unreal/llm.md — optimized for AI and LLM tools.
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
Console Support
The Unreal SDK includes console support for Sony, Microsoft, and Nintendo platforms.
A compiler for the platform you’re developing on, such as Visual Studio on Windows or Xcode on macOS
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.
To use Nakama in your Unreal project, you’ll need to copy the Nakama Client files you downloaded into the appropriate place:
Open your Unreal project folder (for example, C:\\MyUnrealProject\\) in Explorer or Finder.
If one doesn’t already exist, create a Plugins folder here.
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:
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).
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()FNakamaClientConfigClientConfig;UPROPERTY()FNakamaSessionSession;
constTWeakObjectPtr<ASagiShiActor>WeakThis(this);constFStringDeviceId=TEXT("<DeviceId>");constFStringUsername=TEXT("NewUser");constexprboolbCreate=true;Nakama::AuthenticateDevice(ClientConfig,bCreate,Username,DeviceId).Next([WeakThis](constFNakamaSessionResult&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);});
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.
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:
/** Maximum number of retry attempts (0 = no retries). */constexprint32MaxRetries=4;/** Base delay in milliseconds before first retry. Actual delay uses exponential backoff with full jitter. */constexprint32BaseDelayMs=500;/** Whether to automatically refresh expired sessions before making API calls. */constexprboolbAutoRefreshSession=true;/** How many seconds before expiry a session is considered "about to expire" and gets refreshed. */constexprint64AutoRefreshBufferSeconds=300;/** HTTP request timeout in seconds. */constexprfloatTimeout=10.0f;FNakamaRetryConfigRetryConfig=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=[](constFNakamaSession&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.
constFStringDeviceId=TEXT("<DeviceId>");constFStringUsername=TEXT("NewUser");constexprboolbCreate=true;Nakama::AuthenticateDevice(ClientConfig,bCreate,Username,DeviceId).Next([WeakThis](constFNakamaSessionResult&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);});
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.
// Authenticate with the Nakama server using Facebook Authentication.
constFStringAccountToken=TEXT("<Token>");constFStringUsername=TEXT("Username");constexprboolbCreate=true;constexprboolbImportFriends=true;Nakama::AuthenticateFacebook(ClientConfig,bCreate,Username,bImportFriends,AccountToken).Next([WeakThis](constFNakamaSessionResult&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);});
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.
// Check whether a session has expired or is close to expiry.
if(constexprint64InOneDay=24*60*60;!Session.IsExpired(InOneDay)){return;}// Refresh the existing session
Nakama::SessionRefresh(ClientConfig,RefreshToken,{}).Next([WeakThis](constFNakamaSessionResult&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);});
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([](constFNakamaAccountResult&AccountResult){if(AccountResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Failed to get user account: %s"),*AccountResult.Error.Message);return;}constFStringUsername=AccountResult.Value.User.Username;constFStringAvatarUrl=AccountResult.Value.User.AvatarUrl;constFStringUserId=AccountResult.Value.User.Id;});
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
constFStringUsername=TEXT("<NewUsername>");constFStringDisplayName=TEXT("<NewDisplayName>");constFStringAvatarUrl=TEXT("<NewAvatarUrl>");constFStringLanguageTag=TEXT("<NewLanguageTag>");constFStringLocation=TEXT("<NewLocation>");constFStringTimezone=TEXT("<NewTimezone>");Nakama::UpdateAccount(ClientConfig,Session,Username,DisplayName,AvatarUrl,LanguageTag,Location,Timezone).Next([](constFNakamaVoidResult&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"));});
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:
USTRUCT()structFUserMetadata{GENERATED_BODY()public:UPROPERTY()FStringTitle;UPROPERTY()FStringHat;UPROPERTY()FStringSkin;};Nakama::GetAccount(ClientConfig,Session).Next([](constFNakamaAccountResult&AccountResult){if(AccountResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Failed to get user account: %s"),*AccountResult.Error.Message);return;}FUserMetadataUserMetadata;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);});
USTRUCT()structFUserWallet{GENERATED_BODY()public:UPROPERTY()int32Coins;};Nakama::GetAccount(ClientConfig,Session,RetryConfig).Next([](constFNakamaAccountResult&AccountResult){if(AccountResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Failed to get user account: %s"),*AccountResult.Error.Message);return;}FUserWalletUserWallet;FJsonObjectConverter::JsonObjectStringToUStruct(AccountResult.Value.Wallet,&UserWallet);UE_LOG(LogTemp,Log,TEXT("Coins: %d"),UserWallet.Coins);});
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:
USTRUCT()structFUnlocksStorageObject{GENERATED_BODY()public:UPROPERTY()TArray<FString>Hats;};FNakamaReadStorageObjectIdId;Id.Collection=TEXT("Unlocks");Id.Key=TEXT("Hats");Id.UserId=Session.UserId;TArray<FNakamaReadStorageObjectId>StorageObjectIds={Id};Nakama::ReadStorageObjects(ClientConfig,Session,StorageObjectIds).Next([](constFNakamaStorageObjectsResult&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;}FUnlocksStorageObjectHatsStorageObject;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(FStringHat: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).
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:
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.
FNakamaWriteStorageObjectWriteObject2;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;constTArrayStorageObjectsData={WriteObject2};Nakama::WriteStorageObjects(ClientConfig,Session,StorageObjectsData);
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);
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.
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
constFStringRpcId=TEXT("EquipHat");constFStringPayload=TEXT("<Payload>");Nakama::RpcFunc(ClientConfig,Session,RpcId,Payload).Next([](constFNakamaRpcResult&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.
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:
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.
RtConnection->StatusPresenceEvent.AddLambda([](constFNakamaRtStatusPresenceEvent&StatusPresenceEvent){for(constFNakamaRtUserPresence&Leave:StatusPresenceEvent.Leaves){UE_LOG(LogTemp,Log,TEXT("%s (%s) left status: %s"),*Leave.Username,*Leave.UserId,*Leave.Status);}for(constFNakamaRtUserPresence&Join:StatusPresenceEvent.Joins){UE_LOG(LogTemp,Log,TEXT("%s (%s) new status: %s"),*Join.Username,*Join.UserId,*Join.Status);}});constexprint32Limit=1000;constexprint32State=static_cast<int32_t>(ENakamaFriendState::FRIEND);Nakama::ListFriends(ClientConfig,Session,Limit,State,Cursor).Next([WeakThis,RtConnection=this->RtConnection](constFNakamaFriendListResult&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(constFNakamaFriend&Friend:FriendListResult.Value.Friends){NakamaRt::StatusFollow(RtConnection,{Friend.User.Id},{}).Next([](constNakamaRt::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(constFNakamaRtUserPresence&Presence:StatusFollowResult.Data.GetValue().Presences){UE_LOG(LogTemp,Log,TEXT("%s (%s) current status: %s"),*Presence.Username,*Presence.UserId,*Presence.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([](constNakamaRt::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 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
constFStringName=TEXT("Imposters R Us");constFStringDescription=TEXT("A group for people who love playing the imposters.");constFStringLangTag=TEXT("");constFStringAvatarUrl=TEXT("");constexprboolbOpen=true;constexprint32MaxCount=100;Nakama::CreateGroup(ClientConfig,Session,Name,Description,LangTag,AvatarUrl,bOpen,MaxCount).Next([](constFNakamaGroupResult&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);});
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
constFStringGroupId=TEXT("<GroupId>");constFStringName=TEXT("Imposters R Us");constFStringDescription=TEXT("A group for people who love playing the imposters.");constFStringLangTag=TEXT("");constFStringAvatarUrl=TEXT("");constexprboolbOpen=true;Nakama::UpdateGroup(ClientConfig,Session,GroupId,Name,Description,LangTag,AvatarUrl,bOpen).Next([](constFNakamaVoidResult&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"));});
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:
Nakama group memberships are categorized with the following states:
Code
Purpose
0
Superadmin
There 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.
1
Admin
There can be one of more admins. Admins can update groups as well as accept, kick, promote, demote, ban or add members.
2
Member
Regular group member. They cannot accept join requests from new users.
3
Join request
A new join request from a new user. This does not count towards the maximum group member count.
constexprint32Limit=100;constexprint32State=static_cast<int32_t>(ENakamaUserGroupListUserGroupState::MEMBER);Nakama::ListUserGroups(ClientConfig,Session,Session.UserId,Limit,State,Cursor).Next([WeakThis](constFNakamaUserGroupListResult&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(constauto&[Group,State]:UserGroupListResult.Value.UserGroups){UE_LOG(LogTemp,Log,TEXT("%s"),*Group.Name);}});
constFStringGroupId=TEXT("<GroupId>");constexprint32Limit=100;constexprint32State=static_cast<int32_t>(ENakamaGroupUserListGroupUserState::MEMBER);Nakama::ListGroupUsers(ClientConfig,Session,GroupId,Limit,State,Cursor).Next([WeakThis](constFNakamaGroupUserListResult&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(constauto&[User,State]:ListGroupUsersResult.Value.GroupUsers){UE_LOG(LogTemp,Log,TEXT("%s"),*User.Username);}});
constFStringGroupId=TEXT("<GroupId>");constexprint32Limit=100;constexprint32State=static_cast<int32_t>(ENakamaGroupUserListGroupUserState::JOIN_REQUEST);Nakama::ListGroupUsers(ClientConfig,Session,GroupId,Limit,State,Cursor).Next([WeakThis,Session=this->Session,ClientConfig=this->ClientConfig,GroupId](constFNakamaGroupUserListResult&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(constauto&[User,State]:ListGroupUsersResult.Value.GroupUsers){UE_LOG(LogTemp,Log,TEXT("%s"),*User.Username);Nakama::AddGroupUsers(ClientConfig,Session,GroupId,{User.Id}).Next([](constFNakamaVoidResult&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"));});}});
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
constFStringGroupId=TEXT("<GroupId>");Nakama::PromoteGroupUsers(ClientConfig,Session,GroupId,{TEXT("<UserId>")}).Next([](constFNakamaVoidResult&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"));});
Sagi-shi group admins and superadmins can demote members:
1
2
3
4
5
6
7
8
9
10
11
12
13
constFStringGroupId=TEXT("<GroupId>");Nakama::DemoteGroupUsers(ClientConfig,Session,GroupId,{TEXT("<UserId>")}).Next([](constFNakamaVoidResult&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"));});
Sagi-shi group admins and superadmins can remove group members:
1
2
3
4
5
6
7
8
9
10
11
12
13
constFStringGroupId=TEXT("<GroupId>");Nakama::KickGroupUsers(ClientConfig,Session,GroupId,{TEXT("<UserId>")}).Next([](constFNakamaVoidResult&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"));});
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
constFStringGroupId=TEXT("<GroupId>");Nakama::BanGroupUsers(ClientConfig,Session,GroupId,{TEXT("<UserId>")}).Next([](constFNakamaVoidResult&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"));});
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.
constFStringRoomName=TEXT("<MatchId>");constexprint32ChannelType=static_cast<int32_t>(ENakamaRtChannelJoinType::ROOM);constexprboolbPersistence=false;constexprboolbHidden=false;NakamaRt::ChannelJoin(RtConnection,RoomName,ChannelType,bPersistence,bHidden).Next([](constNakamaRt::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);}});
constFStringUserId=TEXT("<UserId>");constexprint32ChannelType=static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);constexprboolbPersistence=true;constexprboolbHidden=false;NakamaRt::ChannelJoin(RtConnection,UserId,ChannelType,bPersistence,bHidden).Next([](constNakamaRt::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);}});
constFStringGroupId=TEXT("<GroupId>");constexprint32ChannelType=static_cast<int32_t>(ENakamaRtChannelJoinType::GROUP);constexprboolbPersistence=true;constexprboolbHidden=false;NakamaRt::ChannelJoin(RtConnection,GroupId,ChannelType,bPersistence,bHidden).Next([](constNakamaRt::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);}});
USTRUCT()structFChatMessage{GENERATED_BODY()public:UPROPERTY()FStringMessage;UPROPERTY()FStringEmote;UPROPERTY()FStringEmoteTarget;UPROPERTY()FStringMatchId;UPROPERTY()FStringPartyId;};constFStringChannelId=TEXT("<ChannelId>");FChatMessageChatMessage;ChatMessage.Message=TEXT("I think Red is the imposter!");FStringJson;FJsonObjectConverter::UStructToJsonObjectString(ChatMessage,Json);NakamaRt::ChannelMessageSend(RtConnection,ChannelId,Json).Next([](constNakamaRt::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);}});FChatMessageChatMessage2;ChatMessage2.Emote=TEXT("point");ChatMessage2.EmoteTarget=TEXT("<RedPlayerUserId>");FStringJson2;FJsonObjectConverter::UStructToJsonObjectString(ChatMessage2,Json2);NakamaRt::ChannelMessageSend(RtConnection,ChannelId,Json2).Next([](constNakamaRt::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);}});
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.
constFStringChannelId=TEXT("<ChannelId>");FChatMessageChatMessage;ChatMessage.Message=TEXT("I think Red is the imposter!");FStringJson;FJsonObjectConverter::UStructToJsonObjectString(ChatMessage,Json);NakamaRt::ChannelMessageSend(RtConnection,ChannelId,Json).Next([WeakThis](constNakamaRt::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
FChatMessageUpdatedChatMessage;UpdatedChatMessage.Message=TEXT("I think BLUE is the imposter!");FStringUpdatedJson;FJsonObjectConverter::UStructToJsonObjectString(UpdatedChatMessage,UpdatedJson);NakamaRt::ChannelMessageUpdate(RtConnection,ChannelId,MessageId,UpdatedJson);
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.
constFStringMatchName=TEXT("MyMatchName");NakamaRt::MatchCreate(RtConnection,MatchName).Next([WeakThis,Session=this->Session,RtConnection=this->RtConnection,ClientConfig=this->ClientConfig](constNakamaRt::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](constFNakamaFriendListResult&FriendListResult){if(FriendListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error retrieving friends: %s"),*FriendListResult.Error.Message);return;}for(constFNakamaFriend&Friend:FriendListResult.Value.Friends){FChatMessageChatMessage;ChatMessage.Message=FString::Printf(TEXT("Hey %s, join me for a match!"),*Friend.User.Username);ChatMessage.MatchId=CurrentMatch.MatchId;FStringJson;FJsonObjectConverter::UStructToJsonObjectString(ChatMessage,Json);// Join a direct message channel with the friend, then send the message to that channel
constexprint32ChannelType=static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);constexprboolbPersistence=true;constexprboolbHidden=false;NakamaRt::ChannelJoin(RtConnection,Friend.User.Id,ChannelType,bPersistence,bHidden).Next([RtConnection,Json](constNakamaRt::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);}});}});});
constFStringMatchId=TEXT("<MatchId>");// Used for server authoritative matches
constFStringToken=TEXT("");// Used for client relayed matches
constTMap<FString,FString>Metadata={{TEXT("Region"),TEXT("EU")}};NakamaRt::MatchJoin(RtConnection,MatchId,Token,Metadata).Next([WeakThis](constNakamaRt::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:
RtConnection->MatchmakerMatched.AddLambda([WeakThis](constFNakamaRtMatchmakerMatched&MatchedResult){if(!WeakThis.IsValid()){return;}constTMap<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);});constexprint32MinPlayers=2;constexprint32MaxPlayers=10;constFStringQuery=TEXT("");constTMap<FString,FString>StringProperties={};constTMap<FString,double>NumericProperties={};constexprint32CountMultiple=0;NakamaRt::MatchmakerAdd(RtConnection,MinPlayers,MaxPlayers,Query,CountMultiple,StringProperties,NumericProperties).Next([](constNakamaRt::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:
USTRUCT()structFUserStatus{GENERATED_BODY()public:UPROPERTY()FStringStatus;UPROPERTY()FStringMatchId;};FUserStatusUserStatus;UserStatus.Status=TEXT("Playing a match");UserStatus.MatchId=TEXT("<MatchId>");FStringJson;FJsonObjectConverter::UStructToJsonObjectString(UserStatus,Json);NakamaRt::StatusUpdate(RtConnection,Json);
When their followers receive the real-time status event they can try and join the match:
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:
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(constFNakamaRtUserPresence&Presence:CurrentMatch.Presences){FGameObjectGameObject=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:
RtConnection->MatchPresenceEvent.AddLambda([WeakThis](constFNakamaRtMatchPresenceEvent&MatchPresenceEvent){if(!WeakThis.IsValid()){return;}for(constFNakamaRtUserPresence&Join:MatchPresenceEvent.Joins){FGameObjectGameObject=WeakThis->SpawnPlayer();// Instantiate player object
WeakThis->Players.Add(Join.SessionId,GameObject);}for(constFNakamaRtUserPresence&Leave:MatchPresenceEvent.Leaves){if(WeakThis->Players.Contains(Leave.SessionId)){WeakThis->Players.Remove(Leave.SessionId);}}});
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
FPositionStatePositionState;PositionState.X=Position.X;PositionState.Y=Position.Y;PositionState.Z=Position.Z;FStringJson;FJsonObjectConverter::UStructToJsonObjectString(PositionState,Json);TArray<uint8>Bytes;constFTCHARToUTF8Converter(*Json);Bytes.Append(reinterpret_cast<constuint8*>(Converter.Get()),Converter.Length());constexprint64OpCode=1;constexprboolbReliable=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:
RtConnection->MatchData.AddLambda([WeakThis](constFNakamaRtMatchData&MatchDataResult){if(!WeakThis.IsValid()){return;}switch(MatchDataResult.OpCode){caseOPCODE_POSITION:{// The data is received as bytes, so convert it to a string first.
FStringJson;if(MatchDataResult.Data.Num()>0){constFUTF8ToTCHARConverter(reinterpret_cast<constANSICHAR*>(MatchDataResult.Data.GetData()),MatchDataResult.Data.Num());Json=FString(Converter.Length(),Converter.Get());}FPositionStatePositionState;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.
FVectorNewPosition=FVector(PositionState.X,PositionState.Y,PositionState.Z);WeakThis->Players[MatchDataResult.Presence.SessionId].Position=NewPosition;}break;}default:UE_LOG(LogTemp,Error,TEXT("Unsupported opcode"));break;}});
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.
RtConnection->MatchmakerMatched.AddLambda([WeakThis](constFNakamaRtMatchmakerMatched&MatchedResult){if(!WeakThis.IsValid()){return;}constTMap<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);});constexprint32MinPlayers=2;constexprint32MaxPlayers=10;constFStringQuery=TEXT("+skill:>100 mode:sabotage");constTMap<FString,FString>StringProperties={{"mode","sabotage"}};constTMap<FString,double>NumericProperties={{"skill",125}};constexprint32CountMultiple=0;NakamaRt::MatchmakerAdd(RtConnection,MinPlayers,MaxPlayers,Query,CountMultiple,StringProperties,NumericProperties).Next([](constNakamaRt::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);}});
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:
Nakama::ListFriends(ClientConfig,Session,100,0,TEXT("")).Next([RtConnection=this->RtConnection,CurrentParty=this->CurrentParty](constFNakamaFriendListResult&FriendListResult){if(FriendListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error retrieving friends: %s"),*FriendListResult.Error.Message);return;}for(constFNakamaFriend&Friend:FriendListResult.Value.Friends){FChatMessageChatMessage;ChatMessage.Message=FString::Printf(TEXT("Hey %s, wanna join the party?"),*Friend.User.Username);ChatMessage.PartyId=CurrentParty.PartyId;FStringJson;FJsonObjectConverter::UStructToJsonObjectString(ChatMessage,Json);// Join a direct message channel with the friend, then send the message to that channel
constexprint32ChannelType=static_cast<int32_t>(ENakamaRtChannelJoinType::DIRECT_MESSAGE);constexprboolbPersistence=false;constexprboolbHidden=false;NakamaRt::ChannelJoin(RtConnection,Friend.User.Id,ChannelType,bPersistence,bHidden).Next([RtConnection,Json](constNakamaRt::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);}});}});
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(constFNakamaRtUserPresence&Presence:CurrentParty.Presences){NakamaRt::PartyPromote(RtConnection,CurrentParty.PartyId,Presence).Next([](constNakamaRt::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"));});}
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](constFNakamaRtMatchmakerMatched&MatchedResult){if(!WeakThis.IsValid()){return;}constTMap<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:
constexprint32MinPlayers=2;constexprint32MaxPlayers=10;constFStringQuery=TEXT("");constTMap<FString,FString>StringProperties={};constTMap<FString,double>NumericProperties={};constexprint32CountMultiple=0;NakamaRt::PartyMatchmakerAdd(RtConnection,CurrentParty.PartyId,MinPlayers,MaxPlayers,Query,CountMultiple,StringProperties,NumericProperties).Next([](constNakamaRt::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);}});
RtConnection->PartyData.AddLambda([](constFNakamaRtPartyData&PartyDataResult){switch(PartyDataResult.OpCode){caseOPCODE_CALL_VOTE:{// The data is received as bytes, so convert it to a string first.
FStringJson;if(PartyDataResult.Data.Num()>0){constFUTF8ToTCHARConverter(reinterpret_cast<constANSICHAR*>(PartyDataResult.Data.GetData()),PartyDataResult.Data.Num());Json=FString(Converter.Length(),Converter.Get());}FVoteStateVoteState;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;}});
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.
constFStringLeaderboardId=TEXT("weekly_imposter_wins");constTArray<FString>OwnerIds={};constexprint32Limit=20;constFStringCursor=TEXT("");constexprint64Expiry=0;// From current time
Nakama::ListLeaderboardRecords(ClientConfig,Session,LeaderboardId,OwnerIds,Limit,Cursor,Expiry).Next([](constFNakamaLeaderboardRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing leaderboard records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&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:
constFStringLeaderboardId=TEXT("weekly_imposter_wins");constexprint32Limit=5;constexprint64Expiry=0;// From current time
constFStringCursor=TEXT("");Nakama::ListLeaderboardRecordsAroundOwner(ClientConfig,Session,LeaderboardId,Limit,Session.UserId,Expiry,Cursor).Next([](constFNakamaLeaderboardRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing leaderboard records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&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:
constFStringLeaderboardId=TEXT("weekly_imposter_wins");constexprint32Limit=20;constFStringCursor=TEXT("");constexprint64Expiry=0;// From current time
TArray<FString>FriendIds={};for(constFNakamaFriend&Friend:FriendsList.Friends){FriendIds.Add(Friend.User.Id);}Nakama::ListLeaderboardRecords(ClientConfig,Session,LeaderboardId,FriendIds,Limit,Cursor,Expiry).Next([](constFNakamaLeaderboardRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing leaderboard records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&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:
constFStringLeaderboardId=TEXT("weekly_imposter_wins");constexprint32Limit=20;constFStringCursor=TEXT("");constexprint64Expiry=0;// From current time
TArray<FString>MemberIds={};for(constFNakamaGroupUserListGroupUser&GroupUser:GroupUsers.GroupUsers){MemberIds.Add(GroupUser.User.Id);}Nakama::ListLeaderboardRecords(ClientConfig,Session,LeaderboardId,MemberIds,Limit,Cursor,Expiry).Next([](constFNakamaLeaderboardRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing leaderboard records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&Record:RecordListResult.Value.Records){UE_LOG(LogTemp,Log,TEXT("%s: %lld"),*Record.OwnerId,Record.Score);}});
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.
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.).
constFStringTournamentId=TEXT("weekly_top_detectives");constTArray<FString>OwnerIds={};constexprint32Limit=20;constFStringCursor=TEXT("");constexprint64Expiry=0;// From current time
Nakama::ListTournamentRecords(ClientConfig,Session,TournamentId,OwnerIds,Limit,Cursor,Expiry).Next([](constFNakamaTournamentRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing tournament records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&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:
constFStringTournamentId=TEXT("weekly_top_detectives");constexprint32Limit=20;constexprint64Expiry=0;// From current time
constFStringCursor=TEXT("");Nakama::ListTournamentRecordsAroundOwner(ClientConfig,Session,TournamentId,Limit,Session.UserId,Expiry,Cursor).Next([](constFNakamaTournamentRecordListResult&RecordListResult){if(RecordListResult.bIsError){UE_LOG(LogTemp,Error,TEXT("Error listing tournament records: %s"),*RecordListResult.Error.Message);return;}for(constFNakamaLeaderboardRecord&Record:RecordListResult.Value.Records){UE_LOG(LogTemp,Log,TEXT("%s: %lld"),*Record.OwnerId,Record.Score);}});
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([](constFNakamaRtNotifications&NotificationsResult){for(constFNakamaRtNotification&Notification:NotificationsResult.Notifications){constexprint32RewardCode=100;switch(Notification.Code){caseRewardCode: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;}}});