Repository: michaelbrusegard/chess-evolved
Pull request #60: Feature/59 add rematch functionality
Author: login: sivertun
Labels: name: enhancement
description: New feature or request
name: ready for review
description: PR ready for reviewing
Created: 2025-04-15T23:42:02Z | Merged: 2025-04-17T16:26:02Z

<!-- You can remove sections that aren't applicable. -->
<!-- Please remember to add additional labels as well as related milestone. -->

## Description
<!-- A clear and concise description of what this PR does. -->
This PR subscribes the app to supabase game-row-updates, to listen for rematch or player left events. 

## Related Issues
<!-- Reference related issues using `Fixes #issue_number` or `Closes #issue_number`. -->
Closes #59 

## Changes Made
- [X] Add functionality to rematch button, updating row in database, and display visual information. If both players click the rematch button, they both get sent to a new lobby with the same id.
- [X] Add functionality to return to lobby button. Updates the other player on if the player has left the lobby.

## Screenshots (if applicable)
<!-- Add screenshots if your change includes UI updates. -->

## Checklist
### Check applicable items
- [X] My code follows the style guidelines of this project.
- [X] I have performed a self-review of my own code.
- [X] I have commented on my code where necessary.
- [ ] I have added tests that prove my fix is effective or that my feature works.
- [ ] New and existing unit tests pass locally with my changes.

## Additional Notes
<!-- Add any other context about the PR here. -->


Linked issue #59: [Feature] Add rematch functionality
<!-- You can remove sections that aren't applicable. -->
<!-- Please remember to add additional labels as well as related milestone. -->
## Description
<!-- Provide a clear and concise description of the issue. Avoid vague language. -->
Add navigation back to lobby if both players press rematch, and show if rematch is not available cause the other player left.

## Proposed Solution
<!-- Describe the proposed solution or feature. Include details on how it should work. -->

## Screenshots
<!-- If applicable, add screenshots, logs, or error messages to help explain the issue. -->

## Prerequisite Issues
<!-- Link any issues that must be resolved before this one can be worked on. -->

## Additional Context
<!-- Add any other relevant information, context, or references. -->
This issue got quite a lot bigger than intended, as a lot of functionality had to be implemented for this to work. This includes subscribing to game updates, unsubscribing, and giving a clean application if the user chooses to return to lobby.

## Task Checklist
<!-- Create a checklist to track what needs to be done to complete the issue. -->
- [x] When rematch is clicked, update column in game-row (named want_rematch), update text on the requester's screen showing "Waiting for answer" and update text on the requested's screen showing "Other player waiting for rematch".
- [x] When one of the players click "return to menu", update column in game-row (named player_disconnected). Update rematch button for the other player to be gray and display toast explaining that user left

Diff:
diff --git a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
new file mode 100644
index 0000000..ea57567
--- /dev/null
+++ b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
@@ -0,0 +1,73 @@
+package io.github.chessevolved.singletons
+
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
+import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
+import kotlin.reflect.KFunction1
+
+object Game {
+    private var inGame: Boolean = false
+    private var subscribers = mutableMapOf<String, KFunction1<SupabaseGameHandler.Game, Unit>>()
+    private var hasAskedForRematch = false
+
+    suspend fun joinGame(gameId: String) {
+        try {
+            SupabaseGameHandler.joinGame(gameId, ::onGameRowUpdate)
+            this.inGame = true
+        } catch (e: Exception) {
+            throw Exception("Problem with joining game: " + e.message)
+        }
+    }
+
+    suspend fun leaveGame() {
+        if (!isInGame()) {
+            throw IllegalStateException("Can't leave game if not in a game.")
+        }
+        try {
+            hasAskedForRematch = false
+            SupabaseGameHandler.leaveGame(Lobby.getLobbyId()!!)
+            this.inGame = false
+        } catch (e: Exception) {
+            throw Exception("Problem with leaving game: " + e.message)
+        }
+    }
+
+    suspend fun askForRematch() {
+        if (!isInGame() && !hasAskedForRematch) {
+            throw IllegalStateException("Can't ask for rematch if not in a game or have already asked for rematch!")
+        }
+        try {
+            hasAskedForRematch = true
+            SupabaseLobbyHandler.setupRematchLobby(Lobby.getLobbyId()!!)
+            SupabaseGameHandler.requestRematch(Lobby.getLobbyId()!!)
+        } catch (e: Exception) {
+            throw Exception("Problem with asking for rematch: " + e.message)
+        }
+    }
+
+    fun getWantsRematch(): Boolean = hasAskedForRematch
+
+    fun getGameId(): String? = Lobby.getLobbyId()
+
+    fun isInGame(): Boolean = inGame
+
+    private fun onGameRowUpdate(game: SupabaseGameHandler.Game) {
+        subscribers.forEach {
+            it.value.invoke(game)
+        }
+    }
+
+    fun subscribeToGameUpdates(
+        subscriberName: String,
+        onEventListener: KFunction1<SupabaseGameHandler.Game, Unit>,
+    ) {
+        subscribers.put(subscriberName, onEventListener)
+    }
+
+    fun unsubscribeFromGameUpdates(subscriberName: String) {
+        if (!subscribers.containsKey(subscriberName)) {
+            return
+        }
+
+        subscribers.remove(subscriberName)
+    }
+}
diff --git a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Lobby.kt b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Lobby.kt
index a0f462f..557e77c 100644
--- a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Lobby.kt
+++ b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Lobby.kt
@@ -23,10 +23,38 @@ object Lobby {
         }
     }
 
+    suspend fun joinRematchLobbyAsHost() {
+        println("Lobby: Joining rematch-lobby with ID: $lobbyId...")
+        if (!isInLobby()) {
+            throw Exception("Can't rematch when not in a lobby!")
+        }
+        try {
+            SupabaseLobbyHandler.leaveLobbyNoUpdateSecondPlayer(lobbyId!!)
+            SupabaseLobbyHandler.joinLobbyNoUpdateSecondPlayer(lobbyId!!, ::onLobbyRowUpdate)
+        } catch (e: Exception) {
+            println(e.message)
+            error(e)
+            // throw e
+        }
+    }
+
+    suspend fun joinRematchLobbyNonHost() {
+        if (!isInLobby()) {
+            throw Exception("Can't rematch when not in a lobby!")
+        }
+        try {
+            SupabaseLobbyHandler.leaveLobbyNoUpdateSecondPlayer(lobbyId!!)
+            SupabaseLobbyHandler.joinLobby(lobbyId!!, ::onLobbyRowUpdate)
+        } catch (e: Exception) {
+            throw e
+        }
+    }
+
     suspend fun createLobby() {
         try {
             val lobbyId = SupabaseLobbyHandler.createLobby(::onLobbyRowUpdate)
             this.lobbyId = lobbyId
+            println("Lobby: Creating lobby with ID: $lobbyId...")
         } catch (e: Exception) {
             throw Exception("Problem when creating lobby! " + e.message)
         }
@@ -38,6 +66,19 @@ object Lobby {
         }
         try {
             SupabaseLobbyHandler.leaveLobby(lobbyId!!)
+            this.lobbyId = null
+        } catch (e: Exception) {
+            throw e
+        }
+    }
+
+    suspend fun leaveLobbyWithoutUpdating() {
+        if (!isInLobby()) {
+            throw Exception("Can't leave lobby when not in a lobby!")
+        }
+        try {
+            SupabaseLobbyHandler.leaveLobbyNoUpdateSecondPlayer(lobbyId!!)
+            lobbyId = null
         } catch (e: Exception) {
             throw e
         }
@@ -81,9 +122,9 @@ object Lobby {
             throw IllegalStateException("Can't start game when not in a lobby!")
         }
         try {
-            SupabaseLobbyHandler.startGame(lobbyId!!, GameSettings.getGameSettings())
+            SupabaseLobbyHandler.startGame(lobbyId!!)
         } catch (e: Exception) {
-            // TODO: Elevate exception with appropriate message
+            throw e
         }
     }
 
diff --git a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseChannelManager.kt b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseChannelManager.kt
index 8430c25..1f9427d 100644
--- a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseChannelManager.kt
+++ b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseChannelManager.kt
@@ -66,7 +66,6 @@ internal object SupabaseChannelManager {
      */
     suspend fun unsubscribeFromChannel(channelName: String) {
         val channel = channels[channelName] ?: throw Exception("Can't unsubscribe from non-existent channel.")
-
         // TODO: Error handling for when removing a channel fails.
         supabase.realtime.removeChannel(channel)
         channels.remove(channelName)
diff --git a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseGameHandler.kt b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseGameHandler.kt
index e6f6d7a..c14b745 100644
--- a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseGameHandler.kt
+++ b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseGameHandler.kt
@@ -1,6 +1,7 @@
 package io.github.chessevolved.singletons.supabase
 
 import io.github.chessevolved.singletons.supabase.SupabaseClient.getSupabaseClient
+import io.github.jan.supabase.postgrest.exception.PostgrestRestException
 import io.github.jan.supabase.postgrest.from
 import io.github.jan.supabase.postgrest.query.filter.FilterOperator
 import io.github.jan.supabase.realtime.PostgresAction
@@ -42,6 +43,8 @@ object SupabaseGameHandler {
         val pieces: Array<String>,
         // TODO: Change this to be an array of board squares when that is implemented
         val board_squares: Array<String>,
+        val player_disconnected: Boolean,
+        val want_rematch: Boolean,
     )
 
     /**
@@ -62,6 +65,94 @@ object SupabaseGameHandler {
         addGameListener(lobbyCode, onEventListener)
     }
 
+    /**
+     * Method that unsubscribes from row-level game updates, and sets player_disconnected to true in the game-row.
+     * Cleans up the row if the other player has already left.
+     * @param lobbyCode identifying the game.
+     * @throws IllegalArgumentException if the game doesn't exist.
+     * @throws PostgrestRestException if there are errors within supabase.
+     */
+    suspend fun leaveGame(lobbyCode: String) {
+        val response =
+            supabase
+                .from(SUPABASE_GAME_TABLE_NAME)
+                .select {
+                    filter {
+                        eq("lobby_code", lobbyCode)
+                    }
+                }.decodeList<Game>()
+
+        if (response.isEmpty()) {
+            throw IllegalArgumentException("Game does not exist.")
+        }
+
+        if (response[0].player_disconnected) {
+            try {
+                supabase.from(SUPABASE_GAME_TABLE_NAME).delete {
+                    filter {
+                        eq("lobby_code", lobbyCode)
+                    }
+                }
+            } catch (e: PostgrestRestException) {
+                // Error trying to delete a game
+                throw e
+            }
+        } else {
+            try {
+                supabase
+                    .from(SUPABASE_GAME_TABLE_NAME)
+                    .update(
+                        {
+                            set("player_disconnected", value = true)
+                        },
+                    ) {
+                        filter {
+                            eq("lobby_code", lobbyCode)
+                        }
+                    }
+            } catch (e: PostgrestRestException) {
+                // Error with supabase when setting disconnected to true on game row.
+                throw e
+            }
+        }
+
+        SupabaseChannelManager.unsubscribeFromChannel("game_$lobbyCode")
+    }
+
+    /**
+     * Method to set want_rematch in game-row to true.
+     * @param lobbyCode
+     */
+    suspend fun requestRematch(lobbyCode: String) {
+        try {
+            val response =
+                supabase
+                    .from(SUPABASE_GAME_TABLE_NAME)
+                    .select {
+                        filter {
+                            eq("lobby_code", lobbyCode)
+                        }
+                    }.decodeList<Game>()
+
+            if (response.isEmpty()) throw IllegalStateException("Illegal state when requesting rematch. Game does not exist.")
+
+            supabase
+                .from(SUPABASE_GAME_TABLE_NAME)
+                .update(
+                    {
+                        // This line ensures that the value of want_rematch is the opposite of what it previously were. This way we get the ack-behaviour.
+                        set("want_rematch", value = !response[0].want_rematch)
+                    },
+                ) {
+                    filter {
+                        eq("lobby_code", lobbyCode)
+                    }
+                }
+        } catch (e: Exception) {
+            throw e
+        }
+    }
+
     /**
      * Method to subscribe to updates on a specific game-table row.
      * @param lobbyCode of the game to subscribe to
@@ -102,7 +193,7 @@ object SupabaseGameHandler {
 
     /**
      * Method to update the board-column in a specific game.
-     * @param lobbyCode of the game to update the column for
+     * @param lobbyCode identifying the game.
      * @param pieces representing the placement of pieces
      * @param boardSquares representing the events occurring on specific tiles
      * @param turn representing which player's turn it is
@@ -138,7 +229,7 @@ object SupabaseGameHandler {
 
     /**
      * Method that checks if a game-row with a corresponding lobbyCode exists.
-     * @param lobbyCode of the game to look for
+     * @param lobbyCode identifying the game.
      * @return Boolean of whether the game exists or not
      */
     private suspend fun checkIfGameExists(lobbyCode: String): Boolean {
diff --git a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseLobbyHandler.kt b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseLobbyHandler.kt
index e41f6e3..d175847 100644
--- a/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseLobbyHandler.kt
+++ b/chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/supabase/SupabaseLobbyHandler.kt
@@ -127,6 +127,34 @@ object SupabaseLobbyHandler {
         }
     }
 
+    /**
+     * Method that joins and subscribes to row-level lobby updates without
+     * updating second_player column to true.
+     * @param lobbyCode identifying the lobby
+     * @param onEventListener as the method to be called upon row-updates
+     */
+    suspend fun joinLobbyNoUpdateSecondPlayer(
+        lobbyCode: String,
+        onEventListener: KFunction1<Lobby, Unit>,
+    ) {
+        val response =
+            supabase
+                .from(SUPABASE_LOBBY_TABLE_NAME)
+                .select {
+                    filter {
+                        eq("lobby_code", lobbyCode)
+                    }
+                }.decodeList<Lobby>()
+
+        if (response.isEmpty()) {
+            throw Exception("Lobby does not exist.")
+        }
+        if (response[0].second_player) {
+            throw Exception("Lobby is full!")
+        }
+        addLobbyListener(lobbyCode, onEventListener) // Throws illegalStateException upon already joined lobby
+    }
+
     /**
      * Method to leave a lobby, marking the row in supabase as not having a second player anymore,
      * or straight up deleting the row if the second player column is already false.
@@ -182,6 +210,16 @@ object SupabaseLobbyHandler {
         SupabaseChannelManager.unsubscribeFromChannel("lobby_$lobbyCode")
     }
 
+    /**
+     * Method to leave a lobby without updating the row. Useful for when you want to unsubscribe from row updates.
+     * Typically used in rematches where a lobby will be reused.
+     * @param lobbyCode identifying the lobby
+     * @throws Exception if trying to leave nonexistent channel
+     */
+    suspend fun leaveLobbyNoUpdateSecondPlayer(lobbyCode: String) {
+        SupabaseChannelManager.unsubscribeFromChannel("lobby_$lobbyCode")
+    }
+
     /**
      * Method that subscribes to row-level-updates on the lobby created/joined
      * @param lobbyCode corresponding to the lobby to subscribe to
@@ -217,36 +255,17 @@ object SupabaseLobbyHandler {
         }
     }
 
-    /**
-     * Class used to create a new row in game table.
-     */
-    @Serializable
-    private class InsertGame(
-        val lobby_code: String,
-        val settings: Map<String, String>,
-    )
-
     /**
      * Method to set the "game_started"-column for the lobby table to true in supabase.
      * Also creates a row in the game table on supabase.
      * @param lobbyCode which is the code of the lobby to start the game for.
-     * @param gameSettings which are the game-settings to use for this game.
      */
-    suspend fun startGame(
-        lobbyCode: String,
-        gameSettings: SettingsDTO,
-    ) {
+    suspend fun startGame(lobbyCode: String) {
         try {
-            val settingsMap =
-                mapOf(
-                    "boardSize" to gameSettings.boardSize.toString(),
-                    "fogOfWar" to gameSettings.fogOfWar.toString(),
-                )
-
             supabase
                 .from("games")
                 .insert(
-                    InsertGame(lobby_code = lobbyCode, settings = settingsMap),
+                    mapOf("lobby_code" to lobbyCode),
                 )
 
             supabase
@@ -320,4 +339,23 @@ object SupabaseLobbyHandler {
         }
         return response[0]
     }
+
+    suspend fun setupRematchLobby(lobbyCode: String) {
+        try {
+            supabase
+                .from("lobbies")
+                .update(
+                    {
+                        set("game_started", value = false)
+                        set("second_player", value = false)
+                    },
+                ) {
+                    filter {
+                        eq("lobby_code", lobbyCode)
+                    }
+                }
+        } catch (e: Exception) {
+            throw e
+        }
+    }
 }
diff --git a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/Navigator.kt b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/Navigator.kt
index 2c78627..d1087ab 100644
--- a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/Navigator.kt
+++ b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/Navigator.kt
@@ -42,7 +42,7 @@ class Navigator(
     }
 
     fun navigateToLobby(lobbyId: String) {
-        if (PresenterManager.getCurrent() is JoinGamePresenter) {
+        if (PresenterManager.getCurrent() is JoinGamePresenter || PresenterManager.getCurrent() is EndGamePresenter) {
             PresenterManager.set(createLobbyPresenter(lobbyId))
         } else {
             PresenterManager.push(createLobbyPresenter(lobbyId))
diff --git a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt
index ec12576..78a2316 100644
--- a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt
+++ b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt
@@ -1,20 +1,57 @@
 package io.github.chessevolved.presenters
 
+import com.badlogic.gdx.Gdx
 import com.badlogic.gdx.graphics.g2d.SpriteBatch
 import io.github.chessevolved.Navigator
+import io.github.chessevolved.singletons.Game
+import io.github.chessevolved.singletons.Lobby
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
 import io.github.chessevolved.views.EndGameView
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 
 class EndGamePresenter(
     private val endGameView: EndGameView,
     private val endGameStatus: Boolean,
     private val navigator: Navigator,
 ) : IPresenter {
+    private var otherPlayerHasAskedForRematch = false
+    private var otherPlayerLeft = false
+
     init {
+        Game.subscribeToGameUpdates(this.toString(), ::onGameRowUpdate)
         endGameView.endGameStatus = endGameStatus
         endGameView.init()
-        endGameView.onReturnToMenuClicked = { navigator.goBack() }
-        // We need to get the current lobby ID
-        endGameView.onRematchClicked = { navigator.navigateToLobby("222222") }
+        endGameView.onReturnToMenuClicked = { returnToMenu() }
+        endGameView.onRematchClicked = { requestRematch() }
+    }
+
+    private fun requestRematch() {
+        endGameView.disableRematchButton()
+        endGameView.updateRematchText("Rematch request sent...")
+        runBlocking {
+            launch {
+                Game.askForRematch()
+            }
+        }
+    }
+
+    private fun returnToMenu() {
+        runBlocking {
+            launch {
+                val wantsRematch = Game.getWantsRematch()
+                Game.leaveGame()
+
+                // This is to skip the edge-case where you have requested a rematch, which calls setupRematchLobby,
+                // setting second_player = false in the lobby row. If you then try to leave it will cause an error cause the other player is still in the lobby.
+                if (wantsRematch && !otherPlayerLeft) {
+                    Lobby.leaveLobbyWithoutUpdating()
+                } else {
+                    Lobby.leaveLobby()
+                }
+            }
+        }
+        navigator.goBack()
     }
 
     override fun render(sb: SpriteBatch) {
@@ -30,9 +67,61 @@ class EndGamePresenter(
 
     override fun dispose() {
         endGameView.dispose()
+        Game.unsubscribeFromGameUpdates(this.toString())
+
+        runBlocking {
+            launch {
+                val wantsRematch = Game.getWantsRematch()
+                Game.leaveGame()
+
+                if (wantsRematch && !otherPlayerLeft) {
+                    Lobby.leaveLobbyWithoutUpdating()
+                } else {
+                    Lobby.leaveLobby()
+                }
+            }
+        }
     }
 
     override fun setInputProcessor() {
         endGameView.setInputProcessor()
     }
+
+    fun onGameRowUpdate(gameRow: SupabaseGameHandler.Game) {
+        if (gameRow.want_rematch) {
+            if (!Game.getWantsRematch()) {
+                otherPlayerHasAskedForRematch = true
+                endGameView.updateRematchText("Other player wants a rematch.")
+            }
+        }
+
+        // If want_rematch gets set back to false, it's an ACK from the other player, accepting the rematch.
+        if (!gameRow.want_rematch && Game.getWantsRematch()) {
+            Gdx.app.postRunnable {
+                // This ensures that the first one to request a rematch doesn't update the lobby-column "second_player". Needed to avoid a full lobby exception.
+                if (otherPlayerHasAskedForRematch) {
+                    runBlocking {
+                        launch {
+                            Game.leaveGame()
+                            Lobby.joinRematchLobbyNonHost()
+                        }
+                    }
+                } else {
+                    runBlocking {
+                        launch {
+                            Game.leaveGame()
+                            Lobby.joinRematchLobbyAsHost()
+                        }
+                    }
+                }
+                navigator.navigateToLobby(Lobby.getLobbyId()!!)
+            }
+        }
+
+        if (gameRow.player_disconnected) {
+            otherPlayerLeft = true
+            endGameView.disableRematchButton()
+            endGameView.updateRematchText("Other player has left...")
+        }
+    }
 }
diff --git a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
index e53437a..2d124fd 100644
--- a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
+++ b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
@@ -16,8 +16,13 @@ import io.github.chessevolved.components.WeatherEvent
 import io.github.chessevolved.entities.BoardSquareFactory
 import io.github.chessevolved.entities.PieceFactory
 import io.github.chessevolved.singletons.ECSEngine
+import io.github.chessevolved.singletons.Game
+import io.github.chessevolved.singletons.Game.unsubscribeFromGameUpdates
+import io.github.chessevolved.singletons.Lobby
 import io.github.chessevolved.systems.RenderingSystem
 import io.github.chessevolved.views.GameView
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 
 class GamePresenter(
     private val view: GameView,
@@ -35,8 +40,15 @@ class GamePresenter(
     private val gameBatch: SpriteBatch
 
     private val renderingSystem: RenderingSystem
+    private var navigatingToEndGame = false
 
     init {
+        runBlocking {
+            launch {
+                // Crash app if not in lobby. App should never be in a state where it is in a game without being in a lobby first.
+                Game.joinGame(Lobby.getLobbyId() ?: throw IllegalStateException("Can't join a game if not in a lobby first!"))
+            }
+        }
         view.init()
         gameBatch = view.getGameBatch()
 
@@ -89,6 +101,11 @@ class GamePresenter(
         )
     }
 
+    private fun goToGameOverScreen(didWin: Boolean) {
+        navigatingToEndGame = true
+        navigator.navigateToEndGame(didWin)
+    }
+
     override fun render(sb: SpriteBatch) {
         Gdx.gl.glClearColor(0.1f, 0.1f, 0.1f, 1f)
         Gdx.gl.glClear(GL20.GL_COLOR_BUFFER_BIT)
@@ -116,6 +133,19 @@ class GamePresenter(
         view.dispose()
         engine.removeAllEntities()
         unloadAssets()
+        unsubscribeFromGameUpdates(this.toString())
+        if (Game.isInGame() && !navigatingToEndGame) {
+            runBlocking {
+                launch {
+                    try {
+                        Game.leaveGame()
+                        Lobby.leaveLobby()
+                    } catch (e: Exception) {
+                        error("Non fatal error: Problem with calling leaveGame(). Error: " + e.message)
+                    }
+                }
+            }
+        }
     }
 
     private fun unloadAssets() {
diff --git a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
index 2264a3c..6ecf85d 100644
--- a/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
+++ b/chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
@@ -1,5 +1,6 @@
 package io.github.chessevolved.presenters
 
+import com.badlogic.gdx.Gdx
 import com.badlogic.gdx.graphics.g2d.SpriteBatch
 import io.github.chessevolved.Navigator
 import io.github.chessevolved.shared.SettingsDTO
@@ -7,6 +8,7 @@ import io.github.chessevolved.singletons.GameSettings
 import io.github.chessevolved.singletons.Lobby
 import io.github.chessevolved.singletons.Lobby.getLobby
 import io.github.chessevolved.singletons.Lobby.leaveLobby
+import io.github.chessevolved.singletons.Lobby.startGame
 import io.github.chessevolved.singletons.Lobby.subscribeToLobbyUpdates
 import io.github.chessevolved.singletons.Lobby.unsubscribeFromLobbyUpdates
 import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
@@ -20,9 +22,11 @@ class LobbyPresenter(
     private val lobbyView: LobbyView,
     private val navigator: Navigator,
 ) : IPresenter {
+    private var lobbyStarted = false
+
     init {
         lobbyView.onLeaveButtonClicked = { returnToMenu() }
-        lobbyView.onStartGameButtonClicked = { navigator.navigateToGame() }
+        lobbyView.onStartGameButtonClicked = { onStartGameButtonClicked() }
         lobbyView.onOpenSettingsButtonClicked = { navigator.navigateToSettings() }
         lobbyView.init()
         subscribeToLobbyUpdates(this.toString(), ::lobbyUpdateHandler)
@@ -40,7 +44,11 @@ class LobbyPresenter(
 
     private fun lobbyStartedCheck(lobbyStarted: Boolean) {
         if (lobbyStarted) {
-            // TODO: Send player to GamePresenter.
+            // Get back on main thread.
+            Gdx.app.postRunnable {
+                this.lobbyStarted = true
+                navigator.navigateToGame()
+            }
         }
     }
 
@@ -60,6 +68,20 @@ class LobbyPresenter(
         settingsChanged(settingsDTO)
     }
 
+    private fun onStartGameButtonClicked() {
+        if (lobbyStarted) return
+        runBlocking {
+            launch {
+                try {
+                    startGame()
+                    lobbyStarted = true
+                } catch (e: Exception) {
+                    lobbyView.showError("Failure when starting game from presenter. " + e.message)
+                }
+            }
+        }
+    }
+
     /**
      * Change to MenuPresenter
      */
@@ -68,7 +90,7 @@ class LobbyPresenter(
             try {
                 leaveLobby()
             } catch (e: Exception) {
-                error("Non fatal error: Problem with calling leaveLobby(). Error: " + e.message)
+                println("Non fatal error: Problem with calling leaveLobby(). Error: " + e.message)
             }
         }
         unsubscribeFromLobbyUpdates(this.toString())
@@ -88,13 +110,14 @@ class LobbyPresenter(
 
     override fun dispose() {
         lobbyView.dispose()
-        if (Lobby.isInLobby()) {
+        unsubscribeFromLobbyUpdates(this.toString())
+        if (Lobby.isInLobby() && !lobbyStarted) {
             runBlocking {
                 launch {
                     try {
                         leaveLobby()
                     } catch (e: Exception) {
-                        error("Non fatal error: Problem with calling leaveLobby(). Error: " + e.message)
+                        println("Non fatal error: Problem with calling leaveLobby(). Error: " + e.message)
                     }
                 }
             }
diff --git a/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt b/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt
index c0a7b8d..b04e952 100644
--- a/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt
+++ b/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt
@@ -3,6 +3,7 @@ package io.github.chessevolved.views
 import com.badlogic.gdx.Gdx
 import com.badlogic.gdx.scenes.scene2d.Stage
 import com.badlogic.gdx.scenes.scene2d.ui.Label
+import com.badlogic.gdx.scenes.scene2d.ui.TextButton
 import com.badlogic.gdx.utils.viewport.FitViewport
 import ktx.actors.onClick
 import ktx.scene2d.label
@@ -12,8 +13,10 @@ import ktx.scene2d.textButton
 
 class EndGameView : IView {
     private lateinit var stage: Stage
-    private lateinit var statusLabel1: Label
-    private lateinit var statusLabel2: Label
+    private lateinit var winInformationLabelTop: Label
+    private lateinit var winInformationLabelBottom: Label
+    private lateinit var rematchLabel: Label
+    private lateinit var rematchButton: TextButton
 
     var endGameStatus: Boolean = false
         set(value) {
@@ -39,10 +42,13 @@ class EndGameView : IView {
             scene2d.table {
                 setFillParent(true)
                 defaults().pad(10f).center()
+                rematchLabel =
+                    label("Waiting for rematch request...") { it.padBottom(10f) }
 
-                statusLabel1 = label("") { it.padBottom(5f) }
                 row()
-                statusLabel2 = label("") { it.padBottom(20f) }
+                winInformationLabelTop = label("") { it.padBottom(5f) }
+                row()
+                winInformationLabelBottom = label("") { it.padBottom(20f) }
                 row()
 
                 textButton("Return to Menu") {
@@ -50,7 +56,7 @@ class EndGameView : IView {
                     onClick { onReturnToMenuClicked() }
                 }
                 row()
-                textButton("Request Rematch") { onClick { onRematchClicked() } }
+                rematchButton = textButton("Request Rematch") { onClick { onRematchClicked() } }
             }
 
         stage.addActor(root)
@@ -59,14 +65,24 @@ class EndGameView : IView {
 
     private fun updateStatusLabels() {
         if (endGameStatus) {
-            statusLabel1.setText("Congratulations!")
-            statusLabel2.setText("You won!")
+            winInformationLabelTop.setText("Congratulations!")
+            winInformationLabelBottom.setText("You won!")
         } else {
-            statusLabel1.setText("Better luck next time!")
-            statusLabel2.setText("You lost!")
+            winInformationLabelTop.setText("Better luck next time!")
+            winInformationLabelBottom.setText("You lost!")
         }
     }
 
+    fun disableRematchButton() {
+        rematchButton.isDisabled = true
+        rematchButton.clearListeners()
+        rematchButton.setColor(0f, 0f, 0f, 0.3f)
+    }
+
+    fun updateRematchText(text: String) {
+        rematchLabel.setText(text)
+    }
+
     override fun render() {
         stage.act(Gdx.graphics.deltaTime)
         stage.draw()
diff --git a/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/LobbyView.kt b/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/LobbyView.kt
index 23abef9..0176597 100644
--- a/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/LobbyView.kt
+++ b/chessevolved_view/src/main/kotlin/io/github/chessevolved/views/LobbyView.kt
@@ -23,6 +23,7 @@ class LobbyView(
     private lateinit var stage: Stage
     private lateinit var secondPlayerStatusLabel: Label
     private lateinit var startGameButton: TextButton
+    private lateinit var toastManager: ToastManager
 
     var onLeaveButtonClicked: () -> Unit = {}
     var onOpenSettingsButtonClicked: () -> Unit = {}
@@ -36,6 +37,7 @@ class LobbyView(
                     Gdx.graphics.height.toFloat(),
                 ),
             )
+        toastManager = ToastManager(stage)
 
         val copyIconTexture = Texture(Gdx.files.internal("icons/copy-icon.png"))
         val iconDrawable = TextureRegionDrawable(TextureRegion(copyIconTexture))
@@ -82,6 +84,7 @@ class LobbyView(
         if (isConnected) {
             secondPlayerStatusLabel.setText("Second player connected!")
             startGameButton.isDisabled = false
+            startGameButton.clearListeners()
             startGameButton.onClick { onStartGameButtonClicked() }
             startGameButton.color = scene2d.label("tex").color
         } else {
@@ -116,4 +119,8 @@ class LobbyView(
     override fun setInputProcessor() {
         Gdx.input.inputProcessor = stage
     }
+
+    fun showError(message: String) {
+        toastManager.showError(message)
+    }
 }
diff --git a/supabase/migrations/20250406213255_remote_schema.sql b/supabase/migrations/20250415195024_remote_schema.sql
similarity index 96%
rename from supabase/migrations/20250406213255_remote_schema.sql
rename to supabase/migrations/20250415195024_remote_schema.sql
index 8176e51..e95be0c 100644
--- a/supabase/migrations/20250406213255_remote_schema.sql
+++ b/supabase/migrations/20250415195024_remote_schema.sql
@@ -74,7 +74,9 @@ CREATE TABLE IF NOT EXISTS "public"."games" (
     "last_move" "text",
     "turn" "public"."color" DEFAULT 'WHITE'::"public"."color" NOT NULL,
     "board_squares" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL,
-    "pieces" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL
+    "pieces" "jsonb" DEFAULT '[]'::"jsonb" NOT NULL,
+    "player_disconnected" boolean DEFAULT false NOT NULL,
+    "want_rematch" boolean DEFAULT false NOT NULL
 );
 
 


Reviews:
user: login: sivertun
body: Testing methods that needs to be removed before merge
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T23:59:07Z
user: login: michaelbrusegard
body: Very good. Mostly had some questions but the code looks good
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-04-16T00:06:30Z
user: login: ChrisKrane
body: Agree with Michael's comments, please address these before merging. good work!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-16T10:49:20Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:55:30Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:57:30Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:58:35Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T13:01:28Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T13:07:59Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T13:09:11Z
user: login: sivertun
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T18:50:25Z
user: login: michaelbrusegard
body: It looks good, but I still think we should only store the lobbycode inside of Lobby.kt and then retrieve it in Game.kt when needed. This is to avoid storing duplicate information
state: APPROVED
author_association: OWNER
submitted_at: 2025-04-17T10:52:36Z

Review comments:
diff_hunk: @@ -0,0 +1,73 @@
+package io.github.chessevolved.singletons
+
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
+import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
+import kotlin.reflect.KFunction1
+
+object Game {
+    private var lobbyCode: String? = null
path: chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
user: login: michaelbrusegard
body: Are we storing the same lobby code in Lobby singleton and game singleton?
created_at: 2025-04-15T23:55:37Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,73 @@
+package io.github.chessevolved.singletons
+
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
+import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
+import kotlin.reflect.KFunction1
+
+object Game {
+    private var lobbyCode: String? = null
+    private var subscribers = mutableMapOf<String, KFunction1<SupabaseGameHandler.Game, Unit>>()
+    private var hasAskedForRematch = false
+
+    suspend fun joinGame(gameId: String) {
+        try {
+            SupabaseGameHandler.joinGame(gameId, ::onGameRowUpdate)
+            this.lobbyCode = gameId
+        } catch (e: Exception) {
+            throw Exception("Problem with joining game: " + e.message)
+        }
+    }
+
+    suspend fun leaveGame() {
+        if (!isInGame()) {
+            throw IllegalStateException("Can't leave game if not in a game.")
+        }
+        try {
+            hasAskedForRematch = false
+            SupabaseGameHandler.leaveGame(lobbyCode!!)
path: chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
user: login: michaelbrusegard
body: Should we maybe call Supabase to delete the game from the database after it has been completed
created_at: 2025-04-15T23:56:28Z
author_association: OWNER
diff_hunk: @@ -102,6 +119,10 @@ class GamePresenter(
 
         // In case we want part of the UI to be scene2d, we render the view on top
         view.render()
+
+        // Test for rematch TODO: Remove this after testing.
+        println("Going to end")
+        goToGameOverScreen(false)
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
user: login: sivertun
body: Remove before merge
created_at: 2025-04-15T23:57:59Z
author_association: COLLABORATOR
diff_hunk: @@ -30,6 +34,8 @@ class LobbyPresenter(
                 lobbyUpdateHandler(lobby)
             }
         }
+        // TODO: Remove this after testing rematch functionality.
+        playerJoinedLeftLobby(true)
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
user: login: sivertun
body: Remove before merge
created_at: 2025-04-15T23:58:23Z
author_association: COLLABORATOR
diff_hunk: @@ -1,20 +1,46 @@
 package io.github.chessevolved.presenters
 
+import com.badlogic.gdx.Gdx
 import com.badlogic.gdx.graphics.g2d.SpriteBatch
 import io.github.chessevolved.Navigator
+import io.github.chessevolved.singletons.Game
+import io.github.chessevolved.singletons.Lobby
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
 import io.github.chessevolved.views.EndGameView
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 
 class EndGamePresenter(
     private val endGameView: EndGameView,
     private val endGameStatus: Boolean,
     private val navigator: Navigator,
 ) : IPresenter {
     init {
+        Game.subscribeToGameUpdates(this.toString(), ::onGameRowUpdate)
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt
user: login: michaelbrusegard
body: Will it work to subscribe to game updates in multiple views? So the point of the Game singleton has to be that both gamepresenter an endgamepresenter can access game information. The same way the lobby singleton is working for the other presenters and lobby info. I guess this is the case here
created_at: 2025-04-16T00:00:48Z
author_association: OWNER
diff_hunk: @@ -116,6 +137,19 @@ class GamePresenter(
         view.dispose()
         engine.removeAllEntities()
         unloadAssets()
+        unsubscribeFromGameUpdates(this.toString())
+        if (Game.isInGame() && !navigatingToEndGame) {
+            runBlocking {
+                launch {
+                    try {
+                        Game.leaveGame()
+                        Lobby.leaveLobby()
+                    } catch (e: Exception) {
+                        error("Non fatal error: Problem with calling leaveGame(). Error: " + e.message)
+                    }
+                }
+            }
+        }
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
user: login: michaelbrusegard
body: Why is this inside of resize?
created_at: 2025-04-16T00:03:09Z
author_association: OWNER
diff_hunk: @@ -47,6 +57,21 @@ class LobbyPresenter(
         lobbyStartedCheck(newLobby.game_started)
     }
 
+    private fun onStartGameButtonClicked() {
+        // This gets called twice for no reason when start game is clicked. Not sure why, but made a quick-fix for this.
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
user: login: michaelbrusegard
body: There must be something wrong with the view then causing it to be called twice. We should probably figure out why before merging
created_at: 2025-04-16T00:04:43Z
author_association: OWNER
diff_hunk: @@ -14,6 +15,8 @@ class EndGameView : IView {
     private lateinit var stage: Stage
     private lateinit var statusLabel1: Label
     private lateinit var statusLabel2: Label
path: chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt
user: login: michaelbrusegard
body: I know it is not part of this PR, but might as well fix it. These names are very generic and make it difficult to understand what the labels are for
created_at: 2025-04-16T00:05:51Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,73 @@
+package io.github.chessevolved.singletons
+
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
+import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
+import kotlin.reflect.KFunction1
+
+object Game {
+    private var lobbyCode: String? = null
path: chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
user: login: sivertun
body: Yes. This was mostly to let Game.kt that we're in a game.
created_at: 2025-04-16T12:55:30Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,73 @@
+package io.github.chessevolved.singletons
+
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
+import io.github.chessevolved.singletons.supabase.SupabaseLobbyHandler
+import kotlin.reflect.KFunction1
+
+object Game {
+    private var lobbyCode: String? = null
+    private var subscribers = mutableMapOf<String, KFunction1<SupabaseGameHandler.Game, Unit>>()
+    private var hasAskedForRematch = false
+
+    suspend fun joinGame(gameId: String) {
+        try {
+            SupabaseGameHandler.joinGame(gameId, ::onGameRowUpdate)
+            this.lobbyCode = gameId
+        } catch (e: Exception) {
+            throw Exception("Problem with joining game: " + e.message)
+        }
+    }
+
+    suspend fun leaveGame() {
+        if (!isInGame()) {
+            throw IllegalStateException("Can't leave game if not in a game.")
+        }
+        try {
+            hasAskedForRematch = false
+            SupabaseGameHandler.leaveGame(lobbyCode!!)
path: chessevolved_model/src/main/kotlin/io/github/chessevolved/singletons/Game.kt
user: login: sivertun
body: SupabaseGameHandler.leaveGame deletes the game if the user is the last player to leave the game. It checks if player_disconnected = true, and if it is, deletes the game from the database. If not, sets player_disconnected = true.
created_at: 2025-04-16T12:57:30Z
author_association: COLLABORATOR
diff_hunk: @@ -1,20 +1,46 @@
 package io.github.chessevolved.presenters
 
+import com.badlogic.gdx.Gdx
 import com.badlogic.gdx.graphics.g2d.SpriteBatch
 import io.github.chessevolved.Navigator
+import io.github.chessevolved.singletons.Game
+import io.github.chessevolved.singletons.Lobby
+import io.github.chessevolved.singletons.supabase.SupabaseGameHandler
 import io.github.chessevolved.views.EndGameView
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 
 class EndGamePresenter(
     private val endGameView: EndGameView,
     private val endGameStatus: Boolean,
     private val navigator: Navigator,
 ) : IPresenter {
     init {
+        Game.subscribeToGameUpdates(this.toString(), ::onGameRowUpdate)
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/EndGamePresenter.kt
user: login: sivertun
body: Yeah, you're right.
created_at: 2025-04-16T12:58:35Z
author_association: COLLABORATOR
diff_hunk: @@ -116,6 +137,19 @@ class GamePresenter(
         view.dispose()
         engine.removeAllEntities()
         unloadAssets()
+        unsubscribeFromGameUpdates(this.toString())
+        if (Game.isInGame() && !navigatingToEndGame) {
+            runBlocking {
+                launch {
+                    try {
+                        Game.leaveGame()
+                        Lobby.leaveLobby()
+                    } catch (e: Exception) {
+                        error("Non fatal error: Problem with calling leaveGame(). Error: " + e.message)
+                    }
+                }
+            }
+        }
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/GamePresenter.kt
user: login: sivertun
body: ![Screenshot 2025-04-16 150029](https://github.com/user-attachments/assets/ede56103-54ff-40e4-b189-950517d75569)
There's a gap between resize and the actual code. If you expand, you can see that it is actually under dispose.
created_at: 2025-04-16T13:01:27Z
author_association: COLLABORATOR
diff_hunk: @@ -14,6 +15,8 @@ class EndGameView : IView {
     private lateinit var stage: Stage
     private lateinit var statusLabel1: Label
     private lateinit var statusLabel2: Label
path: chessevolved_view/src/main/kotlin/io/github/chessevolved/views/EndGameView.kt
user: login: sivertun
body: Fixed 👍 
created_at: 2025-04-16T13:07:59Z
author_association: COLLABORATOR
diff_hunk: @@ -47,6 +57,21 @@ class LobbyPresenter(
         lobbyStartedCheck(newLobby.game_started)
     }
 
+    private fun onStartGameButtonClicked() {
+        // This gets called twice for no reason when start game is clicked. Not sure why, but made a quick-fix for this.
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
user: login: sivertun
body: Will look more into it today!
created_at: 2025-04-16T13:09:11Z
author_association: COLLABORATOR
diff_hunk: @@ -47,6 +57,21 @@ class LobbyPresenter(
         lobbyStartedCheck(newLobby.game_started)
     }
 
+    private fun onStartGameButtonClicked() {
+        // This gets called twice for no reason when start game is clicked. Not sure why, but made a quick-fix for this.
path: chessevolved_presenter/src/main/kotlin/io/github/chessevolved/presenters/LobbyPresenter.kt
user: login: sivertun
body: Fixed 👍 
created_at: 2025-04-16T18:50:25Z
author_association: COLLABORATOR

PR comments:
user: login: sivertun
created_at: 2025-04-15T23:43:16Z
body: I do want to do a little more testing, as there is prone to be bugs in my implementation. But this is just about done.
author_association: COLLABORATOR
user: login: sivertun
created_at: 2025-04-16T18:49:02Z
body: After some more testing, I found some pretty nasty bugs. Should be fixed for now though. 👍 
author_association: COLLABORATOR
user: login: sivertun
created_at: 2025-04-17T12:47:41Z
body: > It looks good, but I still think we should only store the lobbycode inside of Lobby.kt and then retrieve it in Game.kt when needed. This is to avoid storing duplicate information

Refactored to only store lobbycode inside Lobby.kt
author_association: COLLABORATOR