This client library guide will show you how to use the core Nakama features in Java by showing you how to develop the Nakama specific parts (without full game logic or UI) of an Among Us (external) inspired game called Sagi-shi (Japanese for “Imposter”).
Downloaded the Nakama Java SDK. You can download “nakama-java-$version.jar” or “nakama-java-$version-all.jar” which includes a shadowed copy of all dependencies.
There are a few different options for installing the Nakama Java SDK in your project depending on whether you’re using Gradle, Maven or relying on the JAR package directly.
repositories{maven{url'https://jitpack.io'}}dependencies{implementation'com.github.heroiclabs.nakama-java:nakama-java:<commit>'// or, depend on the fat Jar which bundles all of the Nakama Java dependencies into a single Jar.
// implementation 'com.github.heroiclabs.nakama-java:nakama-java-all:<commit>'
}
The Nakama Java SDK uses the SLF4J logging API. You can find more information on how to use this API and how to use different logging bindings by reading the SLF4J User Manual.
All examples in this guide use an SLF4J Logger which can be created as follows:
1
2
// Where App is the name of your classLoggerlogger=LoggerFactory.getLogger(App.class);
Android uses a permissions system which determines which platform services the application will request to use and ask permission for from the user. The client uses the network to communicate with the server so you must add the “INTERNET” permission.
Many of the Nakama APIs are asynchronous and non-blocking and are available in the Java SDK using ListenableFuture objects which are part of the Google Guava library.
Sagi-shi calls these async methods using callbacks to not block the calling thread so that the game is responsive and efficient.
// Create a single thread executorExecutorServiceexecutor=Executors.newSingleThreadExecutor();// Get a ListenableFuture with a Session resultListenableFuture<Session>authFuture=client.authenticateDevice(deviceId);// Setup the success and failure callbacks, specifying which executor to useFutures.addCallback(authFuture,newFutureCallback<Session>(){@OverridepublicvoidonSuccess(@NullableDeclSessionsession){logger.debug("Authenticated user id: "+session.getUserId());executor.shutdown();}@OverridepublicvoidonFailure(Throwablethrowable){logger.error(throwable.getMessage());executor.shutdown();}},executor);// Wait for the executor to finish all taskstry{executor.awaitTermination(5,TimeUnit.SECONDS);}catch(InterruptedExceptione){logger.error(e.getMessage());}
If you wish to chain asynchronous calls together, you can do so using AsyncFunction<> objects and the Futures.transformAsync function.
// Get a ListenableFuture with a Session resultListenableFuture<Session>authFuture=client.authenticateDevice(deviceId);// Create an AsyncFunction to get the Account of a user using a Session objectAsyncFunction<Session,Account>accountFunction=session->client.getAccount(session);// Get a ListenableFuture from Futures.transformAsync by first passing the original Future, followed by the extended Future and finally an exectorListenableFuture<Account>accountFuture=Futures.transformAsync(authFuture,accountFunction,executor);// Setup the success and failture callbacks as usualFutures.addCallback(accountFuture,newFutureCallback<>(){@OverridepublicvoidonSuccess(@NullableDeclAccountaccount){logger.debug("Got account for user id: "+account.getUser().getId());executor.shutdown();}@OverridepublicvoidonFailure(Throwablethrowable){logger.error(throwable.getMessage());executor.shutdown();}},executor);
For brevity, the code samples in this guide will use the simpler but thread blocking .get() function instead.
Network programming requires additional safeguarding against connection and payload issues.
As shown above, API calls in Sagi-Shi use a callback pattern with both a success and a failure callback being provided. If an API call throws an exception it is handled in the onFailure callback and the exception details can be accessed in the throwable object.
When sending and receiving data across the network it will need to be serialized and deserialized appropriately. The two most common ways to do this are using JSON and Binary data.
Both examples will show how to serialize and deserialize the Map object below but can be used with any serializable object.
Using the java.io.* package. Conversion to and from Base64 is only necessary if you wish to send and receive the serialized data as a String; otherwise you can serialize and deserialize using a byte[] array.
The Nakama Client connects to a Nakama Server and is the entry point to access Nakama features. It is recommended to have one client per server per game.
To create a client for Sagi-shi pass in your server connection details:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
finalStringserverKey="defaultkey";// The key used to authenticate with the server without a session.finalstringhost="127.0.0.1";// The host address of the server.finalintport=7349;// The port number of the server.finalbooleanssl=false;// Set connection strings to use the secure mode with the server.finalintdeadlineAfterMs=0;// Timeout for the gRPC messages.finallongkeepAliveTimeMs=Long.MAX_VALUE;// The time without read activity before sending a keepalive ping.finallongkeepAliveTimeoutMs=0L;// The time waiting for read activity after sending a keepalive ping.finalbooleantrace=false;// Trace all actions performed by the client.// explicity passing all parametersClientclient=newDefaultClient(serverKey,host,port,ssl,deadlineAfterMs,keepAliveTimeMs,keepAliveTimeoutMs,trace);// basic parametersClientclient=newDefaultClient(serverKey,host,port,ssl);// minimum parametersClientclient=newDefaultClient(serverKey);
The Nakama Java SDK communicates with the Nakama server directly via gRPC so you will want to use the gRPC port number that you have configured for your Nakama server; by default this is 7349.
Each request to Nakama from the client must complete in a certain period of time before it is considered to have timed out. You can configure how long this period is (in seconds) by setting the deadlineAfterMs value on the client before the constructor is invoked:
The Nakama Socket is used for gameplay and real-time latency-sensitive features such as chat, parties, matches and RPCs.
The socket is exposed on a different port on the server to the client. You’ll need to specify a different port here to ensure that connection is established successfully.
The client can create one or more sockets with the server. Each socket can have it’s own event listeners registered for responses received from the server.
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.
Nakama allows players to Link Authentication methods to their account once they have authenticated.
Linking Device ID authentication
1
2
3
StringdeviceId=UUID.randomUUID().toString();client.linkDevice(session,deviceId);logger.info("Linked device id {} for user {}",deviceId,session.getUserId());
Linking Facebook authentication
1
2
3
4
StringfacebookAccessToken="...";booleanimportFriends=true;client.linkFacebook(session,facebookAccessToken,importFriends);logger.info("Linked facebook authentication for user {}",deviceId,session.getUserId());
Nakama Sessions expire after a time set in your server configuration. Expiring inactive sessions is a good security practice.
It is recommended to store the auth token from the session and check at startup if it has expired. If the token has expired you must reauthenticate. The expiry time of the token can be changed as a setting in the server.
In addition to getting the current authenticated player’s user account, Nakama has a convenient way to get a list of other players’ public profiles from their ids, usernames or Facebook ids.
Sagi-shi uses this method to display player profiles when engaging with other Nakama features:
1
2
3
4
5
6
7
8
9
List<String>ids=Arrays.asList("userid1","userid2");List<String>usernames=Arrays.asList("username1","username1");String[]facebookIds=newString[]{"facebookid1"};Usersusers=client.getUsers(session,ids,usernames,facebookIds).get();for(Useruser:users.getUsersList()){logger.info("User id {} username {}",user.getId(),user.getUsername());}
// Get the updated account object.Accountaccount=client.getAccount(session).get();// Parse the account user metadata and log the result.Gsongson=newGson();Metadatametadata=gson.fromJson(account.getUser().getMetadata(),Metadata.class);logger.info("Title: {}, Hat: {}, Skin: {}",metadata.Title,metadata.Hat,metadata.Skin);
The above code uses the com.google.gson serialization/deserialization library.
Define a class that describes the storage object (or optionally use a HashMap<String, Object>) and create a new storage object id with the collection name, key and user id. Finally, read the storage objects and parse the JSON data:
To read other players’ public storage object, use their UserId instead.
Players can only read storage objects they own or that are public (PermissionRead value of 2).
Nakama allows developers to write to the Storage Engine from the client and server.
Consider what adverse effects a malicious user can have on your game and economy when deciding where to put your write logic, for example data that should only be written authoritatively (i.e. game unlocks or progress).
Sagi-shi allows players to favorite items for easier access in the UI and it is safe to write this data from the client.
Create a write storage object with the collection name, key and JSON encoded data. Finally, write the storage objects to the Storage Engine:
1
2
3
4
5
6
7
8
// Serialize your object as JSONMap<String,List<String>>favoriteHats=newHashMap<>();favoriteHats.put("hats",Arrays.asList("cowboy","alien"));StringfavoriteHatsJson=newGson().toJson(favoriteHats);StorageObjectWritewriteObject=newStorageObjectWrite("favorites","Hats",favoriteHatsJson,PermissionRead.OWNER_READ,PermissionWrite.OWNER_WRITE);StorageObjectAcksacks=client.writeStorageObjects(session,writeObject).get();logger.info("Stored objects {}",acks.getAcksList());
You can also pass multiple objects to the client.writeStorageObjects method:
Storage Engine Conditional Writes ensure that write operations only happen if the object hasn’t changed since you accessed it.
This gives you protection from overwriting data, for example the Sagi-shi server could have updated an object since the player last accessed it.
To perform a conditional write, add a version to the write storage object with the most recent object version:
1
2
3
4
5
6
// Assuming we already got an object (`obj`)// Create a new object with the same Collection and Key and use the previous object's VersionStorageObjectWritewriteObject=newStorageObjectWrite(obj.getCollection(),obj.getKey(),newJsonValue,PermissionRead.OWNER_READ,PermissionWrite.OWNER_WRITE);writeObject.setVersion(obj.getVersion())// ... then write it to the Storage Engine as shown above
Nakama methods that list results return a cursor which can be passed to subsequent calls to Nakama to indicate where to start retrieving objects from in the collection.
For example:
If the cursor has a value of 5, you will get results from the fifth object.
If the cursor is null, you will get results from the first object.
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.
Adding a friend in Nakama does not immediately add a mutual friend relationship. An outgoing friend request is created to each user, which they will need to accept.
Sagi-shi allows players to add friends by their usernames or user ids:
1
2
3
4
5
6
7
// Add friends by User ID.List<String>ids=Arrays.asList("AlwaysTheImposter21","SneakyBoi");// Add friends by Username.String[]usernames=newString[]{"<SomeUserId>","<AnotherUserId>"};client.addFriends(session,ids,usernames).get();
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
intfriendshipState=0;intlimit=20;// Limit is capped at 1000Stringcursor=null;FriendListfriends=client.listFriends(session,friendshipState,limit,cursor).get();friends.getFriendsList().forEach(friend->logger.info("Friend Id: {}",friend.getUser().getId()));
Sagi-shi players can remove friends by their username or user id:
1
2
3
4
5
6
7
// Delete friends by User ID.List<String>ids=Arrays.asList("<SomeUserId>","<AnotherUserId>");// Delete friends by Username.String[]usernames=newString[]{"<SomeUsername>","<AnotherUsername>"};client.deleteFriends(session,ids,usernames).get();
Sagi-shi players can block others by their username or user id:
1
2
3
4
5
6
7
// Block friends by User ID.List<String>ids=Arrays.asList("<SomeUserId>","<AnotherUserId>");// Block friends by Username.String[]usernames=newString[]{"<SomeUsername>","<AnotherUsername>"};client.blockFriends(session,ids,usernames).get();
Blocked friends are represented by a friendship state of 3.
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.
// If one doesn't already exist, create a socket listener and handle status presence eventsSocketListenersocketListener=newSocketListener(){@OverridepublicvoidonStatusPresence(StatusPresenceEvente){if(e.getJoins()!=null){e.getJoins().forEach(presence->logger.info("{} is online with status {}",presence.getUsername(),presence.getStatus()));}if(e.getLeaves()!=null){e.getLeaves().forEach(presence->logger.info("{} went offline",presence.getUsername()));}}// ... other required overrides (e.g. onChannelMessage etc)}// Then create and connect a socket connectionSocketClientsocket=client.createSocket();socket.connect(session,socketListener);// Follow mutual friends and get the initial Status of any that are currently onlineintfriendshipState=0;// Mutual friendsintlimit=20;// Limit is capped at 1000FriendListfriends=client.listFriends(session,friendshipState,limit,null).get();List<String>friendIds=newArrayList<>();for(Friendfriend:friends.getFriendsList()){friendIds.add(friend.getUser().getId());}Statusstatus=socket.followUsers(friendIds).get();if(status.getPresences()!=null){status.getPresences().forEach(presence->logger.info("{} is online with status {}",presence.getUsername(),presence.getStatus()));}
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
Stringname="Imposters R Us";Stringdescription="A group for people who love playing the imposter.";StringavatarUrl="";StringlangTag="";Booleanopen=true;// Public groupintmaxSize=100;Groupgroup=client.createGroup(session,name,description,avatarUrl,langTag,open,maxSize).get();
Groups can be listed like other Nakama resources and also filtered with a wildcard group name.
Sagi-shi players use group listing and filtering to search for existing groups to join:
1
2
3
4
5
6
intlimit=20;GroupListgroupList=client.listGroups(session,"imposter%",limit).get();groupList.getGroupsList().forEach(group->logger.info("{} [{}]",group.getName(),group.hasOpen()&&group.getOpen().getValue()?"Public":"Private"));// Get the next page of results.GroupListnextGroupListResults=client.listGroups(session,"imposter%",limit,groupList.getCursor()).get();
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.
Sagi-shi players can list groups they are a member of:
1
2
3
4
5
6
Stringuserid="<user id>";UserGroupListuserGroups=client.listUserGroups(session,userid).get();for(UserGroupList.UserGroupuserGroup:userGroups.getUserGroupsList()){System.out.format("Group name %s role %d",userGroup.getGroup().getName(),userGroup.getState());}
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 group members can have conversations that span play sessions in a persistent group chat channel:
1
2
3
4
5
StringgroupId="<GroupId>";booleanpersistence=true;booleanhidden=false;Channelchannel=socket.joinChat(groupId,ChannelType.GROUP,persistence,hidden).get();logger.info("Connected to group channel: {}",channel.getId());
Sagi-shi players can also chat privately one-to-one during or after matches and view past messages:
1
2
3
4
5
StringuserId="<UserId>";booleanpersistence=true;booleanhidden=false;Channelchannel=socket.joinChat(userId,ChannelType.DIRECT_MESSAGE,persistence,hidden).get();logger.info("Connected to direct message channel: {}",channel.getId());
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
StringchannelId="<ChannelId>";Map<String,String>messageContent=newHashMap<>();messageContent.put("message","I think Red is the imposter!");ChannelMessageAckmessageSendAck=socket.writeChatMessage(channelId,newGson().toJson(messageContent,messageContent.getClass())).get();Map<String,String>emoteContent=newHashMap<>();emoteContent.put("emote","point");emoteContent.put("emoteTarget","<RedPlayerUserId>");ChannelMessageAckemoteSendAck=socket.writeChatMessage(channelId,newGson().toJson(emoteContent,emoteContent.getClass())).get();
Chat also has cacheable cursors to fetch the most recent messages, which you can store in whichever way you prefer.
1
2
3
4
5
// Store this in whichever way suits your applicationStringcacheableCursor=channelMessageList.getCacheableCursor();// ...then use the cacheable cursor later to retrieve the next resultsChannelMessageListnextResults=client.listChannelMessages(session,groupId,limit,cacheableCursor,forward);
Nakama also supports updating messages. It is up to you whether you want to use this feature, but in a game of deception like Sagi-shi it can add an extra element of deception.
For example a player sends the following message:
1
2
3
4
5
6
7
Map<String,String>messageContent=newHashMap<>();messageContent.put("message","I think Red is the imposter!");ChannelMessageAckack=socket.writeChatMessage(channelId,newGson().toJson(messageContent,messageContent.getClass())).get();Map<String,String>updatedMessageContent=newHashMap<>();updatedMessageContent.put("message","I think BLUE is the imposter!");ChannelMessageAckupdateAck=socket.updateChatMessage(channelId,ack.getMessageId(),newGson().toJson(updatedMessageContent,updatedMessageContent.getClass())).get();
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.
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
Matchmatch=socket.createMatch().get();FriendListfriendList=client.listFriends(session,0,100,"").get();for(Friendfriend:friendList.getFriendsList()){if(friend.getUser().getOnline()){Map<String,String>messageContent=newHashMap<>();messageContent.put("message",String.format("Hey %s, join me for a match!",friend.getUser().getUsername()));messageContent.put("matchId",match.getMatchId());Channelchannel=socket.joinChat(friend.getUser().getId(),ChannelType.DIRECT_MESSAGE).get();ChannelMessageAckack=socket.writeChatMessage(channel.getId(),newGson().toJson(messageContent,messageContent.getClass())).get();}}
Or set up a real-time matchmaker listener and add themselves to the matchmaker:
1
2
3
4
5
6
7
8
9
10
11
// In the SocketListener, override the onMatchmakerMatched function@OverridepublicvoidonMatchmakerMatched(MatchmakerMatchedmatchmakerMatched){Matchmatch=socket.joinMatch(matchmakerMatched.getMatchId()).get();}// ...then, elsewhereintminPlayers=2;intmaxPlayers=10;Stringquery="";MatchmakerTicketmatchmakingTicket=socket.addMatchmaker(minPlayers,maxPlayers,query).get();
Joining matches from player status
Sagi-shi players can update their status when they join a new match:
1
2
3
4
5
Map<String,String>status=newHashMap<>();status.put("Status","Playing a match");status.put("MatchId","<MatchId>");socket.updateStatus(newGson().toJson(status,status.getClass())).get();
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:
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
@OverridepublicvoidonMatchData(MatchDatamatchData){if(matchData.getOpCode()==OpCodes.Position){// Get the updated position dataStringjson=newString(matchData.getData());PositionStateposition=newGson().fromJson(json,newTypeToken<PositionState>(){}.getType());// Update the GameObject associated with that playerif(players.containsKey(matchData.getPresence().getSessionId())){Playerplayer=players.get(matchData.getPresence().getSessionId());// Here we would normally do something like smoothly interpolate to the new position, but for this example let's just set the position directly.player.position=position;}}}
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.
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.
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.
Categories are filtered using a range, not individual numbers, for performance reasons. Structure your categories to take advantage of this (e.g. all PvE tournaments in the 1XX range, all PvP tournaments in the 2XX range, etc.).
Nakama uses a code to differentiate notifications. Codes of 0 and below are system reserved for Nakama internals.
Sagi-shi players can subscribe to the notification received event using the Socket Listener. Sagi-shi uses a code of 100 for tournament winnings:
1
2
3
4
5
6
7
8
9
10
11
@OverridepublicvoidonNotifications(NotificationListnotificationList){finalintrewardCode=100;for(Notificationnotification:notificationList.getNotificationsList()){if(notification.getCode()==rewardCode){logger.info("Congratulations, you won the tournament!\n{}\n{}",notification.getSubject(),notification.getContent());}else{logger.info("Other notification: {}:{}\n{}",notification.getCode(),notification.getSubject(),notification.getContent());}}}
Like other listing methods, notification results can be paginated using a cursor or cacheable cursor from the result.
Assuming the cacheable has been saved, the next time the player logs in the cacheable cursor can be used to list unread notifications.
1
2
3
// Assuming this has been saved and loadedStringcacheableCursor="";NotificationListnextResults=client.listNotifications(session,limit,cacheableCursor);