From ec8b67cf043716e8e84e68f884401d3270128035 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Mon, 3 Aug 2026 13:56:05 +0200 Subject: [PATCH 1/2] feat(discovery)!: spread joins across lobbies instead of packing one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joins now go to the least-occupied lobby, always. The previous policy packed the fullest lobby below a soft cap so that a network of 50 would feel like one lobby of 50 rather than five of ten — a real concern, but the effect was that every player in a region sat in one process, and the cap defaulted to 400, which no region ever reached. Measured on stage, the lobby was never the reason to split: 228 players cost 0.29 cores and a 2 ms average tick against a 50 ms budget. The reason is blast radius — one restart took every player in the region with it. That argues for spreading always, not for a threshold nobody reaches. A newly autoscaled lobby needs no special case: it is empty, so it is the least occupied, so it takes joins until it has caught up. Priority filling falls out of the same rule that does the spreading. BREAKING CHANGE: GROUNDS_AGONES_LOBBY_SOFT_CAP is gone. Deployments setting it keep working, the value is simply ignored; groundsgg/deploy should drop it. --- .../gg/grounds/discovery/DiscoveryConfig.kt | 7 --- .../discovery/DiscoveryPlayerListener.kt | 9 ++- .../gg/grounds/discovery/DiscoveryService.kt | 1 - .../gg/grounds/discovery/LobbyPacking.kt | 28 --------- .../gg/grounds/discovery/LobbySelection.kt | 29 ++++++++++ .../gg/grounds/discovery/LobbyPackingTest.kt | 58 ------------------- .../grounds/discovery/LobbySelectionTest.kt | 57 ++++++++++++++++++ 7 files changed, 90 insertions(+), 99 deletions(-) delete mode 100644 velocity/src/main/kotlin/gg/grounds/discovery/LobbyPacking.kt create mode 100644 velocity/src/main/kotlin/gg/grounds/discovery/LobbySelection.kt delete mode 100644 velocity/src/test/kotlin/gg/grounds/discovery/LobbyPackingTest.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt index 69b38c1..0134cbb 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryConfig.kt @@ -19,8 +19,6 @@ import java.time.Duration * - `GROUNDS_AGONES_ADDRESS_TYPE` — Which `status.addresses` entry to use (`PodIP`, `ExternalIP`, * `InternalIP`, `Hostname`). * - `GROUNDS_AGONES_PORT` — TCP port to dial on the discovered GameServer. - * - `GROUNDS_AGONES_LOBBY_SOFT_CAP` — Players a lobby is packed up to before joins go to the next - * one. Soft: a snapshot-raced join over the cap is fine. */ data class DiscoveryConfig( val namespace: String, @@ -31,7 +29,6 @@ data class DiscoveryConfig( val pollInterval: Duration, val addressType: String, val port: Int, - val lobbySoftCap: Int, ) { companion object { const val DEFAULT_NAMESPACE = "games" @@ -42,7 +39,6 @@ data class DiscoveryConfig( val DEFAULT_POLL_INTERVAL: Duration = Duration.ofSeconds(2) const val DEFAULT_ADDRESS_TYPE = "PodIP" const val DEFAULT_PORT = 25565 - const val DEFAULT_LOBBY_SOFT_CAP = 400 fun fromEnv(env: Map = System.getenv()): DiscoveryConfig = DiscoveryConfig( @@ -63,9 +59,6 @@ data class DiscoveryConfig( ?: DEFAULT_POLL_INTERVAL, addressType = env["GROUNDS_AGONES_ADDRESS_TYPE"] ?: DEFAULT_ADDRESS_TYPE, port = env["GROUNDS_AGONES_PORT"]?.toIntOrNull() ?: DEFAULT_PORT, - lobbySoftCap = - env["GROUNDS_AGONES_LOBBY_SOFT_CAP"]?.toIntOrNull()?.takeIf { it > 0 } - ?: DEFAULT_LOBBY_SOFT_CAP, ) private val DURATION_PATTERN = Regex("""^(\d+)\s*(s|m|h)$""") diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt index d8b70d5..d4147a8 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryPlayerListener.kt @@ -11,11 +11,10 @@ import net.kyori.adventure.text.Component class DiscoveryPlayerListener( private val proxyServer: ProxyServer, private val lobbyServers: Set, - private val lobbySoftCap: Int, /** * Network-wide players per backend server, or null when the network cannot be asked. Null falls - * back to this proxy's own view — enough to keep packing roughly right on a single proxy, and - * strictly better than picking blind. + * back to this proxy's own view — on a single proxy that is the same number, and with several + * it still spreads, just per proxy rather than per network. */ private val networkCounts: () -> Map?, ) { @@ -50,12 +49,12 @@ class DiscoveryPlayerListener( val candidates = lobbies.map { server -> val name = server.serverInfo.name - LobbyPacking.Candidate( + LobbySelection.Candidate( name, if (counts != null) counts[name] ?: 0 else server.playersConnected.size, ) } - val chosen = LobbyPacking.pick(candidates, lobbySoftCap) ?: return null + val chosen = LobbySelection.pick(candidates) ?: return null return lobbies.firstOrNull { it.serverInfo.name == chosen } } } diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt index 940678c..e943a83 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt @@ -81,7 +81,6 @@ class DiscoveryService( DiscoveryPlayerListener( proxyServer, lobbyServers, - config.lobbySoftCap, this::networkCountsCached, ), ) diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/LobbyPacking.kt b/velocity/src/main/kotlin/gg/grounds/discovery/LobbyPacking.kt deleted file mode 100644 index 219aebd..0000000 --- a/velocity/src/main/kotlin/gg/grounds/discovery/LobbyPacking.kt +++ /dev/null @@ -1,28 +0,0 @@ -package gg.grounds.discovery - -/** - * Which lobby a joining player should land on. - * - * Fullest-first, below a soft cap: players go to the most-occupied lobby that still has room, so - * lobbies fill one after another instead of every join being sprayed across all of them — a network - * with 50 players should feel like one lobby with 50 people, not five with 10. Only when every - * lobby is at or over the cap does the choice flip to the least-occupied one, spreading the - * overflow evenly. - * - * The cap is soft on purpose: counts are a snapshot, joins race each other, and a lobby briefly at - * 403/400 is fine. Ties break on the name so that every proxy, working from the same counts, packs - * the same lobby. - */ -object LobbyPacking { - - data class Candidate(val name: String, val players: Int) - - fun pick(candidates: List, softCap: Int): String? { - if (candidates.isEmpty()) return null - val fullestFirst = - candidates.sortedWith(compareByDescending { it.players }.thenBy { it.name }) - val belowCap = fullestFirst.firstOrNull { it.players < softCap } - if (belowCap != null) return belowCap.name - return candidates.sortedWith(compareBy({ it.players }, { it.name })).first().name - } -} diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/LobbySelection.kt b/velocity/src/main/kotlin/gg/grounds/discovery/LobbySelection.kt new file mode 100644 index 0000000..a8a064b --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/discovery/LobbySelection.kt @@ -0,0 +1,29 @@ +package gg.grounds.discovery + +/** + * Which lobby a joining player should land on. + * + * Least-occupied wins. Joins spread across the lobbies instead of filling one and then the next, so + * no single instance holds the whole region: a lobby restart takes a share of the players with it + * rather than all of them. + * + * This also gives a newly autoscaled lobby what it needs without any special case. A fresh lobby is + * empty, so it is the least occupied, so it takes joins until it has caught up with the others — + * priority filling falls out of the same rule that does the spreading. + * + * This replaces a fullest-first-below-a-cap policy. That one existed so a network of 50 would feel + * like one lobby of 50 rather than five of ten, which is a real concern — but it meant that in + * practice every player in a region sat in one process, and measured on stage the lobby was never + * the reason to split: 228 players cost 0.29 cores and a 2 ms average tick against a 50 ms budget. + * The reason to split is blast radius, and that argues for spreading always rather than for a + * threshold nobody ever reached. + * + * Ties break on the name so that every proxy, working from the same counts, makes the same choice. + */ +object LobbySelection { + + data class Candidate(val name: String, val players: Int) + + fun pick(candidates: List): String? = + candidates.minWithOrNull(compareBy({ it.players }, { it.name }))?.name +} diff --git a/velocity/src/test/kotlin/gg/grounds/discovery/LobbyPackingTest.kt b/velocity/src/test/kotlin/gg/grounds/discovery/LobbyPackingTest.kt deleted file mode 100644 index 5d40a74..0000000 --- a/velocity/src/test/kotlin/gg/grounds/discovery/LobbyPackingTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package gg.grounds.discovery - -import gg.grounds.discovery.LobbyPacking.Candidate -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class LobbyPackingTest { - - @Test - fun `packs the fullest lobby below the cap`() { - val chosen = - LobbyPacking.pick( - listOf( - Candidate("lobby-a", 12), - Candidate("lobby-b", 391), - Candidate("lobby-c", 0), - ), - softCap = 400, - ) - assertEquals("lobby-b", chosen) - } - - @Test - fun `a lobby at the cap stops taking joins`() { - val chosen = - LobbyPacking.pick( - listOf(Candidate("lobby-a", 400), Candidate("lobby-b", 17)), - softCap = 400, - ) - assertEquals("lobby-b", chosen) - } - - @Test - fun `overflow spreads to the least occupied when everything is full`() { - val chosen = - LobbyPacking.pick( - listOf(Candidate("lobby-a", 431), Candidate("lobby-b", 405)), - softCap = 400, - ) - assertEquals("lobby-b", chosen) - } - - @Test - fun `ties break on the name so every proxy packs the same lobby`() { - val chosen = - LobbyPacking.pick( - listOf(Candidate("lobby-b", 50), Candidate("lobby-a", 50)), - softCap = 400, - ) - assertEquals("lobby-a", chosen) - } - - @Test - fun `no candidates means no lobby`() { - assertNull(LobbyPacking.pick(emptyList(), softCap = 400)) - } -} diff --git a/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt b/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt new file mode 100644 index 0000000..b520794 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt @@ -0,0 +1,57 @@ +package gg.grounds.discovery + +import gg.grounds.discovery.LobbySelection.Candidate +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class LobbySelectionTest { + + @Test + fun `joins go to the least occupied lobby`() { + val chosen = + LobbySelection.pick( + listOf( + Candidate("lobby-a", 12), + Candidate("lobby-b", 391), + Candidate("lobby-c", 3), + ) + ) + assertEquals("lobby-c", chosen) + } + + @Test + fun `a freshly autoscaled lobby is filled first because it is empty`() { + val chosen = + LobbySelection.pick( + listOf( + Candidate("lobby-a", 150), + Candidate("lobby-b", 148), + Candidate("lobby-new", 0), + ) + ) + assertEquals("lobby-new", chosen) + } + + @Test + fun `repeated picks even the lobbies out rather than filling one`() { + val counts = mutableMapOf("lobby-a" to 4, "lobby-b" to 0, "lobby-c" to 2) + repeat(6) { + val chosen = LobbySelection.pick(counts.map { Candidate(it.key, it.value) })!! + counts[chosen] = counts.getValue(chosen) + 1 + } + assertEquals(listOf(4, 4, 4), counts.values.sorted()) + } + + @Test + fun `ties break on the name so every proxy makes the same choice`() { + val chosen = + LobbySelection.pick(listOf(Candidate("lobby-b", 50), Candidate("lobby-a", 50))) + assertEquals("lobby-a", chosen) + } + + @Test + fun `no candidates means no lobby`() { + assertNull(LobbySelection.pick(emptyList())) + } +} From 60451afc6144fc612ec28097d9a2a8344b0fc6c2 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Mon, 3 Aug 2026 14:02:01 +0200 Subject: [PATCH 2/2] style: apply spotless --- .../main/kotlin/gg/grounds/discovery/DiscoveryService.kt | 6 +----- .../kotlin/gg/grounds/discovery/LobbySelectionTest.kt | 9 ++------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt index e943a83..3f9921b 100644 --- a/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt +++ b/velocity/src/main/kotlin/gg/grounds/discovery/DiscoveryService.kt @@ -78,11 +78,7 @@ class DiscoveryService( private fun registerListeners() { proxyServer.eventManager.register( plugin, - DiscoveryPlayerListener( - proxyServer, - lobbyServers, - this::networkCountsCached, - ), + DiscoveryPlayerListener(proxyServer, lobbyServers, this::networkCountsCached), ) } diff --git a/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt b/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt index b520794..c5322b8 100644 --- a/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/discovery/LobbySelectionTest.kt @@ -11,11 +11,7 @@ class LobbySelectionTest { fun `joins go to the least occupied lobby`() { val chosen = LobbySelection.pick( - listOf( - Candidate("lobby-a", 12), - Candidate("lobby-b", 391), - Candidate("lobby-c", 3), - ) + listOf(Candidate("lobby-a", 12), Candidate("lobby-b", 391), Candidate("lobby-c", 3)) ) assertEquals("lobby-c", chosen) } @@ -45,8 +41,7 @@ class LobbySelectionTest { @Test fun `ties break on the name so every proxy makes the same choice`() { - val chosen = - LobbySelection.pick(listOf(Candidate("lobby-b", 50), Candidate("lobby-a", 50))) + val chosen = LobbySelection.pick(listOf(Candidate("lobby-b", 50), Candidate("lobby-a", 50))) assertEquals("lobby-a", chosen) }