@babylonjs-toolkit/server 0.0.0-stage → 9.35.0

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/DEPLOY.md ADDED
@@ -0,0 +1,2012 @@
1
+ # Deploying the Babylon Game Server
2
+
3
+ This guide takes `@babylonjs-toolkit/server` (the Babylon Game Server) from `npx` on a laptop to a production
4
+ deployment. It covers how the server ships, how to choose a topology, what to set before players arrive, one step-by-step
5
+ recipe per host, and how to run and troubleshoot it once it is live.
6
+
7
+ It assumes you know Node.js and Docker basics. You do not need to know this codebase. Every option, environment variable,
8
+ flag, endpoint and template file named here exists in the package; the [README](README.md) is the full option reference.
9
+
10
+ `babylon-game-server init` also writes a short `DEPLOY.md` into the starter folder, next to the recipe files. This guide
11
+ covers the same recipes in more depth, and the two agree.
12
+
13
+ > **Recommended host: [Colyseus Cloud](#411-colyseus-cloud).** The server is built on Colyseus 0.18, and Colyseus
14
+ > Cloud is Colyseus's own hosting: a fixed monthly price per machine, `wss://` and its certificate provided, one server
15
+ > process per CPU sharing Redis with no setup, rolling deploys that let running sessions finish, and player and room
16
+ > statistics and alerts on its panel. The server detects Colyseus Cloud and configures itself for it. Every other
17
+ > recipe below runs the same server anywhere else.
18
+
19
+ > **Before you rely on a cloud recipe:** the AWS, Fly.io, Railway, Render, DigitalOcean, Heroku and Kubernetes recipes
20
+ > have been parsed and tested, and the Colyseus Cloud recipe has been rehearsed with Colyseus's own deploy scripts, but
21
+ > none has been deployed to a real cloud account yet. Try your recipe on a staging account first, especially the AWS NLB
22
+ > `TCP_QUIC` recipe. See [Known limitations](#known-limitations).
23
+
24
+ Throughout this guide, `<version>` means the server package version you install, for example `9.30.1`. The image tag,
25
+ the npm version and the version that `init` writes into its recipes are always the same number.
26
+
27
+ ## Contents
28
+
29
+ 1. [How it ships](#1-how-it-ships)
30
+ - [The npm package and the CLI](#the-npm-package-and-the-cli)
31
+ - [The starter folder (`init`)](#the-starter-folder-init)
32
+ - [The Docker image](#the-docker-image)
33
+ - [Configuring a container](#configuring-a-container)
34
+ - [Building your own image](#building-your-own-image)
35
+ - [Pushing to a registry](#pushing-to-a-registry)
36
+ - [Pinning versions and upgrading](#pinning-versions-and-upgrading)
37
+ 2. [Choosing a topology](#2-choosing-a-topology)
38
+ - [Where TLS ends](#where-tls-ends)
39
+ - [WebSocket only, or WebSocket + UDP](#websocket-only-or-websocket--udp)
40
+ - [One instance, or scale-out with Redis](#one-instance-or-scale-out-with-redis)
41
+ - [Dedicated sessions need CPU and memory](#dedicated-sessions-need-cpu-and-memory)
42
+ - [Decision table](#decision-table)
43
+ - [What each host supports](#what-each-host-supports)
44
+ 3. [The production checklist](#3-the-production-checklist)
45
+ 4. [Recipes](#4-recipes)
46
+ - [4.1 Local and development](#41-local-and-development)
47
+ - [4.2 A plain VM (Docker or systemd)](#42-a-plain-vm-docker-or-systemd)
48
+ - [4.3 Docker Compose](#43-docker-compose)
49
+ - [4.4 AWS](#44-aws)
50
+ - [4.5 Kubernetes](#45-kubernetes)
51
+ - [4.6 Fly.io](#46-flyio)
52
+ - [4.7 Railway](#47-railway)
53
+ - [4.8 Render](#48-render)
54
+ - [4.9 DigitalOcean App Platform](#49-digitalocean-app-platform)
55
+ - [4.10 Heroku](#410-heroku)
56
+ - [4.11 Colyseus Cloud](#411-colyseus-cloud)
57
+ 5. [Serving dedicated-session scenes](#5-serving-dedicated-session-scenes)
58
+ 6. [Operating it](#6-operating-it)
59
+ 7. [Connecting the game](#7-connecting-the-game)
60
+ 8. [Troubleshooting deployment](#8-troubleshooting-deployment)
61
+ 9. [Known limitations](#known-limitations)
62
+
63
+ ---
64
+
65
+ ## 1. How it ships
66
+
67
+ The server ships two ways: as an npm package with a CLI, and as a multi-architecture Docker image. Both run the same
68
+ code. The server holds no game code: it loads the Babylon Toolkit runtime headless, with Babylon Toolkit Networking built
69
+ in, and a game's own classes stay in its `PROJECT` script bundle. One server deployment can therefore host any number of
70
+ games that speak the same network protocol version.
71
+
72
+ ### The npm package and the CLI
73
+
74
+ | Item | Value |
75
+ |---|---|
76
+ | Package | `@babylonjs-toolkit/server` |
77
+ | Runtime | Node.js 22 or newer (`"engines": { "node": ">=22" }`) |
78
+ | CLI | `babylon-game-server` (`lib/cli.js`) |
79
+ | Library entry | `createGameServer`, `loadGameServerConfig` and the other exports of `lib/index.js` |
80
+ | Optional native package | `@colyseus/h3-transport`, needed only for UDP (HTTP/3 WebTransport) |
81
+
82
+ Run it without installing:
83
+
84
+ ```sh
85
+ npx @babylonjs-toolkit/server
86
+ # Babylon Game Server <version> listening on ws://localhost:2567
87
+ # HTTP: http://localhost:2567
88
+ # WebTransport: off
89
+ # TLS: off UDP: off Auth: off Redis: off
90
+ ```
91
+
92
+ Or install it into a project and use the CLI from there:
93
+
94
+ ```sh
95
+ npm install @babylonjs-toolkit/server@<version>
96
+ npx babylon-game-server # start (the default command)
97
+ ```
98
+
99
+ The CLI has three commands:
100
+
101
+ | Command | What it does | Exit codes |
102
+ |---|---|---|
103
+ | `babylon-game-server [start] [flags]` | Starts the server. `start` is optional. | `2` on a configuration problem (every problem is printed at once), `1` on any other startup failure, `0` after a clean drain. |
104
+ | `babylon-game-server init [folder] [--force]` | Writes the starter folder (default `./babylon-game-server`). | `1` when any file already existed and was kept (`skipped (exists)`); run again with `--force` to overwrite. |
105
+ | `babylon-game-server healthcheck [flags]` | GETs `/game-server/health` on `127.0.0.1` at the configured port, over `https` when TLS is on (the certificate is not checked). | `0` on HTTP 200, else `1`. The Docker image's `HEALTHCHECK` runs it. |
106
+
107
+ Notes for installing on a server rather than in a container:
108
+
109
+ - **`npm install` needs `git`.** A Colyseus dependency (`uWebSockets.js`) is fetched from GitHub, which is why the
110
+ repository's `Dockerfile` installs `git` in its build stage.
111
+ - **UDP needs the optional native package.** `@colyseus/h3-transport` is installed automatically where its native build
112
+ succeeds. Where it does not, a server started with UDP stops with
113
+ `UDP needs @colyseus/h3-transport, which is not installed on this platform`. WebSocket-only servers never need it.
114
+ - **There is no `--version` flag** (unknown flags are refused with exit code 2). The version is in the startup banner,
115
+ in `npm ls @babylonjs-toolkit/server`, and in `serverVersion` of `GET /game-server/info`.
116
+
117
+ **Running from code.** `createGameServer({ config, hooks, routes, handleSignals })` starts the same server inside your
118
+ own Node program. It reads no file or environment unless you call `loadGameServerConfig` yourself, and it drains on
119
+ `SIGTERM`/`SIGINT` only when you pass `handleSignals: true` (the CLI does). You need this only for `custom` auth (a
120
+ `verifyToken` hook), lifecycle hooks or extra HTTP routes. One game server runs per process.
121
+
122
+ ```ts
123
+ import { createGameServer, loadGameServerConfig } from "@babylonjs-toolkit/server";
124
+
125
+ const config = await loadGameServerConfig({ argv: process.argv.slice(2), environment: process.env, workingDirectory: process.cwd() });
126
+ await createGameServer({ config, handleSignals: true, hooks: { /* verifyToken, onSessionCreated, ... */ } });
127
+ ```
128
+
129
+ ### The starter folder (`init`)
130
+
131
+ ```sh
132
+ npx babylon-game-server init # writes ./babylon-game-server/
133
+ npx babylon-game-server init deploy # or into ./deploy/
134
+ ```
135
+
136
+ `init` copies every file of the package's `templates/init/` folder, replacing `{{version}}` with the installed package
137
+ version (so every image reference is pinned to the exact version you installed), and generates `.env.example` from the
138
+ option table. TLS, UDP and auth are off in every file it writes.
139
+
140
+ | File | What it is |
141
+ |---|---|
142
+ | `babylon-game-server.config.mjs` | Every setting at its default, with comments on enabling TLS, UDP, auth and dedicated sessions. |
143
+ | `.env.example` | Every option as a `GAME_SERVER_*` variable with its default; secrets and unset options are left empty. |
144
+ | `Dockerfile` | Your image: `FROM ghcr.io/mackeyk24/babylon-game-server:<version>` plus your config file. |
145
+ | `DEPLOY.md` | The short deployment guide for this folder. |
146
+ | `docker-compose.websocket.yml`, `Caddyfile` | The server behind Caddy: HTTPS at Caddy, WebSocket only. |
147
+ | `docker-compose.full.yml` | TLS on the server, WebSocket + HTTP/3 on 443 TCP + UDP, and Redis. |
148
+ | `aws/README.md`, `aws/ecs-fargate-alb.yml`, `aws/ecs-fargate-nlb-quic.yml` | AWS: ECS Fargate behind an ALB or an NLB with QUIC; single-EC2 and Elastic Beanstalk notes. |
149
+ | `kubernetes/deployment.yaml`, `kubernetes/service.yaml`, `kubernetes/redis.yaml` | Kubernetes Deployment, ClusterIP Service and an in-cluster Redis. |
150
+ | `fly.toml`, `railway.json`, `render.yaml`, `digitalocean-app.yaml`, `heroku.yml`, `app.json` | Platform recipes. |
151
+
152
+ Each recipe file starts with a header that states where TLS ends (`edge` or `server`), whether its host can carry UDP,
153
+ and how to turn on auth.
154
+
155
+ > `.env.example` sets `GAME_SERVER_PORT=2567`. On a platform that sets `PORT` (Heroku, Railway, Render), remove that line
156
+ > if you copy the file into the platform's variables, because `GAME_SERVER_PORT` wins over `PORT`. Empty variables count
157
+ > as unset, so the empty lines are harmless.
158
+
159
+ ### The Docker image
160
+
161
+ | Property | Value |
162
+ |---|---|
163
+ | Image | `ghcr.io/mackeyk24/babylon-game-server:<version>` (also on Docker Hub as `mackeyk24/babylon-game-server:<version>`) |
164
+ | Platforms | `linux/amd64` and `linux/arm64` |
165
+ | Base | `node:22-bookworm-slim` |
166
+ | User | `node` (uid 1000), not root |
167
+ | Environment | `NODE_ENV=production` (JSON logs, development certificates refused) |
168
+ | Ports | `EXPOSE 2567/tcp 2567/udp` |
169
+ | Entrypoint | `node /app/SVR/lib/cli.js` (the CLI; arguments after the image name go to it) |
170
+ | Health check | `HEALTHCHECK --interval=15s --timeout=5s --start-period=20s CMD ["node","/app/SVR/lib/cli.js","healthcheck"]` |
171
+ | Working directory | `/app` |
172
+
173
+ ```sh
174
+ docker run --rm -p 2567:2567 ghcr.io/mackeyk24/babylon-game-server:<version>
175
+ curl -s http://localhost:2567/game-server/health # {"status":"ok"}
176
+ ```
177
+
178
+ ### Configuring a container
179
+
180
+ Settings come from, highest first: command-line flags, `GAME_SERVER_*` environment variables, a config file, the
181
+ defaults. In a container, use **environment variables** or a **mounted config file**, not flags:
182
+
183
+ - **Environment variables** are the simplest and what every recipe uses. `PORT` is read when `GAME_SERVER_PORT` is unset.
184
+ List variables are comma-separated. Booleans accept `true`/`false`, `1`/`0`, `yes`/`no`.
185
+ - **A mounted config file** is named by `GAME_SERVER_CONFIG` (`.mjs`, `.js` or `.json`). Without it, the server looks
186
+ for `babylon-game-server.config.mjs`, `.js` or `.json` in its working directory (`/app` in the image). The dedicated
187
+ session settings (`dedicated.scenes`, `dedicated.boot`) exist only in a config file.
188
+
189
+ ```sh
190
+ docker run -d -p 2567:2567 \
191
+ -v "$PWD/babylon-game-server.config.mjs:/app/config/babylon-game-server.config.mjs:ro" \
192
+ -e GAME_SERVER_CONFIG=/app/config/babylon-game-server.config.mjs \
193
+ ghcr.io/mackeyk24/babylon-game-server:<version>
194
+ ```
195
+
196
+ Why not flags: the image's `HEALTHCHECK` runs `healthcheck` with the container's environment but **without** the flags
197
+ you passed to the start command. A port given only as `--port 3000` makes the health check call 2567 and report the
198
+ container unhealthy. Anything set by environment or config file is seen by both.
199
+
200
+ Unknown config-file keys, unknown flags and wrongly typed values are refused at startup with exit code 2 and a list of
201
+ every problem, each naming the option and its environment variable. (Environment variables the server does not know are
202
+ simply ignored, so check the spelling of every `GAME_SERVER_*` name.)
203
+
204
+ ### Building your own image
205
+
206
+ **From the starter folder (recommended).** The init `Dockerfile` adds your config to the published image:
207
+
208
+ ```dockerfile
209
+ FROM ghcr.io/mackeyk24/babylon-game-server:<version>
210
+ COPY babylon-game-server.config.mjs /app/config/
211
+ ENV GAME_SERVER_CONFIG=/app/config/babylon-game-server.config.mjs
212
+ # Local dedicated scenes (exported glTF folders) referenced by dedicated.scenes[].sceneRoot:
213
+ # COPY scenes/ /app/scenes/
214
+ ```
215
+
216
+ ```sh
217
+ cd babylon-game-server
218
+ docker build -t my-game-server .
219
+ docker run --rm -p 2567:2567 my-game-server
220
+ ```
221
+
222
+ Build for both architectures when the target differs from your machine (for example an Apple Silicon laptop building
223
+ for x86 Fargate, which the AWS templates use):
224
+
225
+ ```sh
226
+ docker buildx build --platform linux/amd64,linux/arm64 -t <registry>/my-game-server:<version> --push .
227
+ ```
228
+
229
+ Keep secrets out of the config file you bake in: put them in the platform's secret store and pass them as environment
230
+ variables, which override the file.
231
+
232
+ **From the Babylon Toolkit repository.** `SVR/Dockerfile` builds the image itself from source. It must be built from the
233
+ repository root, because it copies the runtime bundle in `UMD/`; `SVR/Dockerfile.dockerignore` limits the build context
234
+ to `UMD/package.json`, `UMD/lib/**`, `SVR/package.json`, `SVR/tsconfig.json`, `SVR/src/**` and `SVR/templates/**`.
235
+
236
+ ```sh
237
+ # 1. Build the runtime bundle the server loads.
238
+ cd APP && npm run build-ts && npm run combine-umd && npm run build-umd && cd ..
239
+ # 2. Build the image from the repository root.
240
+ docker build -f SVR/Dockerfile -t babylon-game-server .
241
+ # Multi-architecture, as the release does:
242
+ docker buildx build --platform linux/amd64,linux/arm64 -f SVR/Dockerfile -t <registry>/babylon-game-server:<version> --push .
243
+ ```
244
+
245
+ The Dockerfile runs `npm install` (not `npm ci`) because lock files are git-ignored in that repository; the server's
246
+ direct dependencies are exact pins. The release script `npm run release:image` (in `SVR/`) runs the `buildx` command
247
+ above with tags `ghcr.io/mackeyk24/babylon-game-server:$npm_package_version` and
248
+ `mackeyk24/babylon-game-server:$npm_package_version`; it is for the package maintainer.
249
+
250
+ ### Pushing to a registry
251
+
252
+ Most platforms can pull `ghcr.io/mackeyk24/babylon-game-server` directly (it is public). Push a copy to your own registry
253
+ when you build your own image, when your network cannot reach ghcr.io (for example private subnets without a NAT
254
+ gateway), or when policy requires it. `docker buildx imagetools create` copies the published multi-architecture image
255
+ without pulling it:
256
+
257
+ **Amazon ECR**
258
+
259
+ ```sh
260
+ AWS_REGION=us-east-1
261
+ ACCOUNT=123456789012
262
+ REPO=$ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/babylon-game-server
263
+ aws ecr create-repository --repository-name babylon-game-server --region $AWS_REGION
264
+ aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com
265
+
266
+ # Mirror the published image (both architectures):
267
+ docker buildx imagetools create --tag $REPO:<version> ghcr.io/mackeyk24/babylon-game-server:<version>
268
+ # Or push your own image built from the init Dockerfile:
269
+ docker buildx build --platform linux/amd64,linux/arm64 -t $REPO:<version> --push .
270
+ ```
271
+
272
+ Then pass `Image=$REPO:<version>` to the AWS templates.
273
+
274
+ **GitHub Container Registry**
275
+
276
+ ```sh
277
+ echo "$GITHUB_TOKEN" | docker login ghcr.io -u <your-github-user> --password-stdin # a token with write:packages
278
+ docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/<your-github-user>/my-game-server:<version> --push .
279
+ ```
280
+
281
+ **Docker Hub**
282
+
283
+ ```sh
284
+ docker login
285
+ docker buildx build --platform linux/amd64,linux/arm64 -t <your-docker-user>/my-game-server:<version> --push .
286
+ ```
287
+
288
+ ### Pinning versions and upgrading
289
+
290
+ **Always pin an exact version.** Use the exact tag `init` wrote (`ghcr.io/mackeyk24/babylon-game-server:<version>`), not
291
+ `latest` or a short tag, and install `@babylonjs-toolkit/server@<version>` exactly. The package version follows the
292
+ Babylon Toolkit runtime it ships with (`babylonjs-toolkit`): the published package depends on that exact runtime version
293
+ and its exact Babylon.js versions.
294
+
295
+ **Protocol versions.** The server and the game's Babylon Toolkit runtime share a **network protocol version**
296
+ (`TOOLKIT.NETWORK_PROTOCOL_VERSION`), separate from package versions. Check the server's with:
297
+
298
+ ```sh
299
+ curl -s https://game.example.com/game-server/info
300
+ # {"serverName":"Babylon Game Server","serverVersion":"<version>","protocolVersion":1,"transports":{...},"authRequired":true}
301
+ ```
302
+
303
+ - Releases that keep the protocol version can be mixed freely: upgrade the server and the game independently.
304
+ - A release that changes it says so in its notes. Then upgrade the server and the game together. A game on another
305
+ protocol version is refused at matchmaking with `IncompatibleConfiguration`, naming both versions and the side to
306
+ update; the game also checks `/game-server/info` before it joins.
307
+
308
+ **To upgrade:**
309
+
310
+ 1. Read the release notes for a protocol version change.
311
+ 2. Update the version everywhere `init` pinned it: the `FROM` line of `Dockerfile`, the default image in
312
+ `docker-compose.websocket.yml` and `docker-compose.full.yml` (or `GAME_SERVER_IMAGE`), the `image:` of
313
+ `kubernetes/deployment.yaml`, and the `Image` parameter of the AWS templates (pass `Image=...:<new version>`). For an
314
+ npm install, `npm install @babylonjs-toolkit/server@<new version>`.
315
+ 3. To see what changed in the recipes, write a fresh starter folder beside yours (`npx babylon-game-server init
316
+ starter-<new version>`) and diff it. Do not run `init --force` over your edited folder; it overwrites your config.
317
+ 4. Redeploy with a rolling update. Running sessions finish on the old instance while it drains (see
318
+ [Rolling updates](#rolling-updates-and-draining)).
319
+
320
+ ---
321
+
322
+ ## 2. Choosing a topology
323
+
324
+ ### Where TLS ends
325
+
326
+ Browsers block `ws://` from an `https://` page, so production players need `wss://`. TLS can end in one of two places:
327
+
328
+ | | TLS at the edge | TLS on the server |
329
+ |---|---|---|
330
+ | Who holds the certificate | Caddy, a cloud load balancer (ALB), or the platform (Fly, Railway, Render, DigitalOcean, Heroku, an Ingress) | The game server itself (`tls.enabled` with `tls.certFile`/`tls.keyFile` or `tls.certPem`/`tls.keyPem`) |
331
+ | Server settings | TLS off (the default); usually `trustProxy: true` | `GAME_SERVER_TLS_ENABLED=true` plus a certificate |
332
+ | Certificate renewal | The proxy or platform does it | Files are reloaded automatically; see [Certificate renewal](#certificate-renewal) |
333
+ | UDP (HTTP/3) possible | **No** | **Yes** (with a host that forwards UDP) |
334
+ | Use it when | You want WebSocket only and managed certificates | You want UDP, or the server is the public endpoint with no proxy |
335
+
336
+ HTTP/3 always uses TLS, and the QUIC connection must end at the server, so a proxy that terminates TLS in front of it
337
+ cannot carry UDP. That is the whole reason to terminate TLS on the server.
338
+
339
+ ### WebSocket only, or WebSocket + UDP
340
+
341
+ With UDP on (`GAME_SERVER_UDP_ENABLED=true`), the server also accepts WebTransport over HTTP/3 on the same port number,
342
+ UDP beside TCP. Unreliable frames (snapshots, inputs, acks and unreliable RPCs) then travel as datagrams, so one lost
343
+ packet no longer holds up the ones behind it. Reliable frames and frames over 1000 bytes keep the reliable stream.
344
+
345
+ UDP is an improvement, not a requirement. Players whose browser or network cannot use WebTransport connect over
346
+ WebSocket and play in the same session, so a WebSocket-only host is fully playable.
347
+
348
+ UDP needs all of:
349
+
350
+ - TLS on the server itself (the config refuses `udp.enabled` without `tls.enabled`).
351
+ - The port open for **UDP as well as TCP** at every layer: firewall, security group, `docker run -p 443:443/udp`.
352
+ - A host that forwards inbound UDP to the server, on the **same public port number as the `wss://` URL**. The game
353
+ opens WebTransport to the host and port of its server URL, so `wss://game.example.com` means UDP 443.
354
+ - A browser with WebTransport: Chrome and Edge 97+, Firefox 115+, Safari 26.4+. Others use WebSocket.
355
+ - `@colyseus/h3-transport` installed (it is in the Docker image).
356
+
357
+ Which hosts can carry UDP:
358
+
359
+ | Can carry UDP | Cannot (WebSocket only) |
360
+ |---|---|
361
+ | VMs and bare metal (AWS EC2, DigitalOcean Droplets, Hetzner and similar); Docker Compose on a VM with `docker-compose.full.yml`; AWS ECS behind a Network Load Balancer with a `TCP_QUIC` listener (`aws/ecs-fargate-nlb-quic.yml`); Kubernetes with server TLS and a LoadBalancer Service that forwards TCP and UDP | Caddy (`docker-compose.websocket.yml`); AWS ALB (`aws/ecs-fargate-alb.yml`) and Elastic Beanstalk; Fly.io; Railway; Render; DigitalOcean App Platform; Heroku; Colyseus Cloud; a Kubernetes HTTP Ingress |
362
+
363
+ ### One instance, or scale-out with Redis
364
+
365
+ **One process hosts many sessions** (up to `limits.maxSessions`, default 1000), and **a session always lives in one
366
+ process**. Start with one instance; most games never need more for relayed (Shared, Host/Client) sessions, which only
367
+ forward messages.
368
+
369
+ To run several instances behind one address, every instance needs:
370
+
371
+ 1. **The same Redis**: `GAME_SERVER_REDIS_URL=redis://...` on every instance. Matchmaking on any instance then finds
372
+ every session, the lobby lists every instance's sessions, and the admin API sees and closes all of them.
373
+ 2. **Its own public address**: `GAME_SERVER_PUBLIC_ADDRESS=<host>[:<port>]`, an address players can reach **that one
374
+ instance** on directly. A player who matchmakes on instance A for a session held by instance B is sent to B's public
375
+ address (the seat reservation names it).
376
+
377
+ How the public address is used, which matters for TLS:
378
+
379
+ - Once `publicAddress` is set, **every** seat reservation that instance makes carries it, even with one instance, and
380
+ the browser connects to it with the scheme of the game's server URL: `wss://<publicAddress>` when the game uses
381
+ `wss://`. So the address must serve TLS with a certificate valid for that host name whenever players use `wss://`.
382
+ - A private IP (a pod IP, a VPC address) works only for players on that network.
383
+ - With TLS at a shared edge (one load balancer or one platform host name), you cannot give each instance its own
384
+ address, so **keep one instance per address** there. That is why the AWS templates say to keep `DesiredCount=1`, and
385
+ why the PaaS recipes run one instance.
386
+
387
+ Scale-out patterns that satisfy the rule:
388
+
389
+ | Pattern | Each instance's public address |
390
+ |---|---|
391
+ | Several VMs, TLS on each server | `game-1.example.com`, `game-2.example.com`, each with its own certificate (or a wildcard) |
392
+ | Several processes on one VM, TLS on each server | `game.example.com:2567`, `game.example.com:2568`, ... (open each port for TCP, and UDP if used) |
393
+ | Several AWS stacks, each `DesiredCount=1` with its own domain | `game-1.example.com` → stack 1's load balancer, and so on, all sharing one ElastiCache |
394
+ | Kubernetes StatefulSet with one LoadBalancer Service per pod | Each Service's external host name |
395
+
396
+ The game can use any instance's address as its `serverUrl`; matchmaking finds sessions everywhere.
397
+
398
+ Readiness answers 503 while Redis does not answer, so a load balancer stops sending new players to an instance that
399
+ cannot matchmake.
400
+
401
+ ### Dedicated sessions need CPU and memory
402
+
403
+ The session types cost very different amounts:
404
+
405
+ | Session type | What the server does | Cost |
406
+ |---|---|---|
407
+ | Shared, Host/Client, AutoHostOrClient | Relays messages; keeps Shared-mode state | Light: network and a little CPU per message |
408
+ | Dedicated (Server mode) | Loads the exported scene headless (Babylon.js `NullEngine` with Havok, no materials or textures) and simulates it every tick | Heavy: a full scene simulation per session |
409
+
410
+ All sessions of one process share **one JavaScript thread**. Adding vCPUs to one container does not let one process run
411
+ more dedicated sessions; adding processes (instances) does. Memory grows with each loaded scene.
412
+
413
+ There are no published sizing figures. Size by measurement:
414
+
415
+ 1. Start with the templates' sizes: Fargate `Cpu: "512"`, `Memory: "1024"`; Kubernetes requests `cpu: 250m`,
416
+ `memory: 512Mi`, limit `memory: 1Gi`; DigitalOcean `apps-s-1vcpu-1gb`.
417
+ 2. Run your heaviest scene with the expected player count, and watch
418
+ `babylon_game_server_dedicated_frame_seconds` and `babylon_game_server_dedicated_tick_overruns_total` (frames longer
419
+ than one simulation tick), plus the process memory and event-loop lag metrics. See [Metrics](#metrics).
420
+ 3. Set `limits.maxDedicatedSessions` (default 4) to what one instance sustains without overruns. Joins beyond it are
421
+ refused with `MaxCcuReached`, which is better than every session on the instance falling behind.
422
+
423
+ ### Decision table
424
+
425
+ | You need | Topology | Recipe |
426
+ |---|---|---|
427
+ | To try it locally | One process, no TLS | [Local](#41-local-and-development) |
428
+ | **The recommended production host**: managed, fixed price, several processes behind one address with Redis provided | One process per CPU, TLS at the platform edge, WebSocket | [Colyseus Cloud](#411-colyseus-cloud) |
429
+ | A managed server from a Docker image | One instance, TLS at the platform edge, WebSocket | [Fly.io](#46-flyio), [Railway](#47-railway), [Render](#48-render), [DigitalOcean](#49-digitalocean-app-platform), [Heroku](#410-heroku) |
430
+ | A cheap VM you control, WebSocket only | One instance behind Caddy | [Compose + Caddy](#websocket-behind-caddy) |
431
+ | The best network feel (UDP datagrams) on one machine | One instance, TLS on the server, TCP + UDP 443 | [Compose full](#tls-http3-and-redis) or [single EC2](#single-ec2-instance-with-an-elastic-ip) |
432
+ | AWS-managed, WebSocket only | ECS Fargate + ALB, TLS at the ALB | [ECS + ALB](#ecs-fargate-behind-an-application-load-balancer) |
433
+ | AWS-managed with UDP | ECS Fargate + NLB `TCP_QUIC`, TLS on the server | [ECS + NLB QUIC](#ecs-fargate-behind-a-network-load-balancer-with-quic) |
434
+ | More sessions than one process holds | Several instances, shared Redis, one public address each | [Scale-out](#one-instance-or-scale-out-with-redis) |
435
+ | Many dedicated (Server-mode) sessions | Several instances sized by measurement, `limits.maxDedicatedSessions` per instance | [Dedicated sizing](#dedicated-sessions-need-cpu-and-memory) |
436
+ | An existing Kubernetes cluster | Deployment + Ingress (WebSocket), or server TLS + UDP LoadBalancer | [Kubernetes](#45-kubernetes) |
437
+
438
+ ### What each host supports
439
+
440
+ | Host (template) | TLS | UDP | Scale-out | Health check in the template | Stop timeout in the template |
441
+ |---|---|---|---|---|---|
442
+ | Docker Compose + Caddy (`docker-compose.websocket.yml`) | edge (Caddy) | no | one host | the image's `HEALTHCHECK` | Docker's default 10 s; add `stop_grace_period` |
443
+ | Docker Compose full (`docker-compose.full.yml`) | server | yes (443/udp) | Redis included; add instances with their own addresses | the image's `HEALTHCHECK` | Docker's default 10 s; add `stop_grace_period` |
444
+ | AWS ECS Fargate + ALB (`aws/ecs-fargate-alb.yml`) | edge (ALB) | no | `DesiredCount=1` per stack | target group `/game-server/ready` | `StopTimeout: 45` |
445
+ | AWS ECS Fargate + NLB (`aws/ecs-fargate-nlb-quic.yml`) | server | yes (`TCP_QUIC`) | `DesiredCount=1` per stack | target group HTTPS `/game-server/ready` | `StopTimeout: 45` |
446
+ | AWS single EC2 + Elastic IP (`aws/README.md`) | server | yes | one per address | the image's `HEALTHCHECK` | `docker stop` default 10 s; pass `--stop-timeout` |
447
+ | AWS Elastic Beanstalk (`aws/README.md`) | edge (ALB) | no | one instance | set to `/game-server/ready` | platform default |
448
+ | Kubernetes (`kubernetes/`) | edge (Ingress) or server | only with server TLS + UDP LoadBalancer | Redis + one reachable address per pod | liveness `/game-server/health`, readiness `/game-server/ready` | `terminationGracePeriodSeconds: 45` |
449
+ | Fly.io (`fly.toml`) | edge | no | one machine | `/game-server/ready` | `kill_timeout = "45s"` |
450
+ | Railway (`railway.json`) | edge | no | `numReplicas: 1` | `/game-server/ready` | platform default |
451
+ | Render (`render.yaml`) | edge | no | `numInstances: 1` | `/game-server/ready` | platform default |
452
+ | DigitalOcean App Platform (`digitalocean-app.yaml`) | edge | no | `instance_count: 1` | `/game-server/ready` | platform default |
453
+ | Heroku (`heroku.yml`, `app.json`) | edge | no | one dyno | none (router) | 30 s, so `GAME_SERVER_DRAIN_SECONDS=25` |
454
+ | Colyseus Cloud (`colyseus-cloud/`) | edge | no | Redis, one process per CPU | listening socket (PM2 ready signal) | `kill_timeout` 30 min in the template, drain 600 s |
455
+
456
+ ---
457
+
458
+ ## 3. The production checklist
459
+
460
+ Set each of these before players arrive. A complete environment for a WebSocket server behind a TLS edge looks like
461
+ this; every recipe below shows where to put it.
462
+
463
+ ```sh
464
+ NODE_ENV=production # the image sets it
465
+ GAME_SERVER_TRUST_PROXY=true # only behind your own proxy (see 5)
466
+ GAME_SERVER_AUTH_ENABLED=true
467
+ GAME_SERVER_AUTH_MODE=jwt
468
+ GAME_SERVER_AUTH_JWT_JWKS_URL=https://login.example.com/.well-known/jwks.json
469
+ GAME_SERVER_AUTH_JWT_ISSUER=https://login.example.com/
470
+ GAME_SERVER_AUTH_JWT_AUDIENCE=my-game
471
+ GAME_SERVER_ALLOWED_ORIGINS=https://game.example.com
472
+ GAME_SERVER_ADMIN_TOKEN=<from your secret store> # a secret
473
+ GAME_SERVER_METRICS_REQUIRE_ADMIN_TOKEN=true
474
+ GAME_SERVER_ADMIN_ALLOWED_NETWORKS=203.0.113.0/24 # who may reach the admin console (see 13)
475
+ GAME_SERVER_PUBLIC_URL=wss://game.example.com # shown in the banner
476
+ GAME_SERVER_DRAIN_SECONDS=30 # stop timeout must be longer (see 9)
477
+ ```
478
+
479
+ ### 1. `NODE_ENV=production`
480
+
481
+ **Why:** it switches `logFormat: "auto"` to JSON lines, refuses development certificates
482
+ (`tls.developmentCertificate`), and makes the server warn at startup when auth is off on an address other machines can
483
+ reach.
484
+ **How:** the Docker image sets it. For an npm install, set it in the service environment (systemd `Environment=`).
485
+
486
+ ### 2. TLS
487
+
488
+ **Why:** browsers refuse `ws://` from `https://` pages, and tokens must not travel in clear text.
489
+ **How:** terminate TLS at the edge (Caddy, a load balancer, the platform) and leave server TLS off, or turn on server TLS
490
+ for UDP or when the server is the public endpoint:
491
+
492
+ ```sh
493
+ GAME_SERVER_TLS_ENABLED=true
494
+ GAME_SERVER_TLS_CERT_FILE=/certs/fullchain.pem # PEM chain, reloaded when it changes
495
+ GAME_SERVER_TLS_KEY_FILE=/certs/privkey.pem
496
+ # or the PEM text itself, from a secret store:
497
+ GAME_SERVER_TLS_CERT_PEM="$(cat fullchain.pem)"
498
+ GAME_SERVER_TLS_KEY_PEM="$(cat privkey.pem)"
499
+ ```
500
+
501
+ Set one pair, not both: when both are set, the PEM text is the one served. The container runs as `node` (uid 1000), so
502
+ the key file must be readable by that user.
503
+
504
+ ### 3. Authentication
505
+
506
+ **Why:** with auth off, anyone who can reach the server can create and join sessions.
507
+ **How:** set `GAME_SERVER_AUTH_ENABLED=true` and a mode. Every join (sessions and the lobby) must then carry a token,
508
+ verified at matchmaking before a seat is reserved. The game passes it as `startGame({ authToken })`.
509
+
510
+ | Mode (`GAME_SERVER_AUTH_MODE`) | Settings | Good for | Identity the host sees |
511
+ |---|---|---|---|
512
+ | `shared-secret` | `GAME_SERVER_AUTH_SHARED_SECRET_TOKENS=token-one,token-two` | Private builds, playtests, server-to-server | `{ userId: null, claims: { method: "shared-secret", keyIndex } }` |
513
+ | `jwt` with a JWKS | `GAME_SERVER_AUTH_JWT_JWKS_URL`, optionally `..._ISSUER`, `..._AUDIENCE`, `..._ALGORITHMS` | Players signed in with Auth0, Cognito, Firebase, Clerk or similar | `{ userId: <sub>, claims: <payload> }` |
514
+ | `jwt` with an HMAC secret | `GAME_SERVER_AUTH_JWT_SECRET` (HS256/384/512), optionally `..._ISSUER`, `..._AUDIENCE` | Your own backend signs short-lived tokens | `{ userId: <sub>, claims: <payload> }` |
515
+ | `webhook` | `GAME_SERVER_AUTH_WEBHOOK_URL`, `GAME_SERVER_AUTH_WEBHOOK_TIMEOUT_MS` (default 5000) | Your service approves each player; no code in the server | `{ userId: body.userId ?? null, claims: body }` |
516
+ | `custom` | A `verifyToken` hook passed to `createGameServer` | Your own verifier in code ([Running from code](#the-npm-package-and-the-cli)) | What your hook returns |
517
+ | Discord sign-in (beside any mode, or with auth off) | `GAME_SERVER_DISCORD_SIGN_IN_ENABLED=true`, `GAME_SERVER_DISCORD_CLIENT_ID`, `GAME_SERVER_DISCORD_CLIENT_SECRET`, `GAME_SERVER_DISCORD_REDIRECT_URI` (registered in the Discord application as `https://<server>/game-server/discord/callback`), `GAME_SERVER_DISCORD_RETURN_ORIGINS` | Players who sign in with their Discord account; the server issues its own session token (README, "Discord sign-in") | `{ userId: "discord:<id>", claims: { sub, name, avatar, provider: "discord", name_managed: true } }` |
518
+
519
+ Deployment notes:
520
+
521
+ - **JWKS:** the server must reach the JWKS URL over outbound HTTPS. Keys are fetched on the first join, cached for
522
+ 10 minutes, and refetched (at most every 30 seconds) when a token names an unknown key.
523
+ - **Webhook:** the server POSTs JSON `{ token, kind, sessionName, gameMode, clientAddress, origin }` and waits up to
524
+ `GAME_SERVER_AUTH_WEBHOOK_TIMEOUT_MS`. A 2xx answer with a JSON object admits the player; anything else refuses. Keep
525
+ the webhook close to the server (same region) so the timeout is not spent on the network.
526
+ - **Custom** cannot be set from the environment alone: `auth.mode "custom" needs a verifyToken hook`. Use `webhook`
527
+ when you want your own logic without writing a server entry script.
528
+ - Tokens are compared in constant time and never logged.
529
+
530
+ ### 4. `allowedOrigins`
531
+
532
+ **Why:** limits which web pages can use your server. It also sets the CORS answer, so a browser on another origin cannot
533
+ call the server at all.
534
+ **How:** list your game's origins exactly as the browser sends them (scheme, host and port, no path):
535
+
536
+ ```sh
537
+ GAME_SERVER_ALLOWED_ORIGINS=https://game.example.com,https://www.game.example.com
538
+ ```
539
+
540
+ Empty (the default) allows any origin. `GAME_SERVER_ALLOW_MISSING_ORIGIN` (default `true`) accepts joins that send no
541
+ `Origin` header, which native clients and tools do; set it to `false` if only browsers should join.
542
+ With Discord sign-in on, every origin in `GAME_SERVER_DISCORD_RETURN_ORIGINS` must also be one of the game's origins listed here.
543
+
544
+ ### 5. `trustProxy`
545
+
546
+ **Why:** the per-address join limit and the logs use the client address. Behind a proxy, every player otherwise appears
547
+ as the proxy's address and they all share one join-rate allowance (120 joins per minute by default). Trusting the
548
+ forwarding headers on a server that clients reach directly would let any client choose its address.
549
+ **How:**
550
+
551
+ | Situation | `GAME_SERVER_TRUST_PROXY` |
552
+ |---|---|
553
+ | Behind Caddy, an ALB, an Ingress, Fly, Render, DigitalOcean App Platform, Heroku (they set `X-Forwarded-For`) | `true` (the Caddy, ALB, Fly, Render, DigitalOcean and Heroku templates set it) |
554
+ | Public directly: TLS on the server, no proxy (Compose full, single EC2) | `false` (the default) |
555
+ | Behind an NLB or a TCP LoadBalancer Service (TCP passthrough, no forwarding headers) | `false`; preserve the client IP at the load balancer instead (see the AWS and Kubernetes recipes) |
556
+
557
+ ### 6. The admin token
558
+
559
+ **Why:** the admin API lists and closes sessions, starts dedicated sessions and drains the server. It is off (404) until
560
+ a token is set.
561
+ **How:** generate a long random value, keep it in your secret store, and pass it as `GAME_SERVER_ADMIN_TOKEN`:
562
+
563
+ ```sh
564
+ openssl rand -hex 32
565
+ ```
566
+
567
+ Admin calls send `Authorization: Bearer <token>`; a wrong or missing token answers 401. The admin paths
568
+ (`/game-server/admin/...`) are served on the public port. If your proxy can filter paths, you can also block them at the
569
+ edge and call them from inside your network.
570
+
571
+ ### 7. Protect `/metrics`
572
+
573
+ **Why:** `/metrics` is on by default and public unless you protect it. It reveals player counts, session counts and
574
+ refusal reasons.
575
+ **How:** pick one:
576
+
577
+ - `GAME_SERVER_METRICS_REQUIRE_ADMIN_TOKEN=true` **together with** `GAME_SERVER_ADMIN_TOKEN` (without an admin token,
578
+ every request then gets 403). Your Prometheus sends the token as a bearer token.
579
+ - Block `/metrics` at the proxy and scrape the instance on its private address.
580
+ - `GAME_SERVER_METRICS_ENABLED=false` (then `/metrics` answers 404).
581
+
582
+ ### 8. Health vs readiness probes
583
+
584
+ **Why:** they answer different questions. Wiring readiness as liveness gets a healthy, draining server killed.
585
+
586
+ | Endpoint | Answers | Use it for |
587
+ |---|---|---|
588
+ | `GET /game-server/health` | 200 `{ "status": "ok" }` while the process serves HTTP | **Liveness**: restart the container when it fails. The image's `HEALTHCHECK` uses it. |
589
+ | `GET /game-server/ready` | 200 `{ "ready": true }`, or 503 `{ "ready": false, "reasons": [...] }` with `draining`, `redis unreachable` or `not listening` | **Readiness / load-balancer routing**: stop sending new players there |
590
+
591
+ Platforms with a single health check (Fly, Railway, Render, DigitalOcean, the AWS target groups) use
592
+ `/game-server/ready` in the templates, so a draining instance leaves rotation.
593
+
594
+ ### 9. Stop timeout vs `drainSeconds`
595
+
596
+ **Why:** on `SIGTERM` (or `SIGINT`) the server stops taking joins, lets running sessions continue for up to
597
+ `drainSeconds` (default 30), tells the remaining players the session closed (`GameClosed`) and exits 0. A watchdog exits
598
+ 1 if that takes longer than `drainSeconds` + 5 seconds. If the platform kills the process first (`SIGKILL`), sessions are
599
+ cut without the `GameClosed` message.
600
+ **How:** give the platform's stop timeout at least `drainSeconds` + 10 seconds, or lower `drainSeconds` to fit the
601
+ platform:
602
+
603
+ | Platform | Setting | Template value |
604
+ |---|---|---|
605
+ | ECS | container `StopTimeout` (Fargate maximum 120) | `45` |
606
+ | Kubernetes | `terminationGracePeriodSeconds` | `45` |
607
+ | Fly.io | `kill_timeout` | `"45s"` |
608
+ | Heroku | fixed 30 s | `GAME_SERVER_DRAIN_SECONDS=25` in `app.json` |
609
+ | `docker run` / `docker stop` | `--stop-timeout 45` / `docker stop -t 45` | not set (Docker's default is 10 s) |
610
+ | Docker Compose | `stop_grace_period: 45s` on the `game-server` service | not set (default 10 s) |
611
+ | systemd | `TimeoutStopSec=45` | see [the unit below](#systemd) |
612
+ | Colyseus Cloud | PM2's `kill_timeout`, which `@colyseus/tools` sets to 30 minutes | `drainSeconds` defaults to 600 on this host |
613
+ | Railway, Render, DigitalOcean App Platform | the platform's shutdown grace period | not set: check the platform's documentation and keep `drainSeconds` below it |
614
+
615
+ The drain finishes early when every session has ended, so a long `drainSeconds` costs nothing on a quiet server. The
616
+ exception is a process holding a `boot` dedicated session: that session never empties, so the process drains for the full
617
+ `drainSeconds`.
618
+
619
+ ### 10. Limits
620
+
621
+ **Why:** abuse limits are on by default; check them against your game.
622
+ **How:**
623
+
624
+ | Variable | Default | Change it when |
625
+ |---|---|---|
626
+ | `GAME_SERVER_MAX_SESSIONS` | `1000` | You want to cap one process lower (refused with 503 `MaxCcuReached`) |
627
+ | `GAME_SERVER_MAX_PLAYERS_PER_SESSION` | `255` | You want a hard cap below what games request (larger requests are clamped) |
628
+ | `GAME_SERVER_MAX_DEDICATED_SESSIONS` | `4` | Measured capacity of one instance (see [sizing](#dedicated-sessions-need-cpu-and-memory)) |
629
+ | `GAME_SERVER_JOIN_REQUESTS_PER_MINUTE` | `120` | Many players legitimately share one address (offices, events) |
630
+ | `GAME_SERVER_MAX_MESSAGE_BYTES` | `65536` | Your game sends larger reliable messages (the connection closes over it) |
631
+ | `GAME_SERVER_MAX_MESSAGES_PER_SECOND` | `2000` | A client legitimately sends more (it is disconnected over it) |
632
+ | `GAME_SERVER_MAX_SESSION_NAME_LENGTH` | `128` | Your session names are longer |
633
+
634
+ Every refusal is counted in `babylon_game_server_refusals_total{reason}` and logged as `join refused`.
635
+
636
+ ### 11. Logging
637
+
638
+ **Why:** you need searchable logs when something goes wrong.
639
+ **How:** in production, logs are one JSON object per line (`{"time":...,"level":...,"message":...}` plus
640
+ `sessionName`, `roomId`, `playerId`, `userId` and `clientAddress` when known). Errors and warnings go to stderr, the rest
641
+ to stdout; the startup banner is plain text. Force a format with `GAME_SERVER_LOG_FORMAT=json` or `text`, and set
642
+ `GAME_SERVER_LOG_LEVEL` (`error`, `warn`, `info` (default), `debug`). Use `debug` only while investigating.
643
+
644
+ ### 12. Secrets
645
+
646
+ **Why:** tokens and keys in an image or a committed file leak.
647
+ **How:** these options are secrets, are never logged, and belong in your platform's secret store, passed as environment
648
+ variables:
649
+
650
+ | Secret | Variable |
651
+ |---|---|
652
+ | Admin token | `GAME_SERVER_ADMIN_TOKEN` |
653
+ | Admin console single sign-on client secret | `GAME_SERVER_ADMIN_OIDC_CLIENT_SECRET` |
654
+ | Shared-secret player tokens | `GAME_SERVER_AUTH_SHARED_SECRET_TOKENS` |
655
+ | JWT HMAC secret | `GAME_SERVER_AUTH_JWT_SECRET` |
656
+ | Discord sign-in client secret | `GAME_SERVER_DISCORD_CLIENT_SECRET` |
657
+ | TLS certificate and key text | `GAME_SERVER_TLS_CERT_PEM`, `GAME_SERVER_TLS_KEY_PEM` |
658
+ | Redis URL (it carries the password) | `GAME_SERVER_REDIS_URL` |
659
+
660
+ Environment variables override the config file, so keep the config file free of secrets and commit it safely. Where to
661
+ put them: `fly secrets set`, Railway / Render / DigitalOcean / Heroku encrypted variables, AWS Secrets Manager through the
662
+ task definition's `Secrets`, a Kubernetes `Secret` through `valueFrom.secretKeyRef`, or a root-owned `EnvironmentFile`
663
+ on a VM.
664
+
665
+ ### 13. The admin console
666
+
667
+ **Why:** the console at `/game-server/admin/` can kick and ban players, close sessions, change limits and drain the
668
+ fleet, and its data (accounts, bans, the audit log) must outlive the container.
669
+ **How:** before players arrive:
670
+
671
+ - **HTTPS.** The console and its API refuse plain HTTP (403 `the console needs HTTPS`) except from a loopback address.
672
+ Serve them on your TLS address: TLS on the server, or a TLS edge with `GAME_SERVER_TRUST_PROXY=true` that sends
673
+ `x-forwarded-proto: https` (Caddy, an ALB, an Ingress and the PaaS hosts do).
674
+ - **A data volume or Redis.** Without Redis the console keeps its data in files under `GAME_SERVER_ADMIN_DATA_DIR`; the
675
+ image sets it to `/data` and declares a volume there. Mount a named volume (`-v game-server-data:/data`, as the
676
+ compose files and `fly.toml` do). With `GAME_SERVER_REDIS_URL` set, the data lives in Redis instead and every instance
677
+ shares it: give Redis persistence (append-only file, as `docker-compose.full.yml` and `kubernetes/redis.yaml` do, or
678
+ a managed Redis with backups). Hosts without volumes (Railway, Render, DigitalOcean App Platform, Heroku, ECS
679
+ Fargate) need Redis, or the console forgets its accounts on every deploy. The leaderboards live there too (README,
680
+ "Leaderboards"), so the same rule keeps the boards and scores; the console's Leaderboards page warns when they are not
681
+ durable, and its Settings tab exports a backup.
682
+ - **`allowedNetworks`.** Set `GAME_SERVER_ADMIN_ALLOWED_NETWORKS` to your office or VPN addresses or CIDR ranges; other
683
+ addresses get 404 from every console path. Or block `/game-server/admin/` at the edge and reach it from inside your
684
+ network. Behind a proxy this needs `trustProxy`, so the forwarded address is the one checked.
685
+ - **The first Owner.** The first start logs `admin setup link` at `warn`, valid for 30 minutes and usable once: open its
686
+ path (`/game-server/admin/#/setup?token=…`) on your HTTPS address. Or create the Owner where the data is:
687
+ `docker exec -it <container> node /app/SVR/lib/cli.js admin create-owner --username <name>`. Each account then signs
688
+ in with a password and an authenticator code or passkey; keep the recovery codes safe.
689
+ - **Backups of the data folder.** Copy the data folder or snapshot its volume on a schedule (or turn on Redis backups):
690
+ it holds the bans and the tamper-evident audit log. The CLI (`babylon-game-server admin …`) works on the same store
691
+ when nobody can sign in.
692
+
693
+ README, "Admin console", has the roles, API keys and security advice.
694
+
695
+ ---
696
+
697
+ ## 4. Recipes
698
+
699
+ Every recipe below states its TLS, UDP and scaling support. Commands use `game.example.com` as your domain and
700
+ `<version>` as the server version.
701
+
702
+ ### 4.1 Local and development
703
+
704
+ **TLS:** off, or a local development certificate. **UDP:** yes, locally. **Scaling:** one process.
705
+
706
+ ```sh
707
+ npx @babylonjs-toolkit/server # ws://localhost:2567
708
+ npx @babylonjs-toolkit/server --port 3000 --log-level debug
709
+ ```
710
+
711
+ The game connects to `ws://localhost:2567` from an `http://` page.
712
+
713
+ **Local TLS and UDP.** To test `wss://` and WebTransport on one machine without a real certificate:
714
+
715
+ ```sh
716
+ brew install mkcert && mkcert -install # once; see mkcert's docs for Linux and Windows
717
+ npx @babylonjs-toolkit/server --tls --tls-dev-cert --udp
718
+ # Babylon Game Server <version> listening on wss://localhost:2567
719
+ # HTTP: https://localhost:2567
720
+ # WebTransport: https://localhost:2567
721
+ # TLS: on (development) UDP: on (WebTransport on https://localhost:2567, UDP 2567) Auth: off Redis: off
722
+ ```
723
+
724
+ - HTTPS and WSS use an mkcert certificate for `localhost`, `127.0.0.1` and `::1`, made once and cached in the OS temp
725
+ folder (`babylon-game-server-dev-cert`). It does not cover other host names, so phones and other machines on your LAN
726
+ cannot use it.
727
+ - HTTP/3 uses its own 10-day self-signed certificate whose SHA-256 hash the server publishes in `/game-server/info` and
728
+ at matchmaking, so the browser pins it (browsers refuse WebTransport to locally trusted roots such as mkcert's). It is
729
+ made fresh at every start.
730
+ - Without mkcert, `--tls --tls-dev-cert --udp` still works: the pinned certificate serves HTTPS too. `--tls --tls-dev-cert`
731
+ without `--udp` stops with `install mkcert (brew install mkcert && mkcert -install) or set tls.certFile/keyFile`.
732
+ - Development certificates are refused when `NODE_ENV=production`, so this does not work in the Docker image.
733
+
734
+ **Local Redis scale-out test** (two processes, one machine):
735
+
736
+ ```sh
737
+ redis-server --save "" --appendonly no --port 6379 &
738
+ GAME_SERVER_REDIS_URL=redis://localhost:6379 GAME_SERVER_PUBLIC_ADDRESS=localhost:2567 npx babylon-game-server --port 2567 &
739
+ GAME_SERVER_REDIS_URL=redis://localhost:6379 GAME_SERVER_PUBLIC_ADDRESS=localhost:2568 npx babylon-game-server --port 2568 &
740
+ ```
741
+
742
+ **Stopping:** `Ctrl-C` drains like `SIGTERM`: with sessions running it waits up to `drainSeconds`. Press `Ctrl-C` again
743
+ to quit at once, or start with `--drain-seconds 0`.
744
+
745
+ ### 4.2 A plain VM (Docker or systemd)
746
+
747
+ **TLS:** server (or edge with your own proxy). **UDP:** yes, with server TLS. **Scaling:** one instance per public
748
+ address; several with Redis.
749
+
750
+ Open TCP 443 (and UDP 443 for HTTP/3) in the VM's firewall and the provider's security group, and point your domain's DNS
751
+ at the VM.
752
+
753
+ #### Certificates with certbot
754
+
755
+ ```sh
756
+ sudo certbot certonly --standalone -d game.example.com # needs TCP 80 free during issue and renewal
757
+ ```
758
+
759
+ The files under `/etc/letsencrypt/live/game.example.com/` are readable only by root. Copy them where the server's user
760
+ can read them, on every renewal, with a deploy hook. For the Docker image (user `node`, uid 1000):
761
+
762
+ ```sh
763
+ sudo mkdir -p /srv/game/certs
764
+ sudo tee /etc/letsencrypt/renewal-hooks/deploy/babylon-game-server.sh >/dev/null <<'EOF'
765
+ #!/bin/sh
766
+ install -m 0644 -o 1000 -g 1000 "$RENEWED_LINEAGE/fullchain.pem" /srv/game/certs/fullchain.pem
767
+ install -m 0600 -o 1000 -g 1000 "$RENEWED_LINEAGE/privkey.pem" /srv/game/certs/privkey.pem
768
+ EOF
769
+ sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/babylon-game-server.sh
770
+ sudo RENEWED_LINEAGE=/etc/letsencrypt/live/game.example.com /etc/letsencrypt/renewal-hooks/deploy/babylon-game-server.sh
771
+ ```
772
+
773
+ The server polls the files every 30 seconds and reloads them for HTTPS and WSS without a restart. With UDP on, HTTP/3
774
+ keeps the old certificate until the next restart (the server logs `HTTP/3 uses the new certificate after a restart`), so
775
+ add `docker restart game-server` (or `systemctl restart babylon-game-server`) to the hook when UDP is on.
776
+
777
+ #### Docker
778
+
779
+ ```sh
780
+ docker run -d --name game-server --restart unless-stopped --stop-timeout 45 \
781
+ -p 443:443/tcp -p 443:443/udp \
782
+ -v /srv/game/certs:/certs:ro \
783
+ -e GAME_SERVER_PORT=443 \
784
+ -e GAME_SERVER_TLS_ENABLED=true \
785
+ -e GAME_SERVER_TLS_CERT_FILE=/certs/fullchain.pem \
786
+ -e GAME_SERVER_TLS_KEY_FILE=/certs/privkey.pem \
787
+ -e GAME_SERVER_UDP_ENABLED=true \
788
+ -e GAME_SERVER_ALLOWED_ORIGINS=https://game.example.com \
789
+ --env-file /srv/game/secrets.env \
790
+ ghcr.io/mackeyk24/babylon-game-server:<version>
791
+ ```
792
+
793
+ `/srv/game/secrets.env` holds the secrets (`GAME_SERVER_ADMIN_TOKEN=...`, auth settings), readable only by root. Drop
794
+ `GAME_SERVER_UDP_ENABLED` and `-p 443:443/udp` for WebSocket only. Players use `wss://game.example.com`.
795
+
796
+ To put Caddy or your own proxy in front instead (WebSocket only, TLS at the proxy), use the
797
+ [Compose + Caddy recipe](#websocket-behind-caddy).
798
+
799
+ #### systemd
800
+
801
+ Without Docker, install Node.js 22+ and `git`, then:
802
+
803
+ ```sh
804
+ sudo useradd --system --create-home --home-dir /opt/babylon-game-server game
805
+ cd /opt/babylon-game-server
806
+ sudo -u game npm install @babylonjs-toolkit/server@<version>
807
+ sudo -u game npx babylon-game-server init . # writes the config and .env.example here
808
+ sudo install -m 0600 -o root -g root .env.example /etc/babylon-game-server.env # then edit it
809
+ ```
810
+
811
+ `/etc/systemd/system/babylon-game-server.service`:
812
+
813
+ ```ini
814
+ [Unit]
815
+ Description=Babylon Game Server
816
+ After=network-online.target
817
+ Wants=network-online.target
818
+
819
+ [Service]
820
+ User=game
821
+ WorkingDirectory=/opt/babylon-game-server
822
+ Environment=NODE_ENV=production
823
+ EnvironmentFile=/etc/babylon-game-server.env
824
+ ExecStart=/usr/bin/node /opt/babylon-game-server/node_modules/@babylonjs-toolkit/server/lib/cli.js
825
+ KillSignal=SIGTERM
826
+ # drainSeconds (30) plus a margin
827
+ TimeoutStopSec=45
828
+ Restart=on-failure
829
+ # Exit code 2 is a configuration problem: restarting will not fix it.
830
+ RestartPreventExitStatus=2
831
+ # Only when the server itself binds 443:
832
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
833
+
834
+ [Install]
835
+ WantedBy=multi-user.target
836
+ ```
837
+
838
+ ```sh
839
+ sudo systemctl daemon-reload
840
+ sudo systemctl enable --now babylon-game-server
841
+ journalctl -u babylon-game-server -f
842
+ ```
843
+
844
+ The server finds `babylon-game-server.config.mjs` in its `WorkingDirectory`. In the environment file, empty values
845
+ count as unset, so the copied `.env.example` is a safe starting point. For TLS on the server, point
846
+ `GAME_SERVER_TLS_CERT_FILE` / `GAME_SERVER_TLS_KEY_FILE` at copies readable by the `game` user (adapt the certbot hook
847
+ above to `-o root -g game` with mode `0640` for the key).
848
+
849
+ **Behind nginx instead of Caddy** (not shipped as a template; WebSocket only, set `GAME_SERVER_TRUST_PROXY=true` and
850
+ `GAME_SERVER_HOST=127.0.0.1` so only nginx reaches the server):
851
+
852
+ ```nginx
853
+ location / {
854
+ proxy_pass http://127.0.0.1:2567;
855
+ proxy_http_version 1.1;
856
+ proxy_set_header Upgrade $http_upgrade;
857
+ proxy_set_header Connection "upgrade";
858
+ proxy_set_header Host $host;
859
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
860
+ proxy_read_timeout 3600s;
861
+ }
862
+ ```
863
+
864
+ ### 4.3 Docker Compose
865
+
866
+ Both compose files read `GAME_SERVER_IMAGE` when it is set, so you can try a locally built image first
867
+ (`GAME_SERVER_IMAGE=my-game-server docker compose ...`). Both mount `./babylon-game-server.config.mjs` into the server.
868
+
869
+ Neither file sets a stop grace period, so `docker compose down` and restarts give the server Docker's default 10 seconds
870
+ before `SIGKILL`, shorter than the 30-second drain. Add one to the `game-server` service:
871
+
872
+ ```yaml
873
+ services:
874
+ game-server:
875
+ stop_grace_period: 45s
876
+ ```
877
+
878
+ #### WebSocket behind Caddy
879
+
880
+ `docker-compose.websocket.yml` + `Caddyfile`. **TLS:** edge (Caddy gets and renews a certificate for your domain).
881
+ **UDP:** no (Caddy proxies TCP). **Scaling:** one server.
882
+
883
+ The server publishes no ports of its own; only Caddy reaches it on the compose network. The server runs with
884
+ `GAME_SERVER_TRUST_PROXY: "true"`, because Caddy forwards the player's address.
885
+
886
+ 1. Point your domain's DNS at the host and open TCP 443.
887
+ 2. Put production settings in the config file, or add them under `environment:` in the compose file (secrets through an
888
+ `env_file:` that you do not commit).
889
+ 3. Start it:
890
+
891
+ ```sh
892
+ CADDY_SITE=game.example.com docker compose -f docker-compose.websocket.yml up -d
893
+ # players use wss://game.example.com
894
+ ```
895
+
896
+ Local check without a domain (plain HTTP on 8080):
897
+
898
+ ```sh
899
+ CADDY_SITE=:8080 docker compose -f docker-compose.websocket.yml up -d
900
+ curl -s http://localhost:8080/game-server/health # {"status":"ok"}
901
+ docker compose -f docker-compose.websocket.yml down
902
+ ```
903
+
904
+ The compose file publishes `${CADDY_HTTP_PORT:-443}:443` and `8080:8080`; port 80 is not published, so Caddy obtains its
905
+ certificate over 443 (TLS-ALPN) and there is no automatic `http://` → `https://` redirect.
906
+
907
+ #### TLS, HTTP/3 and Redis
908
+
909
+ `docker-compose.full.yml`. **TLS:** server. **UDP:** yes (443/udp published). **Scaling:** Redis is included and wired
910
+ (`GAME_SERVER_REDIS_URL: redis://redis:6379`), ready for more instances.
911
+
912
+ 1. Get a certificate into `./certs` (`fullchain.pem`, `privkey.pem`) readable by uid 1000, as in
913
+ [Certificates with certbot](#certificates-with-certbot) (copy into `./certs` instead of `/srv/game/certs`).
914
+ 2. Open TCP **and** UDP 443 on the host and its firewall.
915
+ 3. Start it:
916
+
917
+ ```sh
918
+ docker compose -f docker-compose.full.yml up -d
919
+ ```
920
+
921
+ Players use `wss://game.example.com`; browsers that support WebTransport switch to HTTP/3 datagrams automatically and
922
+ fall back to the WebSocket when UDP is blocked.
923
+
924
+ Notes:
925
+
926
+ - The server listens on 443 directly (`GAME_SERVER_PORT: "443"`) as the non-root `node` user; current Docker engines
927
+ allow that inside the container's network namespace.
928
+ - With one server, Redis only adds a dependency: readiness answers 503 if it goes down. Remove `GAME_SERVER_REDIS_URL`
929
+ and the `redis` service if you will never add a second instance. To add one on another host, point it at this Redis
930
+ (do not expose Redis to the internet; use a private network) and give **both** servers a `GAME_SERVER_PUBLIC_ADDRESS`
931
+ that reaches each one directly with a valid certificate (for example `game-1.example.com` and `game-2.example.com`).
932
+ - The Redis container runs without a password and is reachable only on the compose network.
933
+
934
+ ### 4.4 AWS
935
+
936
+ | Setup | Template | TLS | UDP | Scaling |
937
+ |---|---|---|---|---|
938
+ | ECS Fargate + Application Load Balancer | `aws/ecs-fargate-alb.yml` | edge (ALB, ACM certificate) | no | `DesiredCount=1` per stack |
939
+ | ECS Fargate + Network Load Balancer `TCP_QUIC` | `aws/ecs-fargate-nlb-quic.yml` | server (PEM from Secrets Manager) | yes | `DesiredCount=1` per stack |
940
+ | One EC2 instance + Elastic IP | `aws/README.md` | server | yes | one per address |
941
+ | Elastic Beanstalk | `aws/README.md` notes | edge (ALB) | no | one instance |
942
+
943
+ Both CloudFormation templates take the same parameters:
944
+
945
+ | Parameter | Meaning | Default |
946
+ |---|---|---|
947
+ | `VpcId` | The VPC the load balancer and the tasks run in | required |
948
+ | `SubnetIds` | Two or more **public** subnets in different availability zones | required |
949
+ | `CertificateArn` | ALB template: the ACM certificate for your domain. NLB template: not used (kept so both take the same parameters) | required (ALB) / `""` (NLB) |
950
+ | `TlsSecretArn` | NLB template only: Secrets Manager secret with `certPem` and `keyPem` | required (NLB) |
951
+ | `Image` | The server image | `ghcr.io/mackeyk24/babylon-game-server:<version>` |
952
+ | `DesiredCount` | Number of server tasks | `1` |
953
+
954
+ Both create: an ECS cluster, a CloudWatch Logs group (14-day retention), a task execution role, a Fargate task definition
955
+ (`Cpu: "512"`, `Memory: "1024"`, x86_64, `StopTimeout: 45`, `GAME_SERVER_LOG_FORMAT=json`), security groups, the load
956
+ balancer, a target group (`TargetType: ip`, health check `/game-server/ready`, deregistration delay 45 s) and the service
957
+ (tasks get a public IP so they can pull from ghcr.io without a NAT gateway). Output: `LoadBalancerDnsName` (the ALB
958
+ template also outputs `HealthUrl`).
959
+
960
+ The templates have no parameters for auth, origins, the admin token or Redis. Add them to the container's
961
+ `Environment` (plain values) and `Secrets` (from Secrets Manager) in the template before deploying; see
962
+ [Adding settings to the templates](#adding-settings-to-the-ecs-templates).
963
+
964
+ #### ECR (your own image)
965
+
966
+ See [Pushing to a registry](#pushing-to-a-registry). The task definitions do not set a CPU architecture, so Fargate runs
967
+ them on x86_64: the image must include `linux/amd64`. The task execution role's managed policy
968
+ (`AmazonECSTaskExecutionRolePolicy`) already allows pulling from ECR in the same account.
969
+
970
+ #### ACM and Route 53
971
+
972
+ Request a certificate (ALB recipe) in the **stack's region**, validated by DNS:
973
+
974
+ ```sh
975
+ aws acm request-certificate --domain-name game.example.com --validation-method DNS --region us-east-1
976
+ aws acm describe-certificate --certificate-arn <arn> --region us-east-1 \
977
+ --query "Certificate.DomainValidationOptions[0].ResourceRecord"
978
+ # create that CNAME in your DNS (Route 53 can do it from the ACM console), then:
979
+ aws acm wait certificate-validated --certificate-arn <arn> --region us-east-1
980
+ ```
981
+
982
+ After the stack is up, point your domain at the load balancer:
983
+
984
+ ```sh
985
+ LB=$(aws cloudformation describe-stacks --stack-name babylon-game-server \
986
+ --query "Stacks[0].Outputs[?OutputKey=='LoadBalancerDnsName'].OutputValue" --output text)
987
+ aws route53 change-resource-record-sets --hosted-zone-id <your zone id> --change-batch '{
988
+ "Changes": [{ "Action": "UPSERT", "ResourceRecordSet": {
989
+ "Name": "game.example.com", "Type": "CNAME", "TTL": 300, "ResourceRecords": [{ "Value": "'"$LB"'" }] } }] }'
990
+ ```
991
+
992
+ An alias A record works too (it needs the load balancer's hosted zone id from `aws elbv2 describe-load-balancers`).
993
+
994
+ #### ECS Fargate behind an Application Load Balancer
995
+
996
+ `aws/ecs-fargate-alb.yml`. **TLS:** edge (ALB, ACM). **UDP:** no (ALB listeners are HTTP/HTTPS only). **Scaling:**
997
+ `DesiredCount=1` per stack.
998
+
999
+ The ALB listens on HTTPS 443 (`SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06`) and forwards to the tasks on 2567. Its
1000
+ idle timeout is raised to 3600 seconds so quiet WebSockets are not cut. The tasks accept 2567 only from the ALB's
1001
+ security group, and the container sets `GAME_SERVER_TRUST_PROXY=true` because the ALB forwards the player's address.
1002
+
1003
+ ```sh
1004
+ aws cloudformation deploy \
1005
+ --template-file aws/ecs-fargate-alb.yml \
1006
+ --stack-name babylon-game-server \
1007
+ --capabilities CAPABILITY_IAM \
1008
+ --parameter-overrides \
1009
+ VpcId=vpc-0123456789abcdef0 \
1010
+ SubnetIds=subnet-aaaa,subnet-bbbb \
1011
+ CertificateArn=arn:aws:acm:us-east-1:123456789012:certificate/... \
1012
+ DesiredCount=1
1013
+ ```
1014
+
1015
+ Then create the DNS record, and check:
1016
+
1017
+ ```sh
1018
+ curl -s https://game.example.com/game-server/ready # {"ready":true}
1019
+ curl -s https://game.example.com/game-server/info
1020
+ ```
1021
+
1022
+ Set the game's `serverUrl` to `wss://game.example.com`.
1023
+
1024
+ #### ECS Fargate behind a Network Load Balancer with QUIC
1025
+
1026
+ `aws/ecs-fargate-nlb-quic.yml`. **TLS:** server. **UDP:** yes (`TCP_QUIC` listener). **Scaling:** `DesiredCount=1` per
1027
+ stack.
1028
+
1029
+ A `TCP_QUIC` listener on 443 passes TCP and QUIC through to a `TCP_QUIC` target group on 2567, and the server terminates
1030
+ TLS itself with `GAME_SERVER_TLS_ENABLED=true`, `GAME_SERVER_UDP_ENABLED=true`, and the certificate injected from
1031
+ Secrets Manager as `GAME_SERVER_TLS_CERT_PEM` / `GAME_SERVER_TLS_KEY_PEM`. The target group checks HTTPS
1032
+ `/game-server/ready`.
1033
+
1034
+ 1. **Get a certificate whose private key you hold** (the server needs the PEM key, so a certificate that lives only in
1035
+ ACM on a load balancer will not do). For example with certbot and its Route 53 DNS plugin:
1036
+
1037
+ ```sh
1038
+ certbot certonly --dns-route53 -d game.example.com
1039
+ ```
1040
+
1041
+ 2. **Store it** as one JSON secret with the keys `certPem` and `keyPem`:
1042
+
1043
+ ```sh
1044
+ aws secretsmanager create-secret --name babylon-game-server-tls \
1045
+ --secret-string "$(jq -n --rawfile certPem fullchain.pem --rawfile keyPem privkey.pem '{certPem: $certPem, keyPem: $keyPem}')"
1046
+ ```
1047
+
1048
+ 3. **Deploy:**
1049
+
1050
+ ```sh
1051
+ aws cloudformation deploy \
1052
+ --template-file aws/ecs-fargate-nlb-quic.yml \
1053
+ --stack-name babylon-game-server-quic \
1054
+ --capabilities CAPABILITY_IAM \
1055
+ --parameter-overrides \
1056
+ VpcId=vpc-0123456789abcdef0 \
1057
+ SubnetIds=subnet-aaaa,subnet-bbbb \
1058
+ TlsSecretArn=arn:aws:secretsmanager:us-east-1:123456789012:secret:babylon-game-server-tls-AbCdEf
1059
+ ```
1060
+
1061
+ 4. **Point your domain** at the `LoadBalancerDnsName` output. The certificate must cover that domain.
1062
+
1063
+ Notes:
1064
+
1065
+ - **Check QUIC support first.** QUIC listeners on Network Load Balancers are newer than the rest of this setup. Check the
1066
+ current Elastic Load Balancing documentation for QUIC listener and target requirements in your region before you
1067
+ depend on it.
1068
+ - **Security groups.** QUIC listeners cannot have security groups, so the task security group admits 2567 TCP and UDP
1069
+ from `0.0.0.0/0`.
1070
+ - **Client addresses.** There is no proxy header on this path, so `trustProxy` stays off. Check in the logs
1071
+ (`clientAddress`) that players appear with their own addresses. If they all appear as load balancer addresses, every
1072
+ player shares one join-rate allowance: turn on client IP preservation on the target group
1073
+ (`preserve_client_ip.enabled`, if your target type and protocol support it) or raise
1074
+ `GAME_SERVER_JOIN_REQUESTS_PER_MINUTE`.
1075
+ - **Certificate rotation.** The secret is read at task start. After renewing, update the secret and roll the service:
1076
+
1077
+ ```sh
1078
+ aws secretsmanager put-secret-value --secret-id babylon-game-server-tls \
1079
+ --secret-string "$(jq -n --rawfile certPem fullchain.pem --rawfile keyPem privkey.pem '{certPem: $certPem, keyPem: $keyPem}')"
1080
+ CLUSTER=$(aws cloudformation describe-stack-resource --stack-name babylon-game-server-quic --logical-resource-id Cluster \
1081
+ --query StackResourceDetail.PhysicalResourceId --output text)
1082
+ SERVICE=$(aws cloudformation describe-stack-resource --stack-name babylon-game-server-quic --logical-resource-id Service \
1083
+ --query StackResourceDetail.PhysicalResourceId --output text)
1084
+ aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" --force-new-deployment
1085
+ ```
1086
+
1087
+ #### Single EC2 instance with an Elastic IP
1088
+
1089
+ **TLS:** server. **UDP:** yes. **Scaling:** one instance per address. The most direct way to offer HTTP/3: the
1090
+ instance's address receives TCP and UDP 443 itself.
1091
+
1092
+ 1. Launch an instance (for example Amazon Linux 2023, `t4g.small`) with Docker installed, and associate an Elastic IP.
1093
+ Point your domain at the Elastic IP.
1094
+ 2. In its security group, open **TCP 443 and UDP 443** to `0.0.0.0/0` (and TCP 80 if certbot uses the HTTP challenge).
1095
+ 3. Get a certificate and copy it into a folder readable by uid 1000, as in
1096
+ [Certificates with certbot](#certificates-with-certbot).
1097
+ 4. Run the image with TLS and UDP, with `docker-compose.full.yml` or the `docker run` command in
1098
+ [A plain VM](#docker). The `aws/README.md` command is the same without `--stop-timeout`; add it.
1099
+
1100
+ `t4g` instances are arm64; the image includes `linux/arm64`.
1101
+
1102
+ #### Elastic Beanstalk
1103
+
1104
+ **TLS:** edge (ALB). **UDP:** no. **Scaling:** one instance.
1105
+
1106
+ - Use the **Docker** platform and deploy the starter folder with its `Dockerfile` (`eb init -p docker`, `eb create`).
1107
+ - Beanstalk puts an Application Load Balancer in front: attach your ACM certificate to a 443 HTTPS listener, forward to
1108
+ instance port 2567, set the health check path to `/game-server/ready`, and raise the ALB idle timeout to 3600 s so
1109
+ WebSockets are not cut.
1110
+ - Set `GAME_SERVER_TRUST_PROXY=true` and your production settings as environment properties.
1111
+ - The Beanstalk Docker platform can run its own proxy on the instance between the load balancer and the container.
1112
+ Check that a real game client connects (WebSocket upgrades reach the server) before relying on it; a passing health
1113
+ check does not prove that.
1114
+
1115
+ #### ElastiCache for Redis
1116
+
1117
+ Needed only for scale-out. Create a Redis-compatible cache **with cluster mode disabled** (the server opens one Redis
1118
+ connection from a URL; it does not create a cluster client) in the same VPC, and allow TCP 6379 from the task security
1119
+ group. Then:
1120
+
1121
+ ```sh
1122
+ GAME_SERVER_REDIS_URL=redis://<primary endpoint>:6379 # no in-transit encryption
1123
+ GAME_SERVER_REDIS_URL=rediss://:<auth token>@<primary endpoint>:6379 # in-transit encryption + AUTH
1124
+ ```
1125
+
1126
+ Pass it from Secrets Manager (it carries the password). Remember the
1127
+ [public address rule](#one-instance-or-scale-out-with-redis): Redis alone does not let you raise `DesiredCount` behind
1128
+ one load balancer. Run one stack per instance, each with its own domain, `DesiredCount=1`, the same Redis URL and
1129
+ `GAME_SERVER_PUBLIC_ADDRESS` set to that stack's domain.
1130
+
1131
+ Test the URL before deploying: with wrong credentials the server never starts listening (see
1132
+ [Troubleshooting](#8-troubleshooting-deployment)).
1133
+
1134
+ #### Adding settings to the ECS templates
1135
+
1136
+ Edit the `ContainerDefinitions` entry of the template. Plain values go in `Environment`; secrets in `Secrets`, which
1137
+ reads a Secrets Manager secret (or a JSON key of one, as the NLB template does):
1138
+
1139
+ ```yaml
1140
+ Environment:
1141
+ - Name: GAME_SERVER_LOG_FORMAT
1142
+ Value: json
1143
+ - Name: GAME_SERVER_TRUST_PROXY # ALB template only
1144
+ Value: "true"
1145
+ - Name: GAME_SERVER_ALLOWED_ORIGINS
1146
+ Value: https://game.example.com
1147
+ - Name: GAME_SERVER_AUTH_ENABLED
1148
+ Value: "true"
1149
+ - Name: GAME_SERVER_AUTH_MODE
1150
+ Value: jwt
1151
+ - Name: GAME_SERVER_AUTH_JWT_JWKS_URL
1152
+ Value: https://login.example.com/.well-known/jwks.json
1153
+ - Name: GAME_SERVER_METRICS_REQUIRE_ADMIN_TOKEN
1154
+ Value: "true"
1155
+ Secrets:
1156
+ - Name: GAME_SERVER_ADMIN_TOKEN
1157
+ ValueFrom: arn:aws:secretsmanager:us-east-1:123456789012:secret:babylon-game-server-admin-AbCdEf
1158
+ ```
1159
+
1160
+ The ALB template's `TaskExecutionRole` has no Secrets Manager permission. When you add `Secrets` to it, add a policy like
1161
+ the NLB template's `read-tls-secret`:
1162
+
1163
+ ```yaml
1164
+ Policies:
1165
+ - PolicyName: read-game-server-secrets
1166
+ PolicyDocument:
1167
+ Version: "2012-10-17"
1168
+ Statement:
1169
+ - Effect: Allow
1170
+ Action: secretsmanager:GetSecretValue
1171
+ Resource: arn:aws:secretsmanager:us-east-1:123456789012:secret:babylon-game-server-*
1172
+ ```
1173
+
1174
+ #### CloudWatch logs
1175
+
1176
+ The container logs with the `awslogs` driver to the stack's log group, stream prefix `game-server`, as JSON lines.
1177
+
1178
+ ```sh
1179
+ GROUP=$(aws cloudformation describe-stack-resource --stack-name babylon-game-server --logical-resource-id LogGroup \
1180
+ --query StackResourceDetail.PhysicalResourceId --output text)
1181
+ aws logs tail "$GROUP" --follow
1182
+ ```
1183
+
1184
+ A CloudWatch Logs Insights query for refused joins:
1185
+
1186
+ ```text
1187
+ fields time, level, message, sessionName, clientAddress
1188
+ | filter message = "join refused"
1189
+ | sort @timestamp desc
1190
+ ```
1191
+
1192
+ #### IAM the templates need
1193
+
1194
+ - **To deploy** (your user or CI role): CloudFormation, plus create/update/delete rights on what the stack makes: ECS
1195
+ (cluster, task definition, service), IAM (the unnamed task execution role, so the deploy needs
1196
+ `--capabilities CAPABILITY_IAM`, and `iam:PassRole` for it), EC2 security groups, Elastic Load Balancing (load
1197
+ balancer, target group, listener) and CloudWatch Logs (log group). Use an administrator role for the first deploy in
1198
+ a sandbox account, then narrow it to these services.
1199
+ - **Task execution role** (created by the stack): the managed `AmazonECSTaskExecutionRolePolicy` (pull from ECR, write
1200
+ to CloudWatch Logs). The NLB template adds `secretsmanager:GetSecretValue` on `TlsSecretArn`. If your secret is
1201
+ encrypted with a customer-managed KMS key, also allow `kms:Decrypt` on that key.
1202
+ - **Task role:** none. The server calls no AWS APIs.
1203
+
1204
+ ### 4.5 Kubernetes
1205
+
1206
+ `kubernetes/`. **TLS:** edge (Ingress) or server. **UDP:** only with server TLS and a LoadBalancer Service that forwards
1207
+ TCP and UDP. **Scaling:** Redis plus one player-reachable address per pod.
1208
+
1209
+ What the manifests contain:
1210
+
1211
+ | File | Contents |
1212
+ |---|---|
1213
+ | `deployment.yaml` | Deployment `babylon-game-server`, 1 replica, image `ghcr.io/mackeyk24/babylon-game-server:<version>`, port `game` 2567/TCP, `runAsNonRoot` as uid 1000, `terminationGracePeriodSeconds: 45`, liveness `/game-server/health`, readiness `/game-server/ready`, requests `cpu: 250m` / `memory: 512Mi`, limit `memory: 1Gi`. Environment: `GAME_SERVER_PUBLIC_ADDRESS: "$(POD_IP):2567"` (pod IP from the downward API), `GAME_SERVER_REDIS_URL: redis://babylon-game-server-redis:6379`, `GAME_SERVER_LOG_FORMAT: json`. |
1214
+ | `service.yaml` | ClusterIP Service `babylon-game-server` on 2567/TCP, for an Ingress |
1215
+ | `redis.yaml` | A single `redis:7` Deployment and ClusterIP Service `babylon-game-server-redis`, no persistence |
1216
+
1217
+ ```sh
1218
+ kubectl apply -f kubernetes/redis.yaml -f kubernetes/deployment.yaml -f kubernetes/service.yaml
1219
+ kubectl rollout status deployment/babylon-game-server
1220
+ kubectl port-forward deployment/babylon-game-server 2567:2567 &
1221
+ curl -s http://localhost:2567/game-server/ready
1222
+ ```
1223
+
1224
+ #### Before you expose it: the pod public address
1225
+
1226
+ `deployment.yaml` sets `GAME_SERVER_PUBLIC_ADDRESS` to the pod IP. Once a public address is set, **every** seat
1227
+ reservation sends the browser to it (`wss://<pod IP>:2567` when the game uses `wss://`), so it only works when players
1228
+ can reach pod IPs directly with a valid certificate, which is rare. Choose:
1229
+
1230
+ - **One replica behind a TLS Ingress (the common case):** remove the `GAME_SERVER_PUBLIC_ADDRESS` entry (and, if you
1231
+ like, `GAME_SERVER_REDIS_URL` and `redis.yaml`). Seat reservations then use the address the game already connected to.
1232
+ - **Several replicas:** give each pod its own address players can reach, with TLS valid for it, and set
1233
+ `GAME_SERVER_PUBLIC_ADDRESS` to it, for example a StatefulSet with one LoadBalancer Service and DNS name per pod
1234
+ (`game-0.example.com`), or `hostNetwork` nodes with public IPs. Keep the shared Redis.
1235
+
1236
+ #### WebSocket through an Ingress
1237
+
1238
+ Add `GAME_SERVER_TRUST_PROXY: "true"` to the Deployment (the Ingress controller forwards the player's address; the
1239
+ template does not set it, so without it every player shares the controller's address for the join-rate limit). An
1240
+ example Ingress for ingress-nginx (not shipped with the package):
1241
+
1242
+ ```yaml
1243
+ apiVersion: networking.k8s.io/v1
1244
+ kind: Ingress
1245
+ metadata:
1246
+ name: babylon-game-server
1247
+ annotations:
1248
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
1249
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
1250
+ spec:
1251
+ ingressClassName: nginx
1252
+ tls:
1253
+ - hosts: [game.example.com]
1254
+ secretName: game-example-com-tls # for example from cert-manager
1255
+ rules:
1256
+ - host: game.example.com
1257
+ http:
1258
+ paths:
1259
+ - path: /
1260
+ pathType: Prefix
1261
+ backend:
1262
+ service:
1263
+ name: babylon-game-server
1264
+ port:
1265
+ number: 2567
1266
+ ```
1267
+
1268
+ #### Secrets and settings
1269
+
1270
+ ```sh
1271
+ kubectl create secret generic babylon-game-server \
1272
+ --from-literal=GAME_SERVER_ADMIN_TOKEN="$(openssl rand -hex 32)" \
1273
+ --from-literal=GAME_SERVER_AUTH_JWT_SECRET="..."
1274
+ ```
1275
+
1276
+ ```yaml
1277
+ env:
1278
+ - name: GAME_SERVER_ALLOWED_ORIGINS
1279
+ value: https://game.example.com
1280
+ - name: GAME_SERVER_AUTH_ENABLED
1281
+ value: "true"
1282
+ - name: GAME_SERVER_AUTH_MODE
1283
+ value: jwt
1284
+ - name: GAME_SERVER_ADMIN_TOKEN
1285
+ valueFrom:
1286
+ secretKeyRef: { name: babylon-game-server, key: GAME_SERVER_ADMIN_TOKEN }
1287
+ - name: GAME_SERVER_AUTH_JWT_SECRET
1288
+ valueFrom:
1289
+ secretKeyRef: { name: babylon-game-server, key: GAME_SERVER_AUTH_JWT_SECRET }
1290
+ ```
1291
+
1292
+ A config file can be mounted from a ConfigMap and named with `GAME_SERVER_CONFIG` (needed for dedicated scenes).
1293
+
1294
+ #### Server TLS and UDP
1295
+
1296
+ Mount the certificate from a TLS Secret (cert-manager can keep it renewed; the mounted files update and the server
1297
+ reloads them within 30 seconds, HTTP/3 after a restart):
1298
+
1299
+ ```yaml
1300
+ env:
1301
+ - name: GAME_SERVER_TLS_ENABLED
1302
+ value: "true"
1303
+ - name: GAME_SERVER_TLS_CERT_FILE
1304
+ value: /certs/tls.crt
1305
+ - name: GAME_SERVER_TLS_KEY_FILE
1306
+ value: /certs/tls.key
1307
+ - name: GAME_SERVER_UDP_ENABLED
1308
+ value: "true"
1309
+ ports:
1310
+ - { name: game, containerPort: 2567, protocol: TCP }
1311
+ - { name: game-udp, containerPort: 2567, protocol: UDP }
1312
+ volumeMounts:
1313
+ - { name: certs, mountPath: /certs, readOnly: true }
1314
+ livenessProbe:
1315
+ httpGet: { path: /game-server/health, port: game, scheme: HTTPS }
1316
+ readinessProbe:
1317
+ httpGet: { path: /game-server/ready, port: game, scheme: HTTPS }
1318
+ volumes:
1319
+ - name: certs
1320
+ secret: { secretName: game-example-com-tls }
1321
+ ```
1322
+
1323
+ With server TLS the probes must use `scheme: HTTPS` (the kubelet does not verify the certificate). Expose TCP and UDP on
1324
+ the same port with a LoadBalancer Service (example, not shipped; mixed-protocol LoadBalancers need a cluster and cloud
1325
+ provider that support them):
1326
+
1327
+ ```yaml
1328
+ apiVersion: v1
1329
+ kind: Service
1330
+ metadata:
1331
+ name: babylon-game-server-public
1332
+ spec:
1333
+ type: LoadBalancer
1334
+ externalTrafficPolicy: Local # keeps the player's address (no proxy header on this path)
1335
+ selector:
1336
+ app: babylon-game-server
1337
+ ports:
1338
+ - { name: wss, port: 443, targetPort: 2567, protocol: TCP }
1339
+ - { name: h3, port: 443, targetPort: 2567, protocol: UDP }
1340
+ ```
1341
+
1342
+ Players use `wss://game.example.com` (DNS to the Service's external address); TCP 443 and UDP 443 both reach the pod.
1343
+ Leave `GAME_SERVER_TRUST_PROXY` off on this path.
1344
+
1345
+ #### Probes and rolling updates
1346
+
1347
+ - Liveness `/game-server/health` restarts a stuck process; readiness `/game-server/ready` removes a draining pod or one
1348
+ that lost Redis from the Service.
1349
+ - A rolling update with one replica starts the new pod before stopping the old one (the default `maxSurge` rounds up to
1350
+ 1). The old pod gets `SIGTERM`, drains for up to 30 s, and is killed after `terminationGracePeriodSeconds: 45`.
1351
+
1352
+ ### 4.6 Fly.io
1353
+
1354
+ `fly.toml`. **TLS:** edge (Fly's proxy on 443). **UDP:** no. **Scaling:** one machine.
1355
+
1356
+ The template builds the init `Dockerfile`, sets `GAME_SERVER_PORT = "2567"` and `GAME_SERVER_TRUST_PROXY = "true"`, serves
1357
+ 443 (TLS + HTTP) and 80 (redirect to HTTPS), keeps one machine running (`auto_stop_machines = "off"`,
1358
+ `min_machines_running = 1`), checks `/game-server/ready` every 15 s, and waits `kill_timeout = "45s"` on stop.
1359
+
1360
+ ```sh
1361
+ fly launch --copy-config --no-deploy # pick an app name; it replaces "app" in fly.toml
1362
+ fly secrets set GAME_SERVER_ADMIN_TOKEN="$(openssl rand -hex 32)" \
1363
+ GAME_SERVER_AUTH_ENABLED=true GAME_SERVER_AUTH_MODE=shared-secret GAME_SERVER_AUTH_SHARED_SECRET_TOKENS=...
1364
+ fly deploy
1365
+ curl -s https://<app>.fly.dev/game-server/ready
1366
+ ```
1367
+
1368
+ Players use `wss://<app>.fly.dev`, or your domain after `fly certs add game.example.com`. Put non-secret settings
1369
+ (`GAME_SERVER_ALLOWED_ORIGINS`) in the `[env]` section.
1370
+
1371
+ **Why no UDP:** Fly delivers UDP only to a socket bound to the special address `fly-global-services`, while the server's
1372
+ HTTP/3 transport binds one address for TCP and UDP together. **Why one machine:** Fly machines share the app's public
1373
+ address, so they have no individual address for seat reservations. To scale, run separate Fly apps (each with its own
1374
+ host name as `GAME_SERVER_PUBLIC_ADDRESS`) sharing one external Redis.
1375
+
1376
+ ### 4.7 Railway
1377
+
1378
+ `railway.json`. **TLS:** edge. **UDP:** no (Railway has no inbound UDP). **Scaling:** `numReplicas: 1`.
1379
+
1380
+ Railway builds the `Dockerfile`, sets `PORT` (which the server honours), uses `/game-server/ready` as the deployment
1381
+ health check (`healthcheckTimeout: 60`), and restarts on failure. JSON has no comments, so the recipe's header lives in
1382
+ the starter `DEPLOY.md`.
1383
+
1384
+ ```sh
1385
+ railway init
1386
+ railway up
1387
+ railway domain # players use wss://<generated domain>
1388
+ ```
1389
+
1390
+ Set `GAME_SERVER_TRUST_PROXY=true` and your production settings in the service's variables (secrets as sealed
1391
+ variables). Do not set `GAME_SERVER_PORT`. Keep one replica: replicas share the service's domain, so they have no
1392
+ individual address for seat reservations.
1393
+
1394
+ ### 4.8 Render
1395
+
1396
+ `render.yaml`. **TLS:** edge. **UDP:** no (HTTP and WebSocket only). **Scaling:** `numInstances: 1`.
1397
+
1398
+ A Docker web service (`plan: starter`) with `healthCheckPath: /game-server/ready` and `GAME_SERVER_TRUST_PROXY=true`.
1399
+ Render sets `PORT`, which the server honours.
1400
+
1401
+ 1. Commit the starter folder to a repository.
1402
+ 2. In the Render dashboard: **New > Blueprint**, pick the repository.
1403
+ 3. Add your settings under `envVars` in `render.yaml` (secrets with `sync: false`, then set the value in the dashboard).
1404
+
1405
+ Players use `wss://<service>.onrender.com` or your custom domain. Keep one instance (instances share one host name).
1406
+
1407
+ ### 4.9 DigitalOcean App Platform
1408
+
1409
+ `digitalocean-app.yaml`. **TLS:** edge. **UDP:** no (HTTP only). **Scaling:** `instance_count: 1`.
1410
+
1411
+ A service built from the `Dockerfile` (`instance_size_slug: apps-s-1vcpu-1gb`, `http_port: 2567`), health check
1412
+ `/game-server/ready` (`initial_delay_seconds: 20`, `period_seconds: 15`), with `GAME_SERVER_PORT=2567` and
1413
+ `GAME_SERVER_TRUST_PROXY=true`.
1414
+
1415
+ 1. Commit the starter folder to a GitHub repository and set `github.repo` (and `branch`) in the file.
1416
+ 2. Add your settings under `envs` (secrets with `type: SECRET`).
1417
+ 3. Create the app:
1418
+
1419
+ ```sh
1420
+ doctl apps create --spec digitalocean-app.yaml
1421
+ # later changes:
1422
+ doctl apps update <app id> --spec digitalocean-app.yaml
1423
+ ```
1424
+
1425
+ Players use `wss://<app>.ondigitalocean.app` or your custom domain. With `deploy_on_push: true`, every push to the branch
1426
+ redeploys. Keep one instance.
1427
+
1428
+ ### 4.10 Heroku
1429
+
1430
+ `heroku.yml` + `app.json`. **TLS:** edge (Heroku router). **UDP:** no (HTTP routing only). **Scaling:** one dyno.
1431
+
1432
+ The container stack builds the `Dockerfile`. `app.json` sets the stack, `GAME_SERVER_TRUST_PROXY=true`, and
1433
+ `GAME_SERVER_DRAIN_SECONDS=25`, because Heroku stops a dyno 30 seconds after `SIGTERM`. Heroku sets `PORT`.
1434
+
1435
+ ```sh
1436
+ heroku create --stack container
1437
+ heroku config:set GAME_SERVER_ALLOWED_ORIGINS=https://game.example.com \
1438
+ GAME_SERVER_ADMIN_TOKEN="$(openssl rand -hex 32)" \
1439
+ GAME_SERVER_AUTH_ENABLED=true GAME_SERVER_AUTH_MODE=jwt GAME_SERVER_AUTH_JWT_SECRET=...
1440
+ git push heroku main
1441
+ ```
1442
+
1443
+ `app.json` also describes a Deploy-to-Heroku button (formation `web`, size `basic`). Players use
1444
+ `wss://<app>.herokuapp.com`. Keep one dyno: dynos share the app's host name.
1445
+
1446
+ ### 4.11 Colyseus Cloud
1447
+
1448
+ `colyseus-cloud/`. **TLS:** edge (Colyseus Cloud's NGINX serves `wss://`). **UDP:** no. **Scaling:** one process per CPU,
1449
+ sharing Redis, behind one address.
1450
+
1451
+ This is the recommended host. Colyseus Cloud deploys only from git: it clones a branch, runs `npm install` and the
1452
+ `build` script, then starts the processes with PM2. The folder is a small deploy repository that installs the published
1453
+ package, with a no-op build. Push it as a repository of its own, or keep it as a subfolder of another repository (your
1454
+ game's) and set the application's **Root directory** and watch paths to it on the panel. Do not point Colyseus Cloud at
1455
+ this package's own source folder: every push to it would deploy, each deploy would run the TypeScript and console build,
1456
+ and the source repository does not commit `package-lock.json`. The steps below add to the folder's own `README.md`.
1457
+
1458
+ **Publish first.** The recipe installs `@babylonjs-toolkit/server` from npm, so that version must be published:
1459
+
1460
+ ```sh
1461
+ cd SVR && npm run release:publish
1462
+ # then, in the folder init wrote:
1463
+ cd colyseus-cloud && npm install
1464
+ git add package.json package-lock.json
1465
+ ```
1466
+
1467
+ Commit `package-lock.json` with every change to `package.json`. Installing is Colyseus Cloud's own build step, before
1468
+ `colyseus-post-deploy` runs. The `@colyseus/tools` deploy scripts and the `@colyseus/cloud` command never install
1469
+ anything or read a lockfile, and the Cloud documentation does not say which install command it runs, only that a pnpm
1470
+ project must push `pnpm-lock.yaml` so that Cloud can install it. Both `npm install` and `npm ci` follow a lockfile that
1471
+ matches `package.json`, and `npm ci` stops with an error when the two disagree. The rehearsal itself runs `npm install`;
1472
+ `npm ci` passed separately on a generated lockfile with a fresh tarball, on macOS with `--ignore-scripts`. Do not commit `node_modules`: Colyseus's troubleshooting page lists that as a cause of "Permission
1473
+ denied" deploy failures.
1474
+
1475
+ **First deploy checklist:**
1476
+
1477
+ 1. Confirm that the application runs Node 22 or newer. Colyseus does not document Cloud's Node version, so ask
1478
+ Colyseus support before the first deploy. Choose the plan by memory as well as CPU:
1479
+ Colyseus Cloud sizes each process's memory limit from the plan, about 1.1 GB per process with 2 GB per CPU but only
1480
+ 256 MB on the smallest 1 GB plan, and its machines have no swap. A WebSocket-only server for client-hosted games fits
1481
+ the smallest plan; dedicated sessions need measuring (see below).
1482
+ 2. On the panel, set `NODE_ENV=production`, `GAME_SERVER_ALLOWED_ORIGINS` (your game's web address) and
1483
+ `GAME_SERVER_ADMIN_TOKEN` (a long random secret).
1484
+ 3. Connect the repository, or run `npx @colyseus/cloud deploy` from the folder.
1485
+ 4. Read the startup banner in the application logs. It must show the `wss://` address and `Redis: on` (on a plan of more
1486
+ than one CPU):
1487
+
1488
+ ```text
1489
+ Babylon Game Server <version> listening on wss://<application-address>
1490
+ HTTP: https://<application-address>
1491
+ WebTransport: off
1492
+ TLS: off UDP: off Auth: off Redis: on (127.0.0.1:6379)
1493
+ ```
1494
+
1495
+ 5. Put that address in the game's `VITE_FUSION_SERVER_URL`, and rebuild the game.
1496
+ 6. Race two players from two browsers.
1497
+ 7. Open the setup link from the logs, and check three things the rehearsal could only assume, because Colyseus does not
1498
+ document which headers its NGINX forwards:
1499
+ - The console opens. If it answers `the console needs HTTPS`, Cloud's NGINX is not sending `X-Forwarded-Proto: https`.
1500
+ - One console change saves. The CSRF check compares `Origin` with `scheme://<Host>`, so if Cloud's NGINX rewrites
1501
+ `Host`, sign-in works but every console change returns 403.
1502
+ - The console's Players page shows a player's real address. The server reads `X-Real-IP`, then the first entry of
1503
+ `X-Forwarded-For`. If the page shows one internal address for everyone, the address is not reaching the server, and
1504
+ address bans and rate limits would catch every player at once. If NGINX keeps an `X-Forwarded-For` the player sent
1505
+ at the front of that list, and sets no `X-Real-IP`, a player can name any address. Send a request with a made-up
1506
+ `X-Forwarded-For` and see which address is recorded. Until you have checked this, do not rely on address bans.
1507
+
1508
+ **What Cloud sets for you.** These apply only when `COLYSEUS_CLOUD` is set, and sit below everything else, so a config
1509
+ file value, a `GAME_SERVER_*` variable or a flag still wins:
1510
+
1511
+ | Setting | Value on Colyseus Cloud |
1512
+ |---|---|
1513
+ | `publicUrl` | `wss://<SUBDOMAIN>.<SERVER_NAME>` |
1514
+ | `trustProxy` | `true` |
1515
+ | `drainSeconds` | `600` |
1516
+ | `redis.url` | `REDIS_URI` when set, else `redis://127.0.0.1:6379` on a machine of more than one CPU, else none |
1517
+ | `admin.dataDir` | `.babylon-game-server-data` in the home folder, used only without Redis |
1518
+
1519
+ The config page of the admin console labels these values "Colyseus Cloud". The precedence is defaults < Colyseus Cloud's
1520
+ defaults (on this host only) < config file < environment < flags. Panel variables reach the server through `.env.cloud`,
1521
+ which Colyseus loads before the server reads its config.
1522
+
1523
+ **What it refuses.** The server stops at startup and names the setting to change, because each of these breaks routing
1524
+ behind Cloud's NGINX:
1525
+
1526
+ | Setting | Refusal |
1527
+ |---|---|
1528
+ | `GAME_SERVER_TLS_ENABLED` | `Colyseus Cloud serves TLS itself; turn off GAME_SERVER_TLS_ENABLED` |
1529
+ | `GAME_SERVER_UDP_ENABLED` | `UDP does not reach the server on Colyseus Cloud; turn off GAME_SERVER_UDP_ENABLED` |
1530
+ | `GAME_SERVER_PUBLIC_ADDRESS` | `Colyseus Cloud sets the public address; unset GAME_SERVER_PUBLIC_ADDRESS` |
1531
+
1532
+ **Admin console.** The server logs one setup link per process, and any of them works, whichever process NGINX picks. Use
1533
+ one of them: the first owner ends them all. `babylon-game-server admin create-owner` needs a shell on the machine, so on
1534
+ Cloud the link is the way in. Console accounts live in Redis on a plan of more than one CPU, so they survive a deploy.
1535
+
1536
+ **Leaderboards.** The [built-in leaderboards](README.md#leaderboards) need no extra service:
1537
+
1538
+ - Commit the definitions file, for example `leaderboards.json`, to the deploy repository, and set
1539
+ `leaderboards: { definitionsFile: "./leaderboards.json" }` in `babylon-game-server.config.mjs` (or
1540
+ `GAME_SERVER_LEADERBOARDS_DEFINITIONS_FILE` on the panel). Every process creates the missing boards at start; console
1541
+ edits are kept across deploys. A server version without leaderboards refuses the unknown `leaderboards` key and
1542
+ stops, so set it only once the deployed version has them.
1543
+ - The scores live where the console's data does. On a plan of more than one CPU that is the machine's local Redis,
1544
+ and Colyseus does not document whether it survives deploys and restarts; on a 1-CPU plan it is files in the home
1545
+ folder, which a redeploy can wipe. For production, set `REDIS_URI` on the panel to an external Redis with persistence
1546
+ (append-only file or snapshots, as a managed Redis provides). The console's Leaderboards page shows whether the
1547
+ storage is durable, unknown or not durable, and what to change; export a backup from its Settings tab before
1548
+ changing hosts.
1549
+
1550
+ **Dedicated sessions.** A dedicated session runs in the server processes, so it needs no port or container of its own:
1551
+
1552
+ - Keep each scene in the repository, with a `sceneRoot` such as `./scenes/arena/`, or at an `https://` `sceneRoot`.
1553
+ Dedicated scenes are configured only in `babylon-game-server.config.mjs`.
1554
+ - Each process is one thread, so `instances: os.cpus().length` is what adds dedicated capacity. Choose a plan by CPU.
1555
+ - Set `limits.maxDedicatedSessions` by measurement, as in
1556
+ [Dedicated sessions need CPU and memory](#dedicated-sessions-need-cpu-and-memory). `@colyseus/tools` works out a
1557
+ memory limit per process from the machine's RAM. A process over it restarts, which ends every session on it.
1558
+ - A `boot` session runs once, however many processes there are: one process holds the `dedicated-boot` lease in Redis and
1559
+ runs them.
1560
+ - **A `boot` session delays every deploy by the drain time.** It never empties, so the process holding it drains for the
1561
+ full `drainSeconds`: 600 s by default here. The players in it then get `GameClosed`. The lease passes to a new process
1562
+ once the old one stops renewing it, 30 s after the drain starts, but the new process waits for the old session to end
1563
+ so that two sessions never share a name. It starts the session within about 10 s of the old one ending. Set
1564
+ `GAME_SERVER_DRAIN_SECONDS` lower on the panel if your boot
1565
+ session should not hold a deploy that long; the same value limits every other running session too.
1566
+ - **A bad boot scene does not fail a rolling deploy.** The new processes are not the lease holder when they first start,
1567
+ so a scene that cannot load fails later, after the deploy has reported success, and the process stays ready. A failure
1568
+ stops a process at startup only when it takes the lease at its first attempt, as on the very first deploy. After a
1569
+ deploy that changes a boot scene, watch for this line, which the holder logs at `error` every 10 seconds:
1570
+
1571
+ ```text
1572
+ the boot dedicated sessions could not start; trying again
1573
+ ```
1574
+
1575
+ Then confirm that the session is listed: `curl -H "Authorization: Bearer $GAME_SERVER_ADMIN_TOKEN"
1576
+ https://<application-address>/game-server/admin/sessions`.
1577
+
1578
+ **Deploying an update.** Colyseus rolls a deploy out process by process: half the processes come up new, NGINX moves to
1579
+ them, and the old ones drain. Port numbers change between deploys, so never write one down. A joined session keeps
1580
+ running on its old process until it ends, up to `drainSeconds`, and a process with no sessions left exits at once.
1581
+ A draining process gets no new sessions: every process's matchmaker skips it, so new sessions open on the new
1582
+ processes only. A deploy whose new processes do not listen within `listen_timeout` (two minutes in the template) fails,
1583
+ and the old version keeps serving; the very first deploy is not checked that way, so read its logs for the banner.
1584
+ `npx @colyseus/cloud deploy` only asks Cloud to deploy the pushed branch and returns before the deploy finishes, even
1585
+ when it fails, so read the result on the panel. An uncaught error in a process drains it too (exit code 1), so one bad
1586
+ session does not cut off every player on that process at once.
1587
+
1588
+ **Why the template sets `kill_timeout` and `listen_timeout`.** Colyseus Cloud's deploy agent fills in 30 minutes and
1589
+ 60 s when they are unset, but when the agent is not running, deploys fall back to a plain PM2 reload with PM2's own
1590
+ defaults: a 1.6 s kill timeout, which cuts the drain off. Colyseus recorded exactly that in production in the
1591
+ `@colyseus/tools` 0.18.11 changelog. Keep `max_memory_restart` unset unless you have measured: Cloud sizes it from the
1592
+ plan.
1593
+
1594
+ **Watching it from the admin console.** Add a Colyseus Cloud API token (`GAME_SERVER_ADMIN_COLYSEUS_CLOUD_API_TOKEN`,
1595
+ or `COLYSEUS_CLOUD_TOKEN`) on the panel and the console's **Colyseus Cloud** page shows the application's status,
1596
+ host processes, 30-day history, traffic, deploys, host logs and billing next to the server's own pages. Without a token, the page on
1597
+ Colyseus Cloud explains how to add one. See [Colyseus Cloud page](README.md#colyseus-cloud-page).
1598
+
1599
+ **Costs and limits.** Colyseus Cloud is
1600
+ [billed monthly up front](https://docs.colyseus.io/cloud/pricing-billing), per application and location. The live
1601
+ price list behind the [pricing page](https://colyseus.io/pricing) started at $12.50/month (1 shared vCPU, 1 GB) on
1602
+ 2026-11-08, with the recommended High Frequency machines from $18/month; the page's own "from $15" was out of date.
1603
+ There is no free tier, refunds are only within 7 days of the first payment, there are no player (CCU) limits and
1604
+ bandwidth is listed as unlimited. The SLA covers 99.9% network and host uptime, not application crashes; support is by
1605
+ email. Stopping a process has a 30-minute limit (`kill_timeout`), so keep `drainSeconds` below 1800.
1606
+
1607
+ **Open questions from the docs.** Colyseus does not publish its NGINX site configuration, so which headers it forwards
1608
+ stays a first-deploy check (step 7 above). It does not document Cloud's Node version, the install command its build
1609
+ runs (`npm ci` or `npm install`; commit the lockfile so both work), whether the machine's local Redis keeps its data
1610
+ across deploys and restarts (it holds the console accounts on plans of more than one CPU), or custom domains beyond a
1611
+ 2023 announcement for Cloudflare-managed DNS. Check them with Colyseus support before you depend on them.
1612
+
1613
+ ---
1614
+
1615
+ ## 5. Serving dedicated-session scenes
1616
+
1617
+ A dedicated session runs a headless `TOOLKIT.NetworkRunner` in **Server** mode: it loads the same exported scene the
1618
+ browsers load and simulates everything. Players join with `GameMode.Client` and the session name. Dedicated scenes are
1619
+ configured only in the config file:
1620
+
1621
+ ```js
1622
+ // babylon-game-server.config.mjs
1623
+ export default {
1624
+ dedicated: {
1625
+ scenes: [
1626
+ { name: "arena", sceneRoot: "/app/scenes/arena/", sceneFile: "arena.gltf", projectScripts: [], maxPlayers: 8, onDemand: true, idleSeconds: 60 }
1627
+ ],
1628
+ boot: [
1629
+ { sessionName: "arena-1", sceneName: "arena" }
1630
+ ]
1631
+ }
1632
+ };
1633
+ ```
1634
+
1635
+ | Field | Meaning | Default |
1636
+ |---|---|---|
1637
+ | `name` | The name clients and the admin API use | required |
1638
+ | `sceneRoot` | An `http(s)://` URL, or a folder on the server, holding the exported scene | required |
1639
+ | `sceneFile` | The exported scene file in that folder | required |
1640
+ | `projectScripts` | Extra script bundles (URLs or paths) loaded before the scene | `[]` |
1641
+ | `maxPlayers` | Most players | `8` |
1642
+ | `onDemand` | A `Client` join naming this scene may start it | `false` |
1643
+ | `idleSeconds` | Seconds an on-demand session stays up after its last player left (after the reconnect grace period) | `60` |
1644
+
1645
+ Sessions start three ways: each `boot` entry with the server (it never closes); `POST /game-server/admin/dedicated`; or
1646
+ on demand, when a `Client` join's session property `dedicatedScene` names a scene with `onDemand: true`. At most
1647
+ `limits.maxDedicatedSessions` run at once per instance.
1648
+
1649
+ ### Where the scene comes from
1650
+
1651
+ **A folder (recommended for production).** Mount or copy the exported scene folder (the scene file, its buffers, and the
1652
+ project script bundle the exporter writes next to it, which loads automatically) into the container, readable by the
1653
+ `node` user. Relative paths resolve against the server's working directory (`/app` in the image); prefer absolute paths.
1654
+
1655
+ ```sh
1656
+ # Mount at run time:
1657
+ docker run -d -p 2567:2567 \
1658
+ -v /srv/game/export/scenes:/app/scenes/arena:ro \
1659
+ -v "$PWD/babylon-game-server.config.mjs:/app/config/babylon-game-server.config.mjs:ro" \
1660
+ -e GAME_SERVER_CONFIG=/app/config/babylon-game-server.config.mjs \
1661
+ ghcr.io/mackeyk24/babylon-game-server:<version>
1662
+ ```
1663
+
1664
+ Or bake it into your image by uncommenting the line in the init `Dockerfile`:
1665
+
1666
+ ```dockerfile
1667
+ COPY scenes/ /app/scenes/
1668
+ ```
1669
+
1670
+ Baking the scene in ties a server image to a game build, which keeps the two in step and makes rollbacks exact.
1671
+
1672
+ **A URL.** `sceneRoot: "https://game.example.com/scenes/"` makes the server fetch the scene over HTTP(S) when each
1673
+ session starts. The server needs outbound access to that host. A running session keeps the scene it loaded; new sessions
1674
+ fetch the current files.
1675
+
1676
+ **Only configured locations load.** The headless runtime fetches only from the configured `sceneRoot` and
1677
+ `projectScripts` locations; a client can name a scene, never a URL. Anything the scene needs must live under its root.
1678
+
1679
+ ### Keeping the server and the game in step
1680
+
1681
+ The dedicated runner and the browsers must run the same export. After you re-export a game, restart its dedicated
1682
+ sessions (redeploy, or close them with the admin API and start them again); a mismatch shows as
1683
+ `IncompatibleConfiguration` with no detail (different prefab tables). The game's script bundle must register its input
1684
+ struct with `TOOLKIT.NetworkRunner.setDefaultInputDefinition(...)`, because no launcher starts the dedicated runner.
1685
+
1686
+ ### Sizing
1687
+
1688
+ Each dedicated session runs a full scene simulation on the instance's one JavaScript thread. Measure with the dedicated
1689
+ metrics, set `GAME_SERVER_MAX_DEDICATED_SESSIONS` to what one instance sustains, and add instances rather than CPU. See
1690
+ [Dedicated sessions need CPU and memory](#dedicated-sessions-need-cpu-and-memory). Raise the memory limit when you load
1691
+ large scenes.
1692
+
1693
+ A boot session that fails to load stops the server at startup (exit code 1), so a wrong `sceneRoot` shows up at deploy
1694
+ time. An on-demand or admin-started session that fails answers `GameNotFound: the dedicated scene <name> failed to load`
1695
+ or 503 from the admin API, and the log says why.
1696
+
1697
+ ---
1698
+
1699
+ ## 6. Operating it
1700
+
1701
+ ### Health and readiness
1702
+
1703
+ ```sh
1704
+ curl -s https://game.example.com/game-server/health # {"status":"ok"}
1705
+ curl -s https://game.example.com/game-server/ready # {"ready":true} or 503 {"ready":false,"reasons":["draining"]}
1706
+ curl -s https://game.example.com/game-server/info # version, protocol version, transports, authRequired
1707
+ ```
1708
+
1709
+ `/game-server/info` is also how you confirm UDP: `transports.webTransport` is `{ port, certificateHash }` when UDP is on,
1710
+ `null` when off (`certificateHash` is non-null only with a pinned development certificate).
1711
+
1712
+ ### Metrics
1713
+
1714
+ `GET /metrics` serves Prometheus text, every metric prefixed `babylon_game_server_`:
1715
+
1716
+ | Metric | Type | Labels |
1717
+ |---|---|---|
1718
+ | `babylon_game_server_players` | gauge | `game_mode` |
1719
+ | `babylon_game_server_sessions` | gauge | `game_mode` |
1720
+ | `babylon_game_server_dedicated_sessions` | gauge | |
1721
+ | `babylon_game_server_joins_total` | counter | `result` |
1722
+ | `babylon_game_server_refusals_total` | counter | `reason` |
1723
+ | `babylon_game_server_auth_failures_total` | counter | `mode` |
1724
+ | `babylon_game_server_frames_total` | counter | `direction`, `transport`, `channel` |
1725
+ | `babylon_game_server_bytes_total` | counter | `direction`, `transport` |
1726
+ | `babylon_game_server_dedicated_tick_overruns_total` | counter | |
1727
+ | `babylon_game_server_dedicated_frame_seconds` | histogram | |
1728
+
1729
+ The Node.js process metrics (CPU, memory, event-loop lag, garbage collection) carry the same prefix. Each instance
1730
+ reports only itself, so scrape every instance on its own address, not through the load balancer.
1731
+
1732
+ Prometheus scrape configuration (with `metrics.requireAdminToken` on):
1733
+
1734
+ ```yaml
1735
+ scrape_configs:
1736
+ - job_name: babylon-game-server
1737
+ metrics_path: /metrics
1738
+ scheme: http # https when TLS is on the server
1739
+ authorization:
1740
+ credentials_file: /etc/prometheus/game-server-admin-token
1741
+ static_configs:
1742
+ - targets: ["10.0.1.12:2567", "10.0.2.31:2567"]
1743
+ ```
1744
+
1745
+ On Kubernetes with the Prometheus Operator, a PodMonitor (example):
1746
+
1747
+ ```yaml
1748
+ apiVersion: monitoring.coreos.com/v1
1749
+ kind: PodMonitor
1750
+ metadata:
1751
+ name: babylon-game-server
1752
+ spec:
1753
+ selector:
1754
+ matchLabels:
1755
+ app: babylon-game-server
1756
+ podMetricsEndpoints:
1757
+ - port: game
1758
+ path: /metrics
1759
+ ```
1760
+
1761
+ Useful queries for a Grafana dashboard:
1762
+
1763
+ ```promql
1764
+ sum(babylon_game_server_players) # players online
1765
+ sum by (game_mode) (babylon_game_server_sessions) # sessions by mode
1766
+ sum by (reason) (rate(babylon_game_server_refusals_total[5m])) # why joins fail
1767
+ sum by (transport) (rate(babylon_game_server_frames_total{direction="in"}[5m])) # WebSocket vs WebTransport traffic
1768
+ histogram_quantile(0.99, sum by (le) (rate(babylon_game_server_dedicated_frame_seconds_bucket[5m])))
1769
+ rate(babylon_game_server_dedicated_tick_overruns_total[5m]) # dedicated sessions falling behind
1770
+ ```
1771
+
1772
+ On AWS, Amazon Managed Service for Prometheus can store these: run a collector (for example the AWS Distro for
1773
+ OpenTelemetry collector or a Prometheus agent) that scrapes each task's private address and remote-writes to the
1774
+ workspace, and use Amazon Managed Grafana with that workspace as its data source.
1775
+
1776
+ Suggested alerts: readiness failing for more than a few minutes; a rising `refusals_total` for `MaxCcuReached`;
1777
+ `dedicated_tick_overruns_total` increasing steadily; event-loop lag above your tick time.
1778
+
1779
+ ### Logs
1780
+
1781
+ Production logs are JSON lines to stdout (info, debug) and stderr (warn, error). Every platform recipe collects them:
1782
+ CloudWatch Logs on ECS, `kubectl logs`, `fly logs`, `heroku logs --tail`, the Railway / Render / DigitalOcean log views,
1783
+ `docker logs` and `journalctl` on a VM. Messages worth watching:
1784
+
1785
+ | Message | Meaning |
1786
+ |---|---|
1787
+ | `join refused` | A join was refused; the reason and `clientAddress` are in the fields |
1788
+ | `auth is disabled and the server listens on ...` | Production with auth off (a warning at startup) |
1789
+ | `draining` / `waiting for sessions to end` / `drained` | A drain started, is waiting, finished |
1790
+ | `drain did not finish in time` | The drain watchdog exited the process with code 1 |
1791
+ | `certificate reloaded` | Renewed certificate files were applied |
1792
+ | `HTTP/3 uses the new certificate after a restart` | Renewed with UDP on: restart when convenient |
1793
+ | `certificate reload failed` | A half-written renewal; retried at the next 30 s poll |
1794
+ | `token verifier failed` | Your webhook or custom verifier threw |
1795
+ | `the dedicated session did not start` | An admin-started dedicated session failed; the error is in the fields |
1796
+
1797
+ Tokens, secrets and keys are never logged.
1798
+
1799
+ ### The admin API
1800
+
1801
+ Available only when `GAME_SERVER_ADMIN_TOKEN` is set (404 otherwise; 401 with a wrong token).
1802
+
1803
+ ```sh
1804
+ GS=https://game.example.com
1805
+ TOKEN=... # GAME_SERVER_ADMIN_TOKEN
1806
+
1807
+ # List every session (every instance when scaled out with Redis)
1808
+ curl -s -H "Authorization: Bearer $TOKEN" $GS/game-server/admin/sessions
1809
+ # [{"roomId":"abc123","sessionName":"arena","gameMode":"Shared","players":4,"maxPlayers":8,"isDedicated":false}]
1810
+
1811
+ # Disconnect a session's players (200 {"closed":"abc123"}, 404 if unknown)
1812
+ curl -s -X POST -H "Authorization: Bearer $TOKEN" $GS/game-server/admin/sessions/abc123/close
1813
+
1814
+ # Start a dedicated session of a configured scene (201 {"roomId":...}; 400, 404 unknown scene, 503 did not start)
1815
+ curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
1816
+ -d '{"sceneName":"arena","sessionName":"arena-2"}' $GS/game-server/admin/dedicated
1817
+
1818
+ # Start a drain without exiting the process (202 {"draining":true})
1819
+ curl -s -X POST -H "Authorization: Bearer $TOKEN" $GS/game-server/admin/drain
1820
+ ```
1821
+
1822
+ When scaled out, the session list and close work across instances, but **dedicated** and **drain** act on the instance
1823
+ that receives the call: call that instance's own address.
1824
+
1825
+ After an admin drain finishes, the server stops listening while the process keeps running, so health checks fail. Most
1826
+ orchestrators then restart it; on plain Docker or systemd, restart it yourself. Use it to empty an instance before
1827
+ maintenance.
1828
+
1829
+ ### Rolling updates and draining
1830
+
1831
+ On `SIGTERM` (every platform's stop signal), the server:
1832
+
1833
+ 1. Stops taking joins at once: readiness turns 503 and new joins are refused with `GameClosed: the server is shutting
1834
+ down` (a game that joins again reaches another instance).
1835
+ 2. Lets running sessions continue until they end or `drainSeconds` passes (the drain ends early when no sessions are
1836
+ left).
1837
+ 3. Tells the remaining players `GameClosed` and disconnects them.
1838
+ 4. Exits 0 (or 1 if the watchdog at `drainSeconds` + 5 s fires first).
1839
+
1840
+ Sessions never move between instances, so a deploy ends any session still running on the old instance when its drain
1841
+ ends. To roll out with the least disruption:
1842
+
1843
+ - Start new instances before stopping old ones. ECS (default deployment settings) and Kubernetes (default rolling
1844
+ update) do; on the other platforms, check how a deploy replaces running instances.
1845
+ - Give the stop timeout more than `drainSeconds` + 5 s ([checklist item 9](#9-stop-timeout-vs-drainseconds)).
1846
+ - Deploy when few sessions are running, or raise `drainSeconds` (within the platform's limit) so matches can finish.
1847
+ - Without Redis, players who join during the overlap land on the new instance while old sessions finish on the old one.
1848
+ - On ECS, the load balancer deregisters a task before the task receives `SIGTERM` (the templates set the deregistration
1849
+ delay to 45 s). Test a rolling deploy with a live session and confirm the players see the drain as you expect before
1850
+ relying on it.
1851
+ - When a release changes the protocol version, the old game pages are refused by the new server. Deploy the server and
1852
+ the game together, or keep the old server on its own address until players have reloaded.
1853
+
1854
+ ### Certificate renewal
1855
+
1856
+ | How the certificate is supplied | Renewal |
1857
+ |---|---|
1858
+ | At the edge (Caddy, ALB/ACM, platforms, Ingress with cert-manager) | Automatic; nothing to do on the server |
1859
+ | `tls.certFile` / `tls.keyFile` | Replace the files; they are polled every 30 s and applied to HTTPS and WSS without a restart |
1860
+ | `tls.certPem` / `tls.keyPem` (environment, Secrets Manager) | Read at start: update the secret and restart (ECS: `aws ecs update-service --force-new-deployment`) |
1861
+ | Any source, with UDP on | HTTP/3 keeps its certificate until the next restart: restart after renewal |
1862
+
1863
+ Let's Encrypt certificates last 90 days and certbot renews them well before expiry; with UDP on, restart from the
1864
+ certbot deploy hook (as in [Certificates with certbot](#certificates-with-certbot)) so HTTP/3 never serves an expired
1865
+ certificate.
1866
+
1867
+ ### Backups
1868
+
1869
+ None needed. The server is stateless: sessions live in memory and end when the process stops. Redis holds only
1870
+ short-lived session listings and seat reservations (the shipped Redis runs without persistence). Keep in version control
1871
+ or your secret store what you need to rebuild: the config file, the environment and secrets, your image tag, and the
1872
+ exported scenes for dedicated sessions.
1873
+
1874
+ ---
1875
+
1876
+ ## 7. Connecting the game
1877
+
1878
+ The game points its `NetworkRunner` at the server URL and, when the server requires it, passes a token.
1879
+
1880
+ ```ts
1881
+ const runner: TOOLKIT.NetworkRunner = new TOOLKIT.NetworkRunner(scene);
1882
+ const result: TOOLKIT.StartGameResult = await runner.startGame({
1883
+ gameMode: TOOLKIT.GameMode.AutoHostOrClient,
1884
+ sessionName: "arena",
1885
+ config: { serverUrl: "wss://game.example.com" },
1886
+ authToken: playerToken, // only when the server enables auth
1887
+ transportPreference: TOOLKIT.NetworkTransportPreference.Auto // the default
1888
+ });
1889
+ if (!result.ok) console.error(TOOLKIT.ShutdownReason[result.shutdownReason] + " " + result.errorMessage);
1890
+ ```
1891
+
1892
+ - **`serverUrl`**: the public URL, authored on the scene's `NetworkSettings` component or passed in `config`. Use
1893
+ `wss://` whenever the page is served over `https://`; browsers block `ws://` there. With TLS at the edge it is the
1894
+ edge's address (`wss://<app>.fly.dev`, `wss://game.example.com`), never the container's internal port.
1895
+ - **`authToken`**: sent with every matchmaking call, the session lobby included. With auth off the server ignores it.
1896
+ The host's `onConnectRequest(runner, request, token)` sees the server-verified identity as `request.authIdentity`.
1897
+ - **`transportPreference`**: `Auto` (WebTransport when the server offers it, the browser has it and a quick probe
1898
+ succeeds, else WebSocket), `WebSocket`, or `WebTransport` (refused with `ConnectionRefused` when it cannot be used).
1899
+ Keep `Auto` in production.
1900
+ - **Origins:** the page's origin must be in `GAME_SERVER_ALLOWED_ORIGINS` when that list is set; the game reads
1901
+ `/game-server/info` before joining, and CORS applies to it.
1902
+ - **Pages from the exporter (UMD)** need the exporter's **Babylon Toolkit Networking** option on, so the page loads the
1903
+ Colyseus browser client (`scripts/colyseus.js`). **ESM web apps** install `@colyseus/sdk@0.18.5` and set
1904
+ `ColyseusTransport.ClientModule = await import("@colyseus/sdk")` before the first `startGame`.
1905
+ - **Dedicated sessions:** join with `GameMode.Client` and the session name; to start an on-demand scene, pass
1906
+ `sessionProperties: { dedicatedScene: "arena" }`.
1907
+
1908
+ The toolkit's sample launcher reads the same settings from the page URL, which is handy for testing a deployment
1909
+ (the parameter names are the launcher's wire names):
1910
+
1911
+ ```text
1912
+ engine.html?scene=networktestbed.gltf&fusionMode=Shared&fusionSession=race1&fusionServer=wss%3A%2F%2Fgame.example.com&fusionToken=abc&fusionStats=1
1913
+ ```
1914
+
1915
+ | Parameter | Meaning |
1916
+ |---|---|
1917
+ | `fusionServer` | The server URL, URL-encoded |
1918
+ | `fusionToken` | The auth token |
1919
+ | `fusionTransport` | `auto`, `websocket` or `webtransport` |
1920
+ | `fusionDedicatedScene` | With `fusionMode=Client`: an on-demand dedicated scene |
1921
+ | `fusionStats` | `1` shows the network stats overlay, which names the active link (WebSocket or WebTransport) |
1922
+
1923
+ ---
1924
+
1925
+ ## 8. Troubleshooting deployment
1926
+
1927
+ Refusals reach the game as a failed `startGame` with the message `"<ShutdownReason>: <detail>"`, and are logged by the
1928
+ server as `join refused` with the same reason. The [README](README.md#troubleshooting) lists every game-side message;
1929
+ these are the ones deployment causes.
1930
+
1931
+ ### The server does not start or stay up
1932
+
1933
+ | Symptom | Cause and fix |
1934
+ |---|---|
1935
+ | `Babylon Game Server configuration problems:` and exit code 2 | Each line names the option, its variable 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): ...`, `unknown option ...`). Fix them all and redeploy; restarting will not help. |
1936
+ | `the config file ... does not exist (--config / GAME_SERVER_CONFIG)` | The mounted path differs from `GAME_SERVER_CONFIG`. Check the volume mount and the file name. |
1937
+ | `tls.developmentCertificate (GAME_SERVER_TLS_DEVELOPMENT_CERTIFICATE) is refused when NODE_ENV=production` | Development certificates are for local use; supply a real certificate. |
1938
+ | `UDP needs @colyseus/h3-transport, which is not installed on this platform` | The optional native package did not build. Use the Docker image, reinstall where it builds, or run without UDP. |
1939
+ | `babylonjs-toolkit <version> is older than this server needs; ...` | The installed runtime predates the server's protocol: install the `babylonjs-toolkit` version this server was released with (`npm install @babylonjs-toolkit/server@<version>` pins it). |
1940
+ | `EACCES` reading the certificate or key | The container runs as `node` (uid 1000): make the files readable by it. |
1941
+ | `EACCES` / `permission denied` binding port 443 (systemd, Kubernetes) | Non-root processes cannot bind ports below 1024 there. Use `AmbientCapabilities=CAP_NET_BIND_SERVICE` (systemd), or listen on 2567 and map 443 → 2567 at the Service or load balancer. |
1942
+ | Container restarts; the logs show no `listening on` banner; `SIGTERM` exits at once with code 143 | **Redis credentials or address are wrong.** With wrong Redis credentials the server never starts listening, health checks fail, and because the drain handlers are installed only after it listens, `SIGTERM` ends it immediately (exit 143) with no drain log. Check `GAME_SERVER_REDIS_URL` (password, `rediss://` when the cache requires TLS, host and port), and the security group / network policy from the server to Redis. |
1943
+ | The server exits 1 right after starting, with dedicated `boot` entries | A boot dedicated session failed to load; the log names the error (wrong `sceneRoot`, missing file, script error). |
1944
+ | Docker reports the container unhealthy, but the server works | The port or TLS setting was passed as a start flag, which the image's `HEALTHCHECK` does not see. Use environment variables or the config file. |
1945
+
1946
+ ### Players cannot connect or are refused
1947
+
1948
+ | Symptom | Cause and fix |
1949
+ |---|---|
1950
+ | The browser blocks the connection (mixed content) | The page is `https://` and `serverUrl` is `ws://`: use `wss://`. |
1951
+ | `ConnectionRefused: the origin <origin> is not allowed` | Add the page's exact origin (scheme, host, port) to `GAME_SERVER_ALLOWED_ORIGINS`. `(none)` means a client sent no `Origin` while `GAME_SERVER_ALLOW_MISSING_ORIGIN` is `false`. |
1952
+ | `ConnectionRefused: too many join requests from this address; try again in a minute` for many players at once | Every player appears with the proxy's address. Behind an HTTP proxy (Caddy, ALB, Ingress, PaaS), set `GAME_SERVER_TRUST_PROXY=true`. Behind a TCP/UDP load balancer (NLB, LoadBalancer Service), preserve the client IP there (`externalTrafficPolicy: Local`, target group client IP preservation) or raise `GAME_SERVER_JOIN_REQUESTS_PER_MINUTE`. |
1953
+ | Matchmaking succeeds, then the connection fails (players time out after the seat reservation) | `GAME_SERVER_PUBLIC_ADDRESS` is set to an address players cannot reach with the game's scheme, such as the pod IP from `kubernetes/deployment.yaml` behind a TLS Ingress. Remove it on a single instance, or give each instance a reachable TLS host name. |
1954
+ | `GameNotFound` for `Client` joins, or players of one match land in different sessions, with several instances | The instances do not share Redis, or one lacks its public address. Set the same `GAME_SERVER_REDIS_URL` and a reachable `GAME_SERVER_PUBLIC_ADDRESS` on every instance. |
1955
+ | Readiness 503 with `"redis unreachable"` | Redis did not answer within 1 second. Check Redis health, the network path and credentials. Every instance on that Redis leaves rotation together, by design: matchmaking cannot work without it. |
1956
+ | Readiness 503 with `"draining"` | The instance received `SIGTERM` or an admin drain. Expected during deploys; if it persists, the platform is not replacing it. |
1957
+ | `GameClosed: the server is shutting down` | The instance is draining; join again to reach another instance. |
1958
+ | `MaxCcuReached: this server is hosting its most sessions` / `... running its most dedicated sessions` | Over `GAME_SERVER_MAX_SESSIONS` / `GAME_SERVER_MAX_DEDICATED_SESSIONS`: add instances or raise the limit. |
1959
+ | `InvalidAuthentication: the identity provider's keys could not be loaded` | The server cannot reach `GAME_SERVER_AUTH_JWT_JWKS_URL`: allow outbound HTTPS (NAT gateway, egress policy). |
1960
+ | `CustomAuthenticationFailed: the auth webhook did not approve the player` | The webhook answered non-2xx, not a JSON object, or later than `GAME_SERVER_AUTH_WEBHOOK_TIMEOUT_MS`. Check its logs and its network latency from the server. |
1961
+ | `InvalidAuthentication: no token was sent` | Auth is on and the game sent no token: pass `startGame({ authToken })`. |
1962
+
1963
+ ### Connections drop or UDP is not used
1964
+
1965
+ | Symptom | Cause and fix |
1966
+ |---|---|
1967
+ | WebSockets drop after a fixed time (60 s, 100 s, ...) | A proxy in the path has an idle timeout. The server pings every WebSocket every 10 seconds, which keeps most proxies from treating it as idle; if drops persist, raise the proxy's timeout as the templates do (ALB `idle_timeout.timeout_seconds` 3600; nginx `proxy_read_timeout`; ingress-nginx `proxy-read-timeout`) and check that the proxy passes WebSocket upgrades. |
1968
+ | Players are all on WebSocket though UDP is on (`fusionStats=1` shows WebSocket; `frames_total{transport="webtransport"}` stays at 0) | Check `GET /game-server/info`: `transports.webTransport` must not be `null`. Then check that UDP reaches the server on the **same port number as the `wss://` URL** at every layer (security group, firewall, load balancer listener, `-p 443:443/udp`), that TLS ends on the server (an edge proxy cannot carry it), and that the browser supports WebTransport. A corporate network may block UDP; those players correctly fall back to WebSocket. |
1969
+ | `ConnectionRefused: the game server does not offer WebTransport, or this browser cannot reach it` | The game forces `transportPreference: WebTransport`. Use `Auto`. |
1970
+ | `HTTP/3 uses the new certificate after a restart` and WebTransport fails after a renewal | Restart the server so HTTP/3 loads the renewed certificate. |
1971
+
1972
+ ### Version problems
1973
+
1974
+ | Symptom | Cause and fix |
1975
+ |---|---|
1976
+ | `IncompatibleConfiguration: this server speaks network protocol <n> but the game sent <m>; update the ...` (or the game's `... the game server speaks network protocol ...`) | The server and the game's runtime differ in protocol version. Update the side the message names; when a release changes the protocol, deploy the server and the game together. |
1977
+ | `IncompatibleConfiguration` with no detail | Peers have different prefab tables: re-export, reload every page and restart dedicated sessions. |
1978
+ | Some players work, others get `IncompatibleConfiguration` during a deploy | Mixed versions: old pages cached in browsers, or old and new server instances side by side across a protocol change. Finish the rollout, have players reload, and avoid rolling a protocol change across instances that share Redis. |
1979
+
1980
+ ---
1981
+
1982
+ ## Known limitations
1983
+
1984
+ - **The cloud recipes have not been deployed to a real account.** The AWS, Fly.io, Railway, Render, DigitalOcean,
1985
+ Heroku and Kubernetes recipes were parsed and tested, but none was deployed to a real cloud account. Try yours on a
1986
+ staging account before relying on it, especially the AWS NLB `TCP_QUIC` recipe.
1987
+ - **The Colyseus Cloud recipe was rehearsed, not deployed.** It ran in a local Linux container with Colyseus's own deploy
1988
+ scripts (`colyseus-post-deploy` and the `@colyseus/tools` agent), PM2, NGINX on unix sockets and Redis, on plain HTTP and
1989
+ Node 22, but never on a real Colyseus Cloud account. The NGINX there is a copy, so the forwarded headers are assumed:
1990
+ see the [first deploy checklist](#411-colyseus-cloud).
1991
+ - **QUIC on a Network Load Balancer is a newer AWS feature.** Check the current Elastic Load Balancing documentation
1992
+ for QUIC listener and target support in your region before depending on `aws/ecs-fargate-nlb-quic.yml`, and confirm
1993
+ that players keep their own client addresses through it.
1994
+ - **A session lives in one process.** Sessions do not move between instances. A deploy, a crash or a drain ends the
1995
+ sessions on that instance (after `drainSeconds` for a drain).
1996
+ - **Scale-out needs one public address per instance.** A single load balancer or platform host name in front of
1997
+ several instances does not work on its own; the PaaS recipes and the AWS templates therefore run one instance each.
1998
+ Colyseus Cloud is the exception: its NGINX gives each process its own `/<port>/` path on one host name.
1999
+ - **Boot dedicated sessions need Redis to run once.** On any host that numbers its processes (PM2 sets
2000
+ `NODE_APP_INSTANCE`, on Colyseus Cloud or on your own VM), a `boot` session belongs to the holder of the
2001
+ `dedicated-boot` lease. Without Redis there is no shared store, so each process holds its own lease and starts its own
2002
+ copy.
2003
+ - **No UDP on platform edges.** Fly.io, Railway, Render, DigitalOcean App Platform, Heroku, Colyseus Cloud, ALBs and HTTP Ingresses carry
2004
+ WebSocket only.
2005
+ - **Wrong Redis credentials prevent startup without a clear message.** The server never starts listening, and `SIGTERM`
2006
+ then exits with code 143 instead of draining.
2007
+ - **HTTP/3 needs a restart to pick up a renewed certificate**, and PEM certificates passed as environment variables need
2008
+ a restart for every transport.
2009
+ - **Custom auth (`custom` mode) works only from code**, through `createGameServer({ hooks: { verifyToken } })`; there is
2010
+ no environment-only way to use it.
2011
+ - **The compose files and the `docker run` examples in the templates set no stop grace period**, so Docker's default
2012
+ 10 seconds cuts the 30-second drain unless you add `stop_grace_period` / `--stop-timeout`.