@babylonjs-toolkit/server 0.0.0-stage → 9.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
package/README.md CHANGED
@@ -1,3 +1,1082 @@
1
- # Temporary Holding Version
1
+ # Babylon Game Server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `@babylonjs-toolkit/server` is the multiplayer session server for Babylon Toolkit games. It hosts every session a
4
+ `TOOLKIT.NetworkRunner` starts — Shared, Host/Client, AutoHostOrClient and dedicated Server mode — on Colyseus 0.18.
5
+
6
+ - **One server for every game.** It holds no game code. It loads the Babylon Toolkit runtime headless, with Babylon
7
+ Toolkit Networking built in (the `TOOLKIT` namespace), so its relay, its Shared-mode state store and its dedicated
8
+ Server-mode runner run the same code as the browser. A game's own classes stay in its `PROJECT` script bundle.
9
+ - **Safe defaults.** TLS, player authentication and UDP are each off until you turn them on. Abuse limits are on.
10
+ - **Ready to operate.** Health and readiness endpoints, Prometheus metrics, a web [admin console](#admin-console) with
11
+ its admin API, graceful draining on `SIGTERM`, scale-out over Redis, structured JSON logs in production and
12
+ deployment recipes for common hosts.
13
+
14
+ Node 22 or newer is required.
15
+
16
+ ## Quick start
17
+
18
+ Run it with npx:
19
+
20
+ ```sh
21
+ npx @babylonjs-toolkit/server
22
+ # Babylon Game Server 9.30.1 listening on ws://localhost:2567
23
+ # HTTP: http://localhost:2567
24
+ # WebTransport: off
25
+ # TLS: off UDP: off Auth: off Redis: off
26
+ ```
27
+
28
+ Or with Docker:
29
+
30
+ ```sh
31
+ docker run -p 2567:2567 ghcr.io/mackeyk24/babylon-game-server
32
+ ```
33
+
34
+ Or install it in your project and write a starter folder with a commented config file, an `.env.example`, a
35
+ `Dockerfile` and deployment recipes:
36
+
37
+ ```sh
38
+ npm install @babylonjs-toolkit/server
39
+ npx babylon-game-server init # writes ./babylon-game-server/
40
+ npx babylon-game-server init deploy # or into ./deploy/; existing files are kept unless --force
41
+ ```
42
+
43
+ The CLI has three commands:
44
+
45
+ | Command | What it does |
46
+ |---|---|
47
+ | `babylon-game-server [start] [flags]` | Starts the server (the default command). |
48
+ | `babylon-game-server init [folder] [--force]` | Writes the starter folder. Existing files are listed as `skipped (exists)` and kept unless `--force`; the exit code is 1 when any were skipped. |
49
+ | `babylon-game-server healthcheck [flags]` | Calls the running server's `/game-server/health` on `127.0.0.1` with the same config and exits 0 when it answers 200, else 1. The Docker image's `HEALTHCHECK` runs it. |
50
+
51
+ A configuration problem prints every problem at once and exits with code 2.
52
+
53
+ ## Connect your game
54
+
55
+ The game points its `NetworkRunner` at the server URL and, when the server requires it, passes a token.
56
+
57
+ **UMD (exported pages and Unity-attached scripts).** Turn on the exporter's **Babylon Toolkit Networking** option so the page
58
+ loads the Colyseus browser client (`scripts/colyseus.js`). The server URL is authored on the scene's `NetworkSettings`
59
+ component, or set at start:
60
+
61
+ ```ts
62
+ const runner: TOOLKIT.NetworkRunner = new TOOLKIT.NetworkRunner(scene);
63
+ const result: TOOLKIT.StartGameResult = await runner.startGame({
64
+ gameMode: TOOLKIT.GameMode.AutoHostOrClient,
65
+ sessionName: "arena",
66
+ config: { serverUrl: "wss://game.example.com" },
67
+ authToken: playerToken, // only when the server enables auth
68
+ transportPreference: TOOLKIT.NetworkTransportPreference.Auto // the default
69
+ });
70
+ if (!result.ok) console.error(TOOLKIT.ShutdownReason[result.shutdownReason] + " " + result.errorMessage);
71
+ ```
72
+
73
+ **ESM (web apps on `@babylonjs-toolkit/next`).** Install the browser client and hand it to the transport before the
74
+ first `startGame`; the `startGame` call is the same, without the `TOOLKIT.` prefix:
75
+
76
+ ```sh
77
+ npm install @colyseus/sdk@0.18.5
78
+ ```
79
+
80
+ ```ts
81
+ import { ColyseusTransport, NetworkRunner, GameMode } from "@babylonjs-toolkit/next/project";
82
+
83
+ ColyseusTransport.ClientModule = await import("@colyseus/sdk");
84
+ ```
85
+
86
+ - `authToken` is sent with every matchmaking call, the session lobby included. With auth off the server ignores it.
87
+ - `transportPreference` is `Auto` (WebTransport when the server offers it and the browser can use it, else
88
+ WebSocket), `WebSocket`, or `WebTransport` (refused with `ConnectionRefused` when it cannot be used).
89
+ - The host's `onConnectRequest(runner, request, token)` (and a dedicated runner's) sees the server-verified identity
90
+ as `request.authIdentity` (`{ userId, claims }`, or `null` when auth is off). The server sets it; a value a client
91
+ sends is discarded.
92
+ - A page served over `https://` needs a `wss://` server URL.
93
+
94
+ **The toolkit's sample launcher** (`Assets/Networking/Samples`) reads these from the page URL:
95
+
96
+ | Query parameter | Meaning | Default |
97
+ |---|---|---|
98
+ | `fusionMode` | A `GameMode` name: `Shared`, `Host`, `Client`, `AutoHostOrClient` | `Shared` |
99
+ | `fusionSession` | The session name | `testbed` |
100
+ | `fusionServer` | The server URL (URL-encoded) | `ws://localhost:2567` |
101
+ | `fusionToken` | The auth token | none |
102
+ | `fusionTransport` | `auto`, `websocket` or `webtransport` | `auto` |
103
+ | `fusionDedicatedScene` | With `fusionMode=Client`: a dedicated scene the server starts on demand | none |
104
+ | `fusionStats` | `1` shows the network stats overlay, which also names the active link | off |
105
+
106
+ ```text
107
+ engine.html?scene=networktestbed.gltf&fusionMode=Shared&fusionSession=race1&fusionServer=wss%3A%2F%2Fgame.example.com&fusionToken=abc
108
+ ```
109
+
110
+ ## Configuration
111
+
112
+ Settings come from, highest first: command-line flags, `GAME_SERVER_*` environment variables, a config file, the
113
+ defaults. The config file is the one named by `--config <path>` or `GAME_SERVER_CONFIG`, else the first of
114
+ `babylon-game-server.config.mjs`, `.js` or `.json` in the working directory. A `.mjs`/`.js` file's default export is an
115
+ object, or a function (async allowed) that returns one. `PORT` is read when `GAME_SERVER_PORT` is unset, as most
116
+ platforms set it. Boolean flags have a `--no-` form (`--no-tls`). List variables are comma-separated. Unknown keys,
117
+ flags and wrongly typed values are refused at startup.
118
+
119
+ Every option:
120
+
121
+ | Option | Environment variable | Flag | Default | Description |
122
+ |---|---|---|---|---|
123
+ | `port` | `GAME_SERVER_PORT` | `--port` | `2567` | TCP (and UDP) port to listen on; 0 picks a free port. Falls back to PORT. |
124
+ | `host` | `GAME_SERVER_HOST` | `--host` | `0.0.0.0` | Address to bind. |
125
+ | `publicUrl` | `GAME_SERVER_PUBLIC_URL` | `--public-url` | none | Public ws:// or wss:// URL players use, shown in the banner. |
126
+ | `publicAddress` | `GAME_SERVER_PUBLIC_ADDRESS` | `--public-address` | none | host[:port] this instance advertises for seat reservations when scaled out with Redis. |
127
+ | `trustProxy` | `GAME_SERVER_TRUST_PROXY` | `--trust-proxy` | `false` | Trust x-forwarded-for / x-real-ip from a reverse proxy for the client address. |
128
+ | `logFormat` | `GAME_SERVER_LOG_FORMAT` | `--log-format` | `auto` | Log format: auto (JSON when NODE_ENV=production), json or text. One of `auto`, `json`, `text`. |
129
+ | `logLevel` | `GAME_SERVER_LOG_LEVEL` | `--log-level` | `info` | Least severe level logged. One of `error`, `warn`, `info`, `debug`. |
130
+ | `tls.enabled` | `GAME_SERVER_TLS_ENABLED` | `--tls` | `false` | Serve https:// and wss:// from the server itself. |
131
+ | `tls.certFile` | `GAME_SERVER_TLS_CERT_FILE` | `--tls-cert-file` | none | Path of the PEM certificate chain; reloaded when it changes. |
132
+ | `tls.keyFile` | `GAME_SERVER_TLS_KEY_FILE` | `--tls-key-file` | none | Path of the PEM private key; reloaded when it changes. |
133
+ | `tls.certPem` | `GAME_SERVER_TLS_CERT_PEM` | none | none | PEM certificate chain text, for container secrets. Secret: never logged. |
134
+ | `tls.keyPem` | `GAME_SERVER_TLS_KEY_PEM` | none | none | PEM private key text, for container secrets. Secret: never logged. |
135
+ | `tls.developmentCertificate` | `GAME_SERVER_TLS_DEVELOPMENT_CERTIFICATE` | `--tls-dev-cert` | `false` | Make a local development certificate with mkcert (refused when NODE_ENV=production). |
136
+ | `udp.enabled` | `GAME_SERVER_UDP_ENABLED` | `--udp` | `false` | Also accept WebTransport over HTTP/3 (UDP) on the same port; needs TLS. |
137
+ | `auth.enabled` | `GAME_SERVER_AUTH_ENABLED` | `--auth` | `false` | Require a valid player token on every join. |
138
+ | `auth.mode` | `GAME_SERVER_AUTH_MODE` | `--auth-mode` | `shared-secret` | How tokens are verified: shared-secret, jwt, webhook or custom. One of `shared-secret`, `jwt`, `webhook`, `custom`. |
139
+ | `auth.sharedSecret.tokens` | `GAME_SERVER_AUTH_SHARED_SECRET_TOKENS` | none | empty | Comma-separated tokens accepted in shared-secret mode. Secret: never logged. |
140
+ | `auth.jwt.secret` | `GAME_SERVER_AUTH_JWT_SECRET` | none | none | HMAC secret (HS256/384/512) that signs player JWTs. Secret: never logged. |
141
+ | `auth.jwt.jwksUrl` | `GAME_SERVER_AUTH_JWT_JWKS_URL` | none | none | JWKS URL of the identity provider that signs player JWTs. |
142
+ | `auth.jwt.issuer` | `GAME_SERVER_AUTH_JWT_ISSUER` | none | none | Required JWT issuer (iss), or none. |
143
+ | `auth.jwt.audience` | `GAME_SERVER_AUTH_JWT_AUDIENCE` | none | none | Required JWT audience (aud), or none. |
144
+ | `auth.jwt.algorithms` | `GAME_SERVER_AUTH_JWT_ALGORITHMS` | none | empty | Comma-separated accepted JWT algorithms (empty: the key's defaults). |
145
+ | `auth.webhook.url` | `GAME_SERVER_AUTH_WEBHOOK_URL` | none | none | URL that approves players in webhook mode. |
146
+ | `auth.webhook.timeoutMs` | `GAME_SERVER_AUTH_WEBHOOK_TIMEOUT_MS` | none | `5000` | Milliseconds the auth webhook may take. |
147
+ | `discordSignIn.enabled` | `GAME_SERVER_DISCORD_SIGN_IN_ENABLED` | `--discord-sign-in` | `false` | Let players sign in with Discord (website OAuth2, scope identify). |
148
+ | `discordSignIn.clientId` | `GAME_SERVER_DISCORD_CLIENT_ID` | none | none | The Discord application's client id. |
149
+ | `discordSignIn.clientSecret` | `GAME_SERVER_DISCORD_CLIENT_SECRET` | none | none | The Discord application's client secret. Secret: never logged. |
150
+ | `discordSignIn.redirectUri` | `GAME_SERVER_DISCORD_REDIRECT_URI` | none | none | This server's Discord callback address, exactly as registered with Discord (…/game-server/discord/callback). |
151
+ | `discordSignIn.returnOrigins` | `GAME_SERVER_DISCORD_RETURN_ORIGINS` | none | empty | Comma-separated game origins a Discord sign-in may return to. |
152
+ | `discordSignIn.sessionDays` | `GAME_SERVER_DISCORD_SESSION_DAYS` | none | `30` | Days a player's Discord sign-in lasts before they sign in again. |
153
+ | `admin.token` | `GAME_SERVER_ADMIN_TOKEN` | `--admin-token` | none | Bearer token of the admin endpoints (unset: the admin API is off). Secret: never logged. |
154
+ | `admin.console` | `GAME_SERVER_ADMIN_CONSOLE` | none | `true` | Serve the web admin console at /game-server/admin/ when an admin account exists or a setup is pending. |
155
+ | `admin.dataDir` | `GAME_SERVER_ADMIN_DATA_DIR` | `--admin-data-dir` | `./babylon-game-server-data` | Folder of the admin data (accounts, bans, audit) when Redis is not configured. |
156
+ | `admin.sessionIdleMinutes` | `GAME_SERVER_ADMIN_SESSION_IDLE_MINUTES` | none | `30` | Console sign-out after this many idle minutes. |
157
+ | `admin.sessionMaxHours` | `GAME_SERVER_ADMIN_SESSION_MAX_HOURS` | none | `12` | Console sign-out after this many hours in all. |
158
+ | `admin.allowedNetworks` | `GAME_SERVER_ADMIN_ALLOWED_NETWORKS` | none | empty | Comma-separated addresses or CIDR ranges that may reach the console and its API (empty: any). |
159
+ | `admin.logBufferEntries` | `GAME_SERVER_ADMIN_LOG_BUFFER_ENTRIES` | none | `10000` | Log entries each instance keeps for the console. |
160
+ | `admin.metricsHistoryHours` | `GAME_SERVER_ADMIN_METRICS_HISTORY_HOURS` | none | `24` | Hours of metrics history kept for the console charts. |
161
+ | `admin.grafanaUrl` | `GAME_SERVER_ADMIN_GRAFANA_URL` | none | none | An external Grafana dashboard URL the console links to. |
162
+ | `admin.instanceLabel` | `GAME_SERVER_ADMIN_INSTANCE_LABEL` | none | none | A region or label the console shows beside this instance. |
163
+ | `admin.oidc.enabled` | `GAME_SERVER_ADMIN_OIDC_ENABLED` | none | `false` | Offer OpenID Connect single sign-on on the console (needs issuer and clientId). |
164
+ | `admin.oidc.issuer` | `GAME_SERVER_ADMIN_OIDC_ISSUER` | none | none | OpenID Connect issuer URL (with a /.well-known/openid-configuration). |
165
+ | `admin.oidc.clientId` | `GAME_SERVER_ADMIN_OIDC_CLIENT_ID` | none | none | The console's OpenID Connect client id. |
166
+ | `admin.oidc.clientSecret` | `GAME_SERVER_ADMIN_OIDC_CLIENT_SECRET` | none | none | The console's OpenID Connect client secret. Secret: never logged. |
167
+ | `admin.oidc.label` | `GAME_SERVER_ADMIN_OIDC_LABEL` | none | `Single sign-on` | Label of the single sign-on button. |
168
+ | `admin.oidc.roleClaim` | `GAME_SERVER_ADMIN_OIDC_ROLE_CLAIM` | none | `groups` | ID token claim that carries the groups or roles mapped to console roles. |
169
+ | `admin.oidc.roleMapping` | `GAME_SERVER_ADMIN_OIDC_ROLE_MAPPING` | none | empty | Comma-separated claim value=role pairs, e.g. admins=admin,support=moderator. |
170
+ | `admin.oidc.allowLocalSignIn` | `GAME_SERVER_ADMIN_OIDC_ALLOW_LOCAL_SIGN_IN` | none | `true` | Allow username and password sign-in besides single sign-on (the CLI always works). |
171
+ | `admin.passkeys.enabled` | `GAME_SERVER_ADMIN_PASSKEYS_ENABLED` | none | `true` | Offer passkeys as a second factor and for password-less console sign-in. |
172
+ | `admin.passkeys.rpId` | `GAME_SERVER_ADMIN_PASSKEYS_RP_ID` | none | none | Passkey relying party id (unset: the console host). |
173
+ | `admin.colyseusCloud.apiToken` | `GAME_SERVER_ADMIN_COLYSEUS_CLOUD_API_TOKEN` | none | none | Colyseus Cloud API token for the console's Colyseus Cloud page (read-only, team-wide; created under API tokens in the Colyseus Cloud dashboard). Falls back to `COLYSEUS_CLOUD_TOKEN`. Secret: never logged. |
174
+ | `admin.colyseusCloud.application` | `GAME_SERVER_ADMIN_COLYSEUS_CLOUD_APPLICATION` | none | none | Slug of the Colyseus Cloud application the page shows, for example `1234-my-game`. Empty: the team's only application. |
175
+ | `admin.colyseusCloud.apiUrl` | `GAME_SERVER_ADMIN_COLYSEUS_CLOUD_API_URL` | none | `https://cloud-prod.colyseus.io/api/v1` | Base URL of the Colyseus Cloud API. |
176
+ | `metrics.enabled` | `GAME_SERVER_METRICS_ENABLED` | none | `true` | Serve Prometheus metrics on GET /metrics. |
177
+ | `metrics.requireAdminToken` | `GAME_SERVER_METRICS_REQUIRE_ADMIN_TOKEN` | none | `false` | Require the admin token on /metrics. |
178
+ | `limits.allowedOrigins` | `GAME_SERVER_ALLOWED_ORIGINS` | `--allowed-origins` | empty | Comma-separated browser origins allowed to join (empty: any). |
179
+ | `limits.allowMissingOrigin` | `GAME_SERVER_ALLOW_MISSING_ORIGIN` | none | `true` | Accept joins that send no Origin header (native clients). |
180
+ | `limits.joinRequestsPerMinutePerAddress` | `GAME_SERVER_JOIN_REQUESTS_PER_MINUTE` | none | `120` | Join requests one client address may make per minute. |
181
+ | `limits.maxSessions` | `GAME_SERVER_MAX_SESSIONS` | none | `1000` | Sessions one process hosts. |
182
+ | `limits.maxPlayersPerSession` | `GAME_SERVER_MAX_PLAYERS_PER_SESSION` | none | `255` | Players one session accepts; larger requests are clamped. |
183
+ | `limits.maxDedicatedSessions` | `GAME_SERVER_MAX_DEDICATED_SESSIONS` | none | `4` | Dedicated sessions one process runs. |
184
+ | `limits.maxMessageBytes` | `GAME_SERVER_MAX_MESSAGE_BYTES` | none | `65536` | Largest WebSocket message in bytes. |
185
+ | `limits.maxMessagesPerSecond` | `GAME_SERVER_MAX_MESSAGES_PER_SECOND` | none | `2000` | Messages one client may send per second before it is disconnected (a live change applies to new sessions). |
186
+ | `limits.maxSessionNameLength` | `GAME_SERVER_MAX_SESSION_NAME_LENGTH` | none | `128` | Longest session name in characters. |
187
+ | `redis.url` | `GAME_SERVER_REDIS_URL` | `--redis-url` | none | redis:// URL shared by every instance when scaled out. Secret: never logged. |
188
+ | `leaderboards.definitionsFile` | `GAME_SERVER_LEADERBOARDS_DEFINITIONS_FILE` | `--leaderboards-definitions-file` | none | JSON file of leaderboard boards created on start when missing (relative to the working directory). |
189
+ | `leaderboards.signedInPlayersOnly` | `GAME_SERVER_LEADERBOARDS_SIGNED_IN_PLAYERS_ONLY` | `--leaderboards-signed-in-players-only` | `false` | Only signed-in players (an auth identity with a user id) may submit leaderboard scores; anonymous entries are hidden on start. See [Signed-in players only](#signed-in-players-only). |
190
+ | `drainSeconds` | `GAME_SERVER_DRAIN_SECONDS` | `--drain-seconds` | `30` | Seconds running sessions may continue after SIGTERM. |
191
+
192
+ The config file also holds the dedicated sessions, which have no environment variable or flag (see
193
+ [Dedicated sessions](#dedicated-sessions)):
194
+
195
+ | Option | Meaning | Default |
196
+ |---|---|---|
197
+ | `dedicated.scenes` | The scenes dedicated sessions may run: `{ name, sceneRoot, sceneFile, projectScripts, maxPlayers, onDemand, idleSeconds }` | empty |
198
+ | `dedicated.boot` | Dedicated sessions started with the server: `{ sessionName, sceneName }` | empty |
199
+
200
+ From code, `createGameServer` takes overrides of the defaults (it reads no file or environment unless you call
201
+ `loadGameServerConfig` yourself):
202
+
203
+ ```ts
204
+ import { createGameServer, loadGameServerConfig } from "@babylonjs-toolkit/server";
205
+
206
+ const running = await createGameServer({ config: { port: 2567, limits: { allowedOrigins: ["https://game.example.com"] } } });
207
+ console.log(running.urls.webSocket); // ws://localhost:2567
208
+ await running.drain(); // or close() to stop at once
209
+
210
+ // The CLI's way: defaults < Colyseus Cloud's defaults (on that host only) < file < env < flags.
211
+ const config = await loadGameServerConfig({ argv: process.argv.slice(2), environment: process.env, workingDirectory: process.cwd() });
212
+ ```
213
+
214
+ `createGameServer({ config, hooks, routes, handleSignals })` also takes lifecycle hooks (`onSessionCreated`,
215
+ `onPlayerJoined`, `onPlayerLeft`, `onSessionClosed`, and `verifyToken` for custom auth), extra HTTP endpoints made with
216
+ the re-exported `createEndpoint`, and `handleSignals` (drain on `SIGTERM`/`SIGINT` and exit; the CLI turns it on). One
217
+ game server runs per process.
218
+
219
+ ## Enabling TLS
220
+
221
+ Leave TLS off when a reverse proxy or the platform edge terminates it (Caddy, an AWS load balancer, Fly.io, Railway,
222
+ Render, DigitalOcean, Heroku): players then use the platform's `wss://` address. Turn it on when the server itself is
223
+ the public endpoint, and always for UDP.
224
+
225
+ ```sh
226
+ # Certificate files (for example from certbot); they are polled every 30 s and reloaded without a restart.
227
+ babylon-game-server --tls --tls-cert-file /etc/ssl/game/fullchain.pem --tls-key-file /etc/ssl/game/privkey.pem
228
+
229
+ # PEM text from container secrets.
230
+ GAME_SERVER_TLS_ENABLED=true GAME_SERVER_TLS_CERT_PEM="$(cat fullchain.pem)" GAME_SERVER_TLS_KEY_PEM="$(cat privkey.pem)" babylon-game-server
231
+
232
+ # A local development certificate (needs mkcert: brew install mkcert && mkcert -install).
233
+ babylon-game-server --tls --tls-dev-cert
234
+ # Babylon Game Server 9.30.1 listening on wss://localhost:2567
235
+ ```
236
+
237
+ - `--tls-dev-cert` is refused when `NODE_ENV=production`. Without mkcert it stops with
238
+ `install mkcert (brew install mkcert && mkcert -install) or set tls.certFile/keyFile`, unless UDP is on (below).
239
+ - A renewed certificate reaches `https://` and `wss://` at once. With UDP on, HTTP/3 keeps the certificate it started
240
+ with until the next restart, and the server logs that.
241
+
242
+ ## Enabling authentication
243
+
244
+ With auth on, every join — sessions and the lobby — must carry a token that the server verifies at matchmaking, before
245
+ a seat is reserved. The game sends it with `startGame({ authToken })`. A refused join fails `startGame` with the
246
+ reason named in the message (see [Troubleshooting](#troubleshooting)). A player reconnecting within the reconnect grace
247
+ period resumes its seat and its original identity without a new check.
248
+
249
+ **Shared secret** — a fixed list of accepted tokens, compared in constant time. Good for private builds and
250
+ server-to-server games:
251
+
252
+ ```sh
253
+ GAME_SERVER_AUTH_ENABLED=true GAME_SERVER_AUTH_MODE=shared-secret GAME_SERVER_AUTH_SHARED_SECRET_TOKENS=token-one,token-two babylon-game-server
254
+ ```
255
+
256
+ The identity is `{ userId: null, claims: { method: "shared-secret", keyIndex } }`.
257
+
258
+ **JWT** — tokens signed by your identity provider (Auth0, Cognito, Firebase, Clerk, your own) or by your backend with
259
+ an HMAC secret. The identity is `{ userId: <sub>, claims: <the token's payload> }`. An expired token is refused with
260
+ `AuthenticationTicketExpired`.
261
+
262
+ A walk-through with a JWKS (the provider's public keys):
263
+
264
+ 1. Your provider publishes its keys at a JWKS URL, for example `https://login.example.com/.well-known/jwks.json`, and
265
+ signs tokens with issuer `https://login.example.com/` and an audience you choose, for example `my-game`.
266
+ 2. Configure the server with that URL and the claims it must check:
267
+ ```js
268
+ // babylon-game-server.config.mjs
269
+ export default {
270
+ auth: {
271
+ enabled: true,
272
+ mode: "jwt",
273
+ jwt: { jwksUrl: "https://login.example.com/.well-known/jwks.json", issuer: "https://login.example.com/", audience: "my-game", algorithms: ["RS256"] }
274
+ }
275
+ };
276
+ ```
277
+ The keys are fetched on the first join and cached for 10 minutes. A token signed by an unknown key refetches them,
278
+ at most once every 30 seconds.
279
+ 3. The game signs the player in with the provider, then passes the provider's access token:
280
+ `startGame({ gameMode, sessionName, authToken: accessToken })`.
281
+ 4. The host's `onConnectRequest` reads `request.authIdentity.userId` (the token's `sub`) and any claim it needs, for
282
+ example a display name or a team.
283
+
284
+ With an HMAC secret instead (your backend signs `HS256` tokens):
285
+
286
+ ```sh
287
+ GAME_SERVER_AUTH_ENABLED=true GAME_SERVER_AUTH_MODE=jwt GAME_SERVER_AUTH_JWT_SECRET="$(openssl rand -hex 32)" GAME_SERVER_AUTH_JWT_ISSUER=my-backend babylon-game-server
288
+ ```
289
+
290
+ ```js
291
+ // Your backend, with jose: a 1-hour player token.
292
+ import { SignJWT } from "jose";
293
+ const secret = new TextEncoder().encode(process.env.GAME_SERVER_AUTH_JWT_SECRET);
294
+ const token = await new SignJWT({ name: "Ada" }).setProtectedHeader({ alg: "HS256" }).setSubject("player-42").setIssuer("my-backend").setExpirationTime("1h").sign(secret);
295
+ ```
296
+
297
+ **Webhook** — your service approves each player. The server POSTs JSON
298
+ `{ token, kind, sessionName, gameMode, clientAddress, origin }` (`kind` is `"session"` or `"lobby"`) to
299
+ `GAME_SERVER_AUTH_WEBHOOK_URL` and waits up to `GAME_SERVER_AUTH_WEBHOOK_TIMEOUT_MS`. A 2xx answer with a JSON object
300
+ admits the player with the identity `{ userId: body.userId ?? null, claims: body }`. Anything else refuses it.
301
+
302
+ **Custom** — your own verifier, when you start the server from code:
303
+
304
+ ```ts
305
+ await createGameServer({
306
+ config: { auth: { enabled: true, mode: "custom" } },
307
+ hooks: {
308
+ async verifyToken(request) {
309
+ const player = await lookUpSession(request.token); // your code
310
+ return (player != null) ? { userId: player.id, claims: { name: player.name } } : null; // null refuses
311
+ }
312
+ }
313
+ });
314
+ ```
315
+
316
+ A verifier may also throw `new AuthRefusal("InvalidAuthentication", "<detail>")` to choose the reason. With
317
+ `NODE_ENV=production`, auth off and a bind address other machines can reach, the server logs one warning at startup.
318
+
319
+ ## Discord sign-in
320
+
321
+ ### What it does
322
+
323
+ Players can sign in with their Discord account. The server runs Discord's website OAuth2 flow (authorization code with
324
+ PKCE) and asks for the `identify` scope only, so it learns a player's Discord id, username, display name and avatar,
325
+ never their email or servers. Discord's own tokens are used once to read the profile and then dropped. The server then
326
+ issues its own session token, which lasts `discordSignIn.sessionDays` (30 days by default) and needs no Discord call
327
+ afterwards. Sign-in is optional: players who never sign in play exactly as before. The server shows no Discord presence.
328
+
329
+ ### Setting it up
330
+
331
+ 1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications).
332
+ 2. Under **OAuth2 → Redirects**, add each server's callback address exactly, for example
333
+ `https://game.example.com/game-server/discord/callback` (and `http://localhost:2567/game-server/discord/callback`
334
+ for local testing). Discord accepts only exact matches.
335
+ 3. Copy the application's client id and client secret, and set the six options:
336
+
337
+ | Option | Environment variable | Value |
338
+ |---|---|---|
339
+ | `discordSignIn.enabled` | `GAME_SERVER_DISCORD_SIGN_IN_ENABLED` | `true` to turn sign-in on (default `false`). |
340
+ | `discordSignIn.clientId` | `GAME_SERVER_DISCORD_CLIENT_ID` | The application's client id. |
341
+ | `discordSignIn.clientSecret` | `GAME_SERVER_DISCORD_CLIENT_SECRET` | The application's client secret. Secret: never logged. |
342
+ | `discordSignIn.redirectUri` | `GAME_SERVER_DISCORD_REDIRECT_URI` | This server's callback, exactly as registered in step 2. |
343
+ | `discordSignIn.returnOrigins` | `GAME_SERVER_DISCORD_RETURN_ORIGINS` | Comma-separated game origins a sign-in may return to, e.g. `https://game.example.com`. |
344
+ | `discordSignIn.sessionDays` | `GAME_SERVER_DISCORD_SESSION_DAYS` | Days a sign-in lasts, 1–365 (default `30`). |
345
+
346
+ ```sh
347
+ GAME_SERVER_DISCORD_SIGN_IN_ENABLED=true \
348
+ GAME_SERVER_DISCORD_CLIENT_ID=<your client id> \
349
+ GAME_SERVER_DISCORD_CLIENT_SECRET=<your client secret> \
350
+ GAME_SERVER_DISCORD_REDIRECT_URI=https://game.example.com/game-server/discord/callback \
351
+ GAME_SERVER_DISCORD_RETURN_ORIGINS=https://game.example.com \
352
+ babylon-game-server
353
+ ```
354
+
355
+ With sign-in on, the server refuses to start until the client id, the secret, a callback address ending in
356
+ `/game-server/discord/callback` and at least one plain origin (`scheme://host[:port]`, no path) are set.
357
+ 4. Keep the client secret in your host's environment or secret store only, never in the config file or the game.
358
+ 5. In the Discord application, set the **Privacy Policy URL** to your game's privacy page.
359
+
360
+ `GET /game-server/info` answers `discordSignIn: true` once it is on, so a game can show its sign-in button only then.
361
+ The session signing key is created on the first start and kept in the admin data (`admin.dataDir` or Redis), so every
362
+ instance and every restart accept the same sessions. If that data is lost, players simply sign in again.
363
+
364
+ ### How it works with player auth
365
+
366
+ The game sends the session token as its auth token, on joins (`startGame({ authToken })`) and on leaderboard calls
367
+ (`Authorization: Bearer`). A token issued by this server's Discord sign-in is recognised first and verified by the
368
+ server itself. Any other token goes to the configured auth mode, or the player is anonymous when auth is off. So Discord
369
+ sign-in works beside every mode, and anonymous play stays exactly what each mode allows. An expired session is refused
370
+ with `AuthenticationTicketExpired` and a tampered one with `InvalidAuthentication`; the player signs in again.
371
+
372
+ The identity a host sees is `{ userId: "discord:<id>", claims }`, where the claims are the session token's payload:
373
+
374
+ | Claim | Value |
375
+ |---|---|
376
+ | `sub` | `discord:<Discord user id>` |
377
+ | `name` | The Discord display name (the global name, else the username) |
378
+ | `avatar` | The Discord avatar hash, or `null` for the default avatar |
379
+ | `provider` | `"discord"` |
380
+ | `name_managed` | `true`: the name comes from the sign-in, so the player cannot rename themselves |
381
+
382
+ The console's token tester and key tools test the configured auth mode only.
383
+
384
+ ### Routes
385
+
386
+ All four answer 404 `{ reason: "discordSignInOff" }` while sign-in is off.
387
+
388
+ | Method | Path | Answer |
389
+ |---|---|---|
390
+ | GET | `/game-server/discord/start?returnTo=<url>` | 302 to Discord's consent page (Discord skips it for a player who already approved). `returnTo` must be an absolute http(s) address on one of `returnOrigins`, with no query, fragment or user info; anything else answers 400 `{ reason: "returnNotAllowed" }` and redirects nowhere. Sets a short-lived `bt_discord_return` cookie (10 minutes, `HttpOnly`, `SameSite=Lax`). |
391
+ | GET | `/game-server/discord/callback` | Discord's redirect back. 302 to `returnTo?code=<code>` on success, or to `returnTo?signin=cancelled` (the player pressed Cancel), `signin=busy` (Discord is rate limiting or down) or `signin=failed` (an expired, replayed or tampered sign-in, or Discord refused). Without a usable sign-in or cookie it answers a 400 text page. |
392
+ | POST | `/game-server/discord/session` | Body `{ code, playerKey? }`, sent from the game's origin. Trades the single-use code (valid 2 minutes) for `{ session: { token, expiresAt, userId, discordId, displayName, avatarHash }, merged }`. 400 `invalidRequest`, 401 `handoffInvalid` (unknown, used or expired code), 403 `originNotAllowed` (another origin). |
393
+ | GET | `/game-server/discord/session-players?sessionName=<name>` | `{ players: [{ playerId, userId, displayName, avatar }] }`: the players of a running session who joined signed in with Discord, so every page can mark verified names. Typed names are never listed. `{ players: [] }` for an unknown session; 400 without a name; 403 for an origin outside `limits.allowedOrigins`. |
394
+
395
+ ### Leaderboards
396
+
397
+ A signed-in player's leaderboard player is `user:discord:<id>`, named with their Discord display name.
398
+
399
+ - **First sign-in merge.** When the exchange sends the browser's anonymous player key (`playerKey`) and the Discord
400
+ player does not exist yet, the anonymous player's entries move onto the new Discord player once, by each board's keep
401
+ policy, and the anonymous player is erased (`merged: true`). An existing Discord player is never merged into: the
402
+ anonymous entries stay where they are, and the Discord player's name and avatar are refreshed from the sign-in.
403
+ - **Avatars.** Public `entries`, `around` and `players` reads carry each Discord player's `avatar` (the hash; `null`
404
+ for everyone else).
405
+ - **Names.** Renaming (`POST players/me/name`) answers 403 `{ reason: "nameManaged" }` for a signed-in player. Delete My
406
+ Data (`POST players/me/delete`) works with the session, as it does with an anonymous key.
407
+
408
+ ## Enabling UDP
409
+
410
+ With UDP on, the server also accepts WebTransport over HTTP/3 on the same port number (UDP beside TCP). Frames on
411
+ `RpcChannel.Unreliable` — snapshots, inputs, acks and unreliable RPCs — then travel as datagrams, so a lost packet no
412
+ longer holds up the ones behind it. Reliable frames, and frames over 1000 bytes, keep the reliable stream. Players
413
+ whose browser or network cannot use WebTransport connect over WebSocket and play in the same session.
414
+
415
+ ```sh
416
+ babylon-game-server --tls --tls-cert-file fullchain.pem --tls-key-file privkey.pem --udp
417
+ # Babylon Game Server 9.30.1 listening on wss://localhost:2567
418
+ # HTTP: https://localhost:2567
419
+ # WebTransport: https://localhost:2567
420
+ # TLS: on (files) UDP: on (WebTransport on https://localhost:2567, UDP 2567) Auth: off Redis: off
421
+ ```
422
+
423
+ Requirements:
424
+
425
+ - **TLS on the server itself.** HTTP/3 always uses TLS, and the QUIC connection must end at the server, so a proxy that
426
+ terminates TLS in front of it cannot carry UDP.
427
+ - **The port open for UDP as well as TCP**: firewall, cloud security group, `docker run -p 443:443/tcp -p 443:443/udp`.
428
+ - **A host that forwards inbound UDP.** VMs and bare metal (AWS EC2, DigitalOcean Droplets, Hetzner and similar),
429
+ Kubernetes with a UDP-capable load balancer, and AWS ECS behind a Network Load Balancer with `TCP_QUIC` listeners do.
430
+ Fly.io, Railway, Render, DigitalOcean App Platform and Heroku do not; there the server runs WebSocket only.
431
+ - **A browser with WebTransport**: Chrome and Edge 97+, Firefox 115+, Safari 26.4+. Others fall back to WebSocket.
432
+ - **The optional native package `@colyseus/h3-transport`**, installed automatically where it builds. Where it does not,
433
+ startup stops with `UDP needs @colyseus/h3-transport, which is not installed on this platform`.
434
+
435
+ **Local development.** `--tls --tls-dev-cert --udp` works on one machine without a real certificate: HTTPS and WSS use
436
+ the mkcert certificate, and HTTP/3 uses its own 10-day self-signed certificate whose SHA-256 hash the server sends at
437
+ matchmaking and in `/game-server/info`, so the browser pins it (browsers refuse WebTransport to locally trusted roots
438
+ such as mkcert's). Without mkcert that pinned certificate serves HTTPS too. HTTP/3 binds every IPv6 and IPv4 interface
439
+ (`::`) when the machine has IPv6, because browsers resolve `localhost` to `::1` first.
440
+
441
+ ## Abuse limits
442
+
443
+ These are on by default and checked at matchmaking. Every refusal is counted in
444
+ `babylon_game_server_refusals_total{reason}` and logged.
445
+
446
+ | Limit | Option | Default | Refusal |
447
+ |---|---|---|---|
448
+ | Browser origins allowed to join (empty: any) | `limits.allowedOrigins` | empty | 403 `ConnectionRefused` |
449
+ | Joins with no `Origin` header (native clients, tools) | `limits.allowMissingOrigin` | `true` | 403 `ConnectionRefused` |
450
+ | Join requests per client address per minute | `limits.joinRequestsPerMinutePerAddress` | `120` | 429 `ConnectionRefused` |
451
+ | Sessions per process | `limits.maxSessions` | `1000` | 503 `MaxCcuReached` |
452
+ | Players per session (larger requests are clamped) | `limits.maxPlayersPerSession` | `255` | — |
453
+ | Dedicated sessions per process | `limits.maxDedicatedSessions` | `4` | 503 `MaxCcuReached` |
454
+ | Largest WebSocket message, in bytes | `limits.maxMessageBytes` | `65536` | the connection closes |
455
+ | Messages per client per second | `limits.maxMessagesPerSecond` | `2000` | the client is disconnected |
456
+ | Session name length; control characters are refused | `limits.maxSessionNameLength` | `128` | 400 `InvalidArguments` |
457
+
458
+ `allowedOrigins` also sets the CORS answer, so a browser on another origin cannot call the server at all. Set it to your
459
+ game's origins in production, for example `GAME_SERVER_ALLOWED_ORIGINS=https://game.example.com`.
460
+
461
+ The client address is the socket's own address. Behind your own reverse proxy, set `trustProxy` so the server reads
462
+ `X-Forwarded-For` / `X-Real-IP` instead; never set it on a server clients reach directly, or any client could choose
463
+ its address.
464
+
465
+ ## Endpoints
466
+
467
+ Every endpoint answers on the server's port, over HTTP and HTTPS alike, with CORS.
468
+
469
+ | Method | Path | Answer |
470
+ |---|---|---|
471
+ | GET | `/game-server/health` | 200 `{ "status": "ok" }` while the process runs (liveness). |
472
+ | GET | `/game-server/ready` | 200 `{ "ready": true }`, or 503 `{ "ready": false, "reasons": [...] }` while draining, when Redis does not answer, or before it listens (readiness). |
473
+ | GET | `/game-server/info` | `{ serverName, serverVersion, protocolVersion, transports: { webSocket, webTransport: { port, certificateHash } \| null }, authRequired, discordSignIn }`. Games read it before joining. |
474
+ | GET, POST | `/game-server/leaderboards/*` | The [leaderboards public API](#public-api): boards, rankings, a player's own entries and score submissions. |
475
+ | GET | `/game-server/discord/start` | [Discord sign-in](#discord-sign-in): 302 to Discord, or 400 `returnNotAllowed`. 404 while sign-in is off (all four Discord routes). |
476
+ | GET | `/game-server/discord/callback` | Discord's redirect back: 302 to the game with `code=` or `signin=cancelled\|busy\|failed`. |
477
+ | POST | `/game-server/discord/session` | `{ code, playerKey? }` → `{ session, merged }`; 401 `handoffInvalid`, 403 `originNotAllowed`. |
478
+ | GET | `/game-server/discord/session-players?sessionName=` | `{ players: [{ playerId, userId, displayName, avatar }] }`: the verified Discord players of a session. |
479
+ | GET | `/metrics` | Prometheus text. 404 when `metrics.enabled` is off; 403 without the admin token when `metrics.requireAdminToken` is on. |
480
+ | GET | `/game-server/admin/sessions` | Admin: every session `{ roomId, sessionName, gameMode, players, maxPlayers, isDedicated }` (every instance when scaled out). |
481
+ | POST | `/game-server/admin/sessions/:roomId/close` | Admin: disconnects that session's players. 200 `{ closed }`, 404 when there is no such session. |
482
+ | POST | `/game-server/admin/dedicated` | Admin: body `{ sceneName, sessionName }` starts a dedicated session of a configured scene. 201 `{ roomId }`; 400, 404 (unknown scene) or 503 (it did not start). |
483
+ | POST | `/game-server/admin/drain` | Admin: starts a drain without exiting the process. 202 `{ draining: true }`. |
484
+
485
+ These four legacy admin endpoints exist only when `admin.token` is set; without it they answer 404. Admin calls send
486
+ `Authorization: Bearer <token>`; a missing or wrong token answers 401. They keep their paths and answers; the
487
+ [admin console](#admin-console) and its API at `/game-server/admin/api/` do everything they do and more, with accounts,
488
+ roles and API keys.
489
+
490
+ ```sh
491
+ curl -H "Authorization: Bearer $GAME_SERVER_ADMIN_TOKEN" https://game.example.com/game-server/admin/sessions
492
+ ```
493
+
494
+ ## Admin console
495
+
496
+ The server ships a web console at `/game-server/admin/` for the people who run it: the fleet overview and its
497
+ instances, sessions and players (kick, notices, bans), maintenance mode and drains, dedicated sessions, metrics charts,
498
+ live logs, live-editable limits, auth tools, alerts with webhook, Slack, Discord, Teams and PagerDuty channels, users,
499
+ API keys, an audit log of every action, your own [custom actions and panels](CUSTOM.md#custom-admin-actions-panels-and-admin-hooks),
500
+ and these docs. It runs inside the server process on the server's own port; there is nothing extra to install or
501
+ deploy. Every page is also an HTTP API at `/game-server/admin/api/`, which scripts call with an API key.
502
+
503
+ The console is on (`admin.console`, default `true`) whenever an admin account exists or a first-owner setup is pending.
504
+ With no account, no pending setup and no `admin.token`, every admin path answers 404.
505
+
506
+ ### The first Owner
507
+
508
+ On the first start with no admin account, the server logs a one-time setup link at `warn`:
509
+
510
+ ```text
511
+ [GameServer] admin setup link url=http://localhost:2567/game-server/admin/#/setup?token=…
512
+ ```
513
+
514
+ Open it within 30 minutes (it works once) to create the first Owner: a username, a display name and a password of at
515
+ least 12 characters that is not a common password and does not contain the username. Then scan the QR code with an
516
+ authenticator app (or type its key), confirm a code, and keep the ten recovery codes it shows. Behind a TLS edge, open
517
+ the link's path on your HTTPS address. With several processes on Redis, each logs its own link and any one works through
518
+ the load balancer; the first Owner ends them all.
519
+
520
+ Or create the Owner from the command line, where the server's data is (the same `--config`, Redis or data folder):
521
+
522
+ ```sh
523
+ GAME_SERVER_OWNER_PASSWORD='…' npx babylon-game-server admin create-owner --username ada --display-name "Ada" --password-env GAME_SERVER_OWNER_PASSWORD
524
+ ```
525
+
526
+ The new Owner enrols an authenticator at the first sign-in.
527
+
528
+ ### Signing in
529
+
530
+ Every account signs in with two factors: its password, then a six-digit authenticator code (TOTP), a passkey, or one of
531
+ its recovery codes. A passkey can also sign in without the password when `admin.passkeys.enabled` is on (the default).
532
+ With `admin.oidc.*` set, the sign-in page also offers OpenID Connect single sign-on (authorization code with PKCE);
533
+ roles come from `admin.oidc.roleClaim` through `admin.oidc.roleMapping`, and the account's own code is still asked
534
+ unless the identity provider reports multi-factor sign-in.
535
+
536
+ Ten failed sign-ins in 15 minutes lock an account (an Owner or Admin unlocks it, or `admin unlock`), and repeated
537
+ failures from one address are slowed down. A console session ends after `admin.sessionIdleMinutes` (30) idle minutes
538
+ or `admin.sessionMaxHours` (12) hours. Dangerous actions (users, API keys, fleet-wide maintenance and notices, drains,
539
+ rolling updates, closing every session, importing bans, alert channels, audit retention) ask for a fresh code, which
540
+ then covers 5 minutes.
541
+
542
+ ### Roles
543
+
544
+ Each account has one fixed role. Every role can view the overview, instances, sessions, players, dedicated sessions,
545
+ metrics, configuration, alerts and the Colyseus Cloud page except its billing.
546
+
547
+ | Role | What it adds |
548
+ |---|---|
549
+ | Owner | Everything, including other Owners and the audit log retention. Colyseus Cloud billing. |
550
+ | Admin | Everything except audit log retention; cannot grant or change the Owner role. Colyseus Cloud billing. |
551
+ | Operator | Fleet (maintenance, drains, rolling updates, schedules), close and lock sessions, dedicated sessions, live config edits, alerts, log levels, logs, auth status, the audit log, the user list, player notes, custom actions. |
552
+ | Moderator | Players (addresses, kick, notices, lobby kick), bans, close and lock sessions, player notes, logs, auth status, the audit log, the user list, custom actions. |
553
+ | Developer | The session inspector, dedicated sessions, logs, auth status, the audit log, the user list, bans (read), player addresses, developer custom actions. |
554
+ | Viewer | Read-only pages only. |
555
+
556
+ Only an Owner may create, change or remove another Owner, and the last enabled Owner cannot be removed, demoted,
557
+ disabled or locked. The permission names (`ADMIN_PERMISSIONS`) and each role's set (`ROLE_PERMISSIONS`) are exported
558
+ from the package.
559
+
560
+ ### API keys and the legacy token
561
+
562
+ For scripts and CI, create an API key on the console's API keys page or with
563
+ `babylon-game-server admin create-api-key --name ci --permissions sessions.view,players.manage [--expires-days 90]`.
564
+ A key looks like `bgsk_<id>_<secret>`, is shown once, holds only the permissions you give it, and can expire or be
565
+ limited to networks. Only its hash is stored. Send it as a bearer token:
566
+
567
+ ```sh
568
+ curl -H "Authorization: Bearer $GAME_SERVER_API_KEY" https://game.example.com/game-server/admin/api/sessions
569
+ ```
570
+
571
+ The configured `admin.token` is accepted by the API too, as a key named "legacy admin token" with every permission
572
+ except managing users and the audit log retention. Every change, by key, token or account, is in the audit log.
573
+
574
+ ### Command line
575
+
576
+ `babylon-game-server admin <command> [--config <file>] [--redis-url <url>] [--admin-data-dir <folder>]` works on the
577
+ same store as the server, running or not, and is the way back in when nobody can sign in:
578
+
579
+ | Command | What it does |
580
+ |---|---|
581
+ | `list-users` | Lists the accounts with their roles and state. |
582
+ | `create-owner --username <name> [--display-name <name>] [--password-env <VARIABLE>]` | Creates an Owner (asks for the password on a terminal). |
583
+ | `reset-password --username <name> [--password-env <VARIABLE>]` | Sets a new password and ends the account's sessions. |
584
+ | `reset-second-factor --username <name>` | The account enrols a new authenticator at its next sign-in. |
585
+ | `unlock --username <name>` | Unlocks a locked account. |
586
+ | `end-sessions --username <name>` | Signs the account out everywhere. |
587
+ | `create-api-key --name <name> --permissions <comma list> [--expires-days <days>]` | Creates an API key and prints it once. |
588
+ | `revoke-api-key --key-id <id>` | Revokes an API key. |
589
+
590
+ Every command is written to the audit log.
591
+
592
+ ### Network restriction and HTTPS
593
+
594
+ - The console and its API refuse plain HTTP with 403 `the console needs HTTPS`, except from a loopback address
595
+ (`127.0.0.1`, `::1`). Serve them over TLS on the server, or behind a TLS edge with `trustProxy` on (the edge's
596
+ `x-forwarded-proto: https` is trusted only then).
597
+ - `admin.allowedNetworks` (`GAME_SERVER_ADMIN_ALLOWED_NETWORKS`, addresses or CIDR ranges) limits who can reach the
598
+ console and its API at all; other addresses get 404. With `trustProxy` on, the address is the forwarded one.
599
+ - Console pages send a strict content security policy, `x-frame-options: DENY` and no referrer. The session cookie is
600
+ `HttpOnly` and `SameSite=Strict`, and every change carries a CSRF header.
601
+
602
+ ### Where the data lives
603
+
604
+ Accounts, console sessions, API keys, bans, the audit log, alert rules, console config overrides and player notes live
605
+ in one admin store:
606
+
607
+ - **Redis**, when `redis.url` is set: every instance shares it, so the console sees and manages the whole fleet from
608
+ any instance. Turn on Redis persistence (append-only file or your provider's backups).
609
+ - **Files** in `admin.dataDir` (`GAME_SERVER_ADMIN_DATA_DIR`, default `./babylon-game-server-data`) otherwise: one JSON
610
+ file per collection and a JSON-lines file per log stream, written atomically. The Docker image sets it to `/data`
611
+ and declares a volume there: mount one (`-v game-server-data:/data`), or the data goes with the container.
612
+
613
+ The audit log is append-only with a hash chain the console can verify; it keeps 365 days by default (an Owner changes
614
+ it).
615
+
616
+ The [leaderboards](#storage-and-durability) keep their scores in the same Redis or data folder, and the console warns
617
+ when that storage does not survive a restart.
618
+
619
+ ### Security advice
620
+
621
+ - Serve the console over HTTPS only, and limit `admin.allowedNetworks` to your office or VPN, or block
622
+ `/game-server/admin/` at the edge and reach it from inside your network.
623
+ - Give each person their own account with the lowest role that does their job; use API keys with only the permissions
624
+ a script needs, and an expiry. Keep `admin.token` only while old scripts need it.
625
+ - Keep the recovery codes somewhere safe; the CLI resets a lost factor (`reset-second-factor`).
626
+ - Back up the data folder or Redis: it holds the bans, the audit log and the leaderboards.
627
+ - Treat the setup link like a password; it is valid for 30 minutes and works once.
628
+
629
+ ### Colyseus Cloud page
630
+
631
+ When the server runs on Colyseus Cloud, or has a Colyseus Cloud API token anywhere else, the console's **Colyseus
632
+ Cloud** page shows the application as Colyseus reports it: each location and instance and their status, each host
633
+ process's status, restarts and Node.js version (silent instances flagged), charts of the last 30 days, traffic per day,
634
+ recent deploys with their build output, an instance's latest host log lines (startup failures and crashes included), and
635
+ the billing summary. Live players, rooms, CPU and memory stay on **Instances**, the last 24 hours on **Metrics**, and the
636
+ server's own log lines on **Logs**.
637
+
638
+ 1. In the Colyseus Cloud dashboard, open **API tokens** and create a token. It is read-only and covers the whole team.
639
+ 2. Set `GAME_SERVER_ADMIN_COLYSEUS_CLOUD_API_TOKEN` to it (on Colyseus Cloud, on the application's environment
640
+ variables; `COLYSEUS_CLOUD_TOKEN` works too). If the team has more than one application, also set
641
+ `GAME_SERVER_ADMIN_COLYSEUS_CLOUD_APPLICATION` to the slug of the one this server runs, for example `1234-my-game`.
642
+ 3. Restart or redeploy.
643
+
644
+ The server reads the Cloud API itself: one process reads it for all of them, no faster than Colyseus refreshes each
645
+ figure, and the token never reaches a browser. Log lines and deploy output are read only when you ask, and Colyseus
646
+ allows two log requests a minute for the whole team. Every role can view the page; billing needs Owner or Admin, and
647
+ logs need a role that can view logs. When Colyseus is busy or unreachable, each part keeps its last figures and says
648
+ why they are not current.
649
+
650
+ ### Leaderboards page
651
+
652
+ The **Leaderboards** section, in the Players group after Bans, runs the [built-in leaderboards](#leaderboards). Every
653
+ role can view it (`leaderboards.view`); Owner, Admin, Operator and Moderator can change it (`leaderboards.manage`).
654
+
655
+ - A status strip above every tab shows scores accepted and refused per minute across the fleet, the storage (Redis or
656
+ files), a warning when it is not durable or a note when that is unknown (with what to change), and any problem with
657
+ the definitions file.
658
+ - **Boards** lists every board. Create, edit, duplicate and delete boards here; a board whose current season has entries refuses a change of sort order, score format or keep rule
659
+ unless the edit starts a new season.
660
+ - **A board** shows one ranking at a time: pick the season and the variant, search players, and add an entry, correct a
661
+ score or remove one (each with a reason), or restore a removed entry. Reset the season (with a reason) or delete an
662
+ archived one. Export CSV downloads the ranking shown.
663
+ - **Players** lists every leaderboard player: rename, hide (their entries leave every ranking and their new scores are
664
+ discarded until they are shown again), merge one player into another (after a lost key, say), or erase all of a
665
+ player's data.
666
+ - **Activity** lists every score the server accepted, recognised as a duplicate delivery, or refused, with the reason,
667
+ newest first. Client addresses show only to roles that can view player addresses.
668
+ - **Settings** holds the fleet-wide abuse limits and blocked words, and the backups: export every board, season, entry
669
+ and player as `leaderboards.json`, and import a backup or a definitions file after a preview.
670
+
671
+ The **Metrics** page charts accepted and refused scores. Deleting a board, resetting or deleting a season, merging or
672
+ erasing a player and a real (not preview) import ask for a fresh code. Every change is in the audit log under
673
+ `leaderboard.*` actions.
674
+
675
+ ### Help inside the console
676
+
677
+ The console's Documentation page shows this README, [DEPLOY.md](DEPLOY.md) and [CUSTOM.md](CUSTOM.md) from the
678
+ installed package, and each page's help panel (the `?` key) links to the section that explains it.
679
+
680
+ ## Leaderboards
681
+
682
+ Every server ranks the scores games send it, with no code and no extra service: boards, seasons, trust levels, abuse
683
+ limits, a [console page](#leaderboards-page) and backups. Leaderboards are on by default. Games talk to them over HTTP
684
+ at `/game-server/leaderboards/` (the toolkit's `TOOLKIT.LeaderboardClient` does it for you), so a single-player menu
685
+ needs no network session. Clients never create boards: they come from the [definitions file](#the-definitions-file) or
686
+ the console.
687
+
688
+ ### Boards and seasons
689
+
690
+ A board ranks one kind of score. Its definition sets:
691
+
692
+ | Field | Meaning |
693
+ |---|---|
694
+ | `boardId` | Stable id, lower-case letters, digits and `-` (`/^[a-z0-9][a-z0-9-]{0,63}$/`). |
695
+ | `displayName`, `description` | Shown to players and in the console (1–80 and 0–500 characters). |
696
+ | `sortOrder` | `higherIsBetter` (points, counters) or `lowerIsBetter` (times). |
697
+ | `scoreFormat` | `integer`, `points`, or `timeMilliseconds` (shown as a time). Scores are always whole numbers; times are milliseconds. |
698
+ | `keepPolicy` | `best` (keep a player's best score), `latest` (always replace) or `sum` (add every score: a running total). |
699
+ | `minimumScore`, `maximumScore` | Limits for each submitted score, or `null`. On a `sum` board they apply to each score, not to the total. |
700
+ | `acceptedTrustLevels` | Which [trust levels](#players-and-trust-levels) the board accepts: any of `client`, `session`, `server`. |
701
+ | `visibility` | `public`, or `hidden`: a hidden board is absent from the public API and refuses scores. |
702
+ | `resetSchedule` | `never`, `manual`, `daily`, `weekly` or `monthly` (UTC). |
703
+ | `dimensions` | Up to 4 ways to split the rankings, each `{ name, label, values }` with 1–100 allowed values. |
704
+
705
+ **Variants.** A board with dimensions keeps one ranking per combination of values: `arena-high-score` split by `map`
706
+ and `difficulty` has a ranking for `harbor` + `hard`, another for `canyon` + `normal`, and so on. A score names every
707
+ dimension with an allowed value (`variant: { map: "harbor", difficulty: "hard" }`); anything else is refused
708
+ `variantNotAllowed`. A board without dimensions has one ranking.
709
+
710
+ **Ranking.** Better scores rank first; a tie goes to the score reached first. Ranks start at 1. Each score answers with
711
+ the player's new rank, the ranking's size, and whether it was a personal best.
712
+
713
+ **Seasons.** Every board starts at season 1. A reset, scheduled or from the console, archives the current season and
714
+ starts the next one empty. Archived seasons stay readable (`?season=<id>`) until the console deletes them; the current
715
+ season cannot be deleted. `daily` resets at 00:00 UTC, `weekly` on Monday 00:00 UTC and `monthly` on the 1st at 00:00
716
+ UTC; with several processes exactly one of them runs the reset. `manual` boards reset only from the console, and a
717
+ `never` board refuses resets until its schedule changes.
718
+
719
+ ### Players and trust levels
720
+
721
+ **Players.** When the server's [authentication](#enabling-authentication) yields a user id (jwt, webhook or custom), the
722
+ player is that user (`user:<userId>`), named from the token. Otherwise the game creates an anonymous player once (`POST
723
+ players`), which answers a one-time `playerKey`. The game keeps it on the device and sends it in the JSON body of every
724
+ request that names the player, never in a header or URL. The server stores only a hash of the key. A lost key means a
725
+ new player; the console can merge the two. Players choose a display name of up to 24 characters (`POST
726
+ players/me/name`); names with a blocked word are refused. A player can erase their own data (`POST
727
+ players/me/delete`).
728
+
729
+ **Trust levels.** The server decides how far each score can be trusted; a client cannot claim a level:
730
+
731
+ | Level | When |
732
+ |---|---|
733
+ | `client` | A score from a game client with no session proof. Fine for single-player boards. |
734
+ | `session` | A public submission whose `sessionName` names a session the player (their address, or their auth user) joined within the last 6 hours, early enough for the score: on a `timeMilliseconds` board the join must be at least the score's time before the submission, give or take 5 seconds. |
735
+ | `server` | Only `POST /game-server/admin/api/leaderboards/submit`, called with an API key holding `leaderboards.submit` (for example from a dedicated session or your own backend). |
736
+
737
+ Each board lists the levels it accepts; a score at any other level is refused `trustLevelNotAccepted`. A board of
738
+ network results accepts `["session", "server"]`; a single-player board accepts all three.
739
+
740
+ **Moderation.** Bans refuse every leaderboard request from a banned player or address. A hidden player's entries leave
741
+ every ranking, and their new scores are discarded quietly (the game sees an accepted score with no rank) until the
742
+ console shows them again.
743
+
744
+ ### Signed-in players only
745
+
746
+ Set `leaderboards.signedInPlayersOnly` (`GAME_SERVER_LEADERBOARDS_SIGNED_IN_PLAYERS_ONLY`,
747
+ `--leaderboards-signed-in-players-only`) to `true` to keep anonymous names off the boards. Guests can still play,
748
+ networked sessions included; only their scores stay off.
749
+
750
+ - **What it refuses.** A public `POST submissions` whose verified identity has no user id — the shared-secret token,
751
+ or a server with authentication off — answers 403 `{ reason: "signInRequired" }` and writes nothing. A
752
+ [Discord sign-in](#discord-sign-in) session, or a jwt, webhook or custom auth user, submits as before. Server-trust
753
+ submissions (`POST /game-server/admin/api/leaderboards/submit` with an API key) and entries added on the console are
754
+ not affected.
755
+ - **The start-up sweep.** On every start with the option on, each anonymous player's (`anon-…`) live entry, in every
756
+ season of every board, moves to its ranking's Removed list (reason `anonymousPlayer`, removed by `server`). Ranks and
757
+ totals close up at once on every read; nothing is deleted and player records are untouched. The sweep is safe to
758
+ repeat and to run on several instances: a second start finds nothing left to move.
759
+ - **Restoring an entry.** Open the board on the console's Leaderboards page, pick the season and variant, and restore
760
+ the entry from its Removed list. Turning the option off restores nothing by itself.
761
+
762
+ ### Submitting at key stages
763
+
764
+ Send scores at key stages only — the end of a match, a round or a level, the end of a multi-part run — never per
765
+ event, per kill or per frame. One stage is **one request**, carrying every board that stage updates: a match finish
766
+ sends its high score and its win counter together. That keeps requests few, inside the rate limits, and lets the
767
+ server check the whole run at once.
768
+
769
+ Every submission is the same envelope:
770
+
771
+ ```json
772
+ {
773
+ "playerKey": "anon-….<secret>",
774
+ "submissionId": "3f1c9a52-6f0e-4bd4-9d1e-0c2a7b4e8f10",
775
+ "gameMode": "arena",
776
+ "buildVersion": "1.4.0",
777
+ "sessionName": null,
778
+ "durationMs": 184000,
779
+ "clientTime": "2026-11-09T18:20:00.000Z",
780
+ "scores": [
781
+ { "boardId": "arena-high-score", "variant": { "map": "harbor", "difficulty": "hard" }, "score": 1840, "metadata": { "hero": "knight" } },
782
+ { "boardId": "arena-wins", "variant": null, "score": 1, "metadata": null }
783
+ ]
784
+ }
785
+ ```
786
+
787
+ - `submissionId` makes delivery exactly-once: a repeat of the same id within 7 days answers `duplicate` and writes
788
+ nothing, so a game can safely send again after a timeout.
789
+ - `durationMs` is how long the run lasted. On a `timeMilliseconds` board, a time longer than the run (plus 5 seconds)
790
+ is refused `implausibleDuration`.
791
+ - `sessionName` asks for the `session` trust level.
792
+ - `metadata` is display-only: free JSON (1024 bytes by default) shown next to the entry, never ranked, filtered or
793
+ queried. Put the ranked facts in the score and the variant.
794
+ - A submission carries at most 10 scores by default. Each answers `accepted` (with `created`, `improved`, `replaced`,
795
+ `added` or `kept`), `duplicate` or `refused` with its reason.
796
+
797
+ ### The definitions file
798
+
799
+ Point `leaderboards.definitionsFile` (`GAME_SERVER_LEADERBOARDS_DEFINITIONS_FILE`, `--leaderboards-definitions-file`)
800
+ at a JSON file in your repository, resolved against the working directory. Every server process reads it at start and
801
+ creates the boards that do not exist yet; a board that exists, created by the file before or by the console, is left
802
+ as it is, so console edits survive restarts. A missing or invalid file is logged as an error, the server starts anyway,
803
+ and the console's Boards page shows the problem.
804
+
805
+ ```json
806
+ {
807
+ "format": "babylon-game-server-leaderboard-definitions",
808
+ "version": 1,
809
+ "boards": [
810
+ {
811
+ "boardId": "arena-high-score", "displayName": "Arena High Score", "description": "Best score in one arena match.",
812
+ "sortOrder": "higherIsBetter", "scoreFormat": "points", "keepPolicy": "best",
813
+ "minimumScore": 0, "maximumScore": 1000000,
814
+ "acceptedTrustLevels": ["client", "session", "server"], "visibility": "public", "resetSchedule": "monthly",
815
+ "dimensions": [
816
+ { "name": "map", "label": "Map", "values": ["harbor", "canyon"] },
817
+ { "name": "difficulty", "label": "Difficulty", "values": ["normal", "hard"] }
818
+ ]
819
+ },
820
+ {
821
+ "boardId": "arena-wins", "displayName": "Arena Wins", "description": "Arena matches won.",
822
+ "sortOrder": "higherIsBetter", "scoreFormat": "integer", "keepPolicy": "sum",
823
+ "minimumScore": 1, "maximumScore": 1,
824
+ "acceptedTrustLevels": ["client", "session", "server"], "visibility": "public", "resetSchedule": "manual",
825
+ "dimensions": []
826
+ },
827
+ {
828
+ "boardId": "speedrun-time", "displayName": "Speedrun", "description": "Fastest full run.",
829
+ "sortOrder": "lowerIsBetter", "scoreFormat": "timeMilliseconds", "keepPolicy": "best",
830
+ "minimumScore": 1000, "maximumScore": 3600000,
831
+ "acceptedTrustLevels": ["client", "session", "server"], "visibility": "public", "resetSchedule": "never",
832
+ "dimensions": []
833
+ }
834
+ ]
835
+ }
836
+ ```
837
+
838
+ ```js
839
+ // babylon-game-server.config.mjs
840
+ export default { leaderboards: { definitionsFile: "./leaderboards.json" } };
841
+ ```
842
+
843
+ The console's backup export (`leaderboards.json` with every season, entry and player) is a different format; import it
844
+ from the console's Settings tab.
845
+
846
+ ### Storage and durability
847
+
848
+ Scores live where the admin store does: in Redis when `redis.url` is set (keys under `bgs:lb:`), otherwise in files
849
+ under `admin.dataDir/leaderboards/`. The console reports, never assumes, whether that storage survives a restart:
850
+
851
+ - **Files** are durable only when the data folder is persistent (a mounted volume). On a host without volumes, or a
852
+ container without one, every deploy loses the boards.
853
+ - **Redis** is durable when it keeps an append-only file or RDB snapshots. When the server cannot ask (`INFO` or
854
+ `CONFIG` restricted, as on some managed hosts), durability shows as unknown.
855
+
856
+ The Boards page shows a warning when the boards are not durable, a note when it cannot tell, and what to change. For
857
+ production, use a Redis with persistence (on Colyseus Cloud, an external `REDIS_URI`; see DEPLOY.md) or a mounted
858
+ volume, and export a backup from the console's Settings tab before risky changes.
859
+
860
+ ### Public API
861
+
862
+ Paths are under `/game-server/leaderboards/`. With the server's [authentication](#enabling-authentication) on, every
863
+ request sends `Authorization: Bearer <token>` (the token the game joins with). Requests that name a player are POSTs
864
+ carrying `playerKey` in the JSON body (omitted for an auth user); anonymous reads of public data are GETs. Bodies are
865
+ limited to 64 KB, browser origins follow `limits.allowedOrigins`, and each address may read 240 times a minute by
866
+ default.
867
+
868
+ | Method | Path | Body or query | Answer |
869
+ |---|---|---|---|
870
+ | POST | `players` | `{ playerKey? }` | `{ player, playerKey }`: creates an anonymous player when there is no auth user and no valid key (`playerKey` is non-null only then). |
871
+ | POST | `players/me` | `{ playerKey? }` | The player `{ playerId, displayName, kind, createdAt }`. |
872
+ | POST | `players/me/name` | `{ playerKey?, displayName }` | The renamed player. |
873
+ | POST | `players/me/delete` | `{ playerKey? }` | `{ erasedEntries }`: the player's entries and record are erased. |
874
+ | GET | `boards` | — | The public boards with their current season and next reset. |
875
+ | GET | `boards/:boardId/seasons` | — | The board's seasons. |
876
+ | GET | `boards/:boardId/entries` | `?variant=<JSON object>&season=<id>&offset=&limit=` | A page of one ranking `{ boardId, seasonId, variantKey, total, offset, entries }` (10 by default, at most 100). |
877
+ | POST | `boards/:boardId/around` | `{ playerKey?, variant, seasonId?, window? }` | The entries around the player (up to 10 each side), with `playerRank` (null without an entry). |
878
+ | POST | `boards/:boardId/players` | `{ variant, seasonId?, playerIds }` | Those players' entries (at most 50 ids), for a friends list. |
879
+ | POST | `entries/mine` | `{ playerKey?, targets: [{ boardId, variant, seasonId? }] }` | The player's entry in each ranking (at most 20), or null. |
880
+ | POST | `submissions` | The [submission envelope](#submitting-at-key-stages) | `{ submissionId, playerId, results }`, one result per score. |
881
+
882
+ A refused request answers JSON `{ error, reason }` with its HTTP status: 400 for a bad request (`invalidRequest`,
883
+ `tooManyScores`, `nameInvalid`, `nameBlocked`, …), 401 `authRequired`, `authRefused` or `invalidPlayer` (an unknown
884
+ player key), 403 `banned`, `signInRequired` (a submission without a signed-in player while
885
+ [`leaderboards.signedInPlayersOnly`](#signed-in-players-only) is on) or a disallowed origin, 404 `unknownBoard`, 429 `rateLimited`. A score refused inside a
886
+ submission is reported in its own result instead, beside the accepted ones. Limits
887
+ per minute (submissions per player and per address, reads per address), new players per hour per address, scores per
888
+ submission, metadata size and blocked words are fleet-wide and editable on the console's Settings tab.
889
+
890
+ ## Metrics
891
+
892
+ `GET /metrics` serves Prometheus metrics, all prefixed `babylon_game_server_`:
893
+
894
+ | Metric | Type | Labels |
895
+ |---|---|---|
896
+ | `players` | gauge | `game_mode` |
897
+ | `sessions` | gauge | `game_mode` |
898
+ | `dedicated_sessions` | gauge | — |
899
+ | `joins_total` | counter | `result` |
900
+ | `refusals_total` | counter | `reason` (the `ShutdownReason` name) |
901
+ | `auth_failures_total` | counter | `mode` |
902
+ | `frames_total` | counter | `direction` (`in`/`out`), `transport` (`websocket`/`webtransport`), `channel` (`reliable`/`unreliable`) |
903
+ | `bytes_total` | counter | `direction`, `transport` |
904
+ | `dedicated_tick_overruns_total` | counter | — |
905
+ | `dedicated_frame_seconds` | histogram | — |
906
+ | `leaderboard_scores_total` | counter | `result` (`accepted`, `duplicate`, `refused`) |
907
+ | `leaderboard_refusals_total` | counter | `reason` (the leaderboard refusal reason, for refused scores and refused requests) |
908
+
909
+ The Node.js process metrics (CPU, memory, event-loop lag, garbage collection) carry the same prefix.
910
+
911
+ ## Scaling with Redis
912
+
913
+ One process hosts many sessions; a session always lives in one process. To run several instances behind one address,
914
+ give every instance the same Redis and its own public address:
915
+
916
+ ```sh
917
+ GAME_SERVER_REDIS_URL=redis://redis.internal:6379 GAME_SERVER_PUBLIC_ADDRESS=game-1.example.com babylon-game-server
918
+ GAME_SERVER_REDIS_URL=redis://redis.internal:6379 GAME_SERVER_PUBLIC_ADDRESS=game-2.example.com babylon-game-server
919
+ ```
920
+
921
+ Matchmaking on any instance then finds every session, and a player joining a session on another instance is sent to
922
+ that instance's public address (`host[:port]`, reachable by players). The lobby lists every instance's sessions, and
923
+ the admin API sees and closes them. Readiness answers 503 while Redis does not answer. The Kubernetes recipe sets each
924
+ pod's public address from the pod IP.
925
+
926
+ Draining: on `SIGTERM` (or `POST /game-server/admin/drain`) the server stops taking joins (readiness 503, joins refused
927
+ with `GameClosed`), lets running sessions continue for up to `drainSeconds`, then tells the remaining players the
928
+ session closed (`GameClosed`) and exits. Give the platform's stop timeout a few seconds more than `drainSeconds`.
929
+
930
+ ## Dedicated sessions
931
+
932
+ A dedicated session runs a headless `TOOLKIT.NetworkRunner` in **Server** mode: it loads the same exported scene the
933
+ browsers load, on a Babylon.js `NullEngine` with Havok and no materials or textures, and simulates everything. Players
934
+ join with `GameMode.Client` and its session name.
935
+
936
+ Configure the scenes it may run, and the sessions it starts with:
937
+
938
+ ```js
939
+ // babylon-game-server.config.mjs
940
+ export default {
941
+ dedicated: {
942
+ scenes: [
943
+ { name: "arena", sceneRoot: "/app/scenes/arena/", sceneFile: "arena.gltf", projectScripts: [], maxPlayers: 8, onDemand: true, idleSeconds: 60 }
944
+ ],
945
+ boot: [
946
+ { sessionName: "arena-1", sceneName: "arena" }
947
+ ]
948
+ }
949
+ };
950
+ ```
951
+
952
+ - `sceneRoot` is an `http(s)://` URL or a folder on the server (mount the exported scene into the container). Only the
953
+ configured `sceneRoot` and `projectScripts` locations are ever loaded.
954
+ - The scene's own project script bundle loads automatically. `projectScripts` names extra bundles to load first.
955
+ - **At boot:** each `boot` entry starts with the server and never closes. On a host that numbers its processes (PM2,
956
+ with `NODE_APP_INSTANCE`) it starts on one process, the holder of the `dedicated-boot` lease in Redis, and moves to a
957
+ new process during a rolling deploy once the old session ends.
958
+ - **Through the admin API:** `POST /game-server/admin/dedicated` with `{ sceneName, sessionName }`.
959
+ - **On demand:** a `Client`-mode join whose session property `dedicatedScene` names a scene with `onDemand: true`
960
+ starts a dedicated session of it (one, however many players join at once). The sample launcher sends it from
961
+ `?fusionDedicatedScene=`; in code, `startGame({ gameMode: GameMode.Client, sessionName, sessionProperties: { dedicatedScene: "arena" } })`.
962
+ Once every player has left, the session closes after the reconnect grace period plus `idleSeconds`.
963
+ - At most `limits.maxDedicatedSessions` run at once.
964
+
965
+ The game's script bundle must register its input struct with `TOOLKIT.NetworkRunner.setDefaultInputDefinition(...)`,
966
+ because no launcher starts the dedicated runner. A launcher in the scene adopts the running session's runner instead of
967
+ starting its own, and guards presentation code (DOM, GUI, audio, input) with `runner.isPlayer`.
968
+
969
+ The single-session flags from earlier versions still work, and become one scene named after the session plus one boot
970
+ entry:
971
+
972
+ ```sh
973
+ babylon-game-server --dedicated srv1 --scene-root http://localhost:8877/scenes/ --scene networktestbed.gltf --players 8
974
+ ```
975
+
976
+ | Flag | Meaning | Default |
977
+ |---|---|---|
978
+ | `--dedicated <sessionName>` | Starts a dedicated session clients join by this name. | none |
979
+ | `--scene-root <url>` | The folder URL or path the exported scene is served from. | `http://localhost:8877/scenes/` |
980
+ | `--scene <file>` | The exported scene file. Required with `--dedicated`. | none |
981
+ | `--project-script <url>` | An extra script bundle to load before the scene. Repeatable. | none |
982
+ | `--players <n>` | The most players the session accepts. | `8` |
983
+
984
+ ## Deploying
985
+
986
+ `babylon-game-server init` writes the deployment starter folder: a commented `babylon-game-server.config.mjs` (every
987
+ setting at its default), `.env.example` (every environment variable), a `Dockerfile` built on the published image, and
988
+ recipes for Docker Compose (WebSocket behind Caddy; or TLS + UDP + Redis), AWS (ECS Fargate behind an ALB for WebSocket,
989
+ or behind an NLB with `TCP_QUIC` for UDP), Fly.io, Railway, Render, DigitalOcean App Platform, Heroku, Colyseus Cloud
990
+ and Kubernetes. Its `DEPLOY.md` has the production checklist and each platform's steps.
991
+
992
+ **Colyseus Cloud is the recommended host.** The server is built on Colyseus 0.18, and Colyseus Cloud is Colyseus's own
993
+ hosting, at a fixed monthly price. Its recipe (`colyseus-cloud/`) is a small deploy repository that installs the
994
+ published package: TLS at the edge, WebSocket only, one process per CPU sharing Redis, rolling deploys in which
995
+ running sessions finish and new ones go to the new processes. The server picks its Cloud settings by itself, refuses
996
+ `GAME_SERVER_TLS_ENABLED`, `GAME_SERVER_UDP_ENABLED` and `GAME_SERVER_PUBLIC_ADDRESS` there, and drains for 600 s, so a
997
+ process holding a `boot` session holds each deploy that long. See
998
+ [DEPLOY.md](DEPLOY.md#411-colyseus-cloud).
999
+
1000
+ The production checklist in short: set `NODE_ENV=production` (the image does), terminate TLS at the platform or on the
1001
+ server, turn on auth, set `allowedOrigins`, set an admin token if you use the legacy admin endpoints, serve the
1002
+ admin console over HTTPS with its data on a volume or Redis, `admin.allowedNetworks` and a first Owner, health-check
1003
+ `/game-server/health` and route traffic on `/game-server/ready`, give the stop timeout more than `drainSeconds`, and
1004
+ size the instance for its dedicated sessions (each runs a full scene simulation).
1005
+
1006
+ The Docker image `ghcr.io/mackeyk24/babylon-game-server` is built for `linux/amd64` and `linux/arm64`, runs as the
1007
+ `node` user with `NODE_ENV=production`, exposes `2567/tcp` and `2567/udp`, keeps the admin console's data in the
1008
+ `/data` volume (`GAME_SERVER_ADMIN_DATA_DIR=/data`), and reads its settings from `GAME_SERVER_*` variables or a mounted
1009
+ config file (`GAME_SERVER_CONFIG=/config/babylon-game-server.config.json`).
1010
+
1011
+ ## Upgrading and protocol versions
1012
+
1013
+ The server and the game's Babylon Toolkit runtime share a **network protocol version** (`TOOLKIT.NETWORK_PROTOCOL_VERSION`),
1014
+ separate from package versions. Patch and minor releases that keep it can be mixed freely. A release that changes it
1015
+ says so in its notes; then update the server and the game together. A game on another protocol version is refused at
1016
+ matchmaking with `IncompatibleConfiguration`, naming both versions and which side to update, and the game also checks
1017
+ `/game-server/info` before it joins.
1018
+
1019
+ The package version follows the Babylon Toolkit runtime it ships with (`babylonjs-toolkit`). To upgrade, change the
1020
+ version you install (`npm install @babylonjs-toolkit/server@<version>`) or the image tag, and redeploy. Running
1021
+ sessions finish on the old instance while it drains.
1022
+
1023
+ ## Troubleshooting
1024
+
1025
+ Refusals reach the game as a failed `startGame` (`result.shutdownReason`) with the message
1026
+ `"<ShutdownReason>: <detail>"`, and are logged by the server as `join refused` with the same reason.
1027
+
1028
+ | Message | What to do |
1029
+ |---|---|
1030
+ | `InvalidAuthentication: no token was sent` | Auth is on and the game sent no token: pass `startGame({ authToken })` (`?fusionToken=` in the sample). |
1031
+ | `InvalidAuthentication: the token is not valid` | Shared secret: the token is not in `auth.sharedSecret.tokens`. Check for whitespace or a stale token. |
1032
+ | `InvalidAuthentication: the token is not valid (<code>)` | JWT: the signature, issuer, audience, algorithm or format is wrong (the code is jose's, for example `ERR_JWT_CLAIM_VALIDATION_FAILED` for a wrong `iss`/`aud`, `ERR_JWS_SIGNATURE_VERIFICATION_FAILED` for the wrong key). Compare the token's claims with `auth.jwt.*`. |
1033
+ | `AuthenticationTicketExpired: the token has expired` | JWT: get a fresh token from the identity provider and start again. |
1034
+ | `InvalidAuthentication: the identity provider's keys could not be loaded` | JWT: the server cannot fetch `auth.jwt.jwksUrl`. Check the URL and the server's outbound network. |
1035
+ | `CustomAuthenticationFailed: the auth webhook did not approve the player` | Webhook: it answered non-2xx, not a JSON object, or later than `auth.webhook.timeoutMs`. Check the webhook's logs. |
1036
+ | `CustomAuthenticationFailed: the custom verifier did not approve the player` | Custom: your `verifyToken` hook returned `null`. |
1037
+ | `CustomAuthenticationFailed: the token could not be verified` | The verifier itself threw an error; the server logs it as `token verifier failed`. Fix the hook or webhook code. |
1038
+ | `IncompatibleConfiguration: this server speaks network protocol <n> but the game sent <m>; update the …` | The server and the game's runtime differ in protocol version (see [Upgrading](#upgrading-and-protocol-versions)); update the side the message names. The game reports the same as `IncompatibleConfiguration: the game server speaks network protocol …` when it reads `/game-server/info`. |
1039
+ | `IncompatibleConfiguration` (no detail) | Peers have different prefab tables: re-export, then reload every page and restart dedicated sessions. |
1040
+ | `ConnectionRefused: the origin <origin> is not allowed` | Add the game's origin (scheme, host and port, no path) to `limits.allowedOrigins`. `(none)` means a native client sent no `Origin` while `allowMissingOrigin` is off. |
1041
+ | `ConnectionRefused: too many join requests from this address; try again in a minute` | Over `joinRequestsPerMinutePerAddress`. Behind a proxy where every player shares one address, set `trustProxy`; otherwise raise the limit or fix a retry loop. |
1042
+ | `ConnectionRefused: the game server does not offer WebTransport, or this browser cannot reach it` | The game asked for `transportPreference: WebTransport` and UDP is off, the browser has no WebTransport, or UDP is blocked. Use `Auto`. |
1043
+ | `InvalidArguments: session name is longer than <n> characters` / `… contains control characters` | Shorten the session name or remove control characters, or raise `maxSessionNameLength`. |
1044
+ | `GameNotFound: no on-demand dedicated scene named <name>` | The join's `dedicatedScene` is not in `dedicated.scenes`, or that scene has `onDemand: false`. |
1045
+ | `GameNotFound: the dedicated scene <name> failed to load` | The server could not load the scene; its log says why (a wrong `sceneRoot`, a missing file, a script error). |
1046
+ | `GameNotFound` (no detail) | A `Client` join found no session of that name: start the host or dedicated session first. |
1047
+ | `GameClosed: the server is shutting down` | The instance is draining; join again to reach another instance. |
1048
+ | `MaxCcuReached: this server is hosting its most sessions` | Over `limits.maxSessions`: add instances (Redis) or raise the limit. |
1049
+ | `MaxCcuReached: this server is running its most dedicated sessions` | Over `limits.maxDedicatedSessions`. |
1050
+ | `GameIsFull` | The session has its most players (`playerCount`, at most `maxPlayersPerSession`). |
1051
+ | `Colyseus client not loaded …` | The page does not load the Colyseus browser client: turn on the exporter's Babylon Toolkit Networking option, or set `ColyseusTransport.ClientModule` (ESM). |
1052
+
1053
+ Startup problems:
1054
+
1055
+ | Message | What to do |
1056
+ |---|---|
1057
+ | `Babylon Game Server configuration problems:` followed by a list | Each line names the option, where it came from and what it needs, for example `tls.enabled (GAME_SERVER_TLS_ENABLED) needs a certificate: …`, `udp.enabled (GAME_SERVER_UDP_ENABLED) needs tls.enabled (GAME_SERVER_TLS_ENABLED): …`, or `auth.mode (GAME_SERVER_AUTH_MODE) jwt needs auth.jwt.secret (…) or auth.jwt.jwksUrl (…)`. Fix them and start again (exit code 2). |
1058
+ | `auth.mode "custom" needs a verifyToken hook (createGameServer({ hooks: { verifyToken } }))` | Custom auth works only from code; pass the hook, or pick another mode. |
1059
+ | `install mkcert (brew install mkcert && mkcert -install) or set tls.certFile/keyFile` | `--tls-dev-cert` needs mkcert when UDP is off. |
1060
+ | `UDP needs @colyseus/h3-transport, which is not installed on this platform` | The optional native package did not build here. Reinstall where its native build succeeds, or run without `--udp`. |
1061
+ | `babylonjs-toolkit <version> is older than this server needs; …` | The installed runtime predates the protocol version: install the `babylonjs-toolkit` version this server was released with. |
1062
+ | `a Babylon Game Server is already running in this process` | Call `createGameServer` once per process. |
1063
+ | `auth is disabled and the server listens on 0.0.0.0: anyone who can reach it can join …` (a warning) | A production server is open to anyone; turn on auth, or bind a loopback address. |
1064
+ | `HTTP/3 uses the new certificate after a restart` (a warning) | The certificate files were renewed while UDP is on; restart when convenient. |
1065
+
1066
+ Logs are one readable line per entry prefixed `[GameServer]`, or JSON lines in production (`logFormat: "auto"`), with
1067
+ `sessionName`, `roomId`, `playerId`, `userId` and `clientAddress` when known. Tokens, secrets and keys are never logged.
1068
+ Set `GAME_SERVER_LOG_LEVEL=debug` for more.
1069
+
1070
+ ## Developing this package
1071
+
1072
+ In the Babylon Toolkit repository the server lives in `SVR/` and depends on the runtime bundle in `../UMD`
1073
+ (`file:../UMD`, replaced by the exact published version when the package is packed):
1074
+
1075
+ ```sh
1076
+ cd APP && npm run build-ts && npm run combine-umd && npm run build-umd # the runtime bundle the server loads
1077
+ cd ../SVR && npm install && npm run build && npm start -- --port 2567 # build: the server and the console app
1078
+ npm test # builds, then runs the server test suites with real @colyseus/sdk clients
1079
+ ```
1080
+
1081
+ The room names (`fusion_session`, `fusion_lobby`) and messages (`fusion:*`) are the wire protocol the runtime speaks and
1082
+ keep their names.