Tic Tac Toe: Building the Android Client

This is part of the article Unified Gaming: Tic-Tac-Toe on Android with Spring Boot

The code is available on Github at the link: Tic-tac-toe-client-side

The Android Tic-Tac-Toe app is designed using the MVVM (Model-View-ViewModel) architectural approach, which provides a clear separation of business logic, user interface and data management logic.

Architecture components:
  • PlayerPreferencesManager: Manages player preferences using the DataStore to save data.
  • RegistrationFragment: Fragment for registering a player in the application.
  • HomeFragment: The main fragment of the application that displays the home screen and provides the ability to start a new game or view statistics.
  • GameFragment: A fragment for displaying the playing field and playing a game.
  • PreferencesViewModel: ViewModel for managing player preferences.
  • WebSocketViewModel: ViewModel for managing the WebSocket connection to the server.
  • GameViewModel: ViewModel for controlling gameplay.

RegistrationViewModel and HomeViewModel are not used; they are necessary for the correct architecture, but in this case their role is too small and in order not to clutter up with a large number of empty classes, their roles are delegated to WebSocketViewModel.

Separation of responsibilities: Each component (fragments, ViewModels) is responsible for its own tasks and does not directly depend on other components.
To ensure asynchronous data processing, LiveData and Kotlin Coroutines are used.
DataStore is used to store data on the player’s device, providing secure and efficient storage of player settings.
WebSocket is used to interact with the server, providing fast and reliable data transfer between the client and the server.

PlayerPreferencesManager

The PlayerPreferencesManager class manages player preferences in the application. It uses DataStore to store data and provides a data stream to retrieve the player’s current settings. The class provides the ability to update player settings and provides secure data storage using keys to access various settings. Using DataStore ensures efficient and reliable storage of data on the user’s device.

class PlayerPreferencesManager(private val dataStore: DataStore<Preferences>) {

    val preferencesFlow: Flow<PlayerPreferences> = dataStore.data.map { preferences ->
        val id = preferences[PLAYER_ID] ?: ID_IS_MISSING

        PlayerPreferences(id)
    }

    suspend fun updatePlayerPreferences(id: Int){
        dataStore.edit { preferences ->
            preferences[PLAYER_ID] = id
        }
    }

    companion object {
        val Context.datastore by preferencesDataStore(PREFERENCES_NAME)
    }

    private object Keys{
        val PLAYER_ID = intPreferencesKey("player_id")
    }
Request and Response Classes

The project uses the following classes to exchange data between the client and the server; these classes are complete copies of the server ones:

  • BaseRequest
  • GameRequest
  • GameResponse
  • LobbyResponse
  • PreGameResponse
  • RegistrationRequest
  • RegistrationResponse
  • StatisticsResponse

I will not show the code of all classes because they are a copy of the server classes translated into Kotlin.

data class BaseRequest(val playerId: Int)

data class GameRequest(val playerId: Int, val cellIndex: Int)

class GameResponse {
    enum class Status{
        IN_PROGRESS, FINISHED
    }

    var status: Status
        private set
    var cellIndex: Int = -1
        private set
    var symbol: Char = Char.MIN_VALUE
        private set
    var currentPlayer: String = ""
        private set
    var canMove: Boolean = false
        private set
    var winnerMsg: String = ""
        private set

    constructor(status: Status, cellIndex: Int, symbol: Char, currentPlayer: String, canMove: Boolean){
        this.status = status
        this.cellIndex = cellIndex
        this.symbol = symbol
        this.canMove = canMove
        this.currentPlayer = currentPlayer
    }

    constructor(status: Status, cellIndex: Int, symbol: Char, winnerMsg: String){
        this.status = status
        this.cellIndex = cellIndex
        this.symbol = symbol
        this.winnerMsg = winnerMsg
    }
}

RegistrationFragment

The registration fragment is a screen where the user can register to start playing. Interaction with the server occurs via WebSocket. This fragment uses a ViewModel to manage data and a DataStore to store player settings. When a user registers, the name is sent to the server, which responds with a successful registration status or an error message if a user with the same name already exists. After successful registration, you will be taken to the HomeFragment screen.

Listing the main properties and methods of the class:

WebSocketViewModel:
Used to send and receive messages via WebSocket. An instance of this model is provided at the activity level.

PreferencesViewModel:
Used to manage player settings such as player ID. Gets an instance of PlayerPreferencesManager that manages the storage of preferences using the DataStore.

androidId:
The Android device ID used as part of the player’s unique ID.

onCreate():
Subscribes the fragment to receive a response from the server upon registration.

onCreateView():
Creates and returns a fragment layout.

onViewCreated():
Initializes button click handlers and sets up observers for the response from the server.

handleSignUpBtn():
Handles a click on the register button. When attempting to register, sends the username via WebSocket.

observeRegistrationResponse():
Monitors the server’s response to a registration request. If registration is successful, it updates the player’s settings and goes to the HomeFragment screen. If a user with the same name already exists, an error message is displayed.

onDestroyView():
Clears response subscriptions from the server when the fragment is finished.

class RegistrationFragment : Fragment() {
    private var _binding: FragmentRegistrationBinding? = null
    private val  binding get() = _binding!!

    private val webSocketViewModel: WebSocketViewModel by activityViewModels()
    private val preferencesViewModel: PreferencesViewModel by viewModels {
        PreferencesViewModel.Factory(PlayerPreferencesManager(requireContext().datastore))
    }
    private var androidId: String = ID_IS_MISSING.toString()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        androidId = Util.getAndroidId(requireContext())
        webSocketViewModel.subscribeToRegistration(androidId)
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentRegistrationBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        handleSignUpBtn()
        observeRegistrationResponse()
    }

    private fun handleSignUpBtn(){
        binding.signUpBtn.setOnClickListener{
            it.isEnabled = false
            val name = binding.editText.text.toString()
            name.trim()
            if (name.isEmpty()){
                Toast.makeText(requireContext(), getString(R.string.name_mustnt_be_empty), Toast.LENGTH_SHORT).show()
            }else{
                webSocketViewModel.sendMsgToRegistration(androidId,name)
            }
        }
    }

    private fun observeRegistrationResponse(){
        webSocketViewModel.registrationResponse.observe(viewLifecycleOwner){response ->
            response?.let {
                when(response.status){
                    RegistrationResponse.Status.SUCCESS -> {
                        preferencesViewModel.updatePreferences(response.playerId)
                        findNavController().navigate(R.id.action_registrationFragment_to_homeFragment)
                    }

                    RegistrationResponse.Status.ALREADY_EXISTS -> {
                        Toast.makeText(requireContext(), response.message, Toast.LENGTH_SHORT).show()
                        binding.signUpBtn.isEnabled = true
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        webSocketViewModel.clearRegistrationResponse()
        webSocketViewModel.unsubscribeRegistration()
        super.onDestroyView()
    }
}

HomeFragment

The HomeFragment represents the main screen of the application, where the user can get an overview of the game’s statistics and begin searching for a game. Interaction with the server is done via WebSocket, and player settings are managed using PreferencesViewModel and PlayerPreferencesManager.
This screen displays information about the user’s number of games and wins, as well as his name. This data is updated automatically when a response is received from the server.

HomeFragment also implements a game search mechanism. When you click on the “Play” button, the application sends a request to the server to search for an opponent. While the search continues, a corresponding message is displayed. Once an opponent is found, the screen redirects to GameFragment to begin the game.

Here is a short description of the main methods implemented in HomeFragment:

onCreate():
Initializes retrieving player settings.

onCreateView():
This is where you configure the binding to the layout and subscribe to the player ID.

onViewCreated():
Monitors the server’s responses, updating the UI according to the received data, and initializes the “Play” button click handler.

observeStatisticsResponse():
A method for observing changes in a player’s statistics.
Updates text fields on the screen with information about the number of games and wins.

setTextViews():
Method for setting text fields with information about player statistics.

observeLobbyResponse():
Method for monitoring changes in lobby status (game search).
Displays a search message or redirects to the game screen after finding an opponent.

setPreGameInfo():
Method for setting information before the start of the game (player name, opponent name, current player, move possibility).

collectPreferences():
Method for getting player settings from PreferencesViewModel.
Used to obtain the player ID.

observeId():
Method for monitoring player ID.
Subscribes to ID changes and connects WebSocket channels.

subscribeToWebSocket():
Method for subscribing to WebSocket channels to obtain lobby data and player statistics.

handlePlayBtn():
The handler method for clicking the “Play” button. Sends a request to the server to search for an opponent.

onDestroyView():
Unsubscribes from WebSocket channels and cleans up resources to prevent memory leaks.

class HomeFragment : Fragment() {
    private var _binding: FragmentHomeBinding? = null
    private val binding get() = _binding!!

    private val preferencesViewModel: PreferencesViewModel by viewModels {
        PreferencesViewModel.Factory(PlayerPreferencesManager(requireContext().datastore))
    }
    private val webSocketViewModel: WebSocketViewModel by activityViewModels()
    private val gameViewModel: GameViewModel by activityViewModels()
    private val id: MutableLiveData<Int> = MutableLiveData(ID_IS_MISSING)
    private lateinit var snackbar: Snackbar

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        collectPreferences()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentHomeBinding.inflate(inflater, container, false)
        observeId()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        observeStatisticsResponse()
        observeLobbyResponse()
        handlePlayBtn()
    }

    private fun observeStatisticsResponse() {
        webSocketViewModel.statisticsResponse.observe(viewLifecycleOwner) {
            setTextViews(it)
        }
    }

    private fun setTextViews(it: StatisticsResponse) {
        binding.numOfGames.text =
            String.format(getString(R.string.num_of_games), it.numberOfGames.toString())
        binding.numOfWins.text =
            String.format(getString(R.string.num_of_wins), it.numberOfWins.toString())
        binding.name.text = it.name
    }

    private fun observeLobbyResponse(){
        snackbar = Snackbar.make(requireActivity(), binding.content, getString(R.string.searching_lobby), Snackbar.LENGTH_INDEFINITE)
        webSocketViewModel.lobbyResponse.observe(viewLifecycleOwner){
            it?.let {
                when(it.status){
                    LobbyResponse.Status.WAITING -> {
                        snackbar.show()
                    }
                    LobbyResponse.Status.FOUND -> {
                        snackbar.dismiss()
                        setPreGameInfo(it)
                        findNavController().navigate(R.id.gameFragment)
                    }
                }
            }
        }
    }

    private fun setPreGameInfo(it: LobbyResponse) {
        gameViewModel.setPlayerName(it.playerName)
        gameViewModel.setEnemyName(it.enemyName)
        gameViewModel.setCurrentPlayer(it.preGameResponse.currentPlayer)
        gameViewModel.setCanMove(it.preGameResponse.canMove)
    }

    private fun collectPreferences(){
        lifecycleScope.launch(Dispatchers.IO) {
            preferencesViewModel.preferencesFlow.collect {
                id.postValue(it.id)
            }
        }
    }

    private fun observeId(){
        id.observe(viewLifecycleOwner){
            if (it != ID_IS_MISSING){
                subscribeToWebSocket(it)
            }
        }
    }

    private fun subscribeToWebSocket(id: Int) {
        webSocketViewModel.subscribeToLobby(id)
        webSocketViewModel.subscribeToStatistics(id)
        webSocketViewModel.sendMsgToStatistics(id)
    }

    private fun handlePlayBtn(){
        binding.playBtn.setOnClickListener{
            id.value?.let { _id -> webSocketViewModel.sendMsgToLobby(_id) }
        }
    }

    override fun onDestroyView() {
        if (id.value != ID_IS_MISSING){
            webSocketViewModel.unsubscribeLobby()
            webSocketViewModel.unsubscribeStatistics()
        }
        webSocketViewModel.clearLobbyResponse()
        super.onDestroyView()
    }
}

GameFragment

GameFragment is a part of an application for playing Tic-Tac-Toe. This fragment provides the user interface for gameplay, including displaying the game board, the current player, and handling user interaction events.
The fragment’s main functions include displaying the current player, the playing field, and processing moves. The user can make moves by clicking on the cells of the playing field, after which the game state is updated and transmitted to the server.
The fragment subscribes to WebSocket events to receive updates about the current state of the game. When the game ends, a dialog box appears telling you that one of the players has won.

PreferencesViewModel and GameViewModel are used to store the player ID and general game state, respectively. These models provide access to data and allow it to be updated when necessary.

Here is a short description of the methods implemented in GameFragment:

onCreate():
Initializes retrieving player settings.

onCreateView():
This is where you configure the binding to the layout and subscribe to the player ID.

onViewCreated():
Sets text fields, click handlers on game field cells, and monitoring of server responses.

setTextViews():
Method to set text fields with player names and current player.

collectPreferences():
Method for getting player settings from PreferencesViewModel.
Used to obtain the player ID.

observeId():
Method for monitoring player ID.
Subscribes to ID changes and connects a WebSocket channel for the game.

observeGameResponse():
A method for observing changes in server responses.
Updates the UI and processes the winning message.

setListenersForCells():
Method for installing click handlers on the cells of the playing field.

sendMsg():
Method for sending a progress message to the server via WebSocket.

updateUI():
Method for updating the interface according to received data.

showWinDialog():
Method to display a dialog box telling you “you won” and go to the home screen.

onDestroyView():
Unsubscribes from WebSocket channels and cleans up resources to prevent memory leaks.

class GameFragment : Fragment() {
    private var _binding: FragmentGameBinding? = null
    private val binding get() = _binding!!

    private val preferencesViewModel: PreferencesViewModel by viewModels {
        PreferencesViewModel.Factory(PlayerPreferencesManager(requireContext().datastore))
    }
    private val webSocketViewModel: WebSocketViewModel by activityViewModels()
    private val gameViewModel: GameViewModel by activityViewModels()
    private val id: MutableLiveData<Int> = MutableLiveData(ID_IS_MISSING)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        collectPreferences()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentGameBinding.inflate(inflater, container,false)
        observeId()
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setTextViews()
        setListenersForCells()
        observeGameResponse()
    }

    private fun setTextViews() {
        binding.names.text =
            getString(R.string.names_of_players, gameViewModel.playerName, gameViewModel.enemyName)
        binding.currentMove.text = getString(R.string.current_move, gameViewModel.currentPlayer)
    }

    private fun collectPreferences(){
        lifecycleScope.launch(Dispatchers.IO) {
            preferencesViewModel.preferencesFlow.collect {
                id.postValue(it.id)
            }
        }
    }

    private fun observeId(){
        id.observe(viewLifecycleOwner){
            if (it != ID_IS_MISSING){
                webSocketViewModel.subscribeToGame(it)
            }
        }
    }

    private fun observeGameResponse(){
        webSocketViewModel.gameResponse.observe(viewLifecycleOwner){
            it?.let {
                if (it.status == GameResponse.Status.IN_PROGRESS){
                    updateUI(it)
                    gameViewModel.updateValues(it)
                }else{
                    gameViewModel.updateValues(it)
                    updateUI(it)
                    showWinDialog(it.winnerMsg)
                }
            }
        }
    }

    private fun setListenersForCells(){
        val cellIndexes = listOf("0", "1", "2", "3", "4", "5", "6", "7", "8")
        cellIndexes.forEach {
            binding.root.findViewWithTag<ImageView>(it).setOnClickListener{_ ->
                sendMsg(it.toInt())
            }
        }
    }

    private fun sendMsg(cellIndex: Int){
        if (gameViewModel.canMove){
            webSocketViewModel.sendMsgToGame(id.value!!, cellIndex)
        }
    }

    private fun updateUI(gameResponse: GameResponse){
        binding.currentMove.text = getString(R.string.current_move, gameResponse.currentPlayer)
        val cell = binding.root.findViewWithTag<ImageView>(gameResponse.cellIndex.toString())
        if (gameResponse.symbol == SYMBOL_X){
            cell.setImageResource(R.drawable.x_cell)
        }else{
            cell.setImageResource(R.drawable.o_cell)
        }
    }

    private fun showWinDialog(winnerMsg: String){
        MaterialAlertDialogBuilder(requireContext())
            .setTitle(resources.getString(R.string.dialog_title))
            .setMessage(winnerMsg)
            .setPositiveButton(resources.getString(R.string.close)) { _, _ ->
                view?.post{
                    findNavController().navigate(R.id.action_gameFragment_to_homeFragment)
                }
            }
            .setCancelable(false)
            .show()

    }

    override fun onDestroyView() {
        webSocketViewModel.clearGameResponse()
        webSocketViewModel.unsubscribeGame()
        super.onDestroyView()
    }
}

PreferencesViewModel

The PreferencesViewModel class is part of the application architecture for managing player preferences. It provides access to settings such as player ID and allows you to update them if necessary.

The main functions of the class include:
preferencesFlow: A data flow that provides access to the player’s current preferences. By subscribing to this stream, other parts of the application can receive real-time settings updates.

updatePreferences(): Method for updating player preferences, taking the new player ID. Settings are updated in the background so as not to block the main application thread.

The PreferencesViewModel class also contains a nested Factory class that implements the ViewModelProvider.Factory interface. This class is used to create PreferencesViewModel instances with given parameters. The create method of this class checks whether the requested class is an instance of PreferencesViewModel and returns the appropriate instance using the provided PlayerPreferencesManager.

class PreferencesViewModel(private val  preferencesManager: PlayerPreferencesManager) : ViewModel() {

    var preferencesFlow = preferencesManager.preferencesFlow
        private set

    fun updatePreferences(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            preferencesManager.updatePlayerPreferences(id)
        }
    }

    class Factory(
        private val preferencesManager: PlayerPreferencesManager
    ) : ViewModelProvider.Factory {
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            if (modelClass.isAssignableFrom(PreferencesViewModel::class.java)) {
                @Suppress("UNCHECKED_CAST")
                return PreferencesViewModel(preferencesManager) as T
            }
            throw IllegalArgumentException("Unknown ViewModel class")
        }
    }
}

WebSocketViewModel

The WebSocketViewModel class is part of the application architecture and provides interaction with the server via WebSocket. It is responsible for connecting to the server, sending and receiving messages, and processing responses.

The main methods and functionality of the class include:
connect(): Method for establishing a connection to the WebSocket server.

disconnect(): Method for disconnecting from the WebSocket server.

subscribeToRegistration(): Method for subscribing to a player’s registration channel. Waits to receive a response from the server about the registration result.

subscribeToLobby(): Method for subscribing to the player’s lobby channel. Waits to receive messages about the status of the lobby and the enemy found.

subscribeToStatistics(): Method for subscribing to a player’s statistics channel. Waits to receive information about player statistics.

subscribeToGame(): Method for subscribing to the player’s game channel. Waits to receive messages about the progress of the game and its result.

sendMsgToRegistration(): Method for sending a player registration message to the server.

sendMsgToLobby(): Method for sending a lobby search message to the server.

sendMsgToStatistics(): Method for sending a player statistics message to the server.

sendMsgToGame(): Method for sending a message with the game progress to the server.

unsubscribeRegistration(), unsubscribeLobby(), unsubscribeStatistics(), unsubscribeGame(): Methods for unsubscribing the corresponding channels when leaving the fragment.

clearRegistrationResponse(), clearLobbyResponse(), clearGameResponse(): Methods to clear LiveData after use.

The WebSocketViewModel class uses the StompClient library to establish a connection to the WebSocket server and Gson to serialize and deserialize JSON objects.

class WebSocketViewModel : ViewModel(){
    private val client = StompClient(OkHttpWebSocketClient())
    private lateinit var session: StompSession

    private lateinit var registrationSubscription: Flow<String>
    private lateinit var lobbySubscription: Flow<String>
    private lateinit var statisticsSubscription: Flow<String>
    private lateinit var gameSubscription: Flow<String>

    private lateinit var sessionJob: Job
    private lateinit var registrationJob: Job
    private lateinit var lobbyJob: Job
    private lateinit var statisticsJob: Job
    private lateinit var gameJob: Job

    private val gson: Gson = Gson()

    private val _registrationResponse = MutableLiveData<RegistrationResponse?>()
    val registrationResponse: LiveData<RegistrationResponse?> = _registrationResponse

    private val _statisticsResponse = MutableLiveData<StatisticsResponse>()
    val statisticsResponse: LiveData<StatisticsResponse> = _statisticsResponse

    private val _lobbyResponse = MutableLiveData<LobbyResponse?>()
    val lobbyResponse: LiveData<LobbyResponse?> = _lobbyResponse

    private val _gameResponse = MutableLiveData<GameResponse?>()
    val gameResponse: LiveData<GameResponse?> = _gameResponse

    fun connect(){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob = launch {
                try {
                    session = client.connect(WEBSOCKET_URL)
                }catch (e: WebSocketConnectionException){
                    Log.d(TAG, e.toString())
                }
            }
        }
    }

    fun disconnect(){
        viewModelScope.launch(Dispatchers.IO) {
            session.disconnect()
        }
    }

    fun subscribeToRegistration(name: String){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob.join()
            registrationSubscription = session.subscribeText(SUBSCRIPTION_REGISTRATION_DESTINATION.format(name))

            registrationJob = launch{
                registrationSubscription.collect{
                    val response = gson.fromJson(it, RegistrationResponse::class.java)
                    _registrationResponse.postValue(response)
                }
            }
        }
    }

    fun subscribeToLobby(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob.join()
            lobbySubscription = session.subscribeText(SUBSCRIPTION_LOBBY_DESTINATION.format(id.toString()))

            lobbyJob = launch{
                lobbySubscription.collect{
                    val response = gson.fromJson(it, LobbyResponse::class.java)
                    _lobbyResponse.postValue(response)
                }
            }
        }
    }

    fun subscribeToStatistics(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob.join()
            statisticsSubscription = session.subscribeText(SUBSCRIPTION_STATISTICS_DESTINATION.format(
                id.toString()))

            viewModelScope.launch(Dispatchers.IO) {
                statisticsJob = launch{
                    statisticsSubscription.collect{
                        val response = gson.fromJson(it, StatisticsResponse::class.java)
                        _statisticsResponse.postValue(response)
                    }
                }
            }
        }
    }

    fun subscribeToGame(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob.join()
            gameSubscription = session.subscribeText(SUBSCRIPTION_GAME_DESTINATION.format(id.toString()))

            gameJob = launch{
                gameSubscription.collect{
                    val response = gson.fromJson(it, GameResponse::class.java)
                    _gameResponse.postValue(response)
                }
            }
        }
    }

    fun unsubscribeRegistration(){
        registrationJob.cancel()
    }

    fun unsubscribeLobby(){
        lobbyJob.cancel()
    }

    fun unsubscribeStatistics(){
        statisticsJob.cancel()
    }

    fun unsubscribeGame(){
        gameJob.cancel()
    }

    private fun sendMsgToDestination(msg: String, destination: String){
        viewModelScope.launch(Dispatchers.IO) {
            sessionJob.join()
            session.sendText(destination, msg)
        }
    }

    fun sendMsgToRegistration(androidId: String, name: String){
        viewModelScope.launch(Dispatchers.IO) {
            val request = RegistrationRequest(androidId, name)
            sendMsgToDestination(gson.toJson(request), REGISTRATION_DESTINATION)
        }
    }

    fun sendMsgToLobby(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            val request = BaseRequest(id)
            sendMsgToDestination(gson.toJson(request), LOBBY_DESTINATION)
        }
    }

    fun sendMsgToStatistics(id: Int){
        viewModelScope.launch(Dispatchers.IO) {
            val request = BaseRequest(id)
            sendMsgToDestination(gson.toJson(request), STATISTICS_DESTINATION)
        }
    }

    fun sendMsgToGame(id: Int, cellIndex: Int){
        viewModelScope.launch(Dispatchers.IO) {
            val request = GameRequest(id, cellIndex)
            sendMsgToDestination(gson.toJson(request), GAME_DESTINATION)
        }
    }

    fun clearRegistrationResponse(){
        _registrationResponse.value = null
    }

    fun clearLobbyResponse() {
        _lobbyResponse.value = null
    }

    fun clearGameResponse(){
        _gameResponse.value = null
    }
}

GameViewModel

The GameViewModel class is part of the application architecture and is designed to manage game data. It stores information about the current player, his opponent, the current progress of the game and the possibility of making a move.

The main methods and functionality of the class include:
setPlayerName(): Method to set the name of the current player.

setEnemyName(): Method to set the enemy name.

setCurrentPlayer(): Method for setting the current player to make a move.

setCanMove(): Method for setting the current player’s ability to move.

updateValues(): Method for updating game values based on response from the server. Updates the current player and his ability to make a move based on the received data.

class GameViewModel : ViewModel() {
    var playerName = String()
        private set
    var enemyName = String()
        private set
    var currentPlayer = String()
        private set
    var canMove: Boolean = false
        private set

    fun setPlayerName(playerName: String){
        this.playerName = playerName
    }

    fun setEnemyName(enemyName: String){
        this.enemyName = enemyName
    }

    fun setCurrentPlayer(currentPlayer: String){
        this.currentPlayer = currentPlayer
    }

    fun setCanMove(canMove: Boolean){
        this.canMove = canMove
    }

    fun updateValues(gameResponse: GameResponse){
        currentPlayer = gameResponse.currentPlayer
        canMove = gameResponse.canMove
    }
}

Conclusion

In this article, we looked at developing an application for playing Tic-Tac-Toe on the Android platform using WebSocket technology to exchange data with the server.

We have described the application architecture in detail. It identified three main fragments: RegistrationFragment for registering a player, HomeFragment for displaying statistics and searching for a lobby, and GameFragment for gameplay. Each of these fragments communicates with the server through a WebSocketViewModel, allowing messages to be sent and received.

We also looked at the WebSocketViewModel, PreferencesViewModel, and GameViewModel classes, which play a key role in data management and interaction with the server.

Finally, we discussed how the classes and methods of each Fragment and ViewModel interact with each other to provide functionality throughout the application.

The development of this application demonstrates the effective use of Android and WebSocket technologies to create a gaming application with real-time multiplayer play.