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