@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.
- package/CUSTOM.md +1690 -0
- package/DEPLOY.md +2012 -0
- package/README.md +1081 -2
- package/lib/AbuseLimits.d.ts +64 -0
- package/lib/AbuseLimits.js +121 -0
- package/lib/Authentication.d.ts +86 -0
- package/lib/Authentication.js +272 -0
- package/lib/BootSessionOwner.d.ts +86 -0
- package/lib/BootSessionOwner.js +116 -0
- package/lib/ColyseusLoggerAdapter.d.ts +32 -0
- package/lib/ColyseusLoggerAdapter.js +73 -0
- package/lib/DedicatedSceneRegistry.d.ts +52 -0
- package/lib/DedicatedSceneRegistry.js +74 -0
- package/lib/DedicatedSession.d.ts +152 -0
- package/lib/DedicatedSession.js +173 -0
- package/lib/DualTransport.d.ts +112 -0
- package/lib/DualTransport.js +197 -0
- package/lib/GameServer.d.ts +64 -0
- package/lib/GameServer.js +647 -0
- package/lib/GameServerConfig.d.ts +387 -0
- package/lib/GameServerConfig.js +1059 -0
- package/lib/GameServerLobbyRoom.d.ts +33 -0
- package/lib/GameServerLobbyRoom.js +56 -0
- package/lib/GameServerLogger.d.ts +111 -0
- package/lib/GameServerLogger.js +190 -0
- package/lib/GameServerMetrics.d.ts +158 -0
- package/lib/GameServerMetrics.js +276 -0
- package/lib/GameServerState.d.ts +164 -0
- package/lib/GameServerState.js +26 -0
- package/lib/HeadlessRuntime.d.ts +316 -0
- package/lib/HeadlessRuntime.js +380 -0
- package/lib/JoinGate.d.ts +34 -0
- package/lib/JoinGate.js +221 -0
- package/lib/NetworkAddresses.d.ts +33 -0
- package/lib/NetworkAddresses.js +90 -0
- package/lib/PlayerTokens.d.ts +17 -0
- package/lib/PlayerTokens.js +24 -0
- package/lib/RoomServerTransport.d.ts +73 -0
- package/lib/RoomServerTransport.js +90 -0
- package/lib/ServerEndpoints.d.ts +133 -0
- package/lib/ServerEndpoints.js +247 -0
- package/lib/ServerInit.d.ts +28 -0
- package/lib/ServerInit.js +113 -0
- package/lib/SessionRoom.d.ts +391 -0
- package/lib/SessionRoom.js +930 -0
- package/lib/TlsCertificates.d.ts +35 -0
- package/lib/TlsCertificates.js +130 -0
- package/lib/admin/AccountRoutes.d.ts +8 -0
- package/lib/admin/AccountRoutes.js +502 -0
- package/lib/admin/AdminAccounts.d.ts +329 -0
- package/lib/admin/AdminAccounts.js +607 -0
- package/lib/admin/AdminApiRouter.d.ts +202 -0
- package/lib/admin/AdminApiRouter.js +465 -0
- package/lib/admin/AdminApiTypes.d.ts +1782 -0
- package/lib/admin/AdminApiTypes.js +4 -0
- package/lib/admin/AdminAudit.d.ts +113 -0
- package/lib/admin/AdminAudit.js +266 -0
- package/lib/admin/AdminCli.d.ts +7 -0
- package/lib/admin/AdminCli.js +351 -0
- package/lib/admin/AdminClock.d.ts +51 -0
- package/lib/admin/AdminClock.js +10 -0
- package/lib/admin/AdminConsole.d.ts +39 -0
- package/lib/admin/AdminConsole.js +325 -0
- package/lib/admin/AdminConstants.d.ts +147 -0
- package/lib/admin/AdminConstants.js +167 -0
- package/lib/admin/AdminCrypto.d.ts +19 -0
- package/lib/admin/AdminCrypto.js +29 -0
- package/lib/admin/AdminExtensionsHost.d.ts +176 -0
- package/lib/admin/AdminExtensionsHost.js +307 -0
- package/lib/admin/AdminPasswords.d.ts +33 -0
- package/lib/admin/AdminPasswords.js +92 -0
- package/lib/admin/AdminPermissions.d.ts +28 -0
- package/lib/admin/AdminPermissions.js +74 -0
- package/lib/admin/AdminRecords.d.ts +196 -0
- package/lib/admin/AdminRecords.js +3 -0
- package/lib/admin/AdminRequestBodies.d.ts +47 -0
- package/lib/admin/AdminRequestBodies.js +84 -0
- package/lib/admin/AdminRequestGate.d.ts +38 -0
- package/lib/admin/AdminRequestGate.js +59 -0
- package/lib/admin/AdminResponses.d.ts +103 -0
- package/lib/admin/AdminResponses.js +164 -0
- package/lib/admin/AdminServices.d.ts +99 -0
- package/lib/admin/AdminServices.js +4 -0
- package/lib/admin/AdminStaticFiles.d.ts +29 -0
- package/lib/admin/AdminStaticFiles.js +97 -0
- package/lib/admin/AdminStore.d.ts +152 -0
- package/lib/admin/AdminStore.js +46 -0
- package/lib/admin/AlertChannels.d.ts +43 -0
- package/lib/admin/AlertChannels.js +120 -0
- package/lib/admin/AlertEngine.d.ts +231 -0
- package/lib/admin/AlertEngine.js +681 -0
- package/lib/admin/AlertRoutes.d.ts +8 -0
- package/lib/admin/AlertRoutes.js +180 -0
- package/lib/admin/ApiKeyRoutes.d.ts +8 -0
- package/lib/admin/ApiKeyRoutes.js +53 -0
- package/lib/admin/ApiKeys.d.ts +63 -0
- package/lib/admin/ApiKeys.js +167 -0
- package/lib/admin/AuditRoutes.d.ts +8 -0
- package/lib/admin/AuditRoutes.js +106 -0
- package/lib/admin/AuthToolRoutes.d.ts +13 -0
- package/lib/admin/AuthToolRoutes.js +164 -0
- package/lib/admin/BanList.d.ts +87 -0
- package/lib/admin/BanList.js +152 -0
- package/lib/admin/CborDecoder.d.ts +18 -0
- package/lib/admin/CborDecoder.js +201 -0
- package/lib/admin/ColyseusCloudClient.d.ts +313 -0
- package/lib/admin/ColyseusCloudClient.js +272 -0
- package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
- package/lib/admin/ColyseusCloudRoutes.js +91 -0
- package/lib/admin/ColyseusCloudService.d.ts +180 -0
- package/lib/admin/ColyseusCloudService.js +476 -0
- package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
- package/lib/admin/ColyseusCloudSettings.js +29 -0
- package/lib/admin/ColyseusCloudViews.d.ts +105 -0
- package/lib/admin/ColyseusCloudViews.js +371 -0
- package/lib/admin/CommonPasswords.d.ts +6 -0
- package/lib/admin/CommonPasswords.js +19 -0
- package/lib/admin/ConfigOverrides.d.ts +174 -0
- package/lib/admin/ConfigOverrides.js +406 -0
- package/lib/admin/ConfigRoutes.d.ts +8 -0
- package/lib/admin/ConfigRoutes.js +91 -0
- package/lib/admin/ConsoleSessions.d.ts +112 -0
- package/lib/admin/ConsoleSessions.js +201 -0
- package/lib/admin/DedicatedRoutes.d.ts +21 -0
- package/lib/admin/DedicatedRoutes.js +254 -0
- package/lib/admin/ExtensionRoutes.d.ts +8 -0
- package/lib/admin/ExtensionRoutes.js +145 -0
- package/lib/admin/FileAdminStore.d.ts +157 -0
- package/lib/admin/FileAdminStore.js +349 -0
- package/lib/admin/FleetDirectory.d.ts +323 -0
- package/lib/admin/FleetDirectory.js +489 -0
- package/lib/admin/FleetLeader.d.ts +43 -0
- package/lib/admin/FleetLeader.js +89 -0
- package/lib/admin/FleetRoutes.d.ts +55 -0
- package/lib/admin/FleetRoutes.js +465 -0
- package/lib/admin/HealthWarnings.d.ts +10 -0
- package/lib/admin/HealthWarnings.js +95 -0
- package/lib/admin/LeaderboardRoutes.d.ts +8 -0
- package/lib/admin/LeaderboardRoutes.js +569 -0
- package/lib/admin/LogBuffer.d.ts +60 -0
- package/lib/admin/LogBuffer.js +92 -0
- package/lib/admin/LogRedaction.d.ts +15 -0
- package/lib/admin/LogRedaction.js +72 -0
- package/lib/admin/LogRoutes.d.ts +45 -0
- package/lib/admin/LogRoutes.js +307 -0
- package/lib/admin/MaintenanceMode.d.ts +41 -0
- package/lib/admin/MaintenanceMode.js +74 -0
- package/lib/admin/MaintenanceWindows.d.ts +91 -0
- package/lib/admin/MaintenanceWindows.js +207 -0
- package/lib/admin/MetricsRoutes.d.ts +8 -0
- package/lib/admin/MetricsRoutes.js +209 -0
- package/lib/admin/MetricsSampler.d.ts +124 -0
- package/lib/admin/MetricsSampler.js +211 -0
- package/lib/admin/OneTimeCodes.d.ts +60 -0
- package/lib/admin/OneTimeCodes.js +162 -0
- package/lib/admin/OpenIdConnect.d.ts +146 -0
- package/lib/admin/OpenIdConnect.js +335 -0
- package/lib/admin/Passkeys.d.ts +130 -0
- package/lib/admin/Passkeys.js +432 -0
- package/lib/admin/PlayerRoutes.d.ts +20 -0
- package/lib/admin/PlayerRoutes.js +676 -0
- package/lib/admin/RedisAdminStore.d.ts +114 -0
- package/lib/admin/RedisAdminStore.js +258 -0
- package/lib/admin/RolloutCoordinator.d.ts +99 -0
- package/lib/admin/RolloutCoordinator.js +321 -0
- package/lib/admin/ServerEvents.d.ts +57 -0
- package/lib/admin/ServerEvents.js +89 -0
- package/lib/admin/SessionRoutes.d.ts +113 -0
- package/lib/admin/SessionRoutes.js +386 -0
- package/lib/admin/UserRoutes.d.ts +8 -0
- package/lib/admin/UserRoutes.js +214 -0
- package/lib/cli.d.ts +2 -0
- package/lib/cli.js +221 -0
- package/lib/console/assets/index-CI5N7c-T.js +21 -0
- package/lib/console/assets/index-DPitvzLp.css +1 -0
- package/lib/console/favicon.svg +1 -0
- package/lib/console/index.html +16 -0
- package/lib/discord/DiscordOAuth.d.ts +57 -0
- package/lib/discord/DiscordOAuth.js +127 -0
- package/lib/discord/DiscordSessions.d.ts +79 -0
- package/lib/discord/DiscordSessions.js +135 -0
- package/lib/discord/DiscordSignInConstants.d.ts +54 -0
- package/lib/discord/DiscordSignInConstants.js +55 -0
- package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
- package/lib/discord/DiscordSignInRoutes.js +359 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +18 -0
- package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
- package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
- package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
- package/lib/leaderboards/LeaderboardConstants.js +70 -0
- package/lib/leaderboards/LeaderboardError.d.ts +15 -0
- package/lib/leaderboards/LeaderboardError.js +20 -0
- package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
- package/lib/leaderboards/LeaderboardIdentity.js +72 -0
- package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
- package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
- package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
- package/lib/leaderboards/LeaderboardRankStore.js +18 -0
- package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
- package/lib/leaderboards/LeaderboardRules.js +472 -0
- package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
- package/lib/leaderboards/LeaderboardScheduler.js +103 -0
- package/lib/leaderboards/LeaderboardService.d.ts +669 -0
- package/lib/leaderboards/LeaderboardService.js +1667 -0
- package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
- package/lib/leaderboards/LeaderboardTransfer.js +580 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
- package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
- package/package.json +64 -4
- package/templates/init/Caddyfile +6 -0
- package/templates/init/DEPLOY.md +233 -0
- package/templates/init/Dockerfile +12 -0
- package/templates/init/app.json +21 -0
- package/templates/init/aws/README.md +118 -0
- package/templates/init/aws/ecs-fargate-alb.yml +207 -0
- package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
- package/templates/init/babylon-game-server.config.mjs +158 -0
- package/templates/init/colyseus-cloud/README.md +133 -0
- package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
- package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
- package/templates/init/colyseus-cloud/gitignore +6 -0
- package/templates/init/colyseus-cloud/package.json +15 -0
- package/templates/init/digitalocean-app.yaml +30 -0
- package/templates/init/docker-compose.full.yml +45 -0
- package/templates/init/docker-compose.websocket.yml +41 -0
- package/templates/init/fly.toml +56 -0
- package/templates/init/heroku.yml +11 -0
- package/templates/init/kubernetes/deployment.yaml +71 -0
- package/templates/init/kubernetes/redis.yaml +73 -0
- package/templates/init/kubernetes/service.yaml +24 -0
- package/templates/init/monitoring/grafana-dashboard.json +539 -0
- package/templates/init/monitoring/prometheus-alerts.yml +51 -0
- package/templates/init/monitoring/prometheus-scrape.yml +17 -0
- package/templates/init/railway.json +13 -0
- 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`.
|