Introductions
Nakama is an open-source game server that can be used to build various multiplayer games and applications. It provides features such as user authentication, real-time multiplayer functionality, chat messaging, and more. In this blog post, we’ll show you how to create a simple chat application using Nakama’s Java Client Library.
Requirements
- A working Nakama server, you can check Nakama Getting Started page on how to install the server
- Some Kotlin and Gradle Knowledge
- A Java/Kotlin code editor preferably JetBrains IntelliJ, you can get the community version here
What we will be creating
Below is a video showing the simple chat application in action.
Creating the application.
We are going to use the IntelliJ IDEA Community edition for this project. Start the IDE and create a New Project, you should have a dialog like the one below. Have the settings as shown in the dialog or change whatever you need to suit your liking. Click the Create button to create the Project.
Dependencies
Let’s start by adding dependencies. Go ahead and modify your build.gradle
file. so that it resembles the one below. See the highlighted line 36 – 37
buildscript { repositories { mavenCentral() } dependencies { classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20' } } plugins { id 'application' id 'java' id 'org.jetbrains.kotlin.jvm' version '1.8.20' id "org.jetbrains.kotlin.kapt" version "1.8.20" } // apply plugin: 'kotlin-kapt' repositories { maven { url 'https://jitpack.io' } mavenCentral() // resolve Guava } application { mainClass = 'SimpleChatKt' } configurations { generateConfig } dependencies { implementation 'com.github.heroiclabs.nakama-java:nakama-java:04bf397414fbb03f49e1cda0ad442f789d934ea6' implementation 'com.github.cliftonlabs:json-simple:4.0.1' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.20" implementation 'info.picocli:picocli:4.7.2' kapt 'info.picocli:picocli-codegen:4.7.2' } jar { manifest { attributes "Main-Class": "co.example.SimpleChatKt" } from { configurations.compileClasspath.collect { it.isDirectory() ? it : zipTree(it) } } }
Creating the Client Class
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. Go ahead and create a new Kotlin class, give it a good name, or just call it ChatClient
like I did.
import com.github.cliftonlabs.json_simple.JsonObject import com.github.cliftonlabs.json_simple.Jsoner import com.heroiclabs.nakama.* import java.util.* import java.util.concurrent.ExecutionException class ChatClient { private val client: DefaultClient private var channel: Channel? = null private var socket: SocketClient? = null init { client = createClient() } @Throws(ExecutionException::class, InterruptedException::class) fun initSession(): Session { val deviceIdFileName = "deviceId.json" val tokenFileName = "token.json" var deviceId = FileUtils.getDeviceId(deviceIdFileName) val authToken = FileUtils.getToken(tokenFileName) val session: Session = if (deviceId.isNullOrEmpty() || authToken.isNullOrEmpty()) { deviceId = UUID.randomUUID().toString() FileUtils.saveDeviceId(deviceIdFileName, deviceId) authenticate(deviceId, true) } else { val session = restoreSession(authToken) if (session.IsExpired()) { authenticate(deviceId, false) } else { session } } FileUtils.saveAuthToken(tokenFileName, session.authToken) return session } @Throws(ExecutionException::class, InterruptedException::class) fun startChat(userSession: Session, abstractSocketListener: AbstractSocketListener) { joinChat(userSession, abstractSocketListener) } private fun createClient(): DefaultClient { return DefaultClient("defaultkey", "127.0.0.1", 7349, false) } @Throws(ExecutionException::class, InterruptedException::class) private fun authenticate(deviceId: String, create: Boolean): Session { return client.authenticateDevice(deviceId, create).get() } private fun restoreSession(authToken: String): Session { return DefaultSession.restore(authToken) } @Throws(ExecutionException::class, InterruptedException::class) private fun joinChat(session: Session, abstractSocketListener: AbstractSocketListener) { socket = client.createSocket() socket?.connect(session, abstractSocketListener)?.get() println("Socket connected successfully.") val roomName = "Public" val persistence = true val hidden = false channel = socket?.joinChat(roomName, ChannelType.ROOM, persistence, hidden)?.get() System.out.format("Now connected to channel id: %s", channel?.id) println() } @Throws(ExecutionException::class, InterruptedException::class) fun sendMessage(message: String) { val token = JsonObject() token["message"] = message val content = Jsoner.serialize(token) // Send message channel?.id?.let {channelId-> val sendAck = socket?.writeChatMessage(channelId, content)?.get() } } }
Explanation.
The ChatClient
class creates a client for the Nakama server to join a chat room, authenticate the device, and send chat messages.
The ChatClient
the class has three properties:
client
is an instance of theDefaultClient
class, which is used to interact with the Nakama server.channel
is an instance of theChannel
class, which represents a chat room on the Nakama server.socket
is an instance of theSocketClient
class, which provides a low-level interface for interacting with the Nakama server over a WebSocket connection.
and the following methods:
initSession()
initializes the session with the Nakama server. If the deviceId or authentication token is empty, it generates a new deviceId and authenticates the device with the Nakama server. If the authentication token is not empty, it restores the session using the authentication token.startChat()
joins a chat room on the Nakama server and starts listening for chat messages using the WebSocket connection. It callsjoinChat()
method to join the chat room.createClient()
creates an instance of theDefaultClient
class with the default key and the Nakama server’s IP address and port.authenticate()
method authenticates the device with the Nakama server using the deviceId and a boolean flag indicating whether or not to create a new user account.restoreSession()
method restores the session using an authentication token.joinChat()
method joins a chat room on the Nakama server and starts listening for chat messages using the WebSocket connection. It first creates a WebSocket connection using thecreateSocket()
method of theDefaultClient
class. It then connects to the WebSocket using theconnect()
method of theSocketClient
class. Finally, it joins the chat room using thejoinChat()
method of theSocketClient
class.sendMessage()
method sends a chat message to the chat room. It creates aJsonObject
with chat message, serializes it to JSON format usingJsoner.serialize()
, and sends it to the chat room using thewriteChatMessage()
method of theSocketClient
class.
Note that the code uses several external libraries to interact with the Nakama server and to work with JSON objects. The com.github.cliftonlabs.json_simple
library is used to create and manipulate JSON objects.
The Interface
Here is the interface class, it utilizes Swing’s components to build the user interface.
import com.github.cliftonlabs.json_simple.JsonObject import com.github.cliftonlabs.json_simple.Jsoner import com.heroiclabs.nakama.AbstractSocketListener import com.heroiclabs.nakama.MatchData import com.heroiclabs.nakama.Session import com.heroiclabs.nakama.api.ChannelMessage import java.awt.GridBagConstraints import java.awt.GridBagLayout import java.awt.Insets import java.util.concurrent.ExecutionException import javax.swing.* fun main(args: Array<String>) { SwingUtilities.invokeLater { SimpleChat().isVisible = true } } class SimpleChat : JFrame() { private val chatClient = ChatClient() private val listModel = DefaultListModel<String>() private lateinit var session: Session init { setDefaultLookAndFeelDecorated(true) defaultCloseOperation = EXIT_ON_CLOSE setupUI() } private fun setupUI() { val constraints = GridBagConstraints() constraints.fill = GridBagConstraints.BOTH constraints.insets = Insets(4, 8, 4, 8) constraints.gridwidth = 1 constraints.gridx = 0 constraints.gridy = 0 constraints.weightx = 1.0 constraints.weighty = 0.90 add(JScrollPane(JList(listModel)), constraints) val messagePanel = createMessagePanel() constraints.gridx = 0 constraints.gridy = 1 constraints.weightx = 1.0 constraints.weighty = 0.10 add(messagePanel, constraints) setSize(480, 640) title = "Simple Chat" initClient() } private fun createMessagePanel(): JPanel { val jPanel = JPanel(GridBagLayout()) val panelConstraints = GridBagConstraints() panelConstraints.fill = GridBagConstraints.BOTH panelConstraints.gridwidth = 1 panelConstraints.gridx = 0 panelConstraints.gridy = 0 panelConstraints.weightx = 0.90 panelConstraints.weighty = 1.0 val messageField = JTextArea(5, 40) messageField.lineWrap = true messageField.wrapStyleWord = true jPanel.add(messageField, panelConstraints) // button constraints panelConstraints.gridx = 1 panelConstraints.gridy = 0 panelConstraints.weightx = 0.10 panelConstraints.weighty = 1.0 val send = JButton("Send") send.addActionListener { val message = messageField.text if (message.isNotEmpty()) { chatClient.sendMessage(message) listModel.addElement("${session.username}: $message ") messageField.text = "" } } jPanel.add(send, panelConstraints) return jPanel } private fun initClient() { try { session = chatClient.initSession() chatClient.startChat(session, object : AbstractSocketListener() { override fun onDisconnect(t: Throwable) { super.onDisconnect(t) t.printStackTrace() } override fun onChannelMessage(message: ChannelMessage) { if (message.senderId != session.userId) { val parser = Jsoner.deserialize(message.content) as JsonObject val strMessage = parser["message"] as String listModel.addElement("${message.username} : $strMessage") } } override fun onMatchData(matchData: MatchData) { println("Match data received") } }) } catch (e: ExecutionException) { e.printStackTrace() } catch (e: InterruptedException) { e.printStackTrace() } } }
SimpleChat
is a Swing-based graphical user interface (GUI) application that allows users to send and receive chat messages.
The SimpleChat
class extends the JFrame
class and creates a GUI that consists of a JList
to display the chat messages and a JTextArea
and a JButton
to send chat messages.
SimpleChat
class has 3 local properties:
chatClient
is an instance of theChatClient
class that is used to communicate with the Nakama server.listModel
is aDefaultListModel<String>
that is used to store and display chat messages.session
is aSession
object that represents the user’s session with the Nakama server.
and the following methods:
main()
the entry point of the application. It creates an instance of theSimpleChat
class and sets it to visible.setupUI()
sets up the GUI components of the application using the GridBagLayout manager. It createsJScrollPane
with aJList
and a message panel with theJTextArea
andJButton
.createMessagePanel()
creates the message panel with theJTextArea
andJButton
. It sets up the event listener for theJButton
to send chat messages to the chat room.initClient()
initializes the client by callinginitSession()
method of theChatClient
class to authenticate the user and create a session with the Nakama server. It then callsstartChat()
method of theChatClient
class to join the chat room and start listening to chat messages using the WebSocket connection.onChannelMessage()
method is an event listener that is called when a new chat message is received. It deserializes the message content using theJsoner.deserialize()
and adds it to thelistModel
.
Finally, we have a utility class that writes/reads to and from file session information.
object FileUtils { fun saveAuthToken(fileName: String, authToken: String) { try { Files.newBufferedWriter(Paths.get(fileName)).use { writer -> val token = JsonObject() token["co.example.AuthToken"] = authToken Jsoner.serialize(token, writer) } } catch (e: IOException) { throw RuntimeException(e) } } fun getToken(fileName: String): String? { var authToken: String? = null try { Files.newBufferedReader(Paths.get(fileName)).use { reader -> val parser = Jsoner.deserialize(reader) as JsonObject authToken = parser["co.example.AuthToken"] as String? } } catch (e: IOException) { e.printStackTrace() } catch (e: JsonException) { e.printStackTrace() } return authToken } fun saveDeviceId(fileName: String, authToken: String) { try { Files.newBufferedWriter(Paths.get(fileName)).use { writer -> val token = JsonObject() token["DeviceId"] = authToken Jsoner.serialize(token, writer) } } catch (e: IOException) { throw RuntimeException(e) } } fun getDeviceId(fileName: String): String? { var deviceId: String? = null try { Files.newBufferedReader(Paths.get(fileName)).use { reader -> val parser = Jsoner.deserialize(reader) as JsonObject deviceId = parser["DeviceId"] as String? } } catch (e: IOException) { e.printStackTrace() } catch (e: JsonException) { e.printStackTrace() } return deviceId } }
I believe the code in the FileUtils object is straightforward and easy to understand. As mentioned early we use the methods to write and read data to and from files.
Conclusion
Hope you have enjoyed this blog, let me know what you think, and see you in the next write-up. The source code can be found on GitHub
just wanted to tell you, I loved this blog post. It was practical. I hope you stay healthy and passionate about providing useful posts for readers Keep on posting!
Hello!
Good luck 🙂