Building a Simple Chat Application using Nakama Server and Kotlin

You are currently viewing Building a Simple Chat Application using Nakama Server and Kotlin
  • Post author:
  • Reading time:14 mins read

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:

  1. client is an instance of the DefaultClient class, which is used to interact with the Nakama server.
  2. channel is an instance of the Channel class, which represents a chat room on the Nakama server.
  3. socket is an instance of the SocketClient class, which provides a low-level interface for interacting with the Nakama server over a WebSocket connection.

and the following methods:

  1. 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.
  2. startChat() joins a chat room on the Nakama server and starts listening for chat messages using the WebSocket connection. It calls joinChat() method to join the chat room.
  3. createClient() creates an instance of the DefaultClient class with the default key and the Nakama server’s IP address and port.
  4. 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.
  5. restoreSession() method restores the session using an authentication token.
  6. 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 the createSocket() method of the DefaultClient class. It then connects to the WebSocket using the connect() method of the SocketClient class. Finally, it joins the chat room using the joinChat() method of the SocketClient class.
  7. sendMessage() method sends a chat message to the chat room. It creates a JsonObject with chat message, serializes it to JSON format using Jsoner.serialize(), and sends it to the chat room using the writeChatMessage() method of the SocketClient 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:

  1. chatClient is an instance of the ChatClient class that is used to communicate with the Nakama server.
  2. listModel is a DefaultListModel<String> that is used to store and display chat messages.
  3. session is a Session object that represents the user’s session with the Nakama server.

and the following methods:

  1. main() the entry point of the application. It creates an instance of the SimpleChat class and sets it to visible.
  2. setupUI()sets up the GUI components of the application using the GridBagLayout manager. It creates JScrollPane with a JList and a message panel with the JTextArea and JButton.
  3. createMessagePanel() creates the message panel with the JTextArea and JButton. It sets up the event listener for the JButton to send chat messages to the chat room.
  4. initClient() initializes the client by calling initSession() method of the ChatClient class to authenticate the user and create a session with the Nakama server. It then calls startChat() method of the ChatClient class to join the chat room and start listening to chat messages using the WebSocket connection.
  5. onChannelMessage() method is an event listener that is called when a new chat message is received. It deserializes the message content using the Jsoner.deserialize() and adds it to the listModel.

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

This Post Has 2 Comments

  1. 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!

  2. umer23Welty

    Hello!

    Good luck 🙂

Leave a Reply