@babylonjs-toolkit/server 0.0.0-stage → 9.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/CUSTOM.md +1690 -0
  2. package/DEPLOY.md +2012 -0
  3. package/README.md +1081 -2
  4. package/lib/AbuseLimits.d.ts +64 -0
  5. package/lib/AbuseLimits.js +121 -0
  6. package/lib/Authentication.d.ts +86 -0
  7. package/lib/Authentication.js +272 -0
  8. package/lib/BootSessionOwner.d.ts +86 -0
  9. package/lib/BootSessionOwner.js +116 -0
  10. package/lib/ColyseusLoggerAdapter.d.ts +32 -0
  11. package/lib/ColyseusLoggerAdapter.js +73 -0
  12. package/lib/DedicatedSceneRegistry.d.ts +52 -0
  13. package/lib/DedicatedSceneRegistry.js +74 -0
  14. package/lib/DedicatedSession.d.ts +152 -0
  15. package/lib/DedicatedSession.js +173 -0
  16. package/lib/DualTransport.d.ts +112 -0
  17. package/lib/DualTransport.js +197 -0
  18. package/lib/GameServer.d.ts +64 -0
  19. package/lib/GameServer.js +647 -0
  20. package/lib/GameServerConfig.d.ts +387 -0
  21. package/lib/GameServerConfig.js +1059 -0
  22. package/lib/GameServerLobbyRoom.d.ts +33 -0
  23. package/lib/GameServerLobbyRoom.js +56 -0
  24. package/lib/GameServerLogger.d.ts +111 -0
  25. package/lib/GameServerLogger.js +190 -0
  26. package/lib/GameServerMetrics.d.ts +158 -0
  27. package/lib/GameServerMetrics.js +276 -0
  28. package/lib/GameServerState.d.ts +164 -0
  29. package/lib/GameServerState.js +26 -0
  30. package/lib/HeadlessRuntime.d.ts +316 -0
  31. package/lib/HeadlessRuntime.js +380 -0
  32. package/lib/JoinGate.d.ts +34 -0
  33. package/lib/JoinGate.js +221 -0
  34. package/lib/NetworkAddresses.d.ts +33 -0
  35. package/lib/NetworkAddresses.js +90 -0
  36. package/lib/PlayerTokens.d.ts +17 -0
  37. package/lib/PlayerTokens.js +24 -0
  38. package/lib/RoomServerTransport.d.ts +73 -0
  39. package/lib/RoomServerTransport.js +90 -0
  40. package/lib/ServerEndpoints.d.ts +133 -0
  41. package/lib/ServerEndpoints.js +247 -0
  42. package/lib/ServerInit.d.ts +28 -0
  43. package/lib/ServerInit.js +113 -0
  44. package/lib/SessionRoom.d.ts +391 -0
  45. package/lib/SessionRoom.js +930 -0
  46. package/lib/TlsCertificates.d.ts +35 -0
  47. package/lib/TlsCertificates.js +130 -0
  48. package/lib/admin/AccountRoutes.d.ts +8 -0
  49. package/lib/admin/AccountRoutes.js +502 -0
  50. package/lib/admin/AdminAccounts.d.ts +329 -0
  51. package/lib/admin/AdminAccounts.js +607 -0
  52. package/lib/admin/AdminApiRouter.d.ts +202 -0
  53. package/lib/admin/AdminApiRouter.js +465 -0
  54. package/lib/admin/AdminApiTypes.d.ts +1782 -0
  55. package/lib/admin/AdminApiTypes.js +4 -0
  56. package/lib/admin/AdminAudit.d.ts +113 -0
  57. package/lib/admin/AdminAudit.js +266 -0
  58. package/lib/admin/AdminCli.d.ts +7 -0
  59. package/lib/admin/AdminCli.js +351 -0
  60. package/lib/admin/AdminClock.d.ts +51 -0
  61. package/lib/admin/AdminClock.js +10 -0
  62. package/lib/admin/AdminConsole.d.ts +39 -0
  63. package/lib/admin/AdminConsole.js +325 -0
  64. package/lib/admin/AdminConstants.d.ts +147 -0
  65. package/lib/admin/AdminConstants.js +167 -0
  66. package/lib/admin/AdminCrypto.d.ts +19 -0
  67. package/lib/admin/AdminCrypto.js +29 -0
  68. package/lib/admin/AdminExtensionsHost.d.ts +176 -0
  69. package/lib/admin/AdminExtensionsHost.js +307 -0
  70. package/lib/admin/AdminPasswords.d.ts +33 -0
  71. package/lib/admin/AdminPasswords.js +92 -0
  72. package/lib/admin/AdminPermissions.d.ts +28 -0
  73. package/lib/admin/AdminPermissions.js +74 -0
  74. package/lib/admin/AdminRecords.d.ts +196 -0
  75. package/lib/admin/AdminRecords.js +3 -0
  76. package/lib/admin/AdminRequestBodies.d.ts +47 -0
  77. package/lib/admin/AdminRequestBodies.js +84 -0
  78. package/lib/admin/AdminRequestGate.d.ts +38 -0
  79. package/lib/admin/AdminRequestGate.js +59 -0
  80. package/lib/admin/AdminResponses.d.ts +103 -0
  81. package/lib/admin/AdminResponses.js +164 -0
  82. package/lib/admin/AdminServices.d.ts +99 -0
  83. package/lib/admin/AdminServices.js +4 -0
  84. package/lib/admin/AdminStaticFiles.d.ts +29 -0
  85. package/lib/admin/AdminStaticFiles.js +97 -0
  86. package/lib/admin/AdminStore.d.ts +152 -0
  87. package/lib/admin/AdminStore.js +46 -0
  88. package/lib/admin/AlertChannels.d.ts +43 -0
  89. package/lib/admin/AlertChannels.js +120 -0
  90. package/lib/admin/AlertEngine.d.ts +231 -0
  91. package/lib/admin/AlertEngine.js +681 -0
  92. package/lib/admin/AlertRoutes.d.ts +8 -0
  93. package/lib/admin/AlertRoutes.js +180 -0
  94. package/lib/admin/ApiKeyRoutes.d.ts +8 -0
  95. package/lib/admin/ApiKeyRoutes.js +53 -0
  96. package/lib/admin/ApiKeys.d.ts +63 -0
  97. package/lib/admin/ApiKeys.js +167 -0
  98. package/lib/admin/AuditRoutes.d.ts +8 -0
  99. package/lib/admin/AuditRoutes.js +106 -0
  100. package/lib/admin/AuthToolRoutes.d.ts +13 -0
  101. package/lib/admin/AuthToolRoutes.js +164 -0
  102. package/lib/admin/BanList.d.ts +87 -0
  103. package/lib/admin/BanList.js +152 -0
  104. package/lib/admin/CborDecoder.d.ts +18 -0
  105. package/lib/admin/CborDecoder.js +201 -0
  106. package/lib/admin/ColyseusCloudClient.d.ts +313 -0
  107. package/lib/admin/ColyseusCloudClient.js +272 -0
  108. package/lib/admin/ColyseusCloudRoutes.d.ts +8 -0
  109. package/lib/admin/ColyseusCloudRoutes.js +91 -0
  110. package/lib/admin/ColyseusCloudService.d.ts +180 -0
  111. package/lib/admin/ColyseusCloudService.js +476 -0
  112. package/lib/admin/ColyseusCloudSettings.d.ts +16 -0
  113. package/lib/admin/ColyseusCloudSettings.js +29 -0
  114. package/lib/admin/ColyseusCloudViews.d.ts +105 -0
  115. package/lib/admin/ColyseusCloudViews.js +371 -0
  116. package/lib/admin/CommonPasswords.d.ts +6 -0
  117. package/lib/admin/CommonPasswords.js +19 -0
  118. package/lib/admin/ConfigOverrides.d.ts +174 -0
  119. package/lib/admin/ConfigOverrides.js +406 -0
  120. package/lib/admin/ConfigRoutes.d.ts +8 -0
  121. package/lib/admin/ConfigRoutes.js +91 -0
  122. package/lib/admin/ConsoleSessions.d.ts +112 -0
  123. package/lib/admin/ConsoleSessions.js +201 -0
  124. package/lib/admin/DedicatedRoutes.d.ts +21 -0
  125. package/lib/admin/DedicatedRoutes.js +254 -0
  126. package/lib/admin/ExtensionRoutes.d.ts +8 -0
  127. package/lib/admin/ExtensionRoutes.js +145 -0
  128. package/lib/admin/FileAdminStore.d.ts +157 -0
  129. package/lib/admin/FileAdminStore.js +349 -0
  130. package/lib/admin/FleetDirectory.d.ts +323 -0
  131. package/lib/admin/FleetDirectory.js +489 -0
  132. package/lib/admin/FleetLeader.d.ts +43 -0
  133. package/lib/admin/FleetLeader.js +89 -0
  134. package/lib/admin/FleetRoutes.d.ts +55 -0
  135. package/lib/admin/FleetRoutes.js +465 -0
  136. package/lib/admin/HealthWarnings.d.ts +10 -0
  137. package/lib/admin/HealthWarnings.js +95 -0
  138. package/lib/admin/LeaderboardRoutes.d.ts +8 -0
  139. package/lib/admin/LeaderboardRoutes.js +569 -0
  140. package/lib/admin/LogBuffer.d.ts +60 -0
  141. package/lib/admin/LogBuffer.js +92 -0
  142. package/lib/admin/LogRedaction.d.ts +15 -0
  143. package/lib/admin/LogRedaction.js +72 -0
  144. package/lib/admin/LogRoutes.d.ts +45 -0
  145. package/lib/admin/LogRoutes.js +307 -0
  146. package/lib/admin/MaintenanceMode.d.ts +41 -0
  147. package/lib/admin/MaintenanceMode.js +74 -0
  148. package/lib/admin/MaintenanceWindows.d.ts +91 -0
  149. package/lib/admin/MaintenanceWindows.js +207 -0
  150. package/lib/admin/MetricsRoutes.d.ts +8 -0
  151. package/lib/admin/MetricsRoutes.js +209 -0
  152. package/lib/admin/MetricsSampler.d.ts +124 -0
  153. package/lib/admin/MetricsSampler.js +211 -0
  154. package/lib/admin/OneTimeCodes.d.ts +60 -0
  155. package/lib/admin/OneTimeCodes.js +162 -0
  156. package/lib/admin/OpenIdConnect.d.ts +146 -0
  157. package/lib/admin/OpenIdConnect.js +335 -0
  158. package/lib/admin/Passkeys.d.ts +130 -0
  159. package/lib/admin/Passkeys.js +432 -0
  160. package/lib/admin/PlayerRoutes.d.ts +20 -0
  161. package/lib/admin/PlayerRoutes.js +676 -0
  162. package/lib/admin/RedisAdminStore.d.ts +114 -0
  163. package/lib/admin/RedisAdminStore.js +258 -0
  164. package/lib/admin/RolloutCoordinator.d.ts +99 -0
  165. package/lib/admin/RolloutCoordinator.js +321 -0
  166. package/lib/admin/ServerEvents.d.ts +57 -0
  167. package/lib/admin/ServerEvents.js +89 -0
  168. package/lib/admin/SessionRoutes.d.ts +113 -0
  169. package/lib/admin/SessionRoutes.js +386 -0
  170. package/lib/admin/UserRoutes.d.ts +8 -0
  171. package/lib/admin/UserRoutes.js +214 -0
  172. package/lib/cli.d.ts +2 -0
  173. package/lib/cli.js +221 -0
  174. package/lib/console/assets/index-CI5N7c-T.js +21 -0
  175. package/lib/console/assets/index-DPitvzLp.css +1 -0
  176. package/lib/console/favicon.svg +1 -0
  177. package/lib/console/index.html +16 -0
  178. package/lib/discord/DiscordOAuth.d.ts +57 -0
  179. package/lib/discord/DiscordOAuth.js +127 -0
  180. package/lib/discord/DiscordSessions.d.ts +79 -0
  181. package/lib/discord/DiscordSessions.js +135 -0
  182. package/lib/discord/DiscordSignInConstants.d.ts +54 -0
  183. package/lib/discord/DiscordSignInConstants.js +55 -0
  184. package/lib/discord/DiscordSignInRoutes.d.ts +51 -0
  185. package/lib/discord/DiscordSignInRoutes.js +359 -0
  186. package/lib/index.d.ts +24 -0
  187. package/lib/index.js +18 -0
  188. package/lib/leaderboards/FileLeaderboardRankStore.d.ts +293 -0
  189. package/lib/leaderboards/FileLeaderboardRankStore.js +808 -0
  190. package/lib/leaderboards/LeaderboardConstants.d.ts +63 -0
  191. package/lib/leaderboards/LeaderboardConstants.js +70 -0
  192. package/lib/leaderboards/LeaderboardError.d.ts +15 -0
  193. package/lib/leaderboards/LeaderboardError.js +20 -0
  194. package/lib/leaderboards/LeaderboardIdentity.d.ts +44 -0
  195. package/lib/leaderboards/LeaderboardIdentity.js +72 -0
  196. package/lib/leaderboards/LeaderboardPublicApi.d.ts +17 -0
  197. package/lib/leaderboards/LeaderboardPublicApi.js +389 -0
  198. package/lib/leaderboards/LeaderboardRankStore.d.ts +296 -0
  199. package/lib/leaderboards/LeaderboardRankStore.js +18 -0
  200. package/lib/leaderboards/LeaderboardRules.d.ts +122 -0
  201. package/lib/leaderboards/LeaderboardRules.js +472 -0
  202. package/lib/leaderboards/LeaderboardScheduler.d.ts +43 -0
  203. package/lib/leaderboards/LeaderboardScheduler.js +103 -0
  204. package/lib/leaderboards/LeaderboardService.d.ts +669 -0
  205. package/lib/leaderboards/LeaderboardService.js +1667 -0
  206. package/lib/leaderboards/LeaderboardTransfer.d.ts +35 -0
  207. package/lib/leaderboards/LeaderboardTransfer.js +580 -0
  208. package/lib/leaderboards/RedisLeaderboardRankStore.d.ts +282 -0
  209. package/lib/leaderboards/RedisLeaderboardRankStore.js +691 -0
  210. package/package.json +64 -4
  211. package/templates/init/Caddyfile +6 -0
  212. package/templates/init/DEPLOY.md +233 -0
  213. package/templates/init/Dockerfile +12 -0
  214. package/templates/init/app.json +21 -0
  215. package/templates/init/aws/README.md +118 -0
  216. package/templates/init/aws/ecs-fargate-alb.yml +207 -0
  217. package/templates/init/aws/ecs-fargate-nlb-quic.yml +210 -0
  218. package/templates/init/babylon-game-server.config.mjs +158 -0
  219. package/templates/init/colyseus-cloud/README.md +133 -0
  220. package/templates/init/colyseus-cloud/babylon-game-server.config.mjs +36 -0
  221. package/templates/init/colyseus-cloud/ecosystem.config.cjs +35 -0
  222. package/templates/init/colyseus-cloud/gitignore +6 -0
  223. package/templates/init/colyseus-cloud/package.json +15 -0
  224. package/templates/init/digitalocean-app.yaml +30 -0
  225. package/templates/init/docker-compose.full.yml +45 -0
  226. package/templates/init/docker-compose.websocket.yml +41 -0
  227. package/templates/init/fly.toml +56 -0
  228. package/templates/init/heroku.yml +11 -0
  229. package/templates/init/kubernetes/deployment.yaml +71 -0
  230. package/templates/init/kubernetes/redis.yaml +73 -0
  231. package/templates/init/kubernetes/service.yaml +24 -0
  232. package/templates/init/monitoring/grafana-dashboard.json +539 -0
  233. package/templates/init/monitoring/prometheus-alerts.yml +51 -0
  234. package/templates/init/monitoring/prometheus-scrape.yml +17 -0
  235. package/templates/init/railway.json +13 -0
  236. package/templates/init/render.yaml +19 -0
package/CUSTOM.md ADDED
@@ -0,0 +1,1690 @@
1
+ # Running your own code on the Babylon Game Server
2
+
3
+ `@babylonjs-toolkit/server` runs every multiplayer session of a Babylon Toolkit game, and it holds no code of any one
4
+ game. This guide is for developers who installed it for their game and now need some code of their own on the server:
5
+ what kind of code that usually is, where each kind runs, and how to write it. Every API named here is in the package
6
+ or in the Babylon Toolkit runtime; where something you might want does not exist, the guide says so and names the
7
+ closest thing that does.
8
+
9
+ Read the [README](README.md) first for installing, configuring and deploying the server.
10
+
11
+ ## Contents
12
+
13
+ - [What server-side code does a game need?](#what-server-side-code-does-a-game-need)
14
+ - [The design rule: one server for every game](#the-design-rule-one-server-for-every-game)
15
+ - [Choosing a mechanism](#choosing-a-mechanism)
16
+ - [1. Dedicated Server-mode sessions: your gameplay code on the server](#1-dedicated-server-mode-sessions-your-gameplay-code-on-the-server)
17
+ - [What runs](#what-runs)
18
+ - [Configure the scenes](#configure-the-scenes)
19
+ - [Four ways to start a session](#four-ways-to-start-a-session)
20
+ - [How your scripts are loaded](#how-your-scripts-are-loaded)
21
+ - [What the headless runtime has, and what it lacks](#what-the-headless-runtime-has-and-what-it-lacks)
22
+ - [Writing code that runs on the server](#writing-code-that-runs-on-the-server)
23
+ - [Complete example: an arena with a server-side referee](#complete-example-an-arena-with-a-server-side-referee)
24
+ - [Getting results out of a session, and logging](#getting-results-out-of-a-session-and-logging)
25
+ - [Limits, metrics and lifetime](#limits-metrics-and-lifetime)
26
+ - [2. Server hooks](#2-server-hooks)
27
+ - [3. Custom HTTP endpoints](#3-custom-http-endpoints)
28
+ - [4. Wrapping the server in your own Node app](#4-wrapping-the-server-in-your-own-node-app)
29
+ - [5. Custom auth: verifyToken and the webhook](#5-custom-auth-verifytoken-and-the-webhook)
30
+ - [Custom admin actions, panels and admin hooks](#custom-admin-actions-panels-and-admin-hooks)
31
+ - [Security](#security)
32
+ - [Testing your code locally](#testing-your-code-locally)
33
+ - [Versioning](#versioning)
34
+ - [Limits and what is not supported](#limits-and-what-is-not-supported)
35
+
36
+ ## What server-side code does a game need?
37
+
38
+ Most multiplayer games end up needing some of these. Each maps to one mechanism of the server.
39
+
40
+ | Kind of code | Examples | Mechanism |
41
+ |---|---|---|
42
+ | **Gameplay authority** | Physics, hit detection, movement limits, scoring, round timers, spawning, AI and bots, input validation and anti-cheat | [Dedicated Server-mode session](#1-dedicated-server-mode-sessions-your-gameplay-code-on-the-server): your game's own script components, run headless on the server |
43
+ | **Persistence** | Match results, player stats, progression | Dedicated-session code reports results ([bridge or `fetch`](#getting-results-out-of-a-session-and-logging)); [hooks](#2-server-hooks) record who played; [routes](#3-custom-http-endpoints) serve the data. Leaderboards are built in: [README › Leaderboards](README.md#leaderboards) |
44
+ | **Identity and access** | Checking logins, banning accounts or addresses, invite-only rooms | [`verifyToken` or the auth webhook](#5-custom-auth-verifytoken-and-the-webhook); per session, `onConnectRequest` in the dedicated session |
45
+ | **Operations and integration** | Analytics, Discord or Slack notices, a status page, a "create private room" button, starting sessions on a schedule | [Hooks](#2-server-hooks), [routes](#3-custom-http-endpoints), the [admin console and its API](README.md#admin-console) and [custom admin actions](#custom-admin-actions-panels-and-admin-hooks), in [your own Node app](#4-wrapping-the-server-in-your-own-node-app) |
46
+
47
+ ## The design rule: one server for every game
48
+
49
+ The server never contains your game. It loads the Babylon Toolkit runtime headless, with Babylon Toolkit Networking
50
+ built in (the `TOOLKIT` namespace), and runs the same session code as the browser. Your code reaches it in exactly
51
+ these ways:
52
+
53
+ 1. **Dedicated Server-mode sessions.** The server loads your exported scene and your game's script bundle and
54
+ simulates the game itself. This is the only place where **your gameplay code** runs on the server.
55
+ 2. **Server hooks.** Functions you pass to `createGameServer` that hear about sessions and players coming and going.
56
+ 3. **Custom HTTP endpoints.** Routes you pass to `createGameServer`, served on the game server's own port.
57
+ 4. **Your own Node app around the server.** A `server.mjs` that calls `createGameServer` with the above, plus whatever
58
+ else your process needs.
59
+ 5. **Custom auth.** A `verifyToken` hook in your app, or an auth webhook: a service of yours that approves each join.
60
+
61
+ Items 2 to 5 need you to start the server from code (`createGameServer`). The `babylon-game-server` CLI and the plain
62
+ Docker image run no code of yours except dedicated-session scripts.
63
+
64
+ ## Choosing a mechanism
65
+
66
+ In Shared, Host/Client and AutoHostOrClient sessions the game logic runs **in the players' browsers**. The server only
67
+ relays their messages, and in Shared mode keeps the latest state of each object for late joiners. So **server-side
68
+ game logic means a dedicated Server-mode session**.
69
+
70
+ | I want to … | Mechanism | Runs where | Authoritative? |
71
+ |---|---|---|---|
72
+ | Simulate physics and movement that players cannot fake | Dedicated session: a `TOOLKIT.NetworkBehaviour` in your script bundle | Dedicated session (server process) | Yes |
73
+ | Run AI or bots | Dedicated session: objects the server spawns and simulates | Dedicated session | Yes |
74
+ | Validate input, stop speed hacks | Dedicated session: clamp input in `fixedUpdateNetwork` | Dedicated session | Yes |
75
+ | Keep score, run round timers, decide who wins | Dedicated session | Dedicated session | Yes |
76
+ | Spawn and despawn players and items | Dedicated session (`runner.spawn`) | Dedicated session | Yes |
77
+ | The same gameplay without a dedicated server | The same scripts in Host/Client mode | The host player's browser | Only as far as you trust the host |
78
+ | Casual co-op where cheating does not matter | Shared mode | Every browser, each for its own objects | No |
79
+ | Save match results to a database | Dedicated-session code calls your [bridge](#getting-results-out-of-a-session-and-logging) or `fetch`es your backend | Dedicated session, then server process | Yes: the numbers come from the server's simulation |
80
+ | Count players, time sessions, send analytics | `onSessionCreated` / `onPlayerJoined` / `onPlayerLeft` / `onSessionClosed` | Server process | Yes for who joined and left; hooks see no game state |
81
+ | Post "match started" to Discord | Hooks | Server process | Not game state |
82
+ | Ban an account or address | `verifyToken` (auth mode `custom`) or the auth webhook | Server process, or your service | Yes, at join time |
83
+ | Refuse a joiner by game rules (team full, not invited) | `onConnectRequest` → `request.refuse()` | Dedicated session (or the host's browser) | Yes in a dedicated session |
84
+ | Serve custom stats or a status page (leaderboards are built in) | `routes` | Server process | Not applicable |
85
+ | Create a private room | A route that hands out a session name for an on-demand scene, or calls `startDedicatedSession` | Server process | Not applicable |
86
+ | Read or change the messages players send each other | Not supported | — | Use a dedicated session: the server then owns the state |
87
+ | Swap game code without a restart | Not supported | — | Restart the process |
88
+
89
+ ## 1. Dedicated Server-mode sessions: your gameplay code on the server
90
+
91
+ ### What runs
92
+
93
+ A dedicated session is one `fusion_session` room whose authority is a headless `TOOLKIT.NetworkRunner` in
94
+ `TOOLKIT.GameMode.Server`:
95
+
96
+ - It loads **the same exported scene file the browsers load**, from a folder or URL you configure.
97
+ - The scene's own **project script bundle** (the exporter's `PROJECT` bundle, your game classes) loads with it, plus any
98
+ extra bundles you list.
99
+ - It runs on a Babylon.js `NullEngine` with Havok physics, with no materials or textures (nothing is drawn).
100
+ - The server runner simulates every networked object and is player 0. Players join with `GameMode.Client` and the
101
+ session name, send input, predict locally, and keep the server's result.
102
+
103
+ Your script components run there exactly as in a browser: `awake`, `start`, `update`, and for network behaviours
104
+ `spawned`, `fixedUpdateNetwork`, `render` and the runner callbacks.
105
+
106
+ ### Configure the scenes
107
+
108
+ Dedicated scenes are listed in the config file only (there is no environment variable or flag for them, apart from the
109
+ legacy single-session flags in the README):
110
+
111
+ ```js
112
+ // babylon-game-server.config.mjs
113
+ export default {
114
+ dedicated: {
115
+ scenes: [
116
+ { name: "arena", sceneRoot: "./scenes/arena/", sceneFile: "Arena.gltf", projectScripts: [], maxPlayers: 8, onDemand: true, idleSeconds: 60 }
117
+ ],
118
+ boot: [
119
+ { sessionName: "arena-1", sceneName: "arena" }
120
+ ]
121
+ }
122
+ };
123
+ ```
124
+
125
+ | Field | Meaning | Default |
126
+ |---|---|---|
127
+ | `name` | The name clients, the admin API and `startDedicatedSession` use | required |
128
+ | `sceneRoot` | An `http(s)://` URL or a folder. A folder becomes a `file://` URL, resolved against the **process working directory** (not the config file's folder) | required |
129
+ | `sceneFile` | The exported scene file in that folder (`.gltf` or `.glb`) | required |
130
+ | `projectScripts` | Extra script bundles, loaded in order **before** the scene. URLs or paths (paths resolve like `sceneRoot`) | `[]` |
131
+ | `maxPlayers` | Most players the session accepts | `8` |
132
+ | `onDemand` | `true` lets a Client join start a session of this scene | `false` |
133
+ | `idleSeconds` | Seconds an **on-demand** session stays open once empty (after the reconnect grace) | `60` |
134
+
135
+ `dedicated.boot` entries are `{ sessionName, sceneName }`. The config is validated at startup: a duplicate scene name,
136
+ a boot entry naming an unknown scene, or a missing field stops the server with exit code 2.
137
+
138
+ ### Four ways to start a session
139
+
140
+ | How | Call | Closes |
141
+ |---|---|---|
142
+ | **At boot** | Each `dedicated.boot` entry starts once the server listens. A boot session that fails to load stops the server (`createGameServer` rejects). | Never by itself |
143
+ | **On demand** | A `GameMode.Client` join whose `sessionProperties.dedicatedScene` names a scene with `onDemand: true` creates the session (one per session name, however many join at once). The sample launcher sends it from `?fusionDedicatedScene=`. | When empty for `TOOLKIT.NETWORK_RECONNECT_GRACE_SECONDS` (20) plus `idleSeconds` |
144
+ | **Admin API** | `POST /game-server/admin/dedicated` with `{ "sceneName": "arena", "sessionName": "arena-7" }` and `Authorization: Bearer <admin token>`. Answers 201 `{ roomId }`, 400, 404 (unknown scene) or 503 (it did not start). | Never by itself |
145
+ | **From your code** | `await server.startDedicatedSession(sessionName, sceneName)` on the object `createGameServer` returned; resolves the room id | Never by itself |
146
+
147
+ In game code, the on-demand join looks like this (`TOOLKIT.NETWORK_DEDICATED_SCENE_PROPERTY` is `"dedicatedScene"`):
148
+
149
+ ```ts
150
+ const result: TOOLKIT.StartGameResult = await runner.startGame({
151
+ gameMode: TOOLKIT.GameMode.Client,
152
+ sessionName: "arena-7",
153
+ sessionProperties: { [TOOLKIT.NETWORK_DEDICATED_SCENE_PROPERTY]: "arena" },
154
+ config: { serverUrl: "wss://game.example.com" }
155
+ });
156
+ ```
157
+
158
+ Things to know:
159
+
160
+ - Sessions started at boot, by the admin API or by `startDedicatedSession` **never close by themselves**. Close them
161
+ with `POST /game-server/admin/sessions/:roomId/close` (admin token), or they run until the process stops.
162
+ - Neither the admin API nor `startDedicatedSession` checks whether a session of that name already exists. Starting
163
+ the same name twice gives two rooms, and Client joins land in either. Use unique names.
164
+ - An on-demand join of a scene that is not configured, or not `onDemand`, is refused with
165
+ `GameNotFound: no on-demand dedicated scene named <name>`. A scene that fails to load refuses its joiners with
166
+ `GameNotFound: the dedicated scene <name> failed to load`, and the server log says why.
167
+ - **Advanced:** the exported class `DedicatedSession` has `DedicatedSession.start({ sessionName, sceneRootUrl,
168
+ sceneFile, playerCount, projectScripts })`, which runs a scene that is **not** in the config. It needs a running
169
+ server, takes URLs only (`http(s)://` or `file://`, no plain paths), and adds them to the allow-list for the rest of
170
+ the process. The admin API cannot start such scenes, and they never close by themselves.
171
+
172
+ ### How your scripts are loaded
173
+
174
+ When a dedicated session starts, the server:
175
+
176
+ 1. Loads each `projectScripts` entry in order.
177
+ 2. Loads the scene. The toolkit's glTF loader reads the scene's metadata and loads its project bundle (the exporter's
178
+ `PROJECT` bundle, the file the metadata names) **from the same `sceneRoot`**, exactly as a browser page does.
179
+ 3. Creates the scene's script components and waits until the scene is ready.
180
+ 4. Only then creates the Server-mode runner and starts it, which registers the scene's networked objects (their
181
+ `spawned()` runs now) and opens the session to players.
182
+
183
+ How a script runs:
184
+
185
+ - Each file is fetched (or read from disk for `file://`) and run with Node's `vm.runInThisContext`: a **classic
186
+ script in the server's own global scope**, where `BABYLON` and `TOOLKIT` are globals. That is the form of the
187
+ exporter's UMD project bundle (`namespace MY { … }` classes that call `TOOLKIT.SceneManager.RegisterClass`).
188
+ - **ES modules do not run.** A file with `import` / `export` statements fails, and dynamic `import()` throws
189
+ `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`. Game classes written for an ESM web app (`@babylonjs-toolkit/next`) must
190
+ also be built into a classic bundle to run on the server.
191
+ - **Each file loads once per process**, keyed by its lower-cased file name. A second session of the same scene reuses
192
+ the classes already loaded; a re-exported bundle is not picked up until the process restarts.
193
+ - **The URL allow-list.** Once the server runs, the headless runtime only fetches `data:` URLs and URLs under a
194
+ configured `sceneRoot` or `projectScripts` entry. Anything else fails with `URL is not on the dedicated allow-list:
195
+ <url>`. Keep every file the scene needs (buffers, the project bundle, prefab asset containers) under its `sceneRoot`.
196
+ The allow-list guards only these loaders; your own code's `fetch` is not restricted.
197
+ - A project bundle that fails to load only logs a warning (`CVTOOLS: project script bundle not found: …`); the scene
198
+ still loads, its components then fail with `Failed to locate script class: …`. Watch for both in the log.
199
+
200
+ Server-only classes: a `projectScripts` bundle can hold classes that browsers never download. A component authored on
201
+ a scene node whose class only that bundle defines then runs on the server, and browsers log
202
+ `Failed to locate script class: <name>` for it and carry on.
203
+
204
+ ### What the headless runtime has, and what it lacks
205
+
206
+ | Available on the server | Not available on the server |
207
+ |---|---|
208
+ | `BABYLON` (with the glTF loader; `BABYLON.GUI` is loaded too, but nothing is drawn) | `window`, `document` and every DOM API (`typeof window === "undefined"`) |
209
+ | `TOOLKIT`, with Babylon Toolkit Networking | Materials and textures: the glTF loader skips them, so meshes have no material |
210
+ | Your scene graph, meshes and colliders | Drawing anything: the `NullEngine` renders nothing |
211
+ | Havok physics (`globalThis.HavokPhysics`, `globalThis.HK`) | Keyboard, mouse, touch and gamepad input (`TOOLKIT.InputController` is not enabled, and enabling it needs a DOM) |
212
+ | Script component lifecycle, the network tick, physics steps, node animation | Web Audio, so no sound |
213
+ | Node's globals: `process`, `fetch`, `setTimeout` / `setInterval`, `console`, `Buffer`, `URL`, `TextEncoder`, `crypto`, `performance` | `require` and `import()` (classic script, see above) |
214
+ | `process.getBuiltinModule("node:fs")` and the other built-in modules (Node 22.3+) | A separate sandbox: scripts share the server's global scope |
215
+
216
+ Frames are driven by Node timers (Babylon.js falls back to `setTimeout(…, 16)` without `requestAnimationFrame`), so a
217
+ session renders about 60 frames per second. The runner runs the due network ticks each frame (60 Hz by default, at most
218
+ 8 per frame).
219
+
220
+ ### Writing code that runs on the server
221
+
222
+ **Know where you are.** The runner tells you:
223
+
224
+ | Member | True when |
225
+ |---|---|
226
+ | `runner.isServer` | `Single`, `Server` or `Host` mode: this peer has state authority over what it simulates |
227
+ | `runner.isClient` | A Client or Shared peer |
228
+ | `runner.isPlayer` | Any mode except `Server`: there is a local player, a screen and input |
229
+ | `runner.gameMode === TOOLKIT.GameMode.Server` | Only on a dedicated server |
230
+ | `typeof window === "undefined"` | In the headless runtime, before any runner exists |
231
+
232
+ Use `isServer` for authority logic (it is also true on a Host, so the same code serves Host/Client games), and
233
+ `gameMode === TOOLKIT.GameMode.Server` only for code that must never run in a player's browser. Guard every piece of
234
+ presentation (DOM, GUI, audio, cameras, particles, input) with `runner.isPlayer`.
235
+
236
+ **Register the input struct when the bundle loads.** No launcher starts the dedicated runner, so it decodes player
237
+ input with the default definition. Call this at the top level of your bundle, not inside a component:
238
+
239
+ ```ts
240
+ TOOLKIT.NetworkRunner.setDefaultInputDefinition(ARENA_INPUT_DEFINITION);
241
+ ```
242
+
243
+ Without it the server ignores every player's input. The default definition is **process-wide**: every dedicated
244
+ session in one process uses the last one registered.
245
+
246
+ **Get hold of the server's runner.** Two patterns work:
247
+
248
+ - **A network behaviour on a scene object (recommended).** Put a `NetworkObject` and your `TOOLKIT.NetworkBehaviour` on
249
+ a node in the scene. When the runner starts it registers the scene's networked objects, so the behaviour's
250
+ `spawned()` runs on every peer with `this.runner` set. On the server, call `this.runner.addCallbacks(this)` there to
251
+ receive `onPlayerJoined`, `onPlayerLeft`, `onConnectRequest` and the rest.
252
+ - **Adopting the runner from a plain script component**, as the toolkit's networking sample launcher does. On a
253
+ dedicated server, `start()` runs before the runner exists (the runner is created once the scene is ready), so look
254
+ for it in `update()` until it appears:
255
+ `TOOLKIT.NetworkRunner.instances.find((runner) => runner.scene === this.scene && runner.isRunning)`, then
256
+ `addCallbacks(this)` and call your `onPlayerJoined` for `runner.activePlayers` already present.
257
+
258
+ **Rules that matter more on a server:**
259
+
260
+ - Gameplay state lives in networked properties (`getNetworkedProperties()`), simulated in `fixedUpdateNetwork()`.
261
+ Clients predict and re-simulate; the server never re-simulates, so **server-only** code may keep plain fields
262
+ (cooldown maps, user ids) without breaking prediction.
263
+ - Code that only the server runs (random crown positions, scoring, spawning) is not predicted by clients: they see its
264
+ result one round trip later. Keep movement that must feel instant in the object's own `fixedUpdateNetwork`, which
265
+ the owner's browser predicts, and validate there (the server's run is the one that counts).
266
+ - RPCs default to `invokeLocal: true`, so an RPC with `targets: RpcTargets.All` sent by the server also runs its
267
+ receiver on the server. Guard presentation in receivers with `this.runner.isPlayer`.
268
+ - Classes and top-level variables of a bundle are **shared by every dedicated session in the process** (one global
269
+ scope). Keep per-session state on component instances or keyed by scene, never in top-level variables. Static RPCs
270
+ (`registerStaticRpc`) and the default input definition are process-wide too. Run games with different input
271
+ structs in different processes.
272
+ - **An exception thrown in a frame ends the process**, and with it every session it hosts (it is an uncaught exception
273
+ in a timer callback). Write server code defensively, and run the server under a supervisor that restarts it
274
+ (Docker, Kubernetes, systemd, your platform).
275
+
276
+ ### Complete example: an arena with a server-side referee
277
+
278
+ A small king-of-the-hill game. Players move with WASD and press Space next to the crown to claim it. The server alone
279
+ decides claims, moves the crown, keeps score, runs a 90-second round timer, announces the winner by RPC and reports the
280
+ result to the server process.
281
+
282
+ Authoring in Unity (Add Component → Babylon Toolkit → Content Components → Network for the network components):
283
+
284
+ - A `NetworkSettings` component in the scene, with the avatar prefab's asset container in its prefab containers.
285
+ - A node with `MY.ArenaLauncher` (the browser's join).
286
+ - A node named `Referee` with `NetworkObject` and `MY.ArenaReferee`.
287
+ - A node named `Crown` with `NetworkObject` and `NetworkTransform`.
288
+ - A prefab named `ArenaAvatar` with `NetworkObject`, `NetworkTransform` and `MY.ArenaAvatar`.
289
+
290
+ The four scripts are project scripts, compiled by the exporter into the scene's project bundle, so browsers and the
291
+ server run the same code.
292
+
293
+ `ArenaInput.ts`, the input struct and the server bridge interface:
294
+
295
+ ```ts
296
+ namespace MY {
297
+ /** Button bit of the claim action in the arena input struct. */
298
+ export const ARENA_BUTTON_CLAIM: number = 0;
299
+
300
+ /** One tick of a player's input. Clients send it; the server reads it. */
301
+ export interface ArenaInput {
302
+ /** The claim button. */
303
+ buttons: TOOLKIT.NetworkButtons;
304
+ /** Move direction on X, from -1 to 1. */
305
+ moveX: number;
306
+ /** Move direction on Z, from -1 to 1. */
307
+ moveZ: number;
308
+ }
309
+
310
+ /** The input struct's networked fields, identical in every browser and on the server. */
311
+ export const ARENA_INPUT_DEFINITION: TOOLKIT.NetworkedStructDefinition = {
312
+ buttons: TOOLKIT.Networked.buttons(),
313
+ moveX: TOOLKIT.Networked.float(),
314
+ moveZ: TOOLKIT.Networked.float()
315
+ };
316
+
317
+ // No launcher starts the dedicated runner, so the bundle registers the input struct when it loads.
318
+ TOOLKIT.NetworkRunner.setDefaultInputDefinition(ARENA_INPUT_DEFINITION);
319
+
320
+ /** What server.mjs puts on globalThis for server-side game code. Absent in browsers and under the plain CLI. */
321
+ export interface ArenaServerBridge {
322
+ /**
323
+ * Receives a finished round.
324
+ * @param result The round's outcome.
325
+ */
326
+ reportRoundResult(result: ArenaRoundResult): void;
327
+ /**
328
+ * Writes a line to the game server's structured log.
329
+ * @param message What happened.
330
+ * @param fields Context fields.
331
+ */
332
+ log(message: string, fields?: Record<string, unknown>): void;
333
+ }
334
+
335
+ /** A finished round, as the referee reports it. */
336
+ export interface ArenaRoundResult {
337
+ /** The session the round was played in. */
338
+ sessionName: string;
339
+ /** The round number, from 1. */
340
+ roundNumber: number;
341
+ /** The winner's player id, or 0 when nobody scored. */
342
+ winnerPlayerId: number;
343
+ /** The winner's verified user id, or null (auth off, or nobody scored). */
344
+ winnerUserId: string | null;
345
+ /** Every player's score by player id. */
346
+ scores: Record<number, number>;
347
+ }
348
+
349
+ /**
350
+ * The server bridge, when the game server process provides one.
351
+ * @returns The bridge, or null.
352
+ */
353
+ export function findArenaServerBridge(): ArenaServerBridge | null {
354
+ const bridge: unknown = (globalThis as unknown as Record<string, unknown>)["ArenaServerBridge"];
355
+ return (bridge != null) ? bridge as ArenaServerBridge : null;
356
+ }
357
+ }
358
+ ```
359
+
360
+ `ArenaLauncher.ts`, browser only:
361
+
362
+ ```ts
363
+ namespace MY {
364
+ /** The dedicated scene name in the server's dedicated.scenes, and the defaults the page query can override. */
365
+ const ARENA_SCENE_NAME: string = "arena";
366
+ const DEFAULT_SESSION_NAME: string = "arena-1";
367
+ const DEFAULT_SERVER_URL: string = "ws://localhost:2567";
368
+ /** Key codes the launcher reads. */
369
+ const KEY_W: number = 87;
370
+ const KEY_A: number = 65;
371
+ const KEY_S: number = 83;
372
+ const KEY_D: number = 68;
373
+ const KEY_SPACE: number = 32;
374
+
375
+ /**
376
+ * Joins the arena as a Client of a dedicated session and sends the keyboard as input. It does nothing on the
377
+ * server: there the session's own runner simulates the scene and the ArenaReferee runs the match.
378
+ */
379
+ export class ArenaLauncher extends TOOLKIT.ScriptComponent implements TOOLKIT.INetworkRunnerCallbacks {
380
+ /** The browser's runner, or null. */
381
+ private runner: TOOLKIT.NetworkRunner = null;
382
+
383
+ constructor(transform: BABYLON.TransformNode, scene: BABYLON.Scene, properties: any = {}, alias: string = "MY.ArenaLauncher") {
384
+ super(transform, scene, properties, alias);
385
+ }
386
+
387
+ /** Starts the browser's runner; a headless server has no window and returns at once. */
388
+ protected start(): void {
389
+ if (typeof window === "undefined") return;
390
+ if (!TOOLKIT.InputController.IsUserInputEnabled()) TOOLKIT.InputController.EnableUserInput(this.scene.getEngine(), this.scene);
391
+ this.join().catch((error: unknown) => console.error("[Arena] startGame threw", error));
392
+ }
393
+
394
+ /** Leaves the session with the scene. */
395
+ protected destroy(): void {
396
+ if (this.runner != null) void this.runner.shutdown();
397
+ this.runner = null;
398
+ }
399
+
400
+ /**
401
+ * Sends this tick's input: WASD moves, Space claims.
402
+ * @param runner The runner.
403
+ * @param input The tick's input to fill.
404
+ */
405
+ public onInput(runner: TOOLKIT.NetworkRunner, input: TOOLKIT.NetworkInput): void {
406
+ const buttons: TOOLKIT.NetworkButtons = new TOOLKIT.NetworkButtons(0).set(MY.ARENA_BUTTON_CLAIM, TOOLKIT.InputController.GetKeyboardInput(KEY_SPACE));
407
+ const moveX: number = (TOOLKIT.InputController.GetKeyboardInput(KEY_D) ? 1 : 0) - (TOOLKIT.InputController.GetKeyboardInput(KEY_A) ? 1 : 0);
408
+ const moveZ: number = (TOOLKIT.InputController.GetKeyboardInput(KEY_W) ? 1 : 0) - (TOOLKIT.InputController.GetKeyboardInput(KEY_S) ? 1 : 0);
409
+ const arenaInput: MY.ArenaInput = { buttons: buttons, moveX: moveX, moveZ: moveZ };
410
+ input.set(arenaInput);
411
+ }
412
+
413
+ /**
414
+ * Logs why the runner stopped.
415
+ * @param runner The runner.
416
+ * @param reason The reason.
417
+ */
418
+ public onShutdown(runner: TOOLKIT.NetworkRunner, reason: TOOLKIT.ShutdownReason): void {
419
+ console.log("[Arena] left the session: " + TOOLKIT.ShutdownReason[reason]);
420
+ }
421
+
422
+ /** Joins the session named by ?fusionSession= (the server starts it on demand when it does not exist yet). */
423
+ private async join(): Promise<void> {
424
+ const query: URLSearchParams = new URLSearchParams(window.location.search);
425
+ this.runner = new TOOLKIT.NetworkRunner(this.scene);
426
+ this.runner.addCallbacks(this);
427
+ const result: TOOLKIT.StartGameResult = await this.runner.startGame({
428
+ gameMode: TOOLKIT.GameMode.Client,
429
+ sessionName: query.get("fusionSession") ?? DEFAULT_SESSION_NAME,
430
+ sessionProperties: { [TOOLKIT.NETWORK_DEDICATED_SCENE_PROPERTY]: ARENA_SCENE_NAME },
431
+ config: { serverUrl: query.get("fusionServer") ?? DEFAULT_SERVER_URL },
432
+ authToken: query.get("fusionToken") ?? ""
433
+ });
434
+ if (!result.ok) console.error("[Arena] startGame failed: " + TOOLKIT.ShutdownReason[result.shutdownReason] + " " + result.errorMessage);
435
+ }
436
+ }
437
+ TOOLKIT.SceneManager.RegisterClass("MY.ArenaLauncher", ArenaLauncher);
438
+ }
439
+ ```
440
+
441
+ `ArenaAvatar.ts`, predicted movement with server-side validation:
442
+
443
+ ```ts
444
+ namespace MY {
445
+ /** Avatar speed in m/s and the arena's radius in meters. */
446
+ const AVATAR_SPEED: number = 6;
447
+ const ARENA_RADIUS: number = 12;
448
+
449
+ /**
450
+ * A player's avatar (a prefab with NetworkObject + NetworkTransform). It moves itself from its owner's input, so the
451
+ * owner's browser predicts it and the server's result is the one everyone keeps. The score is written only by the
452
+ * server's ArenaReferee.
453
+ */
454
+ export class ArenaAvatar extends TOOLKIT.NetworkBehaviour {
455
+ /** Crowns claimed this round; the server writes it, every peer reads it. */
456
+ public declare score: number;
457
+
458
+ constructor(transform: BABYLON.TransformNode, scene: BABYLON.Scene, properties: any = {}, alias: string = "MY.ArenaAvatar") {
459
+ super(transform, scene, properties, alias);
460
+ }
461
+
462
+ /** Networked state. */
463
+ protected static getNetworkedProperties(): TOOLKIT.NetworkedPropertyDefinitions {
464
+ return {
465
+ score: TOOLKIT.Networked.int({ onChangedRender: "onScoreChanged" })
466
+ };
467
+ }
468
+
469
+ /** One tick: move along the input direction, never faster than AVATAR_SPEED, never outside the arena. */
470
+ public fixedUpdateNetwork(): void {
471
+ const input: MY.ArenaInput | null = this.getInput<MY.ArenaInput>();
472
+ if (input == null) return;
473
+ const direction: BABYLON.Vector3 = new BABYLON.Vector3(input.moveX, 0, input.moveZ);
474
+ const length: number = direction.length();
475
+ // A tampered client can send any float; normalising here is the server-side validation.
476
+ if (length > 1) direction.scaleInPlace(1 / length);
477
+ const position: BABYLON.Vector3 = this.transform.position;
478
+ position.addInPlace(direction.scaleInPlace(AVATAR_SPEED * this.runner.deltaTime));
479
+ const distanceFromCenter: number = Math.sqrt(position.x * position.x + position.z * position.z);
480
+ if (distanceFromCenter > ARENA_RADIUS) {
481
+ position.x *= ARENA_RADIUS / distanceFromCenter;
482
+ position.z *= ARENA_RADIUS / distanceFromCenter;
483
+ }
484
+ }
485
+
486
+ /** onChangedRender of score: presentation only, so the server skips it. */
487
+ public onScoreChanged(): void {
488
+ if (!this.runner.isPlayer) return;
489
+ console.log("[Arena] player " + this.object.inputAuthority.playerId + " has " + this.score);
490
+ }
491
+ }
492
+ TOOLKIT.SceneManager.RegisterClass("MY.ArenaAvatar", ArenaAvatar);
493
+ }
494
+ ```
495
+
496
+ `ArenaReferee.ts`, the server-only component: it reads player input, moves the crown, scores, times rounds and calls
497
+ an RPC:
498
+
499
+ ```ts
500
+ namespace MY {
501
+ /** The avatar prefab (in a NetworkSettings prefab container) and the crown's node name in the scene. */
502
+ const AVATAR_PREFAB_NAME: string = "ArenaAvatar";
503
+ const CROWN_NODE_NAME: string = "Crown";
504
+ /** Round rules: length in seconds, crowns to win, claim distance and cooldown, crown drop radius. */
505
+ const ROUND_SECONDS: number = 90;
506
+ const WINNING_SCORE: number = 5;
507
+ const CLAIM_DISTANCE: number = 2;
508
+ const CLAIM_COOLDOWN_SECONDS: number = 1;
509
+ const CROWN_DROP_RADIUS: number = 10;
510
+ /** Avatars spawn on a ring of this radius. */
511
+ const SPAWN_RADIUS: number = 6;
512
+
513
+ /**
514
+ * The match rules, on a scene object (NetworkObject + ArenaReferee). It exists on every peer, but all of its logic
515
+ * runs on the server only: spawning avatars, reading claims from player input, moving the crown, scoring, the round
516
+ * timer, and announcing the winner by RPC.
517
+ */
518
+ export class ArenaReferee extends TOOLKIT.NetworkBehaviour implements TOOLKIT.INetworkRunnerCallbacks {
519
+ /** Runs while a round lasts. */
520
+ public declare roundTimer: TOOLKIT.TickTimer;
521
+ /** The current round, from 1. */
522
+ public declare roundNumber: number;
523
+ /** The crown's NetworkTransform (server only), or null when the scene has no crown. */
524
+ private crown: TOOLKIT.NetworkTransform = null;
525
+ /** Claim cooldowns by player id (server only: the server never re-simulates, so plain fields are safe here). */
526
+ private readonly claimCooldowns: Map<number, TOOLKIT.TickTimer> = new Map<number, TOOLKIT.TickTimer>();
527
+ /** Verified user ids by player id, from onConnectRequest (server only). */
528
+ private readonly userIds: Map<number, string | null> = new Map<number, string | null>();
529
+
530
+ constructor(transform: BABYLON.TransformNode, scene: BABYLON.Scene, properties: any = {}, alias: string = "MY.ArenaReferee") {
531
+ super(transform, scene, properties, alias);
532
+ }
533
+
534
+ /** Networked state. */
535
+ protected static getNetworkedProperties(): TOOLKIT.NetworkedPropertyDefinitions {
536
+ return {
537
+ roundTimer: TOOLKIT.Networked.tickTimer(),
538
+ roundNumber: TOOLKIT.Networked.int()
539
+ };
540
+ }
541
+
542
+ /** RPCs: the server tells every player who won a round. */
543
+ protected static getRpcDefinitions(): TOOLKIT.RpcDefinitions {
544
+ return {
545
+ rpcRoundOver: TOOLKIT.Rpc.define({ sources: TOOLKIT.RpcSources.StateAuthority, targets: TOOLKIT.RpcTargets.All }, [TOOLKIT.Networked.int(), TOOLKIT.Networked.int()])
546
+ };
547
+ }
548
+
549
+ /** On the server: become a runner callback object, find the crown and start round 1. Elsewhere: nothing. */
550
+ public spawned(): void {
551
+ if (!this.runner.isServer) return;
552
+ this.runner.addCallbacks(this);
553
+ const crownNode: BABYLON.TransformNode = this.scene.getTransformNodeByName(CROWN_NODE_NAME);
554
+ this.crown = (crownNode != null) ? TOOLKIT.SceneManager.FindScriptComponent<TOOLKIT.NetworkTransform>(crownNode, "TOOLKIT.NetworkTransform") : null;
555
+ // Scene objects spawn when the runner starts, before anyone joins; this covers players already present anyway.
556
+ for (const player of this.runner.activePlayers) this.onPlayerJoined(this.runner, player);
557
+ this.startRound(1);
558
+ }
559
+
560
+ /**
561
+ * Records the server-verified identity of a joining player (null when the game server's auth is off).
562
+ * @param runner The runner.
563
+ * @param request The join request.
564
+ * @param token The client's connection token bytes.
565
+ */
566
+ public onConnectRequest(runner: TOOLKIT.NetworkRunner, request: TOOLKIT.NetworkConnectRequestArgs, token: Uint8Array): void {
567
+ const userId: unknown = (request.authIdentity != null) ? request.authIdentity["userId"] : null;
568
+ this.userIds.set(request.player.playerId, (typeof userId === "string") ? userId : null);
569
+ }
570
+
571
+ /**
572
+ * Spawns the joined player's avatar on a ring around the arena center.
573
+ * @param runner The runner.
574
+ * @param player The player who joined.
575
+ */
576
+ public onPlayerJoined(runner: TOOLKIT.NetworkRunner, player: TOOLKIT.PlayerRef): void {
577
+ if (runner.getPlayerObject(player) != null) return;
578
+ const angle: number = player.playerId * 1.7;
579
+ const position: BABYLON.Vector3 = new BABYLON.Vector3(Math.cos(angle) * SPAWN_RADIUS, 0, Math.sin(angle) * SPAWN_RADIUS);
580
+ const avatar: TOOLKIT.NetworkObject = runner.spawn(AVATAR_PREFAB_NAME, position, BABYLON.Quaternion.Identity(), player);
581
+ runner.setPlayerObject(player, avatar);
582
+ }
583
+
584
+ /**
585
+ * Removes the leaving player's avatar and bookkeeping.
586
+ * @param runner The runner.
587
+ * @param player The player who left.
588
+ */
589
+ public onPlayerLeft(runner: TOOLKIT.NetworkRunner, player: TOOLKIT.PlayerRef): void {
590
+ const avatar: TOOLKIT.NetworkObject | null = runner.getPlayerObject(player);
591
+ if (avatar != null) runner.despawn(avatar);
592
+ this.claimCooldowns.delete(player.playerId);
593
+ this.userIds.delete(player.playerId);
594
+ }
595
+
596
+ /** One server tick: end the round on time, else read every player's claim button. */
597
+ public fixedUpdateNetwork(): void {
598
+ if (!this.runner.isServer) return;
599
+ if (this.roundTimer.expired(this.runner)) {
600
+ this.endRound(this.findLeader());
601
+ return;
602
+ }
603
+ if (this.crown == null) return;
604
+ for (const avatar of this.runner.getAllBehaviours(MY.ArenaAvatar)) {
605
+ const player: TOOLKIT.PlayerRef = avatar.object.inputAuthority;
606
+ const input: MY.ArenaInput | null = this.runner.getInputForPlayer<MY.ArenaInput>(player);
607
+ if (input == null || !input.buttons.isSet(MY.ARENA_BUTTON_CLAIM)) continue;
608
+ const cooldown: TOOLKIT.TickTimer | undefined = this.claimCooldowns.get(player.playerId);
609
+ if (cooldown != null && !cooldown.expiredOrNotRunning(this.runner)) continue;
610
+ if (BABYLON.Vector3.Distance(avatar.transform.position, this.crown.transform.position) > CLAIM_DISTANCE) continue;
611
+ this.claimCooldowns.set(player.playerId, TOOLKIT.TickTimer.createFromSeconds(this.runner, CLAIM_COOLDOWN_SECONDS));
612
+ avatar.score = avatar.score + 1;
613
+ this.dropCrownSomewhereElse();
614
+ if (avatar.score >= WINNING_SCORE) {
615
+ this.endRound(avatar);
616
+ return;
617
+ }
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Receives the round result on every peer (invokeLocal also runs it on the server, which has no screen).
623
+ * Calling this.rpcRoundOver(...) on the server SENDS it.
624
+ * @param winnerPlayerId The winner's player id, or 0.
625
+ * @param winningScore The winner's score.
626
+ * @param info Who sent it, and on which tick.
627
+ */
628
+ public rpcRoundOver(winnerPlayerId: number, winningScore: number, info?: TOOLKIT.RpcInfo): void {
629
+ if (!this.runner.isPlayer) return;
630
+ const isMe: boolean = winnerPlayerId === this.runner.localPlayer.playerId;
631
+ console.log("[Arena] round over: " + (winnerPlayerId === 0 ? "nobody scored" : (isMe ? "you won" : "player " + winnerPlayerId + " won")) + " with " + winningScore);
632
+ }
633
+
634
+ /**
635
+ * Starts a round: resets the timer and every score.
636
+ * @param roundNumber The round to start.
637
+ */
638
+ private startRound(roundNumber: number): void {
639
+ this.roundNumber = roundNumber;
640
+ this.roundTimer = TOOLKIT.TickTimer.createFromSeconds(this.runner, ROUND_SECONDS);
641
+ for (const avatar of this.runner.getAllBehaviours(MY.ArenaAvatar)) avatar.score = 0;
642
+ this.dropCrownSomewhereElse();
643
+ }
644
+
645
+ /**
646
+ * Announces and reports the round, then starts the next one.
647
+ * @param winner The winning avatar, or null when nobody scored.
648
+ */
649
+ private endRound(winner: MY.ArenaAvatar | null): void {
650
+ const winnerPlayerId: number = (winner != null) ? winner.object.inputAuthority.playerId : 0;
651
+ const winningScore: number = (winner != null) ? winner.score : 0;
652
+ this.rpcRoundOver(winnerPlayerId, winningScore);
653
+ const scores: Record<number, number> = {};
654
+ for (const avatar of this.runner.getAllBehaviours(MY.ArenaAvatar)) scores[avatar.object.inputAuthority.playerId] = avatar.score;
655
+ const bridge: MY.ArenaServerBridge | null = MY.findArenaServerBridge();
656
+ if (bridge != null) {
657
+ bridge.reportRoundResult({
658
+ sessionName: this.runner.sessionInfo.name,
659
+ roundNumber: this.roundNumber,
660
+ winnerPlayerId: winnerPlayerId,
661
+ winnerUserId: this.userIds.get(winnerPlayerId) ?? null,
662
+ scores: scores
663
+ });
664
+ } else {
665
+ console.log("[Arena] round " + this.roundNumber + " won by player " + winnerPlayerId);
666
+ }
667
+ this.startRound(this.roundNumber + 1);
668
+ }
669
+
670
+ /** @returns The avatar with the highest score above 0, or null. */
671
+ private findLeader(): MY.ArenaAvatar | null {
672
+ let leader: MY.ArenaAvatar | null = null;
673
+ for (const avatar of this.runner.getAllBehaviours(MY.ArenaAvatar)) {
674
+ if (avatar.score > 0 && (leader == null || avatar.score > leader.score)) leader = avatar;
675
+ }
676
+ return leader;
677
+ }
678
+
679
+ /** Teleports the crown to a random point: server-only randomness that clients never need to reproduce. */
680
+ private dropCrownSomewhereElse(): void {
681
+ if (this.crown == null) return;
682
+ const angle: number = Math.random() * Math.PI * 2;
683
+ const radius: number = Math.random() * CROWN_DROP_RADIUS;
684
+ this.crown.teleport(new BABYLON.Vector3(Math.cos(angle) * radius, this.crown.transform.position.y, Math.sin(angle) * radius), null);
685
+ }
686
+ }
687
+ TOOLKIT.SceneManager.RegisterClass("MY.ArenaReferee", ArenaReferee);
688
+ }
689
+ ```
690
+
691
+ The server config for it:
692
+
693
+ ```js
694
+ // babylon-game-server.config.mjs
695
+ export default {
696
+ dedicated: {
697
+ scenes: [{ name: "arena", sceneRoot: "./scenes/arena/", sceneFile: "Arena.gltf", maxPlayers: 8, onDemand: true, idleSeconds: 60 }]
698
+ }
699
+ };
700
+ ```
701
+
702
+ What makes it server-authoritative:
703
+
704
+ - The referee's `spawned()` and `fixedUpdateNetwork()` return at once unless `runner.isServer`, so browsers never
705
+ decide a claim, a score or a round.
706
+ - Input carries only a direction and a button. The server normalises the direction, so a modified client cannot move
707
+ faster, and it checks the claim distance and cooldown itself.
708
+ - Scores are networked properties written by the server, and `rpcRoundOver` is sent by the state authority (the
709
+ server) to everyone.
710
+ - `onConnectRequest` sees the identity the game server verified (`request.authIdentity`, `{ userId, claims }`, or
711
+ `null` with auth off), never a name the client chose.
712
+
713
+ To also refuse joiners by game rules, call `request.refuse()` in `onConnectRequest`; an unanswered request is
714
+ accepted.
715
+
716
+ ### Getting results out of a session, and logging
717
+
718
+ Dedicated-session code has no import of the server package, but two ways out work:
719
+
720
+ **1. A bridge object on `globalThis` (in-process).** Project scripts run in the server process's own global scope, so
721
+ an object your `server.mjs` puts on `globalThis` before `createGameServer` is visible to them. The example's
722
+ `MY.findArenaServerBridge()` reads `globalThis.ArenaServerBridge`, which the [complete server.mjs](#complete-servermjs)
723
+ defines. It needs your own Node app; under the plain CLI or image the bridge is absent and the referee falls back to
724
+ `console.log`. This is a property of how scripts are loaded, not a published API of the package: keep the bridge
725
+ small, check for it before calling it, and never put a secret on it that game code should not hold.
726
+
727
+ **2. `fetch` to your backend.** `fetch` is available and not limited by the allow-list. Read the endpoint and its
728
+ credential from `process.env` at run time, never from constants: browsers download the very same project bundle.
729
+
730
+ ```ts
731
+ /**
732
+ * Posts a round result to the game's backend, on the server only.
733
+ * @param result The round's outcome.
734
+ */
735
+ async function postRoundResult(result: MY.ArenaRoundResult): Promise<void> {
736
+ if (typeof process === "undefined" || process.env.ARENA_RESULTS_URL == null) return;
737
+ const response: Response = await fetch(process.env.ARENA_RESULTS_URL, {
738
+ method: "POST",
739
+ headers: { "content-type": "application/json", authorization: "Bearer " + process.env.ARENA_RESULTS_TOKEN },
740
+ body: JSON.stringify(result)
741
+ });
742
+ if (!response.ok) console.error("[Arena] results upload failed: " + response.status);
743
+ }
744
+ ```
745
+
746
+ (The exporter's TypeScript setup may not include Node's typings; declare `process` yourself or read it through
747
+ `globalThis` if `process` does not compile.)
748
+
749
+ **Logging.** `console.log` / `console.error` from project scripts (and Babylon's `BABYLON.Tools.Log` / `Warn`) go to
750
+ the process's stdout and stderr as plain lines, outside the server's structured log, without `sessionName` fields. In
751
+ production, give game code a logging function on the bridge that calls the package's `getLogger()`, as the example's
752
+ `log(message, fields)` does. The server itself logs `dedicated session "<sessionName>" running <scene URL>` when a
753
+ session is up, and `the dedicated scene <name> failed to load` with the cause when it is not.
754
+
755
+ ### Limits, metrics and lifetime
756
+
757
+ | Limit | Option | Default | When exceeded |
758
+ |---|---|---|---|
759
+ | Dedicated sessions one process runs | `limits.maxDedicatedSessions` | `4` | The start is refused with `MaxCcuReached: this server is running its most dedicated sessions` (503) |
760
+ | Sessions of every kind one process hosts | `limits.maxSessions` | `1000` | `MaxCcuReached: this server is hosting its most sessions` |
761
+ | Players of a dedicated session | the scene's `maxPlayers` | `8` | `GameIsFull` |
762
+
763
+ Prometheus metrics on `GET /metrics` that concern dedicated sessions:
764
+
765
+ | Metric | Meaning |
766
+ |---|---|
767
+ | `babylon_game_server_dedicated_sessions` | Dedicated sessions running now |
768
+ | `babylon_game_server_dedicated_frame_seconds` | Histogram of how long each headless frame took |
769
+ | `babylon_game_server_dedicated_tick_overruns_total` | Frames that took longer than one network tick |
770
+
771
+ Every session of a process, every hook and every route share **one Node event loop**. A slow frame in one dedicated
772
+ session delays every other session and the relay of every Shared or Host session in that process. Size instances for
773
+ their dedicated sessions (each is a full scene simulation), keep `maxDedicatedSessions` honest, and watch the overrun
774
+ counter.
775
+
776
+ How long a dedicated session lives:
777
+
778
+ | Started | Ends |
779
+ |---|---|
780
+ | On demand | Once empty for the reconnect grace (20 s) plus `idleSeconds` |
781
+ | At boot, by the admin API or from code | Only when closed through `POST /game-server/admin/sessions/:roomId/close`, a drain, or when the process stops |
782
+
783
+ During a drain (`SIGTERM`, `server.drain()` or `POST /game-server/admin/drain`) new joins are refused with
784
+ `GameClosed`, running sessions get `drainSeconds` (30) to end by themselves, then their players are told `GameClosed`
785
+ and the runners stop.
786
+
787
+ ## 2. Server hooks
788
+
789
+ Hooks are functions you pass to `createGameServer({ hooks })`. They are plain properties of one object, of type
790
+ `GameServerHooks`:
791
+
792
+ ```ts
793
+ interface GameServerHooks {
794
+ verifyToken?(request: AuthRequest): Promise<AuthIdentity | null>;
795
+ onSessionCreated?(event: SessionEvent): void;
796
+ onPlayerJoined?(event: PlayerEvent): void;
797
+ onPlayerLeft?(event: PlayerEvent): void;
798
+ onSessionClosed?(event: SessionEvent): void;
799
+ }
800
+ ```
801
+
802
+ `verifyToken` is covered in [Custom auth](#5-custom-auth-verifytoken-and-the-webhook). The four lifecycle hooks get:
803
+
804
+ | `SessionEvent` field | Type | Meaning |
805
+ |---|---|---|
806
+ | `roomId` | `string` | The room id (the admin API's `:roomId`) |
807
+ | `sessionName` | `string` | The session name players join by |
808
+ | `gameMode` | `number \| null` | The `TOOLKIT.GameMode` value the **creating** join sent: `1` Shared, `2` Server (every dedicated session), `3` Host, `4` Client, `5` AutoHostOrClient |
809
+ | `isDedicated` | `boolean` | True when a dedicated Server-mode runner hosts the session |
810
+
811
+ `PlayerEvent` has every `SessionEvent` field plus:
812
+
813
+ | `PlayerEvent` field | Type | Meaning |
814
+ |---|---|---|
815
+ | `playerId` | `number` | The player id in the session (the runtime's `PlayerRef.playerId`) |
816
+ | `identity` | `AuthIdentity \| null` | `{ userId: string \| null, claims: Record<string, unknown> }` as the server verified it, or `null` with auth off |
817
+
818
+ After `createGameServer` resolves, `globalThis.TOOLKIT` is the runtime, so `TOOLKIT.GameMode[event.gameMode]` gives
819
+ the mode's name.
820
+
821
+ ### When each hook fires
822
+
823
+ | Hook | Fires | Does not fire |
824
+ |---|---|---|
825
+ | `onSessionCreated` | A session room was created in this process: the first join of a Shared, Host or AutoHostOrClient session; a dedicated session started at boot, by the admin API or from code, **after** its scene loaded and its runner started; an on-demand session as soon as the first Client join creates it, **before** its scene has loaded | For the lobby; when creation is refused (session or dedicated cap, unknown on-demand scene); when a boot, admin or code start fails to load |
826
+ | `onPlayerJoined` | A client was admitted (after the join gate and auth; for on-demand sessions, after the scene loaded) | For a reconnect within the grace period (the player keeps its seat); for the dedicated runner itself |
827
+ | `onPlayerLeft` | A player left for good: it left, or it dropped and the reconnect grace (`TOOLKIT.NETWORK_RECONNECT_GRACE_SECONDS`, 20 s) ran out, or its session was closed | — |
828
+ | `onSessionClosed` | The room was disposed: the last player of a Shared or Host session left; an on-demand session idled out; an admin close; a drain. For dedicated sessions, after the runner stopped | — |
829
+
830
+ In a Host session, a host migration makes the remaining players' runners leave and join again, so expect
831
+ `onPlayerLeft` / `onPlayerJoined` pairs with the same `playerId`.
832
+
833
+ With Redis scale-out, each process calls the hooks for the sessions it hosts only.
834
+
835
+ ### Rules for hook code
836
+
837
+ - **Hooks are called synchronously and never awaited.** Their return value is ignored, so a hook cannot delay or
838
+ refuse anything (only `verifyToken` can refuse, at join time).
839
+ - **A synchronous throw is caught**: the server logs `hook <name> threw` with the error and carries on.
840
+ - **An `async` hook's rejection is not caught by the server.** In the package as built, an optional handler that
841
+ Colyseus loads prints it to stderr; without that handler, Node's default ends the process. Catch inside every async
842
+ hook, or hand the work to a helper that catches, like `track()` below.
843
+ - **Hooks share the event loop with every session.** Never do heavy synchronous work in them. Start I/O and return.
844
+ - Hooks see sessions and players only: **no game messages, no networked state, no Shared-mode store.** Game data
845
+ reaches your code from a dedicated session ([bridge or `fetch`](#getting-results-out-of-a-session-and-logging)).
846
+ - Work still in flight when the process exits is lost. With `handleSignals: true` the process exits right after the
847
+ drain; to flush writes first, handle the signals yourself ([shutdown](#shutdown-and-signals)).
848
+
849
+ ### Example: player analytics in PostgreSQL
850
+
851
+ `pg` is your dependency (`npm install pg`). The table and columns are yours to define.
852
+
853
+ ```js
854
+ // analytics-server.mjs: records every player's time in every session.
855
+ import pg from "pg";
856
+ import { createGameServer, getLogger } from "@babylonjs-toolkit/server";
857
+
858
+ /** The analytics database (DATABASE_URL, for example postgres://user:password@host/game). */
859
+ const database = new pg.Pool({ connectionString: process.env.DATABASE_URL });
860
+ /** When each player joined, by "<roomId>/<playerId>". */
861
+ const joinedAt = new Map();
862
+
863
+ /**
864
+ * Runs a write without letting a failure escape the hook.
865
+ * @param {Promise<unknown>} write The write.
866
+ */
867
+ function track(write) {
868
+ write.catch((error) => getLogger().error("analytics write failed", { error: String(error) }));
869
+ }
870
+
871
+ const server = await createGameServer({
872
+ config: { port: 2567 },
873
+ handleSignals: true,
874
+ hooks: {
875
+ onPlayerJoined(event) {
876
+ joinedAt.set(event.roomId + "/" + event.playerId, Date.now());
877
+ },
878
+ onPlayerLeft(event) {
879
+ const key = event.roomId + "/" + event.playerId;
880
+ const startedAt = joinedAt.get(key) ?? Date.now();
881
+ joinedAt.delete(key);
882
+ track(database.query(
883
+ "INSERT INTO player_sessions (session_name, game_mode, is_dedicated, user_id, seconds_played) VALUES ($1, $2, $3, $4, $5)",
884
+ [event.sessionName, event.gameMode, event.isDedicated, event.identity?.userId ?? null, Math.round((Date.now() - startedAt) / 1000)]
885
+ ));
886
+ }
887
+ }
888
+ });
889
+ getLogger().info("analytics server listening", { url: server.urls.webSocket });
890
+ ```
891
+
892
+ ### Example: a Discord notifier
893
+
894
+ ```js
895
+ // discord-server.mjs: posts to a Discord channel when a session opens and closes.
896
+ import { createGameServer, getLogger } from "@babylonjs-toolkit/server";
897
+
898
+ /** The channel's webhook URL (Server Settings → Integrations → Webhooks in Discord). */
899
+ const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
900
+
901
+ /**
902
+ * Posts one message, logging a failure instead of throwing it.
903
+ * @param {string} content The message.
904
+ */
905
+ function notify(content) {
906
+ fetch(DISCORD_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: content }), signal: AbortSignal.timeout(5000) })
907
+ .then((response) => { if (!response.ok) throw new Error("Discord answered " + response.status); })
908
+ .catch((error) => getLogger().warn("Discord notice failed", { error: String(error) }));
909
+ }
910
+
911
+ await createGameServer({
912
+ config: { port: 2567 },
913
+ handleSignals: true,
914
+ hooks: {
915
+ onSessionCreated(event) {
916
+ notify("Session **" + event.sessionName + "** opened" + (event.isDedicated ? " (dedicated)" : ""));
917
+ },
918
+ onSessionClosed(event) {
919
+ notify("Session **" + event.sessionName + "** closed");
920
+ }
921
+ }
922
+ });
923
+ ```
924
+
925
+ Session names come from players. Discord renders Markdown, so escape or quote them if that matters to you.
926
+
927
+ ### Example: bans, and session bookkeeping
928
+
929
+ Bans belong in `verifyToken` (see [the bans example](#example-bans-with-a-custom-verifier)), because hooks cannot
930
+ refuse anyone. Session bookkeeping, a live map of who is where, is part of the
931
+ [complete server.mjs](#complete-servermjs) and served by its `/api/sessions` route.
932
+
933
+ ## 3. Custom HTTP endpoints
934
+
935
+ Pass extra endpoints as `createGameServer({ routes })`, a record whose values come from the re-exported
936
+ `createEndpoint`. They answer on the game server's own port, over HTTP and HTTPS alike, next to `/game-server/*`.
937
+
938
+ ```ts
939
+ createEndpoint(path: string, options: { method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | … }, handler: (context) => Promise<Response | object>)
940
+ ```
941
+
942
+ `createEndpoint` is Colyseus's router endpoint (built on `better-call`). The handler's context has:
943
+
944
+ | Member | What it is |
945
+ |---|---|
946
+ | `context.params` | Path parameters: `/api/rooms/:roomId` gives `context.params.roomId` |
947
+ | `context.query` | The parsed query string |
948
+ | `context.body` | The parsed JSON body of a `POST` / `PUT` / `PATCH` |
949
+ | `context.getHeader(name)` | A request header, or `null` |
950
+
951
+ Return a plain object to answer `200` with it as JSON, `jsonResponse(status, body)` (exported by the package) for
952
+ another status, or any `Response` (for HTML or text).
953
+
954
+ ```js
955
+ import { createEndpoint, createGameServer, jsonResponse } from "@babylonjs-toolkit/server";
956
+
957
+ await createGameServer({
958
+ routes: {
959
+ hello: createEndpoint("/api/hello", { method: "GET" }, async (context) => ({ greeting: "hello " + (context.query.name ?? "player") })),
960
+ echo: createEndpoint("/api/echo/:id", { method: "POST" }, async (context) => jsonResponse(201, { id: context.params.id, body: context.body }))
961
+ }
962
+ });
963
+ ```
964
+
965
+ Things to know:
966
+
967
+ - **Your routes get no auth, no rate limit and no admin check.** The admin-token check is internal (no helper is
968
+ exported), and `limits.joinRequestsPerMinutePerAddress` applies to joins only. Check credentials yourself, as below.
969
+ - They get the same CORS answer as the built-in endpoints: with `limits.allowedOrigins` set, only those origins'
970
+ pages can call them from a browser.
971
+ - Use your own path prefix (for example `/api/`). A route on a built-in path (`/game-server/health`) is never reached,
972
+ and a route whose **key** equals a built-in one (`gameServerHealth`, `gameServerReady`, `gameServerInfo`,
973
+ `gameServerMetrics`, `gameServerAdminSessions`, `gameServerAdminCloseSession`, `gameServerAdminDedicated`,
974
+ `gameServerAdminDrain`) replaces it.
975
+ - With Redis scale-out, each instance serves its own routes from its own memory.
976
+
977
+ ### Checking a bearer token yourself
978
+
979
+ The same constant-time compare the admin API uses:
980
+
981
+ ```js
982
+ import { createHash, timingSafeEqual } from "node:crypto";
983
+
984
+ /**
985
+ * Whether a route request carries the expected bearer token.
986
+ * @param {{ getHeader(name: string): string | null }} context The route context.
987
+ * @param {string | undefined} expectedToken The configured token; unset turns the route off.
988
+ * @returns {boolean}
989
+ */
990
+ export function hasBearerToken(context, expectedToken) {
991
+ const authorization = context.getHeader("authorization");
992
+ if (expectedToken == null || expectedToken === "" || authorization == null || !authorization.startsWith("Bearer ")) return false;
993
+ const digest = (text) => createHash("sha256").update(text, "utf8").digest();
994
+ return timingSafeEqual(digest(authorization.substring("Bearer ".length).trim()), digest(expectedToken));
995
+ }
996
+ ```
997
+
998
+ For player-facing routes, verify the player's token the way your auth does (for example `jwtVerify` from `jose`, as
999
+ in [the bans example](#example-bans-with-a-custom-verifier)).
1000
+
1001
+ ### Example: a custom stats endpoint
1002
+
1003
+ Prefer the built-in leaderboards ([README › Leaderboards](README.md#leaderboards)): boards, seasons, trust levels and a
1004
+ console page, with no code. A dedicated session's own results can be posted at the server trust level with an API key
1005
+ holding `leaderboards.submit`. The example below stays as a custom stats endpoint.
1006
+
1007
+ In the [complete server.mjs](#complete-servermjs): `GET /api/leaderboard` returns the top entries of what dedicated
1008
+ sessions reported through the bridge.
1009
+
1010
+ ### Example: a status page
1011
+
1012
+ ```js
1013
+ // Add to the routes of your server.mjs. liveSessions is the bookkeeping map the hooks fill.
1014
+ /**
1015
+ * Escapes text for HTML (session names come from players).
1016
+ * @param {string} text The text.
1017
+ * @returns {string}
1018
+ */
1019
+ function escapeHtml(text) {
1020
+ return text.replace(/[&<>"']/g, (character) => "&#" + character.charCodeAt(0) + ";");
1021
+ }
1022
+
1023
+ const statusPage = createEndpoint("/status", { method: "GET" }, async () => {
1024
+ const rows = Array.from(liveSessions.values()).map((session) => "<tr><td>" + escapeHtml(session.sessionName) + "</td><td>" + session.players.size + "</td><td>" + (session.isDedicated ? "dedicated" : "relay") + "</td></tr>").join("");
1025
+ const html = "<!doctype html><meta charset=\"utf-8\"><title>Arena status</title><h1>" + liveSessions.size + " sessions</h1><table><tr><th>Session</th><th>Players</th><th>Kind</th></tr>" + rows + "</table>";
1026
+ return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } });
1027
+ });
1028
+ ```
1029
+
1030
+ A public page shows what anyone may see. Keep user ids and addresses behind a token, as `/api/sessions` does.
1031
+
1032
+ ### Example: create a private room
1033
+
1034
+ Two ways, both in code you own:
1035
+
1036
+ - **Hand out an on-demand session (recommended).** The route returns an unguessable session name for a scene with
1037
+ `onDemand: true`; the first player's Client join (with `dedicatedScene`) starts it, and it closes itself once empty.
1038
+ This is `/api/rooms` in the [complete server.mjs](#complete-servermjs).
1039
+ - **Start it at once** with `server.startDedicatedSession(sessionName, sceneName)`, when the session must be running
1040
+ before anyone joins. Remember that such a session never closes by itself. Close it later through the admin API:
1041
+
1042
+ ```js
1043
+ /**
1044
+ * Closes a session through this server's own admin API (needs admin.token / GAME_SERVER_ADMIN_TOKEN).
1045
+ * @param {string} httpUrl The server's http(s):// URL (server.urls.http).
1046
+ * @param {string} roomId The room id startDedicatedSession resolved.
1047
+ * @returns {Promise<boolean>} True when it was closed.
1048
+ */
1049
+ async function closeSession(httpUrl, roomId) {
1050
+ const response = await fetch(httpUrl + "/game-server/admin/sessions/" + encodeURIComponent(roomId) + "/close", {
1051
+ method: "POST",
1052
+ headers: { authorization: "Bearer " + process.env.GAME_SERVER_ADMIN_TOKEN }
1053
+ });
1054
+ return response.ok;
1055
+ }
1056
+ ```
1057
+
1058
+ "Private" here means unguessable, not hidden: dedicated sessions are always listed in the session lobby
1059
+ (`runner.joinSessionLobby()`), visible and open. To keep strangers out, refuse them at join time: in `verifyToken`
1060
+ (it sees `request.sessionName` and the token) or in the dedicated session's `onConnectRequest`.
1061
+
1062
+ ## 4. Wrapping the server in your own Node app
1063
+
1064
+ ### What createGameServer returns
1065
+
1066
+ ```ts
1067
+ createGameServer(options?: { config?: DeepPartial<GameServerConfig>; hooks?: GameServerHooks; routes?: Record<string, Endpoint>; handleSignals?: boolean; admin?: AdminExtensions }): Promise<GameServer>
1068
+ ```
1069
+
1070
+ | `GameServer` member | What it is |
1071
+ |---|---|
1072
+ | `config` | The resolved, validated `GameServerConfig` |
1073
+ | `port` | The port it listens on (the free one picked for `port: 0`) |
1074
+ | `urls` | `{ webSocket, http, webTransport }`: `ws(s)://` and `http(s)://` URLs, and the `https://` WebTransport URL or `null` when UDP is off. `publicUrl` replaces the derived URL when set |
1075
+ | `close()` | Stops at once and disconnects everyone |
1076
+ | `drain()` | Refuses new joins, gives running sessions `drainSeconds`, tells the rest `GameClosed`, then stops. It does not exit the process |
1077
+ | `startDedicatedSession(sessionName, sceneName)` | Starts a dedicated session of a configured scene; resolves the room id |
1078
+
1079
+ That is all of it. The underlying Colyseus server, its HTTP server and Express are **not reachable**: there is no
1080
+ accessor for them, and custom Express routes are not supported (the WebTransport transport has no Express app). Use
1081
+ `routes` for HTTP.
1082
+
1083
+ Rules:
1084
+
1085
+ - **One game server per process.** A second `createGameServer` throws `a Babylon Game Server is already running in
1086
+ this process`; after `close()` (or a finished drain) you may create another.
1087
+ - `createGameServer` loads the toolkit runtime into the process and sets `globalThis.BABYLON` and `globalThis.TOOLKIT`.
1088
+ - It rejects with `GameServerConfigError` (its `problems` list every problem) for an invalid config, and with the
1089
+ error when a boot dedicated session cannot start (the server is stopped again first).
1090
+
1091
+ ### Configuration from code
1092
+
1093
+ `createGameServer({ config })` takes overrides of the defaults and reads **no file and no environment**. To get the
1094
+ CLI's behaviour (defaults < config file < `GAME_SERVER_*` variables < flags), load the config yourself and pass it:
1095
+
1096
+ ```js
1097
+ import { createGameServer, loadGameServerConfig } from "@babylonjs-toolkit/server";
1098
+
1099
+ const config = await loadGameServerConfig({ argv: process.argv.slice(2), environment: process.env, workingDirectory: process.cwd() });
1100
+ const server = await createGameServer({ config: config, hooks: hooks, routes: routes });
1101
+ ```
1102
+
1103
+ Hooks and routes cannot go in the config file: unknown keys are refused at startup. `resolveGameServerConfig(overrides)`
1104
+ validates a config without starting anything.
1105
+
1106
+ Also exported, for advanced use: `DEFAULT_GAME_SERVER_CONFIG`, `CONFIG_OPTIONS` (every option with its variable and
1107
+ flag), `getLogger` / `setLogger` / `GameServerLogger` (`createGameServer` installs its own logger; call `setLogger`
1108
+ after it to replace it), `buildServerInfo`, `jsonResponse`, `reloadTlsNow`, `DedicatedSession`,
1109
+ `DedicatedSceneRegistry`, `allowScriptUrlPrefixes` and `isUrlAllowed`, `AuthRefusal`, `GameServerMetrics` and
1110
+ `UNKNOWN_CLIENT_ADDRESS`, plus their types.
1111
+
1112
+ ### Shutdown and signals
1113
+
1114
+ - `handleSignals: true` (what the CLI uses): on `SIGTERM` or `SIGINT` the server drains, then calls
1115
+ `process.exit(0)`; a watchdog exits with code 1 if the drain takes more than `drainSeconds` plus 5 seconds.
1116
+ - `handleSignals: false` (the default from code): nothing is installed. Handle the signals yourself when you have
1117
+ work to finish, as the complete `server.mjs` does: `await server.drain()`, flush your writes, then exit.
1118
+
1119
+ ### Complete server.mjs
1120
+
1121
+ Everything above in one file: the server bridge for dedicated-session code, bookkeeping hooks, a leaderboard, an
1122
+ operator-only session list, private rooms, and a shutdown that flushes pending writes.
1123
+
1124
+ ```js
1125
+ // server.mjs: the Babylon Game Server with this game's own hooks, routes and server bridge.
1126
+ // Run: node server.mjs (settings: flags, GAME_SERVER_* variables and babylon-game-server.config.mjs, as with the CLI)
1127
+ import { appendFile } from "node:fs/promises";
1128
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
1129
+ import { createEndpoint, createGameServer, getLogger, jsonResponse, loadGameServerConfig } from "@babylonjs-toolkit/server";
1130
+
1131
+ /** Where finished rounds are appended, one JSON object per line (swap for your database). */
1132
+ const RESULTS_FILE = process.env.ARENA_RESULTS_FILE ?? "./arena-results.jsonl";
1133
+ /** Discord channel webhook for round announcements, or unset for none. */
1134
+ const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL ?? null;
1135
+ /** Bearer token of this game's own operator routes, or unset to turn them off. */
1136
+ const OPERATOR_TOKEN = process.env.ARENA_OPERATOR_TOKEN ?? null;
1137
+ /** The dedicated scene private rooms run (a dedicated.scenes entry with onDemand: true). */
1138
+ const PRIVATE_ROOM_SCENE = "arena";
1139
+ /** Entries the leaderboard returns. */
1140
+ const LEADERBOARD_SIZE = 10;
1141
+
1142
+ /** Round wins by verified user id (kept in memory for this example). */
1143
+ const winsByUser = new Map();
1144
+ /** Live sessions of this process by room id: what the hooks saw. */
1145
+ const liveSessions = new Map();
1146
+ /** Writes still in flight; shutdown waits for them. */
1147
+ const pendingWrites = new Set();
1148
+
1149
+ /**
1150
+ * Remembers an async write until it settles, and logs its failure instead of letting it escape.
1151
+ * @param {Promise<unknown>} write The write.
1152
+ */
1153
+ function track(write) {
1154
+ const settled = write.catch((error) => getLogger().error("background write failed", { error: String(error) }));
1155
+ pendingWrites.add(settled);
1156
+ settled.finally(() => pendingWrites.delete(settled));
1157
+ }
1158
+
1159
+ /**
1160
+ * Posts a message to the Discord channel webhook, when one is configured.
1161
+ * @param {string} content The message.
1162
+ * @returns {Promise<void>}
1163
+ */
1164
+ async function notifyDiscord(content) {
1165
+ if (DISCORD_WEBHOOK_URL == null) return;
1166
+ const response = await fetch(DISCORD_WEBHOOK_URL, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content: content }), signal: AbortSignal.timeout(5000) });
1167
+ if (!response.ok) throw new Error("Discord answered " + response.status);
1168
+ }
1169
+
1170
+ /**
1171
+ * Whether a route request carries the operator bearer token (constant-time compare, as the admin API does).
1172
+ * @param {{ getHeader(name: string): string | null }} context The route context.
1173
+ * @returns {boolean}
1174
+ */
1175
+ function hasOperatorToken(context) {
1176
+ const authorization = context.getHeader("authorization");
1177
+ if (OPERATOR_TOKEN == null || authorization == null || !authorization.startsWith("Bearer ")) return false;
1178
+ const digest = (text) => createHash("sha256").update(text, "utf8").digest();
1179
+ return timingSafeEqual(digest(authorization.substring("Bearer ".length).trim()), digest(OPERATOR_TOKEN));
1180
+ }
1181
+
1182
+ // 1. The bridge server-side game code calls (MY.findArenaServerBridge). Set it before createGameServer, which starts
1183
+ // the boot sessions. Game code runs in this process's global scope, so it sees this object.
1184
+ globalThis.ArenaServerBridge = {
1185
+ reportRoundResult(result) {
1186
+ if (result.winnerUserId != null) winsByUser.set(result.winnerUserId, (winsByUser.get(result.winnerUserId) ?? 0) + 1);
1187
+ track(appendFile(RESULTS_FILE, JSON.stringify(Object.assign({ time: new Date().toISOString() }, result)) + "\n"));
1188
+ if (result.winnerPlayerId !== 0) track(notifyDiscord("Round " + result.roundNumber + " of " + result.sessionName + " won by player " + result.winnerPlayerId));
1189
+ getLogger().info("round finished", { sessionName: result.sessionName, roundNumber: result.roundNumber, winnerPlayerId: result.winnerPlayerId });
1190
+ },
1191
+ log(message, fields) {
1192
+ getLogger().info(message, fields);
1193
+ }
1194
+ };
1195
+
1196
+ // 2. Lifecycle hooks: bookkeeping only, synchronous, never throwing.
1197
+ /** @type {import("@babylonjs-toolkit/server").GameServerHooks} */
1198
+ const hooks = {
1199
+ onSessionCreated(event) {
1200
+ liveSessions.set(event.roomId, { sessionName: event.sessionName, gameMode: event.gameMode, isDedicated: event.isDedicated, createdAt: Date.now(), players: new Map() });
1201
+ },
1202
+ onPlayerJoined(event) {
1203
+ liveSessions.get(event.roomId)?.players.set(event.playerId, event.identity?.userId ?? null);
1204
+ },
1205
+ onPlayerLeft(event) {
1206
+ liveSessions.get(event.roomId)?.players.delete(event.playerId);
1207
+ },
1208
+ onSessionClosed(event) {
1209
+ liveSessions.delete(event.roomId);
1210
+ }
1211
+ };
1212
+
1213
+ // 3. This game's HTTP endpoints, on the game server's own port.
1214
+ const routes = {
1215
+ arenaLeaderboard: createEndpoint("/api/leaderboard", { method: "GET" }, async () => {
1216
+ const entries = Array.from(winsByUser.entries()).map(([userId, wins]) => ({ userId: userId, wins: wins }));
1217
+ entries.sort((left, right) => right.wins - left.wins);
1218
+ return { entries: entries.slice(0, LEADERBOARD_SIZE) };
1219
+ }),
1220
+ arenaSessions: createEndpoint("/api/sessions", { method: "GET" }, async (context) => {
1221
+ if (!hasOperatorToken(context)) return jsonResponse(401, { error: "unauthorized" });
1222
+ const sessions = Array.from(liveSessions.entries()).map(([roomId, session]) => ({ roomId: roomId, sessionName: session.sessionName, gameMode: session.gameMode, isDedicated: session.isDedicated, players: session.players.size, ageSeconds: Math.round((Date.now() - session.createdAt) / 1000) }));
1223
+ return { sessions: sessions };
1224
+ }),
1225
+ arenaPrivateRoom: createEndpoint("/api/rooms", { method: "POST" }, async () => {
1226
+ // An unguessable session name; the first Client join that names the on-demand scene starts the session.
1227
+ return jsonResponse(201, { sessionName: "room-" + randomUUID(), dedicatedScene: PRIVATE_ROOM_SCENE });
1228
+ })
1229
+ };
1230
+
1231
+ // 4. Start it with the CLI's settings, and drain on SIGTERM/SIGINT ourselves so pending writes finish first.
1232
+ const config = await loadGameServerConfig({ argv: process.argv.slice(2), environment: process.env, workingDirectory: process.cwd() });
1233
+ const server = await createGameServer({ config: config, hooks: hooks, routes: routes, handleSignals: false });
1234
+ getLogger().info("arena server listening", { url: server.urls.webSocket });
1235
+
1236
+ /**
1237
+ * Drains the server, waits for pending writes, then exits.
1238
+ * @param {NodeJS.Signals} signalName The signal.
1239
+ */
1240
+ async function shutDown(signalName) {
1241
+ getLogger().info("draining", { signal: signalName });
1242
+ await server.drain();
1243
+ await Promise.allSettled(Array.from(pendingWrites));
1244
+ process.exit(0);
1245
+ }
1246
+ process.once("SIGTERM", shutDown);
1247
+ process.once("SIGINT", shutDown);
1248
+ ```
1249
+
1250
+ The game's page then asks for a room with `POST /api/rooms` and joins it:
1251
+ `startGame({ gameMode: GameMode.Client, sessionName: room.sessionName, sessionProperties: { dedicatedScene: room.dedicatedScene } })`.
1252
+ `/api/rooms` here is open to anyone who can reach the server; check the player's token in it for a real game.
1253
+
1254
+ ### Packaging
1255
+
1256
+ `package.json`:
1257
+
1258
+ ```json
1259
+ {
1260
+ "name": "my-game-server",
1261
+ "private": true,
1262
+ "type": "module",
1263
+ "scripts": { "start": "node server.mjs" },
1264
+ "dependencies": { "@babylonjs-toolkit/server": "9.30.1" }
1265
+ }
1266
+ ```
1267
+
1268
+ Pin the server version that matches your game's Babylon Toolkit runtime ([Versioning](#versioning)).
1269
+
1270
+ **Dockerfile on `node:22`** (recommended when your `server.mjs` has npm dependencies of its own). Installing the
1271
+ package needs `git` in the build stage: a Colyseus peer dependency is fetched from GitHub.
1272
+
1273
+ ```dockerfile
1274
+ FROM node:22-bookworm-slim AS build
1275
+ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
1276
+ WORKDIR /app
1277
+ COPY package.json package-lock.json ./
1278
+ RUN npm ci --omit=dev --no-audit --no-fund
1279
+
1280
+ FROM node:22-bookworm-slim
1281
+ ENV NODE_ENV=production
1282
+ WORKDIR /app
1283
+ COPY --from=build /app/node_modules node_modules
1284
+ COPY package.json server.mjs babylon-game-server.config.mjs ./
1285
+ COPY scenes/ scenes/
1286
+ USER node
1287
+ EXPOSE 2567/tcp 2567/udp
1288
+ HEALTHCHECK --interval=15s --timeout=5s --start-period=20s CMD ["node", "node_modules/@babylonjs-toolkit/server/lib/cli.js", "healthcheck"]
1289
+ CMD ["node", "server.mjs"]
1290
+ ```
1291
+
1292
+ The `healthcheck` command reads the same config file and `GAME_SERVER_*` variables to find the port, so keep the port
1293
+ in those (not hard-coded in `server.mjs`) and the health check probes the right one.
1294
+
1295
+ **Dockerfile on the published image** (only when `server.mjs` needs nothing beyond Node and the server package). The
1296
+ image keeps the package in `/app/SVR`, not in `node_modules`, so import it by path there:
1297
+ `import { createGameServer, … } from "/app/SVR/lib/index.js";`. That path is the image's layout, not a published
1298
+ API: check it when you change image versions.
1299
+
1300
+ ```dockerfile
1301
+ FROM ghcr.io/mackeyk24/babylon-game-server:9.30.1
1302
+ COPY server.mjs /app/server.mjs
1303
+ COPY babylon-game-server.config.mjs /app/config/
1304
+ COPY scenes/ /app/scenes/
1305
+ ENV GAME_SERVER_CONFIG=/app/config/babylon-game-server.config.mjs
1306
+ ENTRYPOINT ["node", "/app/server.mjs"]
1307
+ ```
1308
+
1309
+ The base image's health check, non-root `node` user, `NODE_ENV=production` and exposed ports carry over. Its working
1310
+ directory is `/app`, so a relative `sceneRoot` such as `./scenes/arena/` means `/app/scenes/arena/`.
1311
+
1312
+ **Environment and config.**
1313
+
1314
+ - Server settings: `GAME_SERVER_*` variables, flags or the config file, as in the README. `GAME_SERVER_ADMIN_TOKEN`,
1315
+ `GAME_SERVER_AUTH_JWT_SECRET` and the other secrets belong in the platform's secret store.
1316
+ - Your own settings (`DATABASE_URL`, `DISCORD_WEBHOOK_URL`, `ARENA_OPERATOR_TOKEN`): your own variable names, read
1317
+ from `process.env`.
1318
+ - Colyseus, which the server is built on, loads a `.env.<NODE_ENV>` or `.env` file into `process.env` when the package
1319
+ is imported, and prints `optional .env file not found: …` when there is none. Don't depend on that; set variables
1320
+ explicitly in production.
1321
+
1322
+ ## 5. Custom auth: verifyToken and the webhook
1323
+
1324
+ Both are code you own that approves each join at matchmaking, before a seat is reserved, for session joins **and**
1325
+ lobby joins. Turn auth on with `auth.enabled: true`. The identity you return is what hooks see as `event.identity` and
1326
+ what host or dedicated code sees as `request.authIdentity`.
1327
+
1328
+ ### verifyToken (auth mode "custom")
1329
+
1330
+ ```ts
1331
+ verifyToken?(request: AuthRequest): Promise<AuthIdentity | null>;
1332
+ ```
1333
+
1334
+ | `AuthRequest` field | Type | Meaning |
1335
+ |---|---|---|
1336
+ | `token` | `string` | The token the game passed as `startGame({ authToken })` (an empty token is refused before your hook runs) |
1337
+ | `kind` | `"session" \| "lobby"` | Which room the join is for |
1338
+ | `sessionName` | `string \| null` | The session, or `null` for the lobby |
1339
+ | `gameMode` | `number \| null` | The joiner's `TOOLKIT.GameMode`, or `null` for the lobby |
1340
+ | `clientAddress` | `string` | The client's address (from `X-Forwarded-For` / `X-Real-IP` only with `trustProxy`) |
1341
+ | `origin` | `string \| null` | The `Origin` header |
1342
+
1343
+ - Return `{ userId, claims }` to admit, or `null` to refuse with
1344
+ `CustomAuthenticationFailed: the custom verifier did not approve the player`.
1345
+ - Throw `new AuthRefusal(reasonName, detail)` to choose the refusal. The client's `startGame` fails with
1346
+ `"<reasonName>: <detail>"`; use a `TOOLKIT.ShutdownReason` name (`InvalidAuthentication`,
1347
+ `CustomAuthenticationFailed`, `AuthenticationTicketExpired`, `ConnectionRefused`, …) so the runtime maps it, or the
1348
+ game sees `ShutdownReason.Error`.
1349
+ - Any other error is logged as `token verifier failed` and refuses with
1350
+ `CustomAuthenticationFailed: the token could not be verified`.
1351
+ - It runs **only** with `auth.enabled: true` and `auth.mode: "custom"`. In any other mode the hook is ignored, without
1352
+ a warning. Mode `custom` without the hook stops startup.
1353
+ - It is awaited, with no time limit of the server's own: a slow verifier delays the join. Put your own timeout on
1354
+ network calls.
1355
+ - A player who reconnects within the grace period keeps its seat and identity **without** a new check, so a ban takes
1356
+ effect on the next full join.
1357
+ - The request does not include the session properties, so a verifier cannot see which on-demand scene a join would
1358
+ start.
1359
+
1360
+ #### Example: bans with a custom verifier
1361
+
1362
+ `jose` is your dependency (`npm install jose`). Your backend signs player tokens with `PLAYER_TOKEN_SECRET`.
1363
+
1364
+ ```js
1365
+ // bans.mjs: custom auth that verifies your backend's JWTs and refuses banned accounts and addresses.
1366
+ import { jwtVerify } from "jose";
1367
+ import { AuthRefusal, createGameServer } from "@babylonjs-toolkit/server";
1368
+
1369
+ /** The HMAC secret your backend signs player tokens with, and the issuer it writes. */
1370
+ const PLAYER_TOKEN_SECRET = new TextEncoder().encode(process.env.PLAYER_TOKEN_SECRET ?? "");
1371
+ const PLAYER_TOKEN_ISSUER = "my-backend";
1372
+ /** Banned user ids and client addresses (load these from your database). */
1373
+ const bannedUserIds = new Set(["user-13"]);
1374
+ const bannedAddresses = new Set(["203.0.113.7"]);
1375
+
1376
+ const server = await createGameServer({
1377
+ config: { auth: { enabled: true, mode: "custom" } },
1378
+ handleSignals: true,
1379
+ hooks: {
1380
+ /**
1381
+ * Verifies one join (sessions and the lobby alike).
1382
+ * @param {import("@babylonjs-toolkit/server").AuthRequest} request The token and what the server knows about the join.
1383
+ * @returns {Promise<import("@babylonjs-toolkit/server").AuthIdentity | null>} The identity, or null to refuse.
1384
+ */
1385
+ async verifyToken(request) {
1386
+ if (bannedAddresses.has(request.clientAddress)) throw new AuthRefusal("ConnectionRefused", "this address is banned");
1387
+ let payload;
1388
+ try {
1389
+ ({ payload } = await jwtVerify(request.token, PLAYER_TOKEN_SECRET, { issuer: PLAYER_TOKEN_ISSUER }));
1390
+ } catch {
1391
+ throw new AuthRefusal("InvalidAuthentication", "the token is not valid");
1392
+ }
1393
+ if (typeof payload.sub !== "string") return null;
1394
+ if (bannedUserIds.has(payload.sub)) throw new AuthRefusal("ConnectionRefused", "this account is banned");
1395
+ return { userId: payload.sub, claims: { name: payload.name ?? null } };
1396
+ }
1397
+ }
1398
+ });
1399
+ console.log("listening on " + server.urls.webSocket);
1400
+ ```
1401
+
1402
+ A banned player's `startGame` fails with `ConnectionRefused: this account is banned`, and the server logs
1403
+ `join refused reason=ConnectionRefused detail="this account is banned"`. A custom verifier's ban works at join time
1404
+ only: it does not remove a player who is already connected. To remove one, kick the player from the
1405
+ [admin console](README.md#admin-console) (Players, or a session's player list) or its API
1406
+ (`POST /game-server/admin/api/players/kick` with `{ roomId, playerId, message, allowRejoin }`); the game shows the
1407
+ message. The console's own bans (Bans page, `POST /game-server/admin/api/bans`) refuse at join time **and** disconnect
1408
+ matching players who are already playing, on every instance, so you may not need a custom verifier for bans at all.
1409
+
1410
+ If plain JWT checks are all you need, the built-in `jwt` mode does them without code (README, "Enabling
1411
+ authentication").
1412
+
1413
+ ### The auth webhook (auth mode "webhook")
1414
+
1415
+ The server POSTs each join to your service and waits up to `auth.webhook.timeoutMs` (5000):
1416
+
1417
+ ```json
1418
+ { "token": "…", "kind": "session", "sessionName": "arena-1", "gameMode": 4, "clientAddress": "203.0.113.20", "origin": "https://game.example.com" }
1419
+ ```
1420
+
1421
+ A 2xx answer with a JSON object admits the player with the identity `{ userId: body.userId (when it is a string,
1422
+ else null), claims: <the whole body> }`. Anything else (another status, not a JSON object, a timeout, a network error)
1423
+ refuses with `CustomAuthenticationFailed: the auth webhook did not approve the player`; the webhook cannot choose the
1424
+ reason or the text.
1425
+
1426
+ ```js
1427
+ // auth-webhook.mjs: approves Babylon Game Server joins in auth mode "webhook". Run it where only the game server reaches it.
1428
+ import { createServer } from "node:http";
1429
+
1430
+ /** Port of this service, and the longest request body it reads. */
1431
+ const PORT = Number(process.env.AUTH_WEBHOOK_PORT ?? 8787);
1432
+ const MAX_BODY_BYTES = 16384;
1433
+ /** Session tokens your login flow issued, by token (use your database or cache). */
1434
+ const sessionsByToken = new Map([["token-ada", { userId: "user-1", name: "Ada", banned: false }], ["token-mallory", { userId: "user-13", name: "Mallory", banned: true }]]);
1435
+
1436
+ /**
1437
+ * Reads a JSON request body.
1438
+ * @param {import("node:http").IncomingMessage} request The request.
1439
+ * @returns {Promise<any>} The parsed body.
1440
+ */
1441
+ async function readJson(request) {
1442
+ let text = "";
1443
+ for await (const chunk of request) {
1444
+ text += chunk;
1445
+ if (text.length > MAX_BODY_BYTES) throw new Error("body too large");
1446
+ }
1447
+ return JSON.parse(text);
1448
+ }
1449
+
1450
+ createServer(async (request, response) => {
1451
+ try {
1452
+ // The game server POSTs { token, kind, sessionName, gameMode, clientAddress, origin }.
1453
+ const join = await readJson(request);
1454
+ const player = sessionsByToken.get(join.token);
1455
+ if (player == null || player.banned) {
1456
+ response.writeHead(403).end();
1457
+ return;
1458
+ }
1459
+ // A 2xx JSON object approves: userId becomes the identity's userId, the whole object its claims.
1460
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ userId: player.userId, name: player.name }));
1461
+ } catch {
1462
+ response.writeHead(400).end();
1463
+ }
1464
+ }).listen(PORT, "127.0.0.1", () => console.log("auth webhook on http://127.0.0.1:" + PORT));
1465
+ ```
1466
+
1467
+ ```sh
1468
+ GAME_SERVER_AUTH_ENABLED=true GAME_SERVER_AUTH_MODE=webhook GAME_SERVER_AUTH_WEBHOOK_URL=http://127.0.0.1:8787/ npx babylon-game-server
1469
+ ```
1470
+
1471
+ The webhook works with the plain CLI and image, so it is the way to own auth without writing a `server.mjs`. The
1472
+ server sends no signature or shared secret with the request: anyone who can reach the webhook can ask it about a
1473
+ token. Keep it on a private network or loopback, and put it behind your own check if it must be public.
1474
+
1475
+ ## Custom admin actions, panels and admin hooks
1476
+
1477
+ Your own Node app (section 4) can add buttons, read-only panels and event listeners to the
1478
+ [admin console](README.md#admin-console) with `createGameServer({ admin })`. The console shows them on the page they
1479
+ belong to, checks permissions, asks for confirmation and writes every run to the audit log, so operators get your
1480
+ game's tools without a separate admin site. The types are exported from `@babylonjs-toolkit/server`
1481
+ (`AdminExtensions`, `AdminActionDefinition`, `AdminPanelDefinition`, `AdminEventHooks` and the rest).
1482
+
1483
+ | `admin` member | What it adds |
1484
+ |---|---|
1485
+ | `actions: AdminActionDefinition[]` | A button with a typed form. `target` places it: `"server"` (the Extensions page), `"instance"`, `"session"` or `"player"` (that page). |
1486
+ | `panels: AdminPanelDefinition[]` | A read-only table or list of values, on the `"overview"`, a `"session"` or a `"player"` page. |
1487
+ | `hooks: AdminEventHooks` | `onPlayerKicked`, `onBanCreated`, `onBanRemoved`, `onMaintenanceChanged` and `onAlertFired`, called after the console action succeeded, to mirror it into your own systems. |
1488
+
1489
+ An action:
1490
+
1491
+ | Field | What it is |
1492
+ |---|---|
1493
+ | `name` | Unique, `/^[a-z][a-zA-Z0-9-]{2,40}$/`; the API runs it at `POST /game-server/admin/api/extensions/actions/<name>` with `{ target, inputs }` |
1494
+ | `label`, `description` | The button text and the form's explanation |
1495
+ | `target` | `"server"`, `"instance"`, `"session"` or `"player"`; the handler gets that page's ids in `context.target` (`processId`, `roomId`, or `roomId` + `playerId` + `userId`) |
1496
+ | `fields` | The form: each `{ name, label, type, required, options?, defaultValue? }`, with `type` one of `"text"`, `"number"`, `"boolean"`, `"select"` (with `options`) or `"secret"`. Values arrive typed in `context.inputs`; a `secret` value is never written to the audit log |
1497
+ | `audience` | `"operators"` (default: every role with `extensions.run` — Owner, Admin, Operator, Moderator) or `"developer"` (`extensions.runDeveloper` — Owner, Admin, Developer) |
1498
+ | `confirm` | Ask before running (default `true`) |
1499
+ | `handler(context)` | Does the work and resolves `{ ok, message, data? }`; the console shows the message and the `data` values. `context.caller` says who ran it |
1500
+
1501
+ A panel has a `name`, a `title`, a `placement` and a `handler({ target })` that resolves either
1502
+ `{ kind: "table", columns, rows }` or `{ kind: "values", values: [{ label, value }] }`. Panels are read-only: every
1503
+ signed-in account sees them, so show nothing secret.
1504
+
1505
+ ```js
1506
+ // server.mjs (excerpt): the arena's admin tools.
1507
+ import { createGameServer, getLogger, loadGameServerConfig } from "@babylonjs-toolkit/server";
1508
+
1509
+ /** Rounds a player has won, by user id (swap for your database). */
1510
+ const winsByUserId = new Map();
1511
+ /** Most rows the leaderboard panel shows. */
1512
+ const LEADERBOARD_PANEL_ROWS = 10;
1513
+
1514
+ /** @type {import("@babylonjs-toolkit/server").AdminExtensions} */
1515
+ const admin = {
1516
+ actions: [
1517
+ {
1518
+ name: "grant-wins",
1519
+ label: "Grant wins",
1520
+ description: "Adds wins to this player's record, for example after a disputed round.",
1521
+ target: "player",
1522
+ fields: [
1523
+ { name: "wins", label: "Wins to add", type: "number", required: true, defaultValue: 1 },
1524
+ { name: "reason", label: "Reason", type: "select", required: true, options: ["disputed round", "server fault", "event prize"] }
1525
+ ],
1526
+ handler: async (context) => {
1527
+ const userId = context.target.userId;
1528
+ if (userId == null) return { ok: false, message: "this player is anonymous (turn on auth to track wins)" };
1529
+ const total = (winsByUserId.get(userId) ?? 0) + Number(context.inputs.wins);
1530
+ winsByUserId.set(userId, total);
1531
+ getLogger().info("wins granted", { userId: userId, total: total, by: context.caller.name });
1532
+ return { ok: true, message: "granted " + context.inputs.wins + " win(s): " + context.inputs.reason, data: { userId: userId, totalWins: total } };
1533
+ }
1534
+ },
1535
+ {
1536
+ name: "reset-leaderboard",
1537
+ label: "Reset leaderboard",
1538
+ description: "Clears every recorded win. Developers only.",
1539
+ target: "server",
1540
+ audience: "developer",
1541
+ fields: [{ name: "confirmText", label: "Type RESET to confirm", type: "text", required: true }],
1542
+ handler: async (context) => {
1543
+ if (context.inputs.confirmText !== "RESET") return { ok: false, message: "type RESET to confirm" };
1544
+ winsByUserId.clear();
1545
+ return { ok: true, message: "the leaderboard is empty" };
1546
+ }
1547
+ }
1548
+ ],
1549
+ panels: [
1550
+ {
1551
+ name: "arena-leaderboard",
1552
+ title: "Arena leaderboard",
1553
+ placement: "overview",
1554
+ handler: async () => {
1555
+ const leaders = [...winsByUserId.entries()].sort((first, second) => second[1] - first[1]).slice(0, LEADERBOARD_PANEL_ROWS);
1556
+ return { kind: "table", columns: ["Player", "Wins"], rows: leaders.map(([userId, wins]) => [userId, wins]) };
1557
+ }
1558
+ },
1559
+ {
1560
+ name: "player-record",
1561
+ title: "Arena record",
1562
+ placement: "player",
1563
+ handler: async (context) => ({ kind: "values", values: [{ label: "Wins", value: winsByUserId.get(context.target.userId ?? "") ?? 0 }] })
1564
+ }
1565
+ ],
1566
+ hooks: {
1567
+ onPlayerKicked: (event) => getLogger().info("arena kick", { roomId: event.roomId, userId: event.userId, by: event.by }),
1568
+ onBanCreated: (event) => getLogger().info("arena ban", { kind: event.kind, value: event.value, expiresAt: event.expiresAt, by: event.by })
1569
+ }
1570
+ };
1571
+
1572
+ const config = await loadGameServerConfig({ argv: process.argv.slice(2), environment: process.env, workingDirectory: process.cwd() });
1573
+ const server = await createGameServer({ config: config, admin: admin, handleSignals: true });
1574
+ console.log("listening on " + server.urls.webSocket + "; admin console at " + server.urls.http + "/game-server/admin/");
1575
+ ```
1576
+
1577
+ Rules:
1578
+
1579
+ - **Extensions come from code only**, like hooks and routes: the plain CLI and the config file cannot add them. A bad
1580
+ definition (a duplicate or malformed name, an unknown target, field type or placement) stops `createGameServer`
1581
+ with `admin extension <name>: <problem>`.
1582
+ - **Handlers run in the instance that received the request**, in the server process, unsandboxed, with the caller's
1583
+ permission already checked. With Redis, each instance must register the same extensions; keep shared state in your
1584
+ database, not in memory, when you run more than one instance.
1585
+ - **A handler that throws** answers 500 `the action <name> failed: <message>` and logs `admin action <name> threw`
1586
+ (`admin panel <name> threw` for a panel). Return `{ ok: false, message }` for an expected refusal instead.
1587
+ - **Hooks never block the console.** A hook's throw, or a returned promise's rejection, is logged as
1588
+ `admin hook <name> threw`; the promise is not awaited.
1589
+ - Every run is in the audit log with the caller, the target, the inputs (without `secret` fields) and `ok`.
1590
+
1591
+ ## Security
1592
+
1593
+ - **Project scripts and hooks run in the server's own Node process, unsandboxed.** A dedicated-session script can read
1594
+ `process.env`, reach every built-in module through `process.getBuiltinModule` and call any URL with `fetch`. Load
1595
+ only bundles you build, from a `sceneRoot` and `projectScripts` you control (your own folder or bucket). The URL
1596
+ allow-list stops clients from naming files to load; it does not make a script safe.
1597
+ - **Dedicated sessions share one process.** One session's globals are every session's globals, and an exception in
1598
+ one session's frame stops the process. Do not mix games you don't trust in one process.
1599
+ - **Secrets go in environment variables**, read at run time. Never put a secret in a project bundle: browsers
1600
+ download the same file. Never log tokens: the server never does, and `LogFields` notes "Never put tokens, secrets or
1601
+ PEM text here".
1602
+ - **Your routes are open by default.** Join rate limits, origin checks and auth apply to joins only. Check tokens in
1603
+ each route, limit request rates yourself if the route is public, and escape anything player-provided (session names)
1604
+ in HTML.
1605
+ - **On-demand scenes can be started by any client allowed to join**, up to `maxDedicatedSessions`. Turn on auth in
1606
+ production, and use `verifyToken` (which sees `sessionName` and `gameMode`) to limit who may create sessions.
1607
+ - **The auth webhook is unauthenticated.** Keep it private.
1608
+ - **Trust identities the server verified** (`event.identity`, `request.authIdentity`), never names or ids a client
1609
+ sends in game data.
1610
+
1611
+ ## Testing your code locally
1612
+
1613
+ 1. **Export the scene** with the exporter's Babylon Toolkit Networking option on. The scene file, its project bundle
1614
+ and its asset containers land in the exporter's output folder (for example `<UnityProject>/export/scenes/`).
1615
+ 2. **Point a dedicated scene at that folder.** A folder path is the simplest locally: the server reads the files from
1616
+ disk. (Pointing `sceneRoot` at an `https://` dev server with a local certificate also works only when Node trusts
1617
+ that certificate, for example through `NODE_EXTRA_CA_CERTS`.)
1618
+ ```js
1619
+ // babylon-game-server.config.mjs
1620
+ export default {
1621
+ logLevel: "debug",
1622
+ dedicated: { scenes: [{ name: "arena", sceneRoot: "../MyGame/export/scenes/", sceneFile: "Arena.gltf", onDemand: true }] }
1623
+ };
1624
+ ```
1625
+ 3. **Run your server**: `node server.mjs` (or `npx babylon-game-server` when you have no code of your own). Expect
1626
+ Colyseus's start-up banner, then your own log lines.
1627
+ 4. **Open the game page** from the exporter's dev server, in two browser tabs. With the toolkit's networking sample
1628
+ launcher (or the arena launcher above, which reads the same query names):
1629
+ ```text
1630
+ engine.html?scene=Arena.gltf&fusionMode=Client&fusionSession=arena-1&fusionDedicatedScene=arena&fusionServer=ws%3A%2F%2Flocalhost%3A2567&fusionStats=1
1631
+ ```
1632
+ A page served over `https://` needs a `wss://` server: run the server with `tls: { enabled: true,
1633
+ developmentCertificate: true }` (mkcert), or open the page over `http://`.
1634
+ 5. **Read the server log.** The first join logs `dedicated session "arena-1" running file:///…/Arena.gltf`; load
1635
+ problems log `the dedicated scene arena failed to load` with the cause; refusals log `join refused` with the
1636
+ reason; your script's `console.log` lines appear as plain lines; a failed hook logs `hook <name> threw`.
1637
+ 6. **Inspect it**: `curl -H "Authorization: Bearer $GAME_SERVER_ADMIN_TOKEN" http://localhost:2567/game-server/admin/sessions`
1638
+ lists the sessions (with `isDedicated`), and `curl http://localhost:2567/metrics` shows the dedicated frame metrics.
1639
+ 7. **After every re-export, restart the server.** Script bundles load once per process, and a changed prefab table is
1640
+ refused with `IncompatibleConfiguration` until browsers reload and the dedicated session restarts.
1641
+
1642
+ ## Versioning
1643
+
1644
+ - **The network protocol version must match.** The server and the game runtime share `TOOLKIT.NETWORK_PROTOCOL_VERSION`
1645
+ (separate from package versions). A game on another protocol version is refused with
1646
+ `IncompatibleConfiguration: this server speaks network protocol <n> but the game sent <m>; update the …`, and the game
1647
+ reads the server's from `GET /game-server/info` (`protocolVersion`) before joining.
1648
+ - **Your server-side code runs on the server's runtime, not the browser's.** A dedicated session executes your
1649
+ project bundle against the `babylonjs` and `babylonjs-toolkit` versions installed with the server package (the
1650
+ package version follows the toolkit runtime it ships with). Install the server version that matches the toolkit your
1651
+ game is exported with, so the APIs your scripts call exist on both sides.
1652
+ - **Prefab tables must match.** Browsers and the dedicated session must load the same export. Re-export, reload every
1653
+ page, restart the server.
1654
+ - **Upgrades**: change `@babylonjs-toolkit/server` (or the image tag) and redeploy; sessions on the old instance finish
1655
+ while it drains.
1656
+
1657
+ ## Limits and what is not supported
1658
+
1659
+ Not available, with the closest real mechanism:
1660
+
1661
+ | You might want | Status | Closest mechanism |
1662
+ |---|---|---|
1663
+ | A per-session server plugin or custom room class | Not supported: every session is the same room type | A dedicated session runs your game code per session |
1664
+ | A hook that sees game messages, inputs, RPCs or networked state | Not supported: hooks see sessions and players only; messages other than the runtime's own are ignored | Dedicated-session code, which owns the state, reporting through a bridge or `fetch` |
1665
+ | Custom client-to-server messages outside the networking API | Not supported | RPCs to the state authority, or reliable data (`runner.sendReliableDataToServer`) in a dedicated session |
1666
+ | Hot reload of project scripts | Not supported: a bundle loads once per process | Restart the process (drain first) |
1667
+ | Per-session parameters for a dedicated session (map, mode, rules) | Not supported: on-demand and admin starts pass no session properties to the server runner | Encode it in the session name, or keep a map in `server.mjs` keyed by session name and read it through the bridge |
1668
+ | Hidden or closed dedicated sessions | Not supported: they are listed in the lobby, visible and open | Refuse strangers in `verifyToken` or `onConnectRequest` |
1669
+ | Kicking a connected player | Supported through the [admin console](README.md#admin-console) and its API (`POST /game-server/admin/api/players/kick`); the game shows the message, and a rejoin is blocked for 5 minutes unless allowed | The console's bans also disconnect matching players already playing |
1670
+ | A hook when a dedicated scene finished loading or failed | Not available (on-demand `onSessionCreated` fires before the load) | The server log; `startDedicatedSession` resolves or rejects for boot, admin and code starts |
1671
+ | Listing or closing sessions from code | Not on `GameServer` | The console API (`/game-server/admin/api/sessions`, an API key), or the legacy admin endpoints, called with `fetch`; or a [custom admin action](#custom-admin-actions-panels-and-admin-hooks) in the console |
1672
+ | Adding scenes to the registry at run time | Not supported (the config's `dedicated.scenes` is read once) | `DedicatedSession.start(…)` for a scene outside the config |
1673
+ | Custom Prometheus metrics on `/metrics` | Not supported: the registry is private | Serve your own metrics route |
1674
+ | Hooks or routes in the config file, or with the plain CLI | Not supported | Your own `server.mjs` |
1675
+ | Access to Colyseus, its HTTP server or Express | Not exposed | `routes` |
1676
+ | Isolating sessions from each other | Not supported: one process, one global scope, one event loop | Separate processes or instances per game |
1677
+ | ES module game code on the server | Not supported: scripts run as classic scripts | Build the server-side classes into a classic bundle |
1678
+
1679
+ Other limits worth knowing:
1680
+
1681
+ - One game server per process; one default input definition per process.
1682
+ - Sessions started at boot, by the admin API or from code never idle-close, and starting an existing session name again
1683
+ creates a second room.
1684
+ - An exception in a dedicated session's frame ends the process; an `async` hook's rejection is not handled by the
1685
+ server.
1686
+ - A relative `sceneRoot` resolves against the process's working directory.
1687
+ - The legacy admin endpoints exist only when `admin.token` is set; the admin console and its API exist when an admin
1688
+ account exists or a first-owner setup is pending (README, "Admin console").
1689
+ - Your routes and hooks live in each instance; with Redis, nothing is shared between instances except what the server
1690
+ shares itself (matchmaking, the lobby, the admin list).