FishMMO
A modular, open-source MMO framework built on Unity 6.3 LTS, FishNet, QUIC/WebTransport, and PostgreSQL.
Table of Contents
- Overview
- Supported Platforms
- Prerequisites
- Installation Guide
- Database Setup
- Unity Project Setup
- Configuration
- Infrastructure Setup
- Web Server Configuration
- Running the Servers
- FishMMO-AppHealthMonitor
- Optional Services
- Client Setup
- Production Deployment
- Architecture
- License
Overview
FishMMO is a complete multiplayer online game framework consisting of:
| Component | Description |
|---|---|
| FishMMO-Unity | Unity project containing client, server, and shared game code (900+ C# files) — a unified client-side prediction pipeline with lag-compensated hit resolution, modular character visuals, tick-driven archetype AI with threat and pet systems, ECA trigger system |
| FishMMO-Auth | Transport-agnostic .NET authentication library (SRP-6a, X25519 ECDH, token auth, TOTP 2FA) |
| FishMMO-Database (FishMMO-DB) | PostgreSQL data-access layer using Entity Framework Core + Npgsql (41 entities/tables, 42 services) |
| FishMMO-WebTransport | C++ native library wrapping MsQuic (QUIC/HTTP3) — P/Invoked from C# as a FishNet transport plugin |
| FishMMO-Installer | Cross-platform .NET 8 console tool that automates dependency installation |
| FishMMO-Dependencies | Centralised NuGet dependency aggregator (43 packages, netstandard2.1) — copies DLLs to Unity |
| FishMMO-Logger | Flexible logging library with file, email, and console backends |
| FishMMO-SharedUtility | Pure C# utility library shared between client and server projects |
| FishMMO-AppHealthMonitor | Daemon that monitors, auto-restarts, and health-checks server processes |
| FishMMO-WebServers | ASP.NET Core 8.0 web services — IPFetch (8080), Patcher (8090), and WebGL static server (8000) |
| FishMMO-Patcher | Client-side updater that applies versioned patch files |
| FishMMO-Setup | Configuration templates — nginx.conf, server .cfg files, appsettings.json, deploy hooks, stream config generator |
| FishMMO-DiscordBot | Discord bot bridging in-game chat with a Discord guild |
| FishMMO-CMS | ASP.NET Core 8.0 account-management web API (registration, password/2FA self-service, admin account actions) — scaffold only, every handler is a TODO stub |
The server architecture uses three server types:
- LoginServer — Handles account creation, SRP-6a authentication, character select, and TOTP 2FA.
- WorldServer — Manages world state, character routing, and scene server orchestration.
- SceneServer — Runs gameplay simulation for individual world scenes (chat, combat, inventory, guilds, etc.).
All three are launched from a single GameServer executable with a command-line argument (LOGIN, WORLD, or SCENE).
Networking Architecture
FishMMO uses QUIC/WebTransport (RFC 9000) as its sole transport protocol — there is no TCP or WebSocket fallback for game traffic:
| Layer | Technology | Purpose |
|---|---|---|
| Transport | QUIC (UDP) via MsQuic C++ library | Encrypted, multiplexed transport with 0-RTT support |
| Reliable Channel | QUIC bidirectional streams | FishNet Channel 0 (game state, RPCs, broadcasts) |
| Unreliable Channel | QUIC DATAGRAM frames (RFC 9221) | FishNet Channel 1 (position updates, snapshots) |
| WebGL/Browser | Browser WebTransport API → HTTP/3 | Native browser QUIC without plugin |
| Reverse Proxy | NGINX L4 UDP stream + L7 HTTP | L7: TLS termination for HTTP APIs. L4: zero-copy UDP forwarding (no TLS termination for game traffic) |
| HTTP TLS | TLS 1.2/1.3 terminated by NGINX | NGINX handles HTTPS for api.fishmmo.com and play.fishmmo.com |
| Game TLS | QUIC/TLS 1.3 terminated by each game server | NGINX cannot terminate QUIC TLS — raw UDP packets pass through unmodified. Each game server must have certificates configured |
Default Architecture: Loopback + NGINX Reverse Proxy
All backend servers (LoginServer, WorldServer, SceneServer, IPFetch, Patcher, WebGL) are configured to bind to 127.0.0.1 (loopback) by default. NGINX acts as the sole public-facing reverse proxy, forwarding traffic to these loopback-bound upstream servers. This is a deliberate security posture:
- Backend servers are never directly exposed to the internet — only NGINX ports 80 and 443 (and UDP 7770-7999 via the stream module) are public.
- NGINX handles TLS for HTTP APIs (api.fishmmo.com, play.fishmmo.com) at Layer 7.
- NGINX does NOT terminate TLS for game traffic. QUIC/WebTransport uses UDP at Layer 4 — NGINX forwards raw encrypted QUIC packets without inspecting them. Each game server must have valid TLS certificates configured in its
.cfgfile (CertificatePath/PrivateKeyPath) because it terminates its own QUIC/TLS session. There is no way for NGINX to do this on the game server's behalf. - The real client IP is recovered via a one-time connection token from the HTTP API, since game servers only see NGINX's loopback address.
When to bind to 0.0.0.0 or a specific IP: Only when a backend server runs on a different machine than NGINX. In that case, change the Address in the server's .cfg file (or the BACKEND_IP env var for stream config generation) to the server's network-facing IP, and ensure NGINX's proxy_pass targets that IP instead of 127.0.0.1.
For the complete end-to-end connection flow, see CONNECTION_PIPELINE.md. For server startup, see SERVER_INITIALIZATION_ORDER.md.
Supported Platforms
| Platform | Client | Server |
|---|---|---|
| Windows 10/11 | Yes | Yes |
| Linux (Ubuntu/Debian, Arch/CachyOS) | Yes | Yes |
| macOS | Yes | Yes |
| WebGL | Yes (via browser WebTransport) | N/A |
| Requirement | Version |
|---|---|
| Unity | 6.3 LTS (6000.3.2f1) |
| .NET SDK | 8.0+ |
| PostgreSQL | 14+ |
| CMake | 3.20+ (for WebTransport C++ library) |
| C++ Compiler | GCC 9+/Clang 10+/MSVC 2022+ (C11/C++17) |
| Scripting Backend | IL2CPP |
Prerequisites
- Git for cloning the repository
- .NET 8.0 SDK
- Unity Hub with Unity 6.3 LTS (the installer can install these for you)
- CMake 3.20+ and a C++17 compiler (for building the WebTransport native library)
- OpenSSL 3+ development headers (libssl-dev / openssl-devel)
- PostgreSQL 14+ (the installer can install this for you)
- Administrator/root privileges for system-level installs
- Internet connectivity
Note: The C++ WebTransport library links statically against msquic (fetched automatically by CMake), so you do not need to install msquic separately. OpenSSL is the only system-level dependency needed beyond a C++ compiler.
Installation Guide
1. Clone the Repository
git clone https://github.com/jimdroberts/FishMMO.git
cd FishMMO
Tip: Use the
devbranch if it is more up-to-date thanmain:git checkout dev
2. Build the FishMMO-Installer
The FishMMO-Installer is the recommended way to set up all dependencies. It automates installing .NET, PostgreSQL, NGINX, PgBouncer, Unity Hub, building all C# projects, and setting up the database.
cd FishMMO-Installer
dotnet build
dotnet run --project FishMMO-Installer
Or run the compiled binary directly:
- Windows:
FishMMO-Installer\bin\Debug\net8.0\FishMMO-Installer.exe - Linux:
./FishMMO-Installer/bin/Debug/net8.0/FishMMO-Installer
Windows: Run from an elevated PowerShell (Administrator) for system-level installs. Linux: You will be prompted for
sudowhen needed.
3. Run the Installer
The installer supports two modes: interactive menu (default) and CLI-driven (for headless/automated deployment).
Interactive Menu Mode (default)
Run with no arguments to enter the menu:
=== FishMMO Installer ===
1 : Runtime & Tooling — .NET EF Tool, ASP.NET Runtime, VS Build Tools
2 : Database — PostgreSQL, PgBouncer, DB management
3 : Web Server — NGINX, Let's Encrypt SSL, Firewall, Services
4 : Unity & Build — Unity Hub, Unity Editor, C# project builds
5 : Configuration — appsettings.json setup
0 : Quit
Sub-Menu: Runtime & Tooling
1 : Install DotNet EF Tool (dotnet-ef)
2 : Install ASP.NET Runtime
3 : Install Visual Studio Build Tools (Windows Only)
0 : Back
Sub-Menu: Database
1 : Configure Database Secrets (DB credentials — username, password, host)
2 : Install PostgreSQL
3 : Install PgBouncer (Connection Pooler)
4 : Install FishMMO Database (User/Schema/Initial Migration)
5 : Create New Database Migration
6 : Grant User Permissions on Database
7 : Delete FishMMO Database (DANGEROUS!)
8 : Configure PgBouncer (generate pgbouncer.ini + userlist.txt, Linux)
9 : Configure Server Keys (gate secret, HMAC key, KEK → database)
0 : Back
Sub-Menu: Web Server
1 : Install NGINX (Web Server/Reverse Proxy)
2 : Install/Renew Let's Encrypt Certificate (NGINX)
3 : Deploy FishMMO nginx.conf (from FishMMO-Setup/)
4 : Configure Firewall Rules (open ports 80, 443)
5 : Register FishMMO Web Servers as Services (systemd / NSSM)
0 : Back
Sub-Menu: Unity & Build
1 : Install Unity Hub
2 : Install Unity Editor (+Modules)
3 : Build all C# Projects
4 : Build FishMMO-Unity (Client/Server/Addressables)
0 : Back
Sub-Menu: Configuration
1 : Configure appsettings.json (web servers — IPFetch, Patcher, WebGL)
2 : Configure Discord Bot
3 : Configure CMS
0 : Back
CLI / Non-Interactive Mode
For headless servers and CI/CD pipelines, use CLI arguments:
# Show help
FishMMO-Installer --help
# Show version
FishMMO-Installer --version
# Install a single component
FishMMO-Installer --component postgresql
# Run health checks on all installed components
FishMMO-Installer --validate
# Simulate without making changes
FishMMO-Installer --dry-run --component nginx
# Unattended installation from a config file (-f is an alias for --config)
FishMMO-Installer --non-interactive -f install-config.json
# Unattended install using the bundled quickstart template
FishMMO-Installer --quickstart
# Other flags
FishMMO-Installer --list-components # Print component names and exit
FishMMO-Installer --accept-defaults # (-y / --yes) Skip confirmation prompts
FishMMO-Installer --generate-checksums # Generate SHA256 hashes for downloaded files
FishMMO-Installer --log-file /path/to.log # Tee log output to a file
install-config.json format:
{
"components": ["dotnet-sdk", "postgresql", "nginx", "firewall", "systemd-services"],
"configureFirewall": true,
"firewallPorts": [80, 443],
"registerSystemdServices": true,
"validateAfterInstall": true
}
Available component names for CLI use (--list-components prints this list):
dotnet-ef, aspnet-runtime, vs-build-tools, postgresql, pgbouncer, fishmmo-db, nginx, letsencrypt, unity-hub, unity-editor, build-projects, build-unity, appsettings, create-migration, firewall, systemd-services, all
Pre-flight checks automatically run before any CLI-mode installation, verifying internet connectivity, disk space, memory, admin/sudo access, and port conflicts.
Download integrity: Downloaded files are verified against SHA256 checksums in
checksums.json. Corrupt or tampered downloads are rejected.
Pre-built Install Config Templates
FishMMO-Setup/Development/ includes three ready-to-use install configs:
| Template | Purpose |
|---|---|
install-config.quickstart.json |
Minimal dev setup (postgresql, fishmmo-db, nginx, firewall) |
install-config.web.json |
Web server setup (postgresql, fishmmo-db, nginx, letsencrypt, firewall, systemd-services) |
install-config.full.json |
Complete production setup (all components including UDP game port firewall rules) |
The full template maps to:
{
"components": ["dotnet-ef", "aspnet-runtime", "postgresql", "pgbouncer",
"fishmmo-db", "nginx", "letsencrypt", "firewall", "systemd-services", "appsettings"],
"configureFirewall": true,
"firewallPorts": [80, 443, "7770-7999/udp"],
"registerSystemdServices": true,
"webServers": ["ipfetch", "patcher", "webgl"],
"validateAfterInstall": true
}
Recommended Installation Order (Fresh Setup)
| Step | Menu | Action | Windows | Linux |
|---|---|---|---|---|
| 1 | Runtime & Tooling | Install DotNet EF Tool | Yes | Yes |
| 2 | Runtime & Tooling | Install ASP.NET Runtime | Yes | Yes |
| 3 | Runtime & Tooling | Install VS Build Tools | Yes | (skip) |
| 4 | Unity & Build | Build all C# Projects | Yes | Yes |
| 5 | Unity & Build | Install Unity Hub | Yes | Yes |
| 6 | Unity & Build | Install Unity Editor (+Modules) | Yes | Yes |
| 7 | Database | Configure Database Secrets (DB credentials) | Yes | Yes |
| 8 | Database | Install PostgreSQL | Yes | Yes |
| 9 | Database | Install FishMMO Database | Yes | Yes |
| 10 | Database | Install PgBouncer | Yes | Yes |
| 11 | Database | Configure PgBouncer | (manual) | Yes |
| 12 | Database | Configure Server Keys (REQUIRED) | Yes | Yes |
| 13 | Web Server | Install NGINX | Yes | Yes |
| 14 | Web Server | Deploy FishMMO nginx.conf | Yes | Yes |
| 15 | Web Server | Configure Firewall Rules | Yes | Yes |
| 16 | Web Server | Install/Renew Let's Encrypt Certificate | Optional | Optional |
| 17 | Web Server | Register FishMMO Web Services | Optional | Yes |
| 18 | Configuration | Configure appsettings.json (web servers) | Yes | Yes |
"Configure Server Keys" (step 12) is required. Without the gate secret, KEK, and connection token HMAC key in the database, game servers and web servers will refuse to start. This step runs
SecurityKeyInstallerwhich generates all three keys and stores them in thedeployment_secretsandconnection_token_keystables.After the Installer, open Unity and run these Editor tools (see Unity Project Setup):
- FishMMO Dashboard → Game Settings → Client Secret — pulls the gate secret from the database and writes
ClientApiSecret.generated.cs- FishMMO Dashboard → Game Settings → Certificate Pins — connects to your live hosts and writes
CertificatePins.generated.cs"Build all C# Projects" discovers and builds all
.csprojfiles under the repository root, including:
FishMMO-Dependencies— resolves 43 NuGet packages and copies their DLLs intoFishMMO-Unity/Assets/Dependencies/FishMMO-Auth— authentication library (copies DLL to Unity Dependencies)FishMMO-Database/FishMMO-DB— database libraryFishMMO-Logger— logging library (copies DLL to Unity Dependencies)FishMMO-SharedUtility— shared utility library (copies DLL to Unity Dependencies)FishMMO-WebServers/IPFetchASP.NET— login server discovery APIFishMMO-WebServers/PatcherASP.NET— patch delivery serverFishMMO-WebServers/WebGLServerASP.NET— WebGL static file serverFishMMO-AppHealthMonitor— server health monitor daemonFishMMO-DiscordBot— Discord chat bridge botFishMMO-CMS— account-management web API (scaffold)
4. Build the WebTransport C++ Library
The WebTransport native library (libfishmmo_webtransport) is a C++ shared library that wraps Microsoft's MsQuic QUIC implementation. It is P/Invoked from C# as FishNet's transport plugin. This must be built before running the game server — the Unity project expects the native binary at Assets/Plugins/FishNet/Plugins/WebTransport/Plugins/{platform}/.
Linux (Native Build)
Prerequisites:
# Arch / CachyOS
sudo pacman -S cmake openssl gcc
# Ubuntu / Debian
sudo apt-get install cmake libssl-dev build-essential
# Fedora
sudo dnf install cmake openssl-devel gcc-c++
Build:
cd FishMMO-WebTransport
./build_linux.sh
Output: libfishmmo_webtransport.so placed directly into ../FishMMO-Unity/Assets/Plugins/FishNet/Plugins/WebTransport/Plugins/linux_x86_64/
The Linux binary is committed to the repository. If you're deploying on Linux, you can skip this build step. Windows and macOS binaries must be built on their respective platforms.
Windows (Native Build)
Prerequisites:
- Visual Studio 2022 with C++ workload
- CMake 3.20+ (
winget install Kitware.CMake) - OpenSSL (
vcpkg install openssl:x64-windows)
Build:
cd FishMMO-WebTransport
.\build_windows.ps1
Output: fishmmo_webtransport.dll in .../Plugins/windows_x86_64/ (msquic is statically linked — no separate msquic.dll needed)
Windows Cross-Compile from Linux (Zig)
If you develop on Linux but need a Windows .dll for client builds:
Prerequisites:
# Arch / CachyOS
sudo pacman -S zig
# Or download from https://ziglang.org/download/
Build:
cd FishMMO-WebTransport
./build_windows_cross.sh
This script:
- Downloads the msquic NuGet package (
Microsoft.Native.Quic.MsQuic.Schannel/2.5.9) - Extracts headers and DLL from the NuGet package
- Compiles all
.cppfiles withzig c++ -target x86_64-windows-gnu - Links into a DLL importing
msquic.dll - Copies both
fishmmo_webtransport.dllandmsquic.dllto the Unity plugins directory (cross-compile requiresmsquic.dllalongside the main DLL — unlike native VS builds where msquic is statically linked)
macOS (Native Build)
Must be built on a Mac:
brew install cmake openssl@3
cd FishMMO-WebTransport
./build_macos.sh
Output: libfishmmo_webtransport.dylib in .../Plugins/mac_x86_64/
What CMake Does
The CMakeLists.txt:
- Fetches msquic v2.5.9 from GitHub (
FetchContent) and statically links it - Uses
quictls(OpenSSL fork) as msquic's TLS backend - Finds system OpenSSL for the wrapper library's own TLS needs
- Compiles 7
.cppsource files (server, client, session, stream_manager, datagram_queue, http3, webtransport_api) - Outputs the shared library directly to the Unity project's Plugins directory
Build Options
| CMake Option | Default | Description |
|---|---|---|
BUILD_SHARED_LIBS |
ON | Build as shared library (.so/.dylib/.dll) |
WT_STATIC_MSQUIC |
ON | Statically link msquic (recommended) |
WT_BUILD_TESTS |
OFF | Build test programs |
5. Open the Unity Project
After building all C# projects and the WebTransport native library, open the Unity project:
- Open Unity Hub
- Click ADD → Select the
FishMMO-Unitydirectory - Open the project with Unity 6.3 LTS
- Wait for the initial asset import and script compilation to complete
- Follow the Unity Project Setup section below
Database Setup
The FishMMO-Installer automates database creation (Database menu, option 4 — Install FishMMO Database), but here is what happens under the hood:
- PostgreSQL Installation — The installer installs PostgreSQL via your platform's package manager (option
2). - Database + User Creation — Creates the
fishmmodatabase and a dedicatedfishmmouser role (option4). - EF Core Migration — Creates and applies an initial Entity Framework Core migration (option
4). - Permissions — Grants the user full privileges on the
publicschema (options4and6).
Manual Database Setup (Without the Installer)
If you prefer to set up the database manually:
# Linux — become the postgres user
sudo -u postgres psql
CREATE USER fishmmo WITH PASSWORD 'your_secure_password';
CREATE DATABASE fishmmo OWNER fishmmo;
GRANT ALL PRIVILEGES ON DATABASE fishmmo TO fishmmo;
\c fishmmo
GRANT ALL ON SCHEMA public TO fishmmo;
Then apply the EF Core migrations:
cd FishMMO-Database/FishMMO-DB-Migrator
dotnet run
Creating New Migrations
When your data model changes, create a new migration via the Installer (Database menu, option 5 — Create New Database Migration) or manually:
cd FishMMO-Database/FishMMO-DB
dotnet ef migrations add YourMigrationName \
--startup-project ../FishMMO-DB-Migrator \
--output-dir ../../Migrations
# The scaffolder writes the model snapshot to the project default rather than
# --output-dir, where the SDK glob also compiles it — move it and remove the folder.
mv Migrations/NpgsqlDbContextModelSnapshot.cs ../../Migrations/
rmdir Migrations
cd ../FishMMO-DB-Migrator
dotnet run
Three things about this are not obvious:
--startup-projectis mandatory.FishMMO-DBtargetsnetstandard2.1, which has no runtime, so the EF tooling refuses to run against it alone.FishMMO-DB-Migratoris the only executable project that references it.- Migrations live at the monorepo root, in
Migrations/, which is.gitignored and compiled intoFishMMO-DBvia<Compile Include="..\..\Migrations\*.cs" />. Hence--output-dir ../../Migrationsand the snapshot move above. - Review the scaffold before running it. The model currently carries two pre-existing drifts (
character_pet.abilitiesandcharacter_abilities.ability_eventsaretextin the database butList<int>in the model) that every new migration re-scaffolds as a destructiveALTER … TYPE integer[]. Strip those fromUpandDownunless you are deliberately converting them.
To review the generated SQL without touching a database — dotnet ef database update ignores FISHMMO_CONNECTION_STRING and resolves the design-time factory, which points at your live local database:
dotnet ef migrations script PreviousMigration YourMigrationName \
--startup-project ../FishMMO-DB-Migrator --idempotent
Database Configuration
Environment-based overrides: The database library supports layered configuration in this priority order:
appsettings.json(required)appsettings.{Environment}.json(optional — e.g.,appsettings.Development.json)- Environment variables (highest priority, use
__for nesting)
Set the environment via FISHMMO_ENVIRONMENT (preferred), DOTNET_ENVIRONMENT, or ASPNETCORE_ENVIRONMENT.
Environment variable overrides for database credentials:
DB credentials are resolved by the DatabaseSecrets class, checking environment variables first, then the platform secrets file (/etc/fishmmo/db-secrets.env on Linux, %ProgramData%\FishMMO\db-secrets.env on Windows):
| Variable | Maps To |
|---|---|
FISHMMO_ENVIRONMENT |
Environment name (Development, Production) |
FISHMMO_DB_HOST |
PostgreSQL host |
FISHMMO_DB_PORT |
PostgreSQL port |
FISHMMO_DB_NAME |
Database name |
FISHMMO_DB_USERNAME |
Database user |
FISHMMO_DB_PASSWORD |
Database password |
Example (fish shell):
set -Ux FISHMMO_ENVIRONMENT Production
set -Ux FISHMMO_DB_PASSWORD super_secret
Example (bash):
export FISHMMO_ENVIRONMENT=Production
export FISHMMO_DB_PASSWORD=super_secret
Every timestamp is stored in UTC. The timestamp columns are timestamp without time zone, and CURRENT_TIMESTAMP is a timestamptz — assigning it converts to the session's time zone, so on any server not running in UTC the value written was local time while everything reading it compared against DateTime.UtcNow. Every default and every raw-SQL write now uses timezone('UTC', CURRENT_TIMESTAMP) instead. This matters most for last_pulse: the liveness query already compared in UTC, so a non-UTC database made servers appear stale or fresh by exactly the UTC offset.
Rows written before this change keep whatever offset they were written with.
last_pulsecorrects itself on the next heartbeat; historicaltime_createdvalues do not.
Unapplied migrations are caught at startup. Migrations are generated per developer and applied locally rather than shared through source control, so pulling someone else's entity change brings no migration with it and nothing applies one on your behalf — the server starts and authenticates perfectly, and the mismatch surfaces much later as a query failing on a column that does not exist, which typically reaches a player as missing data rather than as a schema problem. NpgsqlDbContextFactory.ValidateSchemaAsync reports what it finds and Server.VerifyDatabaseSchema logs it with the command that fixes it:
| State | Meaning | Startup | Fix |
|---|---|---|---|
| Pending migrations | Migrations exist that this database has not run | Refused | dotnet ef database update |
| Check unavailable | The migration history could not be read at all | Proceeds, warns | Depends on the reason logged |
Pending migrations are fatal on purpose. A server running against a database that is behind the migration set does not fail loudly — it fails as missing player data, and every write it accepts meanwhile is made against a schema the model does not agree with. A check that could not run at all is only a warning: that leaves the schema unverified rather than known-bad, and a failed diagnostic should not take down a server that is otherwise fine. The check runs concurrently with behaviour initialization and is joined just before the transport opens, so it costs no startup latency.
Model drift is not detected. An entity changed with no migration generated for it leaves nothing pending, so this check passes and the schema is quietly wrong. A drift check lived here and never worked once — EF builds
ModelSnapshot.Modelwith an empty convention set, so the relational model its differ needs is never attached and the comparison threw on every startup. EF Core 5 exposes no supported way to rebuild it at runtime, so the check was removed rather than left reporting a failure forever (#162). Generate a migration whenever you change an entity — nothing will remind you. Catching this properly belongs in CI, where the design-time package is available and a scaffolded migration can be asserted empty.
Unity Project Setup
Client Security Setup (REQUIRED before building)
Before building the client, you must generate two security files from within Unity. Both live in the FishMMO Dashboard → Game Settings panel (FishMMO → FishMMO Dashboard, Ctrl+Shift+D), alongside the Host Configuration and Constants sections.
1. Client Secret (Game Settings → Client Secret section)
- Reads the gate secret from the
deployment_secretsdatabase table - Writes
ClientApiSecret.generated.cs— the shared secret forX-FishMMO-ClientHMAC header signing - Requires database access (uses the same
FISHMMO_DB_*credentials) - Run once per deployment or when the gate secret is rotated
2. Certificate Pins (Game Settings → Certificate Pins section)
- Fetch Pins connects to your live hosts over TLS to download leaf certificates; Write Pins to File then saves them
- Computes SHA-256 SPKI hashes and writes
CertificatePins.generated.cs - Minimum 2 pins (active + backup) required for release builds
- Only hosts serving HTTPS on port 443 work — QUIC-only game hostnames will fail, so pin the API/IPFetch/play hosts
- Run whenever TLS certificates are renewed with new key pairs
Without these files, release client builds will fail. The build validator blocks any build with missing or sentinel-placeholder values. Development builds are exempt.
Build World Scene Details
This caches important game world details (spawn points, teleporters, boundaries, scene metadata) for both clients and servers. Run this whenever you add or modify a scene.
Unity Menu: FishMMO → Rebuild World Scene Details, or FishMMO → FishMMO Dashboard → World Scene Details in the World category
This generates WorldSceneDetailsCache assets that are loaded at runtime by the WorldServer and SceneServer for scene routing and character placement.
Versioning
Manage the project's semantic versioning from the FishMMO Dashboard.
Unity Menu: FishMMO → FishMMO Dashboard (Ctrl+Shift+D) → Select Version in the Core category
The Version panel provides:
| Control | Effect |
|---|---|
| Increment Major | Major++, resets Minor and Patch to 0 |
| Increment Minor | Minor++, resets Patch to 0 |
| Increment Patch | Patch++ |
| Pre-Release Tag | Free-text tag (e.g., alpha, beta, rc1) appended to the version |
| Reset to 0.0.0 | Resets all version fields to zero (with confirmation) |
The panel also shows the current full version, asset path, and Addressable registration status. The VersionConfig.asset is automatically registered as an Addressable under the Shared_Static_Permanent group and label. The final version is written to version.txt in the build output directory during the build process.
FishMMO Builds
Use the FishMMO Dashboard to configure and execute builds.
Unity Menu: FishMMO → FishMMO Dashboard (Ctrl+Shift+D) → Select Build in the Core category
The Build panel provides:
Configuration:
| Setting | Options | Description |
|---|---|---|
| Build Type | Server / Client | Selects which executable to build |
| OS Target | Windows x64 / Linux x64 / WebGL | Target platform for the build |
| Environment | Development / Production | Controls .cfg and appsettings.json copy source and loopback binding |
Actions:
| Button | Description |
|---|---|
| Apply Platform Settings | Switches the Unity Editor to the selected build target |
| Build Addressables | Builds all Addressable asset bundles required by client and server |
| Build Game | Executes the build for the selected Build Type, OS Target, and Environment |
| Update Linker | Regenerates the IL2CPP link.xml file |
The Dashboard also shows the current build settings, active build target, and background task progress (compilation, asset import).
Build Settings via Menu: Individual build settings can also be changed via FishMMO → Build → Build Type, FishMMO → Build → OS Target, and FishMMO → Build → Environment.
Build Environments:
| Environment | Address Binding | Use Case |
|---|---|---|
| Development | 127.0.0.1 (loopback) |
Local testing — all servers on the same machine |
| Production | Configurable via .cfg files |
Production deployment — default is 127.0.0.1 (behind NGINX); change to server's network IP only if NGINX is on a separate machine |
The build process copies the appropriate .cfg and appsettings.json files from FishMMO-Setup/Development/ or FishMMO-Setup/Production/ into the build output.
Important: Build Addressables first, then build the game. The game build depends on the Addressable bundles produced in the first step.
Headless builds and the build-target switch. A CLI build (-batchmode -executeMethod) that has to switch build target used to be rejected for running while scripts were compiling, producing no artifact — and re-running the identical command immediately afterwards succeeded, because by then the target already matched and no switch happened. SwitchActiveBuildTarget is synchronous and already recompiles for the new target, and the AssetDatabase.Refresh(ForceUpdate) that follows settles the define symbols; the extra ForceEditorScriptRecompile on top of that queued a further compile, and under -executeMethod nothing turns the editor loop over until the method returns, so it stayed pending for the rest of the invocation. That recompile is now skipped in batch mode — interactive Dashboard builds keep it, where the editor loop can service it. RunCliBuild also fails fast, naming the reason, if scripts somehow are still compiling: nothing on that thread can advance a pending compile in batch mode, so waiting cannot help and sleeping would block the very thread that runs compilation.
A batch-mode build never reports success without a player. The two failure modes above were both silent in the same way, and the shared guard that starts every build could still reproduce it: when scripts are compiling, CustomBuildTool declines to start and returns. Interactively that is right — it warns, offers a dialog, and the player tries again. Under -batchmode nothing services the pending compile and nothing surfaces the warning, so the process exited 0 having produced no player, while Addressables had already run and written real bundles; the output directory still held the previous build, and the only way to notice was to compare a timestamp. Adding a single editor script is enough to trigger it, because the recompile outlives the Addressables step and the guard then fires. In batch mode that path now logs an error naming the cause and exits 1.
Headless builds need an explicit output path. BuildExecutor falls back to EditorUtility.SaveFolderPanel when it is not given a root path, and under -batchmode that returns an empty string and cancels the player build — while Addressables has already run and written real bundles, and the process still exits 0. The result is a build that reports success and produces no player. RunCliBuild therefore resolves an output path before building: -fishmmoOutputPath <dir> when supplied, otherwise Builds/Server or Builds/Client (chosen by the entry point invoked) in the parent of the editor's working directory — the repository root when Unity is launched from FishMMO-Unity/. Because that default depends on the working directory, CI should pass the path explicitly.
The CLI entry points are BuildClientCLI, BuildServerCLI, BuildAddressablesCLI, BuildClientWithAddressablesCLI and BuildServerWithAddressablesCLI on FishMMO.Shared.CustomBuildTool.Core.CustomBuildTool. OS target is read from -fishmmoOSTarget; -fishmmoBuildType applies only to BuildAddressablesCLI, which otherwise defaults to Client.
Unity -batchmode -quit -projectPath FishMMO-Unity \
-executeMethod FishMMO.Shared.CustomBuildTool.Core.CustomBuildTool.BuildServerWithAddressablesCLI \
-fishmmoOSTarget Linux -fishmmoOutputPath /srv/fishmmo/Builds/Server
Patching — PatchGenerator and Updater
PatchGenerator
A custom Unity Editor window for creating delta patches between game builds.
Unity Menu: FishMMO → FishMMO Dashboard (Ctrl+Shift+D) → Select Patch Generator in the Core category
- Select the new and old build directories.
- Configure options, exclusions, and version details.
- Click Generate Patch to create delta files and a manifest.
Patch files are ZIP archives named <from_version>-<to_version>.zip (e.g., 1.0.0-1.0.1.zip). This naming scheme is a contract between the generator, the patcher server's index, the launcher's download path (Constants.GetPatchFileName), and the Updater's lookup — changing it requires changing all four.
Updater
The FishMMO Updater is a standalone .NET 8 executable that applies one patch archive to the client. It is launched automatically by the game launcher.
Updater.exe -version=1.0.0 -latestversion=1.0.1 -pid=1234 -exe=FishMMOClient.exe [-patches=D:\FishMMO\Patches]
It looks for exactly one archive — {version}-{latestversion}.zip in the patches directory, by default Patches/ resolved relative to its own base directory — and applies it. -patches= overrides that directory and must be absolute; a relative, missing, or unreadable path is ignored with a warning and the default is used. The launcher applies the identical rule to its own Launcher.PatchDirectory setting and passes the resolved path here explicitly, so the two cannot disagree — and a disagreement is silent: the Updater finds no archive, does nothing, restarts the client at the same version, and the launcher detects the same mismatch again on the next run, forever. -patches changes only where a verified archive is read from; the launcher has already checked it against the server-supplied SHA-256 before the Updater is invoked at all, and it does not relocate the install. There is no multi-step chaining: if the server only publishes 1.0.0-1.0.1 and 1.0.1-1.0.2, a 1.0.0 client cannot reach 1.0.2 in one run. Publish a direct 1.0.0-1.0.2 patch for every version you intend to support upgrading from.
Behaviour:
| Aspect | Detail |
|---|---|
| Transaction | Per-file backup with full rollback on any critical failure; parallel file writes, sequential deletes and moves for finalization |
| Verification | SHA-256 hash verification on both sides of each diff |
| Archive lifecycle | Deleted after a successful apply; left in place after a failure so the same run can be retried without re-downloading |
| Exit code | Always 0. Failure is not signalled via exit code — by the time the Updater finishes, the launcher it was reporting to has already been killed by PID. Read its console output for the result |
| Launcher shutdown | Process.CloseMainWindow() on Windows; on Linux/macOS the Updater P/Invokes kill(pid, SIGTERM) from libc, because CloseMainWindow is Windows-only. A forced kill follows if the graceful request is not honoured |
| Restart | The client executable named by -exe is started again on every exit path, patched or not |
| Single writer | A lock file under the install root makes one updater the only process working on it. Two updaters over the same tree would interleave their moves, and each one's rollback would describe a state the other had already changed |
| Crash recovery | Every displaced or created file is recorded in an append-only journal inside the staging directory before the filesystem is touched, and the journal is fsynced so it survives a power loss. An updater that dies mid-apply leaves an install that is neither version; the next updater to run replays the journal and restores it before attempting anything else |
| Manifest checks | Refuses a manifest in which two entries write the same target path, and refuses an archive whose manifest describes a different upgrade than the one requested — the filename says {from}-{to}, but nothing had checked that the contents agreed |
| Permissions | Original POSIX permission bits are re-applied to each replacement, and a newly created file is detected as executable from its own content (ELF, Mach-O or shebang) rather than its extension. On Linux and macOS a patched binary that loses its executable bit is an install that no longer starts |
| Parallelism | Bounded to the machine's core count, so a large patch does not saturate the I/O queue of the disk it is rewriting |
The
Patchesdirectory name is a three-way contract: the Unity client resolves it viaConstants.Configuration.PatchesDirectoryName/Constants.GetPatchesDirectory(), the standalone Updater defaults to"Patches"(it cannot reference the Unity assembly), and the patch server reads it fromPatches:DirectoryNameinappsettings.Patcher.json. Change one, change all three. The player-facingLauncher.PatchDirectoryoverride sits on top of that default and is carried across the launcher → Updater handoff by-patches=, so overriding it does not require touching any of the three.
Patch Server
Build the PatcherASP.NET web server and place your generated patch .zip files in the directory named by Patches:DirectoryName (default Patches, resolved relative to the application base directory — the server refuses to start if it resolves outside). The files are indexed and hashed at startup, and only indexed files are reachable — no caller-controlled string is ever concatenated into a filesystem path.
| Route | Behaviour |
|---|---|
GET/HEAD /latest_version |
{ latest_version } |
GET/HEAD /latest_version?from={clientVersion} |
{ latest_version, up_to_date: true } when the client is current; { latest_version, patch_available: false } when no indexed patch upgrades that version; otherwise { latest_version, patch_available: true, sha256, size } so the launcher can verify the download |
GET /{version} |
Streams {version}-{latest}.zip as application/octet-stream (range requests enabled, rate-limited by the PatchDownload policy). Returns 204 No Content when the client is already current, and 404 when no patch path exists from that version |
Both /latest_version forms send a weak ETag, Cache-Control: public, max-age=30, and an X-FishMMO-Version-Signature HMAC over the manifest (signed with the shared gate secret, so a spoofed origin cannot forge a version answer). A matching If-None-Match short-circuits to 304. Download responses carry X-Patch-Sha256, X-Patch-Size, a strong ETag, and Cache-Control: public, max-age=3600, immutable.
Configuration
Constants.cs — Client Domains
The file FishMMO-Unity/Assets/Scripts/Shared/Implementation/Constants.cs contains domain endpoints used by the client to connect to your infrastructure.
These values are not literals in Constants.cs — each one reads from GeneratedHostConfig, an IL-embedded generated class whose real values are substituted at build time by CI or the FishMMO-Installer. The committed sentinel values are intentionally invalid, and the build validator blocks release builds that still contain them.
public static class Configuration
{
/// Unified API Host URL. NGINX routes to the correct backend by path.
public static readonly string APIHost = GeneratedHostConfig.ApiHost;
/// Game server hostname. Clients connect to this host via QUIC/WebTransport (UDP).
/// NGINX forwards game traffic at Layer 4 to loopback-bound game servers.
public static readonly string GameHost = GeneratedHostConfig.GameHost;
/// Launcher HTML/news page URL.
public static readonly string LauncherHtmlUrl = GeneratedHostConfig.LauncherHtmlUrl;
}
| Field | GeneratedHostConfig source |
Purpose |
|---|---|---|
APIHost |
ApiHost (CI: FISHMMO_API_HOST) |
Base URL for IPFetch and Patcher API calls — NGINX reverse-proxies to loopback web servers |
GameHost |
GameHost (CI: FISHMMO_GAME_HOST) |
Hostname for game QUIC/WebTransport connections — NGINX forwards UDP to loopback game servers |
LauncherHtmlUrl |
LauncherHtmlUrl (CI: FISHMMO_ROOT_DOMAIN) |
Page the launcher fetches its news panel from |
SmtpFromAddress / SmtpFromName |
SmtpFromAddress / SmtpFromName |
From-address and display name for verification emails |
Write these values from the Unity Editor via FishMMO Dashboard → Game Settings, which regenerates HostConfig.generated.cs. GeneratedHostConfig also carries PlayHost (WebGL/player-facing hostname) and RootDomain (TLS certs and email).
For local development, point the hosts at
https://localhost/andlocalhost, or configure your hosts file. Prefer theGlobalSettingsoverride mechanism (seeApiHostResolver) over editing generated constants for one-off testing. When running without NGINX, setAPIHostto your web server's address directly andGameHostto the game server's address.
Server Configuration Files
Each server type reads a .cfg file from its working directory. Templates are in FishMMO-Setup/Development/ and FishMMO-Setup/Production/.
Only the templates under
FishMMO-Setup/are tracked. The.cfgfiles a server actually reads are per-deployment copies and are gitignored, so edits to them never reach source control — change the template, not the copy. In the Editor,Constants.GetWorkingDirectory()resolves to the repository root, so running a server from Play Mode dropsLoginServer.cfg,WorldServer.cfg, andSceneServer.cfgthere; the root.gitignoreexcludes those three paths by name. Runtimeappsettings.jsonfor the web servers is copied to$(OutDir)/$(PublishDir), both underbin/, which the general[Bb]in/rule already covers.
LoginServer.cfg
ServerName=LoginServer
MaximumClients=4000
Address=127.0.0.1
Port=7770
StaleSceneTimeout=5
CertificatePath=/etc/fishmmo/certs/fullchain.pem
PrivateKeyPath=/etc/fishmmo/certs/privkey.pem
WorldServer.cfg
ServerName=World Server
MaximumClients=4000
Address=127.0.0.1
Port=7780
StaleSceneTimeout=5
CertificatePath=/etc/fishmmo/certs/fullchain.pem
PrivateKeyPath=/etc/fishmmo/certs/privkey.pem
SceneServer.cfg
ServerName=Scene Server
MaximumClients=4000
Address=127.0.0.1
Port=7790
StaleSceneTimeout=5
StaleInstanceSceneTimeout=2
MaxInstanceLifetimeMinutes=120
CertificatePath=/etc/fishmmo/certs/fullchain.pem
PrivateKeyPath=/etc/fishmmo/certs/privkey.pem
| Key | Description | Default |
|---|---|---|
ServerName |
Display name for logs and monitoring | varies |
MaximumClients |
Maximum concurrent connections | 4000 |
Address |
Bind address — 127.0.0.1 when behind NGINX (default); set to the server's network IP only if NGINX runs on a different machine |
127.0.0.1 |
Port |
Listen port (Login=7770, World=7780, Scene=7790+) | varies |
StaleSceneTimeout |
Minutes an empty open-world scene instance stays loaded before it is unloaded and its scene row deleted. SceneServer only. The templates ship 5; the code's fallback when the key is missing is 60 |
5 |
StaleInstanceSceneTimeout |
Minutes an empty Group/PvP (instanced) scene stays loaded. Deliberately shorter than StaleSceneTimeout: a dungeon instance belongs to one character or party, so once it empties it is unlikely to be wanted again, while each one holds a full physics scene. Long enough to survive a wipe-and-run-back or a relog. SceneServer only; the code's fallback is 5 |
2 |
MaxInstanceLifetimeMinutes |
Minutes an instanced scene may exist before it is closed regardless of who is inside. SceneServer only. Measured from the scene row's creation, so it includes the time spent queued and loading. StaleInstanceSceneTimeout only ever reclaims an empty instance, so an occupied one had no upper bound at all — and since a party may hold only one instance at a time, a dungeon nobody finishes locks that party out of every other. Occupants are warned at 10 / 5 / 1 minutes and then returned to the open world through the ordinary leave-instance path. Open-world scenes are exempt. A dungeon difficulty that declares its own LifetimeMinutes overrides this for instances opened at it — a timed challenge is one of the few levers that changes how a dungeon plays without touching a number in combat — and this remains the backstop for everything that declares nothing. Code's fallback when the key is missing is 120 |
120 |
CertificatePath |
PEM certificate for QUIC/TLS (game servers terminate their own TLS) | platform-dependent |
PrivateKeyPath |
PEM private key for QUIC/TLS | platform-dependent |
ConnectionTokenHmacKeyBase64 |
Leave empty. Loaded at runtime from the connection_token_keys database table |
empty |
AutoVerifyAccounts |
LoginServer only — skip email verification at account creation and at login (Development builds only, never in Production) | true (Dev) / false (Prod) |
AllowedOrigins |
LoginServer only — comma-separated CORS origins permitted for WebGL clients | https://play.fishmmo.com |
Smtp:Host / Smtp:Port / Smtp:Username / Smtp:Password / Smtp:FromAddress / Smtp:FromName / Smtp:UseSsl |
LoginServer only — verification-email relay. Port 465 = implicit TLS (UseSsl=true); port 587 = STARTTLS (UseSsl=false) |
localhost / 465 / — / — / noreply@fishmmo.com / FishMMO / true |
LoginQueueUpdateRateSeconds |
LoginServer only — how often queued clients receive position updates | 2.0 |
LoginQueueMaxSize |
LoginServer only — queue capacity; excess clients are rejected outright | 500 |
LoginQueueAdmissionRatePerSecond |
LoginServer only — admission rate from the queue | 5.0 |
LoginQueueTimeoutSeconds |
LoginServer only — maximum wait before a queued client is timed out | 300 |
Format: Simple key=value per line. Lines starting with # or ; are comments. The SMTP settings can be overridden via FISHMMO_SMTP_HOST, FISHMMO_SMTP_PORT, FISHMMO_SMTP_USERNAME, FISHMMO_SMTP_PASSWORD, FISHMMO_SMTP_FROM_ADDRESS, FISHMMO_SMTP_FROM_NAME, and FISHMMO_SMTP_USE_SSL.
IPv6 is not supported at the native QUIC layer. The
EnableIPv6andIPv6Addresskeys appear commented out in the templates and are reserved for future implementation — IPv6 clients must reach the servers through an IPv6-enabled NGINX L4 proxy.
The Production login-queue defaults (
500/5.0/300) are conservative, sized for roughly 500 concurrent players. The template's own tuning note suggests1000–8000,10–20/s, and120–600sfor a launch-scale deployment.
Certificate paths are required on every game server.
CertificatePathandPrivateKeyPathmust point to valid PEM files on each server. NGINX cannot terminate QUIC TLS — it only forwards raw UDP. If certificates are missing, the server will fail to start or clients will be unable to connect. See TLS Certificate Setup for Game Servers.
Production: Edit the
Production/templates before building. Each SceneServer needs a unique port if running multiple instances (e.g., 7790, 7791, 7792...).
About the bind address: All server
.cfgtemplates default toAddress=127.0.0.1— servers listen on loopback and NGINX forwards public traffic to them viaproxy_pass. This keeps backend servers off the public network. Only changeAddressif the server runs on a different machine than NGINX; in that case, set it to the machine's network-facing IP and update the corresponding NGINX upstream or stream config to point to that IP.
Platform-Specific Certificate Paths
| Platform | Default Certificate Path |
|---|---|
| Linux | /etc/fishmmo/certs/fullchain.pem |
| Windows | C:\ProgramData\FishMMO\certs\fullchain.pem |
| macOS | /usr/local/share/fishmmo/certs/fullchain.pem |
| Other | certs/fullchain.pem (relative to working directory) |
Logging Configuration
All projects share a single canonical logging.json at FishMMO-Setup/logging.json. Each project's build copies it into the output directory automatically — operators only need to edit the one source file.
{
"LoggingManager": {
"ConsoleAllowedLevels": ["Info", "Warning", "Error", "Critical", "Debug"]
},
"Loggers": []
}
Log levels: Verbose, Debug, Info, Warning, Error, Critical.
Adding file logging (edit FishMMO-Setup/logging.json):
{
"LoggingManager": {
"ConsoleAllowedLevels": ["Info", "Warning", "Error", "Critical"]
},
"Loggers": [
{
"Type": "FileLoggerConfig",
"LoggerType": "FileLogger",
"Enabled": true,
"AllowedLevels": ["Info", "Warning", "Error", "Critical", "Debug"],
"LogDirectory": "logs",
"FileName": "server.log"
}
]
}
Adding email alerts (append to the Loggers array):
{
"Type": "EmailLoggerConfig",
"LoggerType": "EmailLogger",
"Enabled": true,
"AllowedLevels": ["Error", "Critical"],
"SmtpServer": "smtp.example.com",
"Port": 587,
"EnableSsl": true,
"Username": "alerts@example.com",
"Password": "********",
"From": "alerts@example.com",
"To": "ops@example.com",
"Subject": "FishMMO Alert"
}
Security: Keep SMTP credentials out of source control. Load them via environment variables (e.g.,
FISHMMO_SMTP_PASSWORD). Application secrets (gate secret, KEK, connection token HMAC key) are stored in the database — see FishMMO-Auth — Signing Keys & KEK. See FishMMO-Logger README for full configuration details.
Runtime override: Place a modified logging.json in the working directory — it takes precedence over the bundled copy. The log level can also be overridden via the FISHMMO_LOG_LEVEL environment variable (e.g. Debug, Verbose).
Level policy (why it is a data-handling decision, not just noise control): Warning and
above are the tiers that get shipped off-host, aggregated and retained longest. Two rules follow
for server code, and both have been violated in this codebase before:
- A healthy path never logs above
Debug. Every Login→World and World→Scene hop mints a connection token; logging the successful mint atWarningproduced one line per hop on a busy server and buried the failures the tier exists for. - Per-player identifiers do not travel with success. That same line named the client's resolved real IP, putting one player-attributable record into a widely-retained tier on every zone change, for no diagnostic gain — the failure branch already identifies the connection that could not be served.
Failures keep their Warning/Error level and their identifying detail. See
Security Properties.
Configuration Files — FishMMO-Setup/
All project configuration lives in FishMMO-Setup/ as the single source of truth. Non-sensitive defaults (host, port, database name) are stored in JSON. Database credentials are set via environment variables (FISHMMO_DB_*) or the platform secrets file (/etc/fishmmo/db-secrets.env). Application secrets (gate secret, KEK, connection token HMAC key) are stored in the database and loaded by each server at startup — no env vars or secrets files are needed for them. Each template includes a _comment field listing the env var names to set.
Directory structure:
FishMMO-Setup/
├── logging.json # Shared logging — all projects
├── nginx.conf # NGINX reverse-proxy config (L4 UDP + L7 HTTP)
├── Development/ # Dev / local configurations
│ ├── .env.example # Template for FISHMMO_* environment variables
│ ├── appsettings.json # Unity server Npgsql (dev)
│ ├── appsettings.AppHealthMonitor.json # Process supervisor config
│ ├── appsettings.DiscordBot.json # Discord token + Npgsql
│ ├── appsettings.IpFetchServer.json # IP-fetch web server base
│ ├── appsettings.IpFetchServer.Development.json # Dev overrides (DB connection string)
│ ├── appsettings.Patcher.json # Patch delivery web server
│ ├── appsettings.WebGLServer.json # Static asset web server
│ ├── appsettings.CMS.json # CMS web app
│ ├── appsettings.Database.json # Npgsql pool / retry template
│ ├── install-config.full.json # Full production install template
│ ├── install-config.quickstart.json # Minimal dev install template
│ ├── install-config.web.json # Web server install template
│ ├── LoginServer.cfg / WorldServer.cfg / SceneServer.cfg
├── Production/ # Production configurations
│ ├── README.md # Production deployment notes
│ ├── appsettings.json # Unity server Npgsql
│ ├── appsettings.AppHealthMonitor.json # Process supervisor config
│ ├── appsettings.DiscordBot.json # Discord token + Npgsql
│ ├── appsettings.IpFetchServer.Production.json # Prod overrides (empty — must set env vars)
│ ├── appsettings.Patcher.json # Patch delivery web server
│ ├── appsettings.WebGLServer.json # Static asset web server
│ ├── appsettings.CMS.json # CMS web app
│ ├── LoginServer.cfg / WorldServer.cfg / SceneServer.cfg
Not in this repository:
nginx.confand the deployment docs below also reference two operator-supplied shell scripts —gen-fishmmo-stream-config.sh(NGINX UDP stream config generator) and the certbot deploy hookcertbot-fishmmo.sh. Neither is shipped here; the documented behaviour is the contract they must satisfy.
How it works: Each project's .csproj copies the appropriate file from FishMMO-Setup/ into its build output directory, renaming it to appsettings.json (or logging.json). At runtime, applications resolve config with a working-directory-first pattern: if a modified file exists in the working directory, it overrides the bundled copy. Environment variables (prefixed FISHMMO_ or using __ separator) provide the highest-priority overrides.
Unity Npgsql example (Development/appsettings.json):
{
"_comment": "Non-sensitive defaults only. Username and Password are resolved from FISHMMO_DB_USERNAME / FISHMMO_DB_PASSWORD env vars or /etc/fishmmo/db-secrets.env (chmod 600). NEVER put credentials in this file.",
"Npgsql": {
"Database": "fishmmo",
"Host": "127.0.0.1",
"Port": "5432"
}
}
Production Npgsql example (Production/appsettings.json):
{
"_comment": "Non-sensitive defaults only. Username and Password are resolved from FISHMMO_DB_USERNAME / FISHMMO_DB_PASSWORD env vars or /etc/fishmmo/db-secrets.env (chmod 600). NEVER put credentials in this file.",
"Npgsql": {
"Database": "fishmmo",
"Host": "127.0.0.1",
"Port": "5432"
}
}
If using PgBouncer, change Npgsql.Port to 6432 (see Configure pgBouncer).
Database pool and retry configuration (Development/appsettings.Database.json) — sets CommandTimeout: 10, ConnectionTimeout: 15, MinPoolSize: 5, MaxPoolSize: 100, Npgsql retry policy (3 retries, 20ms base, 10ms jitter), optional query performance tracking, and ConnectionPoolHealth thresholds (warn at 70%, critical at 85%, checked every 60s).
Security: Never commit
appsettings.jsonwith real passwords. Use environment variables for secrets in production (see Database Setup for environment variable override syntax).
FishMMO-Auth — Signing Keys & KEK
The authentication system uses HMAC-SHA256 for token signing. In production, signing keys are wrapped with an AES-256 Key Encryption Key (KEK).
Setting the KEK
The KEK is stored in the deployment_secrets database table under the key signing_key_kek. It is generated and stored by the FishMMO-Installer's SecurityKeyInstaller:
# From the Installer interactive menu:
# Database > Configure Server Keys
# Or via CLI — the argument is a comma-separated list of region IDs;
# each region gets its own keyId + HMAC key pair:
FishMMO-Installer --configure-server-secrets default
The CLI form needs the PostgreSQL superuser password: supply it via
FISHMMO_PG_SUPERUSER_PASSWORDor answer the prompt.
All game servers load the KEK from the database at startup via IDeploymentSecretService. No environment variable or secrets file is needed to distribute the KEK between machines.
How It Works
- The LoginServer generates a fresh HMAC-SHA256 signing key at startup.
- The key is wrapped using AES-256-GCM with the KEK and an AAD bound to the LoginServer's database ID.
- The wrapped key envelope is stored in the database.
- World/Scene servers fetch and unwrap the signing key to validate client tokens.
- Keys are rotated each time the LoginServer restarts.
Without a KEK: If the
signing_key_kekrow is missing from thedeployment_secretstable, the LoginServer will log a warning and tokens will not be issued. Client authentication will fail on World/Scene servers. Run the Installer's Database > Configure Server Keys to populate it.
Auth Protocol Constants
The authentication library has several compile-time security constants (in BaseAuthenticatorCore and SrpAuthenticatorCore, under FishMMO-Auth/FishMMO-ServerAuth/Implementation/Auth/):
| Constant | Default | Declared In | Description |
|---|---|---|---|
AuthStaleTtlSeconds |
15 | BaseAuthenticatorCore |
Stale-auth sweep interval |
AuthHardDeadlineSeconds |
60 | BaseAuthenticatorCore |
Hard authentication deadline |
MaxPendingAuthConnections |
10,000 | BaseAuthenticatorCore |
Concurrent pending auth cap |
HandshakeIpWindowSeconds |
2 | BaseAuthenticatorCore |
Sliding window for the per-IP handshake limiter |
HandshakeIpBurstLimit |
8 | BaseAuthenticatorCore |
Phase-2 handshake completions allowed from one IP per window — sustains 4/sec/IP while tolerating a NAT burst |
MaxGlobalHandshakesPerSecond |
500 | BaseAuthenticatorCore |
Global X25519 handshake cap per 1-second window |
MaxTotpAttempts |
5 | SrpAuthenticatorCore |
TOTP attempts per connection |
MaxTotpFailuresPerUsername |
15 | SrpAuthenticatorCore |
Per-account TOTP lockout threshold |
TotpUsernameLockoutDuration |
5 min | SrpAuthenticatorCore |
TOTP lockout duration |
Infrastructure Setup
Configure Unity Hub
Add the Project:
- Click ADD in Unity Hub.
- Select the
FishMMO-Unitydirectory.
Install Required Modules:
- Go to the Installs tab.
- Click the gear icon next to your Unity 6.3 LTS version → Add Modules.
- Install:
- Linux Build Support (IL2CPP and Mono)
- Linux Dedicated Server Build Support
- Mac Build Support (IL2CPP and Mono)
- WebGL Build Support
- Windows Build Support (IL2CPP)
- Windows Dedicated Server Build Support
Open the FishMMO-Unity project from Unity Hub and wait for full compilation.
Configure PostgreSQL
The installer handles PostgreSQL installation. For manual setup, ensure:
- PostgreSQL is running and listening on
localhost:5432. - The
fishmmouser can connect with password authentication. pg_hba.confallows local password connections:local fishmmo fishmmo md5 host fishmmo fishmmo 127.0.0.1/32 md5
Securing PostgreSQL (Production):
The installer's PostgreSQLHardening module applies:
- Password-based authentication only (no trust)
- Restricted listen addresses (
localhostor private network) - Connection limits
- Query logging for audit trails
Configure pgBouncer
PgBouncer is a lightweight PostgreSQL connection pooler that sits between your game servers and PostgreSQL, reducing connection overhead. Each game server opens many short-lived connections — PgBouncer multiplexes these onto a smaller pool of persistent connections.
Installation
Use the Installer (Database menu, option 3 — Install PgBouncer):
- Linux: Package manager install +
systemctl enable --now pgbouncer - Windows:
winget(preferred) or Chocolatey fallback
Configuration
After installation, configure PgBouncer to pool connections to your FishMMO database.
Linux: The installer's "Configure PgBouncer" option (Database menu, option 8) generates pgbouncer.ini and userlist.txt for you. Otherwise, manually edit /etc/pgbouncer/pgbouncer.ini.
Windows: Edit pgbouncer.ini in the install directory.
pgbouncer.ini
[databases]
fishmmo = host=127.0.0.1 port=5432 dbname=fishmmo
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 20
min_pool_size = 5
max_client_conn = 200
max_db_connections = 50
server_idle_timeout = 300
client_idle_timeout = 0
query_timeout = 30
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
admin_users = fishmmo
stats_users = fishmmo
userlist.txt
"fishmmo" "your_secure_password"
On Linux, generate the password hash:
psql -c "SELECT concat('\"', usename, '\" \"', passwd, '\"') FROM pg_shadow WHERE usename = 'fishmmo';" postgres
Update appsettings.json
Point your game servers at PgBouncer (port 6432) instead of PostgreSQL directly:
{
"Npgsql": {
"Port": "6432"
}
}
Verify
Linux:
sudo systemctl status pgbouncer
sudo systemctl restart pgbouncer
# Test connection through PgBouncer
psql -h 127.0.0.1 -p 6432 -U fishmmo -d fishmmo
Windows:
sc.exe query pgbouncer
Configure NGINX
NGINX is the single public-facing reverse proxy for all FishMMO traffic. Every backend server binds to 127.0.0.1 (loopback) and NGINX forwards public traffic to them — backend servers are never directly reachable from the internet. The configuration in FishMMO-Setup/nginx.conf implements a dual-layer architecture:
- Layer 7 (HTTP/HTTPS): Terminates TLS for
api.fishmmo.comandplay.fishmmo.com, then reverse-proxies requests to the loopback-bound ASP.NET web servers. - Layer 4 (UDP Stream): Forwards raw QUIC/UDP packets to loopback-bound game servers. NGINX does not and cannot terminate QUIC TLS — the game servers receive the encrypted QUIC packets directly and must have their own certificates.
Critical: Because NGINX only forwards UDP at Layer 4, every game server must be provisioned with valid TLS certificates via the
CertificatePathandPrivateKeyPathsettings in its.cfgfile. NGINX's Let's Encrypt integration covers the HTTP hostnames only. See TLS Certificate Setup for Game Servers for the full procedure.
NGINX upstream routing table:
| Public Hostname | Protocol | NGINX Role | Upstream Backend | Backend Bind |
|---|---|---|---|---|
api.fishmmo.com |
HTTPS | TLS termination → reverse proxy | IPFetch (127.0.0.1:8080) + Patcher (127.0.0.1:8090) |
loopback |
play.fishmmo.com |
HTTPS | TLS termination → reverse proxy | WebGL Static Server (127.0.0.1:8000) |
loopback |
game.fishmmo.com |
UDP (QUIC) | L4 stream forward | Game Servers (127.0.0.1:7770-7999) |
loopback |
game.fishmmo.com:443 |
TCP | Returns 444 (close) | — | — |
If a backend server is on a different machine than NGINX: Change the
proxy_pass(HTTP) or streamserverblock (UDP) to target that machine's network IP instead of127.0.0.1, and update the server's.cfgAddressaccordingly.
Deploy the nginx config via the Installer (Web Server menu, option 3) or manually:
sudo cp FishMMO-Setup/nginx.conf /etc/nginx/nginx.conf
sudo nginx -t && sudo nginx -s reload
NGINX Stream Configuration (UDP Game Traffic)
Game traffic uses UDP ports 7770-7999 forwarded at Layer 4 through NGINX's stream module. Each port gets its own server {} block. By default, streams forward to 127.0.0.1 — the game servers are expected to be on the same machine, bound to loopback.
NGINX does not terminate TLS for these streams. It forwards the raw encrypted QUIC packets. Each game server terminates its own QUIC/TLS session, so certificates must be configured in every server's
.cfgfile (see TLS Certificate Setup for Game Servers).
Auto-Generating Stream Configs
FishMMO-Setup/nginx.conf includes /etc/nginx/stream.d/*.conf and expects those per-port configs to be produced by a generator script installed at /usr/local/bin/gen-fishmmo-stream-config.sh:
sudo /usr/local/bin/gen-fishmmo-stream-config.sh
The script is not shipped in this repository — it is a deployment-side artifact you provide. The contract below is what
nginx.confassumes of it; write the generator to match, or hand-write thestream.d/*.conffiles.
The generator should:
- Generates individual
.conffiles in/etc/nginx/stream.d/for each port - Port ranges:
- 7770-7779 — Login Servers
- 7780-7789 — World Servers
- 7790-7999 — Scene Servers
- Configurable via environment variables:
| Variable | Default | Description |
|---|---|---|
STREAM_DIR |
/etc/nginx/stream.d |
Output directory for generated .conf files |
BACKEND_IP |
127.0.0.1 |
IP where game servers are listening — change only if servers run on a different machine |
LOGIN_START / LOGIN_END |
7770 / 7779 |
Login server port range |
WORLD_START / WORLD_END |
7780 / 7789 |
World server port range |
SCENE_START / SCENE_END |
7790 / 7999 |
Scene server port range |
PROXY_TIMEOUT |
30s |
UDP session idle timeout — how long NGINX keeps a session mapping alive after the last packet |
- Use atomic temp-directory-then-replace so a failed run cannot leave partial configs
- Validate generated configs with
nginx -tbefore replacing live files
Configuration applied per port:
server {
listen 7770 udp;
proxy_pass 127.0.0.1:7770;
proxy_timeout 30s;
proxy_upload_rate 100m;
proxy_download_rate 100m;
}
Keep
PROXY_TIMEOUTin step withStaleSceneTimeout. The game servers' idle disconnect threshold isStaleSceneTimeoutin the.cfgfiles (default5seconds).proxy_timeoutmust be>= StaleSceneTimeoutplus a margin — the 30s default is deliberately generous — but aproxy_timeoutfar larger than the server's threshold leaves NGINX holding session mappings for connections the server has already freed, wasting memory and file descriptors.The stream module has no health checking, not even passive. If a game server crashes, NGINX keeps forwarding datagrams to the dead upstream until the stream config is regenerated and reloaded. See the comment block at the top of
FishMMO-Setup/nginx.conffor external health-check patterns (systemd timer, cron, or Consul Template).
Multi-machine deployment: If game servers run on a separate machine from NGINX, set
BACKEND_IPto that machine's IP and update each server's.cfgAddressto bind to its own network interface instead of127.0.0.1.
Reload with zero downtime:
sudo nginx -s reload
TLS Certificate Setup for Game Servers
This is not optional. NGINX cannot terminate QUIC/TLS for game traffic — it only forwards raw UDP packets at Layer 4. Every LoginServer, WorldServer, and SceneServer must have valid TLS certificates configured in its
.cfgfile (CertificatePathandPrivateKeyPath). Without certificates, game servers will fail to start or clients will be unable to connect.
Unlike a typical web setup where NGINX terminates all TLS, each FishMMO game server terminates its own QUIC/TLS session on loopback. NGINX forwards raw UDP packets at Layer 4 without inspecting or decrypting them. This means:
- Game data is end-to-end encrypted — NGINX never sees plaintext game traffic.
- TLS certificates must be present on each game server machine (by default, the same machine as NGINX).
- The real client IP is recovered via a one-time connection token from the HTTP API (IPFetch issues a SHA-256 hashed token, the client passes it in the QUIC handshake), since the game server only sees NGINX's
127.0.0.1as the source address.
Multi-machine: If game servers run on separate machines, copy the certificates to each machine and update the
CertificatePath/PrivateKeyPathin each server's.cfgfile. NGINX itself does not need certs for the game ports (it only forwards UDP).
Certificate Files
Game servers read PEM certificates from the paths in their .cfg file. The defaults are:
| File | Path |
|---|---|
| Full chain certificate | /etc/fishmmo/certs/fullchain.pem |
| Private key | /etc/fishmmo/certs/privkey.pem |
Permissions: chmod 640, owned by the fishmmo service user.
Initial Certificate Setup (Let's Encrypt)
# Install certbot and obtain a wildcard certificate
sudo certbot certonly --manual --preferred-challenges dns \
-d fishmmo.com -d '*.fishmmo.com'
# Create the game server cert directory
sudo mkdir -p /etc/fishmmo/certs
# Install the certbot deploy hook (auto-copies renewed certs)
sudo install -m 755 certbot-fishmmo.sh \
/etc/letsencrypt/renewal-hooks/deploy/fishmmo.sh
# Run the hook manually for first setup
sudo /etc/letsencrypt/renewal-hooks/deploy/fishmmo.sh
Certificate Renewal
The deploy hook is not shipped in this repository — you supply
certbot-fishmmo.sh. What follows is the contract it must satisfy for the game servers to pick up renewed certificates.
The certbot deploy hook runs automatically after each successful certificate renewal. It must:
- Validates the renewed certificate (checks existence, non-empty, not expired)
- Copies
fullchain.pemandprivkey.pemto/etc/fishmmo/certs/ - Sets ownership and permissions (
640, userfishmmo) - Reloads NGINX with zero downtime
- Restarts game servers (MsQuic reads certs once at startup — a restart is required to pick up renewed certs)
Important: MsQuic does not auto-reload TLS certificates. After certificate renewal, game servers must be restarted. For zero-downtime rolling restart, use systemd template units with the certbot hook's built-in restart logic.
Web Server Configuration
ClientGate Middleware
The IPFetch and Patcher web servers use a ClientGate middleware that validates the X-FishMMO-Client header on every request. This prevents generic crawlers and unauthorized clients from accessing the API.
How it works:
- An HMAC-SHA256 shared secret is loaded from the
deployment_secretsdatabase table (keyclient_gate_secret) at startup viaIDeploymentSecretServiceand held inGateSecretHolder - The header format is:
v1.<timestamp>.<nonce>.<base64url-hmac> - Replay protection via a 100,000-entry nonce cache with a 30-second timestamp window
- The secret must be at least 32 bytes; comma-separated values enable key rotation
In Production: The server refuses to start if the client_gate_secret row is not present in the deployment_secrets table.
In Development: Logs a warning and passes all requests through.
WebGL Server: Does not use ClientGate (static content, publicly accessible).
The gate secret is generated and stored by the FishMMO-Installer's SecurityKeyInstaller (Database menu > Configure Server Keys, or CLI --configure-server-secrets <regions>). The client-side copy is obtained from the FishMMO Dashboard → Game Settings → Client Secret section in the Unity Editor.
Web Server Security & Environment Variables
All three web servers (IPFetch, Patcher, WebGL) run on Kestrel bound to 127.0.0.1 (localhost only) — they are designed to sit behind NGINX. NGINX handles TLS termination and public exposure; the web servers themselves never accept connections from the public internet. This is enforced at the Kestrel level, not just by firewall rules.
Key environment variables:
| Variable | Used By | Purpose |
|---|---|---|
FISHMMO_ENVIRONMENT |
All servers | Sets DOTNET_ENVIRONMENT and ASPNETCORE_ENVIRONMENT |
Application secrets (gate secret, KEK, connection token HMAC key) are NOT configured via environment variables. They are stored in the database (
deployment_secretsandconnection_token_keystables) and loaded by each server at startup viaIDeploymentSecretServiceandIConnectionTokenKeyService. See FishMMO-Auth -- Signing Keys & KEK and ClientGate Middleware.
Production safety checks (refuse to start if unmet):
- The
client_gate_secretrow must exist in thedeployment_secretstable (IPFetch, Patcher) - Trusted proxy IPs/networks must be configured in
ForwardedHeaders(or setForwardedHeaders:AllowUnconfigured=trueto bypass) - Npgsql connection must use
Ssl Mode=Requireor stricter (IPFetch)
Web server port bindings (all localhost):
| Server | Config Key | Default Port |
|---|---|---|
| IPFetch Server | WebServer:HttpPort |
8080 |
| Patcher Server | WebServer:HttpPort |
8090 |
| WebGL Server | WebServer:HttpPort |
8000 |
Running the Servers
Launch Order
Servers must be started in this exact order:
- PostgreSQL — Must be running before any server starts.
- PgBouncer — If used, must be running before game servers start.
- LoginServer — Must be running and registered in the database before World servers.
- WorldServer — Must be running and registered before Scene servers.
- SceneServer(s) — Can start once WorldServer is registered.
- IPFetch Server — Should start after the LoginServer is registered.
- Patcher Server — Can start independently; needs patch files.
- WebGL Server — Can start independently; needs a WebGL build.
Starting Game Servers
All three server types use the same GameServer executable with different launch arguments. Build the server first from Unity (FishMMO Dashboard → Build: set Build Type to Server, then Build Game).
Recommended: Use the AppHealthMonitor daemon for production deployments — it provides automatic restarts, health checks, and process supervision:
cd FishMMO-AppHealthMonitor
dotnet build
dotnet run --project AppHealthMonitor/AppHealthMonitor.csproj
The installer's
systemd-servicescomponent registers only the three web servers (fishmmo-ipfetch,fishmmo-patcher,fishmmo-webgl). The AppHealthMonitor unit is not exposed as a CLI component — write it by hand as shown in Running as a Systemd Service (Linux) below.
Manual startup (dev/testing):
Linux:
./GameServer LOGIN &
sleep 10
./GameServer WORLD &
sleep 5
./GameServer SCENE &
Windows:
# Start LoginServer
Start-Process GameServer.exe -ArgumentList "LOGIN"
Start-Sleep 10
# Start WorldServer
Start-Process GameServer.exe -ArgumentList "WORLD"
Start-Sleep 5
# Start SceneServer(s)
Start-Process GameServer.exe -ArgumentList "SCENE"
Each server needs these files in its working directory:
GameServer(the executable)LoginServer.cfg/WorldServer.cfg/SceneServer.cfg(matching the server type)logging.json(shared logging config, copied fromFishMMO-Setup/at build)appsettings.json(database config, copied fromFishMMO-Setup/at build)AddressableAssetsData/(Addressable asset bundles from the build)StreamingAssets/(if any)- TLS certificate and private key at the paths specified in the
.cfgfile
The Unity build process copies the correct
.cfg,appsettings.json, andlogging.jsonfromFishMMO-Setup/automatically viaBuildExecutor.CopyConfigurationFiles().
Bind address: Server
.cfgfiles default toAddress=127.0.0.1— the server listens on loopback and expects NGINX to forward public traffic to it. If you are running without NGINX or on a separate machine, changeAddressto0.0.0.0(all interfaces) or the machine's specific network IP. NGINX's stream config (BACKEND_IP) and HTTP upstream blocks must point to the same address.
Starting Web Servers
All web servers load appsettings.json and logging.json from FishMMO-Setup/ (copied to the build output at build time). Place a modified copy in the working directory for local overrides.
For production, install them as OS services via the FishMMO-Installer:
# Linux (systemd) — registers fishmmo-ipfetch, fishmmo-patcher, fishmmo-webgl
FishMMO-Installer --component systemd-services
This configures automatic startup, crash recovery, environment variables (FISHMMO_ENVIRONMENT=Production, FISHMMO_DB_PASSWORD, etc.), and log file capture. It requires each web server to have been published first — the installer looks for FishMMO-WebServers/<project>/bin/Release/net8.0/publish and skips any server it cannot find there.
Linux only.
systemd-servicesis a no-op on Windows. The installer contains an NSSM-based Windows service registration path (FishMMO-IpFetch,FishMMO-Patcher,FishMMO-WebGL), but it is not currently reachable from any CLI component name — configure Windows services via NSSM manually. See the Installer README for details.
For development:
All web servers bind to
127.0.0.1by default (configured viaWebServer:HttpPortin their appsettings). NGINX reverse-proxies to them. To test a web server directly without NGINX, configure itsappsettings.jsonto bind to0.0.0.0or a specific IP.
IPFetch Server (Login Server Discovery):
cd FishMMO-WebServers/IPFetchASP.NET/IpFetchServer
dotnet build # copies configs from FishMMO-Setup/
dotnet run
Patcher Server (Patch Delivery):
cd FishMMO-WebServers/PatcherASP.NET/Patcher
dotnet build
dotnet run
WebGL Server (Static File Serving):
cd FishMMO-WebServers/WebGLServerASP.NET/WebGLServer
dotnet build
dotnet run
Place a Patches/ directory alongside the Patcher server containing your generated patch ZIP files.
Running Multiple Scene Servers
Each SceneServer needs a unique port. Copy SceneServer.cfg and change the port:
# SceneServer A — Port 7790
ServerName=Scene Server A
Port=7790
# SceneServer B — Port 7791
ServerName=Scene Server B
Port=7791
Launch each with ./GameServer SCENE from its own working directory (or pass a custom config path). The WorldServer will distribute characters across all registered scene servers.
Scene servers are not bound to a world server. Each one pulls from a single global pending-scene queue, so any scene server may end up hosting scenes for any world server. This is what lets you scale zone capacity independently of world instances, and it is why scene rows, population counts and routing caches are all keyed by world server ID as well as scene name.
Multiple instances of one open-world scene are channels. When every instance of a scene is at its authored MaxClients, the WorldServer enqueues another and holds arriving clients in its routing queue (they see a position and a reason) until it is ready. Players move between instances of their current scene through the channel picker; the switch is refused in combat and rate-limited per character via characters.last_channel_switch_utc, so it survives the disconnect the switch performs.
An empty instance is unloaded once it has been stale for StaleInstanceSceneTimeout minutes (the templates ship 2), which also deletes its scene row. Open-world scenes use the separate, longer StaleSceneTimeout.
Locking a server and scheduling maintenance
Both world_servers.locked / world_servers.shutdown_at_utc and the matching scene_servers
columns are the authority for a server's lifecycle state. Servers never write them on their
own: each one reads its own row back on every pulse (~5s) and adopts what it finds. Anything that
can write those rows therefore controls the servers — the in-game commands below, a CMS, or plain
psql — exactly as kick_requests already works for accounts.
Locking drains, it does not evict. A locked server keeps the players it has and stops receiving new ones: a locked world refuses logins, and a locked scene server is skipped by the world server's open-world routing and stops taking scene-load requests. Instance routing deliberately ignores the lock, because a character bound to a dungeon can only go to the one server hosting it — refusing there would evict them from their instance rather than drain them.
A locked world still admits accounts above AccessLevel.Player, so locking a world for
maintenance does not lock out the people doing the maintenance. A world with a shutdown
scheduled admits nobody.
In-game commands, all requiring AccessLevel.Admin (3):
| Command | Effect |
|---|---|
/admin status |
Reports this scene server's state and that of the worlds it hosts scenes for |
/admin lockserver |
Locks the character's world server — no new logins except elevated accounts |
/admin unlockserver |
Reopens the world |
/admin shutdown <seconds> |
Locks the world and schedules its shutdown. Players are warned as the countdown passes 15m / 10m / 5m / 2m / 1m / 30s / 10s, then disconnected with a maintenance notice |
/admin stopshutdown |
Cancels the scheduled world shutdown. Leaves the world locked — reopening is a separate decision |
/admin lockscene |
Locks the scene server the admin is standing on |
/admin unlockscene |
Reopens that scene server |
/admin shutdownscene <seconds> |
Locks and shuts down that one scene server; its players are warned, disconnected, and can rejoin on another |
/admin stopshutdownscene |
Cancels it, leaving the scene server locked |
lockworld / unlockworld are accepted as aliases of lockserver / unlockserver.
Player commands for instanced content
A party may hold one dungeon instance at a time, so a run that is finished with — or that
went wrong — otherwise blocks the party from starting anything else until it empties and ages
out. These two commands exist so that is a decision rather than a wait. Both are available at
AccessLevel.Player.
| Command | Effect |
|---|---|
/leaveinstance (/exitinstance) |
Returns the character to the open world at the point it entered from. The system-level guarantee that instanced content cannot trap a player: a dungeon is expected to provide an exit teleporter, but that is content data, and a character bound to an instance is routed back into it on every login — so quitting is not an escape. Refused in combat, so it is not one either |
| Instance panel (Escape → Dungeon) | Shows the dungeon's name and difficulty, how long it has left, and everyone in it, marking who leads. Leadership is the owning party's leader, so it follows a promotion rather than staying with whoever opened the run — and it follows the party away from a leader who has logged out, so a run cannot be left with nobody able to manage it. The leader can remove others and can hide the run from the dungeon finder; anyone can leave, and everyone sees the visibility. Removal is immediate and returns the target to the open world at the point they entered from — it is not a disconnect, so they are not routed back into the instance they were just removed from |
| Dungeon finder (a dungeon entrance) | Lists the runs of that dungeon currently open at the chosen difficulty, with who is running each and how full it is. Joining one enters the dungeon and joins that group's party, so it is refused for anyone already in a party with somebody else. Opening a new run picks a difficulty and whether to list it for strangers |
/closedungeon (/closeinstance) |
Ends the party's current instance. From inside, everyone in it is returned to the open world and the scene is unloaded; from outside, the instance is retired only if it is empty, and the hosting scene server reclaims the scene on its own. Party leader only, since it removes everybody — a character with no party is its own leader. Refused while anyone inside is in combat, because the eviction path deliberately skips state validation (there is nothing to validate against when a lifetime cap expires) and reaching it from a command would otherwise make it an instant escape from a losing fight |
Because this and the instance panel's controls are leader-only, a party whose leader logged out
used to be unable to close, hide or manage the run it was holding — and since a party may hold
only one instance, that locked every member out of every dungeon until the row aged out. Party
leadership now moves off a holder who is not logged in anywhere, once they have been gone long
enough that it cannot be confused with the gap while somebody walks through a teleporter. See
leadershipAbsenceGraceSeconds on the scene server's PartySystem asset, which must comfortably
exceed the slowest scene load on the shard.
Commands are refused silently to anyone below AccessLevel.Admin — the server gives no
indication the command exists, so an unprivileged player cannot probe for command names — but
every refused attempt is logged at warning level with the character and account that tried it.
Note on automatic restarts. The AppHealthMonitor restarts server processes. A world server deregisters itself on a graceful shutdown, so it comes back clean; a scene server clears its own consumed shutdown (and the lock that came with it) as it exits, for the same reason. Either way a scheduled shutdown will be followed by a restart unless you stop the monitor first.
FishMMO-AppHealthMonitor
The AppHealthMonitor is a daemon that monitors, auto-restarts, and health-checks your server processes. It performs process liveness checks, TCP/UDP/WebSocket port probes, CPU and memory threshold monitoring, exponential-backoff restarts, and circuit breaker protection.
Build
cd FishMMO-AppHealthMonitor
dotnet build -c Release
Configure appsettings.json
Place appsettings.json in the AppHealthMonitor's working directory:
{
"Headless": false,
"Applications": [
{
"Name": "LoginServer",
"ApplicationExePath": "/path/to/GameServer",
"MonitoredPort": 7770,
"PortTypes": ["TCP", "UDP"],
"LaunchArguments": "LOGIN",
"CheckIntervalSeconds": 30,
"LaunchDelaySeconds": 2,
"InitialHealthCheckDelaySeconds": 30,
"PostLaunchSettleDelaySeconds": 5,
"GracefulShutdownTimeoutSeconds": 10,
"InitialRestartDelaySeconds": 5,
"MaxRestartDelaySeconds": 60,
"MaxRestartAttempts": 5,
"CircuitBreakerFailureThreshold": 3
},
{
"Name": "WorldServer",
"ApplicationExePath": "/path/to/GameServer",
"MonitoredPort": 7780,
"PortTypes": ["TCP", "UDP"],
"LaunchArguments": "WORLD",
"CheckIntervalSeconds": 30,
"LaunchDelaySeconds": 2,
"InitialHealthCheckDelaySeconds": 30,
"PostLaunchSettleDelaySeconds": 5,
"GracefulShutdownTimeoutSeconds": 10,
"InitialRestartDelaySeconds": 5,
"MaxRestartDelaySeconds": 60,
"MaxRestartAttempts": 5,
"CircuitBreakerFailureThreshold": 3
},
{
"Name": "SceneServer",
"ApplicationExePath": "/path/to/GameServer",
"MonitoredPort": 7790,
"PortTypes": ["TCP", "UDP"],
"LaunchArguments": "SCENE",
"CheckIntervalSeconds": 30,
"LaunchDelaySeconds": 2,
"InitialHealthCheckDelaySeconds": 30,
"PostLaunchSettleDelaySeconds": 5,
"GracefulShutdownTimeoutSeconds": 10,
"InitialRestartDelaySeconds": 5,
"MaxRestartDelaySeconds": 60,
"MaxRestartAttempts": 5,
"CircuitBreakerFailureThreshold": 3
},
{
"Name": "IPFetch Server",
"ApplicationExePath": "/path/to/IpFetchServer",
"MonitoredPort": 0,
"PortTypes": [],
"LaunchArguments": "",
"CheckIntervalSeconds": 30,
"LaunchDelaySeconds": 2,
"InitialHealthCheckDelaySeconds": 30,
"PostLaunchSettleDelaySeconds": 5,
"GracefulShutdownTimeoutSeconds": 10,
"InitialRestartDelaySeconds": 5,
"MaxRestartDelaySeconds": 60,
"MaxRestartAttempts": 5,
"CircuitBreakerFailureThreshold": 3
}
]
}
| Key | Description |
|---|---|
Headless |
true for production (auto-starts monitoring, no interactive console). false for development. |
Name |
Friendly name shown in logs and status command. |
ApplicationExePath |
Absolute or relative path to the executable. |
LaunchArguments |
Server type selector: LOGIN, WORLD, SCENE, or empty for web services. |
MonitoredPort |
Must match the port in the corresponding .cfg file. 0 = process-only monitoring. |
PortTypes |
Health check protocol(s): TCP, UDP, WebSocket. |
CheckIntervalSeconds |
Probe interval (minimum 5). |
InitialHealthCheckDelaySeconds |
Delay before first probe after launch (minimum 1). |
PostLaunchSettleDelaySeconds |
Pause after launch/restart before resuming probes. |
CircuitBreakerFailureThreshold |
Consecutive failures across launches that trip the circuit breaker. |
InitialRestartDelaySeconds |
Base delay for exponential backoff. |
MaxRestartDelaySeconds |
Cap for exponential backoff. |
MaxRestartAttempts |
After this many restarts, the circuit breaker may trip. |
CpuThresholdPercent |
CPU usage above this percentage counts as a resource failure. Production templates use 80. |
MemoryThresholdMB |
Memory usage above this counts as a resource failure. Production templates use 1024 for game servers, 512 for web services. |
ResourceCheckFailureThreshold |
Consecutive CPU/memory breaches before the process is restarted. Production templates use 2. |
HealthCheckHost |
Host the port probe connects to. 127.0.0.1 for loopback-bound servers. |
PortCheckTimeoutMs |
TCP/UDP probe timeout. Production templates use 2000. |
WebSocketCheckTimeoutMs |
WebSocket probe timeout. Production templates use 5000. |
GracefulShutdownTimeoutSeconds |
Time allowed for a graceful stop before escalating. |
ForceKillTimeoutSeconds |
Time allowed for the forced kill to take effect. |
LaunchDelaySeconds |
Delay before launching the process. |
The example above is trimmed for readability. The shipped templates in
FishMMO-Setup/{Development,Production}/appsettings.AppHealthMonitor.jsonalso setCpuThresholdPercent,MemoryThresholdMB,ResourceCheckFailureThreshold,HealthCheckHost,PortCheckTimeoutMs,WebSocketCheckTimeoutMs, andForceKillTimeoutSeconds— start from those rather than from this snippet. The Production template sets"Headless": trueand paths under/opt/fishmmo/.
Console Commands (Headless = false)
| Command | Description |
|---|---|
help |
List all commands |
start |
Start monitoring all configured applications |
stop |
Gracefully terminate monitored applications and return to the waiting state |
force-kill |
Immediately terminate all monitored applications, bypassing graceful shutdown |
force-restart |
Immediately terminate and then restart all applications |
status |
Show status of all monitored applications (PID, state, restart/failure counters) |
shutdown |
Gracefully shut down the daemon and all applications |
exit |
Alias for shutdown |
The commands operate on all configured applications — none of them take a per-application name argument.
Running as a Systemd Service (Linux)
Create /etc/systemd/system/apphealthmonitor.service:
[Unit]
Description=FishMMO Application Health Monitor
[Service]
Type=simple
User=fishmmo
WorkingDirectory=/opt/fishmmo/AppHealthMonitor
ExecStart=/opt/fishmmo/AppHealthMonitor/AppHealthMonitor
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now apphealthmonitor
Optional Services
FishMMO-DiscordBot
The Discord bot bridges in-game chat with a Discord guild, provides account linking, dynamic channel management, and moderation commands.
Prerequisites
- A Discord application with a bot token (create at https://discord.com/developers/applications)
- Required privileged intents:
MESSAGE CONTENT,GUILD MEMBERS - Required scopes:
bot,applications.commands - Bot must have permissions: Read/Send/Manage Messages, Manage Channels, Timeout/Ban Members
Build & Configure
cd FishMMO-DiscordBot
dotnet build FishMMO-DiscordBot.sln -c Release
Create appsettings.json in the output directory:
{
"Discord": {
"Token": "",
"DefaultGuildId": 0
},
"ConnectionStrings": {
"Npgsql": "Host=localhost;Port=5432;Database=fishmmo;Username=;Password=;"
},
"ChatPollingIntervalSeconds": 5,
"BridgeMessageMaxLength": 2000,
"RateLimiting": {
"MaxMessagesPerWindow": 10,
"WindowSeconds": 60
}
}
| Key | Description |
|---|---|
Discord:Token |
Bot token. Set via the Discord__Token environment variable — an empty token fails at startup, and the token must never be committed. |
Discord:DefaultGuildId |
Discord guild snowflake. 0 disables it; dynamic channel creation from game chat needs a real ID. |
ConnectionStrings:Npgsql |
PostgreSQL connection. Set via ConnectionStrings__Npgsql in production. |
ChatPollingIntervalSeconds |
How often the bot polls the game chat tables (default 5). |
BridgeMessageMaxLength |
Maximum bridged message length (default 2000, Discord's limit). |
RateLimiting:MaxMessagesPerWindow / WindowSeconds |
Per-window message cap (defaults 10 per 60 seconds). |
The templates live at FishMMO-Setup/{Development,Production}/appsettings.DiscordBot.json.
Run
dotnet run --project FishMMO-DiscordBot/FishMMO-DiscordBot.csproj
FishMMO-CMS
An ASP.NET Core 8.0 web API for account management — the out-of-game counterpart to the in-game authentication the LoginServer performs. It exposes player self-service routes under api/Account (register, verify, change-password, 2fa/setup) and operator routes under api/Admin (accounts/search, ban, unban, access-level, revoke-tokens, reset-2fa, force-password-reset).
This is not a news CMS. The launcher's news panel is fetched from
Constants.Configuration.LauncherHtmlUrl, baked at build time fromGeneratedHostConfig.LauncherHtmlUrl(CI substitutesFISHMMO_ROOT_DOMAIN). Nothing in this project serves it.
Status: scaffold — do not deploy. The routes exist and are reachable, but every handler body is a
TODOstub returning a canned success response. There is no database wiring, no auth service registration, no persistence, no caller authentication onapi/Account, and no authorization at all onapi/Admin— any caller could invoke every administrative route. Keep it off any network until those are implemented.
cd FishMMO-CMS
dotnet build FishMMO-CMS.slnx -c Release
dotnet run --project FishMMO-CMS.Server
Configuration is layered: the build copies FishMMO-Setup/Development/appsettings.CMS.json (and the Production variant) into the output directory, then ./appsettings.json and ./appsettings.{Environment}.json in the working directory override it. Edit the templates under FishMMO-Setup/, not the copies in bin/. Swagger UI is served at /swagger in the Development environment only.
See FishMMO-CMS/README.md for the full endpoint table and implementation status.
Client Setup
Building the Client
- Open the FishMMO-Unity project in Unity.
- Open the FishMMO Dashboard (
FishMMO → FishMMO Dashboard, Ctrl+Shift+D) and select Build in the Core category. - Set Build Type to Client, pick the OS Target and Environment, then click Apply Platform Settings.
- Click Build Addressables, then Build Game. (Addressables must be built first — the player build depends on those bundles.)
- The output goes to the configured build directory with the client executable, data files, and Addressable bundles.
Build Type, OS Target, and Environment can also be set from the
FishMMO → Build → …menus. The build actions themselves live only in the Dashboard.
Client Launcher Flow
Launcher starts — Fetches the news HTML from
Constants.Configuration.LauncherHtmlUrl(baked at build time fromGeneratedHostConfig.LauncherHtmlUrl, which CI substitutes fromFISHMMO_ROOT_DOMAIN) and extracts the configureddivclass.IHtmlContentFetcheryields the parsed node, not formatted text: UI Toolkit's rich text has no link tag, so a news link cannot be markup inside a label — it has to become a real element that can receive a click, which means the view walks the tree itself rather than being handed a formatted string. Every href is routed throughLauncherLinkPolicy, which parses the URI and allows only absolutehttp/httpsbefore it reachesApplication.OpenURL. News is cosmetic and does not block the version check.IsNewsUrlConfigureddecides whether the request is worth issuing at all. An empty URL and aFISHMMO_SENTINEL_PLACEHOLDERthe build never substituted mean the same thing — no feed — so both are treated alike, the same sentinel conventionClientCertificatePinninguses to screen unsubstituted values out of the pin set; without it a working copy fetched a host that cannot resolve and reported a failure on every run for a feed that was never configured. On both that path and a genuine fetch failure the pane is filled withClientLauncher.newsFallbackSummary, a serialized[TextArea]field a deployment can reword without a code change. It is filled rather than hidden because hiding it collapsed the panel into a header stacked directly on a footer, which reads as a broken window rather than as a launcher with no news today.Version check — Queries
{APIHost}latest_version?from={clientVersion}. If a patch is available, the launcher downloads it into{patch directory}/{from}-{to}.zip(seeLauncher.PatchDirectorybelow;<install root>/Patchesby default), verifies it against thesha256the server reported, then hands off to the external Updater and quits so the Updater can replace the client binaries. Download progress carries aDownloadStatssnapshot — bytes transferred against the manifest's expected total, current throughput, and an ETA — so the bar shows a real total from its first frame rather than waiting on response headers. IfLauncher.AutoUpdateis off, the launcher parks on an Update button with the patch size instead of starting a download the player has not agreed to. If the server reportspatch_available: false, the launcher entersPatchUnavailable— retrying cannot help, the player needs a full reinstall.Play — The launcher loads the
ClientPostbootscene, callsStartBootstrap()on itsClientPostbootSystem, and unloads its own scene.Login — Client connects to the LoginServer discovered via
api.fishmmo.com/LoginServer.QUIC/WebTransport Handshake — X25519 ECDH key agreement + stateless cookie challenge → AES-256-GCM encrypted session.
Auth — SRP-6a authentication handshake, optional TOTP 2FA.
Character Select — Choose or create a character.
World Entry — WorldServer routes the character to the correct SceneServer.
Gameplay — SceneServer handles all in-game simulation.
Launcher Rendering and Settings
The launcher renders through ILauncherView, an interface describing the presentation surface in terms of intent (show this status) rather than widget manipulation, so all version-check and patch logic stays in ClientLauncher and never branches on what is drawing it. UITKClientLauncher is the only implementation. The uGUI adapter and the HtmlToTmpTextConverter behind it were deleted with the UI Toolkit conversion, along with the scene's Canvas root, so ResolveView() has nothing to fall back to: an unassigned or wrongly-typed Launcher View Component is reported as an error and leaves the view null. Saying so beats silently constructing a view over serialized fields that no longer exist.
The panel is a UIDocument bound to UILauncher.uxml and the shared PanelSettings. Its brand banner (Assets/Sprites/FishMMOBanner.png) has its height recomputed from its own resolved width on every geometry change, because USS has no aspect-ratio property and a fixed height on a full-width element can only be wrong: scale-and-crop fills the strip but slices the top and bottom off the artwork, scale-to-fit keeps it whole but leaves the panel background showing down both sides, and at 3.2:1 against a panel nowhere near that shape either was visible. The ratio is read from the assigned image rather than hardcoded, so replacing the artwork needs no code change. In the footer, Quit sits at the far left and Play/Connect at the far right, separated by the whole width of the panel — the affirmative action where the eye and the cursor finish, the destructive one far enough away that neither is hit by accident.
On handoff the launcher hides its UI and unloads its own scene. Addressables is asked first, because that is how a shipped build loaded the scene and its handle has to be released to free the bundle; SceneManager is the fallback, because Addressables only tracks scenes it loaded itself and silently no-ops for any other — which is exactly the editor case, where ClientLauncher.unity is opened directly and the launcher UI otherwise stayed on screen over the login screen for the rest of the session. UITKClientLauncher also watches sceneLoaded and dismisses itself the moment ClientPostboot arrives, whoever loaded it, as a second and independent guarantee against the same failure.
Settings live in the shared Configuration.GlobalSettings file alongside the game's other options, so the launcher and the in-game Options panel cannot end up with two stores. Every getter clamps: the file is plain text a player can edit, and a timeout of 0 or a retry count of 100000 would otherwise be honoured literally and look like a launcher bug.
| Key | Default | Range | Description |
|---|---|---|---|
Launcher.AutoUpdate |
true |
— | Start downloading an available update without asking. Off parks the launcher on an Update button showing the patch size — the difference between a convenience and a cost on a metered connection |
Launcher.RequestTimeout |
serialized field | 5–300 s | Per-request timeout for version checks and patch downloads |
Launcher.MaxRetries |
serialized field | 0–10 | Retries after an initial failure. Honoured by the patch download only — the news fetch is cosmetic and deliberately runs with none |
Launcher.RetryDelay |
serialized field | 0–30 s | Delay between retries |
Launcher.PatchDirectory |
(empty) | absolute path | Where patch archives are downloaded to and read from. Empty uses the install's own Patches folder. Created if missing; anything unusable falls back to the default with a warning. Not a "move the game" setting — the Updater patches files relative to its own location, so the install root is fixed by construction |
Launcher.WindowWidth / Launcher.WindowHeight |
(unset) | ≥ 480 × 360 | Last window size the player chose. Written shortly after a resize settles rather than at shutdown, because the Updater terminates the launcher rather than closing it. Clamped against the current display, so a size saved on a larger monitor cannot come back with the footer buttons off-screen |
On Windows, Browse opens the native folder dialog (NativeFolderPicker, a SHBrowseForFolder shell call). Unity exposes no runtime folder picker, so the button is hidden on other platforms — the path text field sets the folder on every platform and nothing is unreachable without it.
The launcher is a plain
MonoBehaviourand only exists in Standalone builds. In the Editor and on WebGL theClientPrebootbootstrap loadsClientPostbootdirectly, because neither can run the external updater. A transient-state watchdog (transientStateTimeoutSeconds, default120) recovers the UI if a launcher coroutine dies mid-flight.
Client Settings and Options
Every client setting lives in the same Configuration.cfg beside the client executable that the
launcher uses — one file, one store, one owner. ClientSettings creates and loads it, names every
key exactly once, clamps everything read out of it, and owns the single debounced write that puts
it back on disk.
When settings are loaded and applied
Loading happens in two phases, and the split matters.
BeforeSceneLoad—ClientSettingsBootstrapcreates and loadsConfiguration.GlobalSettingsbefore any scene'sAwake, so the first panel to register already has settings to read. It also creates thePlayerControlsasset and applies the player's saved key bindings — without enabling an action map, so the bindings are inspectable and editable from the login screen while nothing in the world becomes live early.MainBootstrapSystem.OnApplyClientBootSettings— raised during client preload, immediately after the bootstrap system installs its boot-time frame-rate cap and forcesvSyncCountto 0. The saved display mode, quality level, brightness, VSync, frame-rate cap, audio levels and interface scale are applied there.
The order is not incidental. Those two bootstrap lines are a default for a client with no
preference, and anything applied before them is silently overwritten by them — so the hook fires
after, and the player's choice always wins. A scene with no MainBootstrapSystem (the UI validation
and unit-test scenes) is covered by an AfterSceneLoad backstop; applying is idempotent either way.
Before this existed, nothing loaded the settings file at client start-up at all: the store was created lazily by whichever of the launcher or the Options panel asked first, and the Options panel ships closed. In a client started past the launcher, neither ran — key binding overrides were skipped without a word, panel positions were never restored, the theme loaded from nothing, and a saved resolution was never applied.
Brightness drives both RenderSettings.ambientLight and ambientIntensity, because which one
a scene reads depends on its ambient mode: ambientLight applies only under AmbientMode.Flat, and
every world scene is authored AmbientMode.Skybox, where it is ignored outright. Either way it is
re-applied on every sceneLoaded, since ambient is per-scene state baked into whichever scene is
active and the client loads several.
Writes are debounced, and there is exactly one pending write in the client. Configuration.Save
serialises and rewrites the whole file, so a slider bound straight to it rewrites the file once per
frame for as long as it is held. Everything — settings, window positions and launcher options alike
— coalesces onto a short quiet period, driven by a hidden DontDestroyOnLoad pump rather than by a
panel's Update, so a scene with no panels open cannot stop the clock on a write that is already
owed. It is forced out when the Options panel closes, on focus loss and pause, and on quit.
In the Editor the disk write is skipped — Constants.GetWorkingDirectory() resolves to the
repository root there rather than to an install directory — so settings behave normally in play mode
but do not rewrite the checked-out file. On WebGL it is not skipped: the working directory is
Application.persistentDataPath, an Emscripten IDBFS mount, and the write is followed by an
explicit IndexedDB sync — without which the file is written, read back correctly all session, and
gone by the next visit.
Numbers are stored culture-invariantly. Writing with the machine's own locale while reading
invariantly meant that on any comma-decimal system 0.75 was stored as "0,75" and read back as
75, the comma taken as a digit-group separator — so interface scale, brightness, volumes and
window positions all round-tripped to roughly a hundred times their value before being clamped.
Options panel
FishMMO → Options (or the O key, or Escape → Menu → Options) opens a five-tab panel. Every row
that belongs to a list — audio channels, gameplay toggles, theme colours, key bindings — is
generated from the table that defines it rather than authored in the UXML, so a configuration key
cannot be lost by editing a scene.
| Tab | Contents |
|---|---|
| Display | Resolution, refresh rate, fullscreen mode, quality level, brightness, frame-rate limit, VSync |
| Audio | Master volume; mute when unfocused. The other five channels are stored and applied but not offered — nothing in the client owns an AudioSource yet, and a slider that saves perfectly while changing nothing audible is worse than a missing one |
| Gameplay | Damage numbers, healing numbers, achievement popups, ignore party invites, ignore guild invites |
| Key Bindings | Every rebindable binding in the Player action map, with conflict detection |
| UI | Interface scale, window snap grid, window layout reset, the ten theme colours, and shareable UI profiles |
Display settings are staged, not live. Every other setting can be undone by the control that set it; a display mode cannot — pick one the monitor will not show and the player cannot see the control that would put it back. The three display dropdowns write to a pending selection, Apply commits it and arms a 12-second countdown, and Keep is the only thing that writes it to the file. Closing the panel with a mode unconfirmed restores the previous one immediately rather than leaving the player waiting out a countdown whose prompt is no longer on screen.
Configuration keys
| Key | Default | Range | Description |
|---|---|---|---|
Resolution Width / Resolution Height |
(unset) | a mode the display reports | Written only by Keep. A saved mode the display no longer offers is refused at boot and the current mode is kept |
Refresh Rate |
(unset) | Hz offered at that resolution | Display refresh rate. Separate from the render cap below — deriving one from the other capped every player at their monitor's rate |
Fullscreen |
FullScreenWindow |
a FullScreenMode value |
Stored as the enum value, not a dropdown index: the list is built per platform, so an index means a different mode on a build without exclusive fullscreen |
Quality Level |
(unset) | a level name | Stored by name, not index — quality levels can be reordered between builds and an index would silently select a different one |
Brightness |
1.0 |
0–1 | Scene ambient light — both ambientLight (Flat scenes) and ambientIntensity (Skybox scenes). Re-applied on every scene load |
Frame Rate Limit |
60 (the boot-time menu cap) |
tick rate – display rate, capped at 500 | Floored at the network tick rate: FishNet derives ticks from the update loop, so a lower frame rate cannot deliver them on schedule. With no preference stored the bootstrap cap stands, rather than jumping to the display's fastest mode — which is what made that cap dead on arrival. A saved value the display no longer offers falls back to the fastest available, since that player did express a preference |
VSync |
false |
— | While on, Application.targetFrameRate is ignored entirely and the frame-rate limit above does nothing. The panel says so |
Audio.Volume.Master |
1.0 |
0–1 | Applied to AudioListener.volume. Stored as the slider position; applied as its square, so the middle of the slider lands near the middle of the perceived range |
Audio.Volume.Music |
0.6 |
0–1 | Below the rest deliberately: it is the only channel that plays continuously, and a score mixed level with combat effects buries the cues a player reacts to |
Audio.Volume.Effects / .Ambient / .Interface / .Voice |
1.0 / 0.8 / 0.8 / 1.0 |
0–1 | Read through ClientAudioSettings.EffectiveVolume(channel) by anything that plays a sound. Stored and applied, but not currently offered in the panel — see ClientAudioSettings.PlayableChannels |
Audio.MuteWhenUnfocused |
false |
— | Silences the client while its window has no focus. Applied on top of Master rather than by writing zero into it, so the saved level survives alt-tabbing |
ShowDamage / ShowHeals / ShowAchievementCompletion |
true |
— | Floating combat text and achievement popups |
IgnorePartyInvites / IgnoreGuildInvites |
false |
— | Invitations are declined, not dropped: an invitation silently discarded leaves the inviter staring at a prompt that never resolves and the server holding an invitation that blocks the next one |
UI.Scale |
1.0 |
0.75–1.5 | Interface scale, applied by dividing the shared PanelSettings reference resolution. Restored to the authored value when Editor play mode ends, so a play session cannot dirty the asset — QualitySettings (current level and its vSyncCount) is protected the same way, for the same reason |
UI.SnapGridSize |
8 |
0–32 points | Grid that dragged panels snap to. 0 disables snapping |
UI.Panel.<name>.X / .Y |
(unset) | panel points | Where the player dragged each window. Re-clamped into the viewport on restore, so a position saved on a 21:9 monitor is still reachable on a 16:9 one |
InputBindingOverrides |
(unset) | JSON | Key binding overrides for the whole asset. A value that cannot be parsed is discarded with a log and the defaults are used — it used to abort input initialisation entirely, leaving the player in the world with no controls and no way to reach the panel that would reset them |
<Name>ColorR/G/B/A |
(unset) | 0–255 | The ten themeable colours. Presence is decided by the R channel, so a legitimately black colour is not mistaken for an absent one. Clearing one removes the keys rather than emptying them, so a reset does not leave forty dead lines in the file |
UI profiles
The UI tab can save the window layout, interface scale, snap grid and colour scheme to a file of
its own under <install root>/UIProfiles/<name>.cfg, and load one back. This is deliberately not
Configuration.cfg: that file holds the whole client's settings, including its API host, launcher
state and this machine's display mode, none of which is meaningful on another player's computer and
some of which is actively wrong there. A profile carries only the parts worth sharing, so it can be
handed to somebody else as a plain text file.
Configuration.cfg stays the source of truth. Loading a profile writes its keys into the global
store and saves; nothing reads a profile at runtime, so a profile that is later deleted cannot take
the player's interface with it. A profile is applied wholesale, including the absence of a key —
a window it says nothing about returns to where the stylesheet puts it, because merging somebody
else's layout over yours produces an arrangement neither of you has ever seen. Every value is
re-validated on the way in: panel coordinates are re-clamped into the viewport, the scale is clamped
to the range the slider offers, and a key the format does not define is ignored rather than copied
through.
Key bindings
The Key Bindings tab lists every rebindable binding in the Player action map, one row per
binding — composite parts (Move / up, Move / down, …) get their own rows, because that is where
the keys a player actually wants to change live. Gamepad rows are captioned as such and only accept
gamepad controls; keyboard rows only accept keyboard and mouse controls.
Three rules govern the prompt, and all three hold on every row:
- Escape cancels. It can never be bound to anything. The cancelling key press is consumed by the rebind, so it cancels the prompt without also closing the Options panel behind it.
- Backspace clears. It can never be bound to anything either. Pressing it while a row is
listening leaves that binding unbound — the action stops working until something is bound to it
again. The row's own
↺restores the key the game shipped with. - Duplicates are not allowed. A rebind that would put two bindings on one control is undone and reported, naming the binding that already holds the key. Two actions on one key produces a client where one of them appears to have stopped working with nothing on screen saying why, and the player who created it is the least likely person to suspect the settings screen.
Those three combine into the way keys are swapped: clear the binding that holds the key you want, then bind it. Without the clear, refusing duplicates would make a swap impossible — there would be no way to free a key.
The left mouse button is also unbindable. PerformInteractiveRebinding suppresses the events it
matches, so with the left button eligible the first click after starting a rebind — including the
click meant to cancel it — was swallowed and bound to the action instead. Other mouse buttons stay
bindable.
A per-row ↺ restores that row's shipped key, and is refused if doing so would create a duplicate.
Reset All Keys discards every rebind at once after a confirmation, and is the unconditional way
back: the state it produces is the shipped one and cannot collide with itself. Both it and the status
line sit outside the scrolling list, so neither is off-screen behind forty rows of bindings.
Escape cannot be captured, so Escape → Menu → Options is reachable no matter what a player binds.
Default bindings
| Action | Keyboard / Mouse | Gamepad |
|---|---|---|
| Move | W A S D |
Left stick |
| Look | Mouse | Right stick |
| Jump | Space |
A / Cross |
| Crouch | C |
L3 |
| Sprint | Left Shift |
Left trigger |
| Interact | E |
X / Square |
| Toggle Mouse Mode | Tab |
Select |
| Toggle First Person | F1 |
— |
| Cancel (interrupt cast) | Escape |
B / Circle |
| Close Last UI | Escape |
B / Circle |
| Menu | Escape |
Start |
| Chat | Enter, / |
— |
| Hotkeys 1–10 | 1–9, 0 |
— |
| Inventory | I |
— |
| Abilities | K |
— |
| Equipment | P |
— |
| Guild | G |
— |
| Party | Y |
— |
| Friends | T |
— |
| Achievements | J |
— |
| Factions | U |
— |
| Minimap | M |
— |
| Lore | L |
— |
| Pet | V |
— |
| Options | O |
— |
Escape drives Cancel, Close Last UI and Menu together by design — Cancel interrupts a cast, Close
Last UI closes the top panel, and Menu opens the menu when neither of the first two had anything to
do. That authored overlap is exempt from duplicate detection; overlaps a player creates are not.
Windows the server opens have no key, and that is deliberate: a merchant, bank, loot window, NPC dialogue, shrine, gathering node, trade container, mailbox or dungeon finder is opened by a server reply after it has validated an interaction with something in the world. A key would either open a window the server never populated or claim to open a mailbox the character is not standing in front of — and a player who found the empty window would reasonably report it as broken. The scene channel picker and the instance panel are reached from the game menu instead, because both are rare, deliberate acts rather than windows a player flicks in and out of.
Client TLS Certificate Pinning
The client pins TLS certificates to prevent man-in-the-middle attacks on API and WebTransport connections. Pins are IL-embedded at compile time via FishMMO-Unity/Assets/Scripts/Client/Security/CertificatePins.generated.cs -- no separate configuration file is needed.
Development builds: Empty pins are allowed (TOFU / trust-on-first-use mode). Release builds: At least 2 pins (active + backup) are required. The build will fail if fewer than 2 valid pins are configured or if sentinel placeholders are still present.
Generating pins via Unity Editor: Use the FishMMO Dashboard → Game Settings → Certificate Pins section. Fetch Pins connects to your live hosts over TLS and extracts SPKI SHA-256 hashes; Write Pins to File saves them to CertificatePins.generated.cs. This embeds the pins at compile time -- no separate config file or CI substitution is needed. Only hosts serving HTTPS on port 443 can be fetched — QUIC-only game hostnames will fail.
To generate SPKI pin hashes manually from your certificate:
# From a live server
openssl s_client -connect api.fishmmo.com:443 -servername api.fishmmo.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl base64
# From a certificate file
openssl x509 -in cert.pem -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl base64
Production Deployment
Linux Config Hardening
The installer applies several hardening measures for production Linux deployments:
- Core dump disabling — Prevents sensitive memory from being written to disk.
- ptrace restrictions — Restricts process tracing to root only (
kernel.yama.ptrace_scope = 1). - File permissions —
appsettings.jsonset to600or640, owned by the service user. - PostgreSQL hardening — Password auth only, restricted listen addresses, connection limits.
Firewall Configuration
The installer can automate host firewall rules for the ports NGINX requires:
- Linux: Uses
ufw(preferred) orfirewalld— adds rules for ports 80/tcp and 443/tcp. - Windows: Uses
netsh advfirewall— adds inbound rules for the same ports. - Menu: Web Server →
4| CLI:--component firewallorinstall-config.json"configureFirewall": true
Standard deployment (NGINX on same machine as backend servers):
Only NGINX's public ports need to be open. Backend servers bind to loopback and are invisible to the public network:
# HTTP/HTTPS (the only ports that need to be publicly accessible)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# UDP game ports — NGINX listens publicly, forwards to loopback game servers
sudo ufw allow 7770:7999/udp
Multi-machine deployment (NGINX on separate machine from backend servers):
Only the NGINX machine opens public ports. Backend servers only need to accept connections from NGINX's IP (private network):
# On the NGINX machine:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 7770:7999/udp
# On each backend server machine (restrict to NGINX's IP):
sudo ufw allow from <nginx-ip> to any port 8000,8080,8090 proto tcp
sudo ufw allow from <nginx-ip> to any port 7770:7999 proto udp
Backend HTTP ports (
8000,8080,8090) should never be opened to the public — NGINX is the only entry point.
Systemd Services
All FishMMO server components are designed to run as systemd services. Reference units:
GameServer Login
[Unit]
Description=FishMMO LoginServer
Requires=postgresql.service
[Service]
Type=simple
User=fishmmo
WorkingDirectory=/opt/fishmmo/LoginServer
ExecStart=/opt/fishmmo/LoginServer/GameServer LOGIN
Restart=on-failure
RestartSec=5
Environment=FISHMMO_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
GameServer World
[Unit]
Description=FishMMO WorldServer
After=fishmmo-login.service
Requires=fishmmo-login.service
[Service]
Type=simple
User=fishmmo
WorkingDirectory=/opt/fishmmo/WorldServer
ExecStart=/opt/fishmmo/WorldServer/GameServer WORLD
Restart=on-failure
RestartSec=5
Environment=FISHMMO_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
GameServer Scene
[Unit]
Description=FishMMO SceneServer
After=fishmmo-world.service
Requires=fishmmo-world.service
[Service]
Type=simple
User=fishmmo
WorkingDirectory=/opt/fishmmo/SceneServer
ExecStart=/opt/fishmmo/SceneServer/GameServer SCENE
Restart=on-failure
RestartSec=5
Environment=FISHMMO_ENVIRONMENT=Production
[Install]
WantedBy=multi-user.target
Enable all services:
sudo systemctl daemon-reload
sudo systemctl enable --now fishmmo-login
sudo systemctl enable --now fishmmo-world
sudo systemctl enable --now fishmmo-scene
sudo systemctl enable --now apphealthmonitor
Web Server Systemd Services (Installer-Generated)
The installer can automatically generate and register systemd units for the ASP.NET web servers (Web Server menu, option 5, or CLI --component systemd-services). It finds each server's publish directory, generates a .service file with the correct working directory, ExecStart, User, and EnvironmentFile/Environment entries, and runs systemctl enable --now.
Generated services:
fishmmo-ipfetch.service— IPFetch Web Server on port 8080fishmmo-patcher.service— Patcher Web Server on port 8090fishmmo-webgl.service— WebGL Web Server on port 8000
The generated service units set Environment=ASPNETCORE_ENVIRONMENT=Production and Environment=FISHMMO_ENVIRONMENT=Production, plus Restart=always / RestartSec=5 and After=network.target postgresql.service. User= is set to the account that ran the installer, not a dedicated service user — edit the unit if you want the servers to run as fishmmo. Each server must have been published to bin/Release/net8.0/publish first; the installer skips any it cannot find. Application secrets (gate secret, KEK, connection token HMAC key) are not set via environment variables or env files -- they are loaded from the database at startup by each server. Database credentials can be provided via EnvironmentFile=-/etc/fishmmo/db-secrets.env or the FISHMMO_DB_* environment variables.
# Verify after installer-generated registration:
systemctl status fishmmo-ipfetch fishmmo-patcher fishmmo-webgl
Port Reference
| Port | Service | Protocol | Listens On | Public Exposure |
|---|---|---|---|---|
| 80 | NGINX (HTTP → HTTPS redirect) | TCP | 0.0.0.0 |
Public |
| 443 | NGINX (HTTPS) | TCP | 0.0.0.0 |
Public |
| 5432 | PostgreSQL | TCP | 127.0.0.1 |
None (loopback only) |
| 6432 | PgBouncer | TCP | 127.0.0.1 |
None (loopback only) |
| 7770-7779 | LoginServer(s) | UDP (QUIC) | 127.0.0.1 by default |
Via NGINX L4 stream only |
| 7780-7789 | WorldServer(s) | UDP (QUIC) | 127.0.0.1 by default |
Via NGINX L4 stream only |
| 7790-7999 | SceneServer(s) | UDP (QUIC) | 127.0.0.1 by default |
Via NGINX L4 stream only |
| 8000 | WebGL Static Server | TCP (HTTP) | 127.0.0.1 |
Via NGINX reverse proxy only |
| 8080 | IPFetch Server | TCP (HTTP) | 127.0.0.1 |
Via NGINX reverse proxy only |
| 8090 | Patcher Server | TCP (HTTP) | 127.0.0.1 |
Via NGINX reverse proxy only |
All backend services bind to loopback by default. NGINX is the only component with public-facing listeners. Desktop/native clients connect to NGINX's public IP for both HTTP APIs and UDP game traffic — from the client's perspective, it talks to
api.fishmmo.com:443andgame.fishmmo.com:7770; NGINX transparently proxies to the loopback-bound backend. If a backend server runs on a different machine, change its bind address and the corresponding NGINX upstream/stream target.
Certbot Deploy Hook (TLS Renewal)
A certbot deploy hook handles the TLS certificate lifecycle. The script is operator-supplied — it is not shipped in this repository — and must perform the following:
┌────────────────────────────────────────────────────┐
│ certbot renew → new certs in /etc/letsencrypt/ │
│ ↓ │
│ certbot-fishmmo.sh (deploy hook) │
│ ├─ Validate renewed certificate │
│ ├─ Copy certs → /etc/fishmmo/certs/ │
│ ├─ chown fishmmo:fishmmo, chmod 640 │
│ ├─ nginx -s reload (zero-downtime) │
│ └─ systemctl restart fishmmo-login fishmmo-world │
│ + fishmmo-scene@* (rolling restart) │
└────────────────────────────────────────────────────┘
Install the hook:
sudo mkdir -p /etc/letsencrypt/renewal-hooks/deploy
sudo install -m 755 certbot-fishmmo.sh \
/etc/letsencrypt/renewal-hooks/deploy/fishmmo.sh
Test the renewal pipeline:
sudo certbot renew --dry-run --deploy-hook /etc/letsencrypt/renewal-hooks/deploy/fishmmo.sh
Architecture
Connection Pipeline
The full end-to-end connection flow is documented in CONNECTION_PIPELINE.md. Key phases:
| Phase | Description |
|---|---|
| 1. Launcher Startup | News fetch, version check, HMAC-signed API requests |
| 2. Server Discovery | IPFetch returns active login servers + one-time connection token |
| 3. QUIC/WebTransport | QUIC Initial → TLS 1.3 handshake → encrypted tunnel |
| 4. X25519 ECDH | Stateless cookie challenge → key agreement → AES-256-GCM session |
| 5. SRP-6a Auth | Zero-knowledge password proof, async worker channel, token issuance |
| 6. TOTP 2FA | 6-digit code or recovery code, failure lockout |
| 7. Token & Character | Token hash persisted, character CRUD, server list |
| 8. World Server | Token auth with HMAC verification, renewal token |
| 9. Scene Server | Same token auth flow, order-independent scene handshake, gameplay begins |
| 9a. Scene-Routing Queue | When there is nowhere to route yet — capacity, a scene still loading, or a combat-logout body only one instance can return — the client waits on the World server and is told its position and the reason |
| 10. Token Lifecycle | Auto-renewal after each auth, revocation on logout/pause/quit |
Server Initialization Order
The 5-phase server startup sequence is documented in SERVER_INITIALIZATION_ORDER.md:
| Phase | What Happens |
|---|---|
| 1. MainBootstrap | Unity loads bootstrap scene, initializes logging, enqueues ServerLauncher |
| 2. ServerLauncher | Determines server type (args), loads server scenes additively |
| 3. Server Scene | Server.Start() — finds NetworkManager, creates CoreServer, fetches external IP |
| 4. Finalize Setup | Data containers initialized, behaviours registered, physics + network started |
| 5. Runtime | Frame-based LateUpdate, client connections, periodic callbacks |
Key guarantee: RuntimeDataContainers are always initialized before ServerBehaviours — no race conditions between logic and data.
Gameplay Simulation
Combat and movement run through one prediction pipeline rather than per-subsystem ones. The
details are in
Assets/Scripts/Shared/Implementation/Entity/Prediction/README.md;
the shape is:
| Layer | What it does |
|---|---|
| One entry point | CharacterPredictionController owns the single [Replicate] / [Reconcile] pair on a character and drives every subsystem through it in Order: KCC movement (80) → buffs (85) → cooldowns (90) → equipment (93) → attributes (95) → abilities (100). The order encodes real data dependencies — contributors settle before the authoritative total that subsumes them is installed. |
| Input, not state | CharacterReplicateData carries only input, quantised on the producer before it predicts, so the owner never simulates at a precision the wire cannot carry. |
| A loss-detecting delta chain | CharacterReconcileData is delta-encoded against the previous state the server sent, with a one-byte sequence so a dropped datagram costs "no correction until the next snapshot" rather than "a wrong correction for a second". |
| Lag compensation | Hits resolve against where the caster's client saw its peers. The client contributes its full round trip plus its interpolation buffer; the server adds its own replicate queue depth and rewinds every character in the scene for the duration of one query. Every latency term cancels exactly. |
| Observers are told, not simulating | State forwarding is deliberately off. Observers receive position via NetworkTransform and everything else via explicit broadcasts, and the spawn payload carries an observer-shaped form of each so a late joiner reconstructs what a continuous observer holds. |
| Server-only physics | A physics query is not reproducible across peers, so every target selector and hit action runs on the server, inside the caster's rewound world, and caps its result only after ranking and per-body dedupe. |
Behaviour is pinned by roughly 1,080 EditMode tests
(Assets/UnitTests/README.md), including a
closed-loop fixture that composes both halves of the lag-compensation derivation across a spread
of round-trip times and asserts the server resolves to the position the shooter was rendering.
Flow Diagram
flowchart TB
subgraph Internet
Player["Player Client<br/>(Desktop / WebGL)"]
end
subgraph NGINX["NGINX Reverse Proxy (ports 80/443)"]
SSL["NGINX<br/>L7: TLS termination (HTTP APIs)<br/>L4: UDP forward (no TLS for QUIC)"]
end
subgraph WebServices["ASP.NET Web Services"]
IPFetch["IPFetch Server<br/>:8080<br/><i>Login server discovery</i>"]
Patcher["Patcher Server<br/>:8090<br/><i>Patch delivery</i>"]
WebGL["WebGL Server<br/>:8000<br/><i>Static file serving</i>"]
CMS["CMS Server<br/><i>Account management API<br/>(scaffold — stubs only)</i>"]
end
subgraph GameServers["Game Servers (GameServer executable)"]
Login["LoginServer<br/>:7770<br/><i>SRP-6a auth, account mgmt</i>"]
World["WorldServer<br/>:7780<br/><i>World state, routing</i>"]
Scene1["SceneServer<br/>:7790<br/><i>Gameplay simulation</i>"]
SceneN["SceneServer<br/>:779x<br/><i>Additional scenes</i>"]
end
subgraph DataLayer["Data Layer"]
PgBouncer["pgBouncer<br/>:6432<br/><i>Connection pooler</i>"]
PostgreSQL["PostgreSQL<br/>:5432"]
end
subgraph Monitoring["Monitoring"]
HealthMon["AppHealthMonitor<br/><i>Process lifecycle,<br/>health checks,<br/>auto-restart</i>"]
end
subgraph BuildPipeline["Build & Patch Pipeline"]
Unity["Unity Editor<br/><i>Build clients & servers</i>"]
PatchGen["PatchGenerator<br/><i>Create delta patches</i>"]
Updater["Updater<br/><i>Apply patches on client</i>"]
end
subgraph OptionalServices["Optional Services"]
DiscordBot["DiscordBot<br/><i>Chat bridge</i>"]
end
Player -->|"HTTPS :443"| SSL
Player -->|"QUIC/UDP :7770-7999"| SSL
SSL -->|"/loginserver"| IPFetch
SSL -->|"/latest_version<br/>/{version}"| Patcher
SSL -->|"/ (static)"| WebGL
SSL -->|"UDP stream :7770"| Login
SSL -->|"UDP stream :7780"| World
SSL -->|"UDP stream :7790+"| Scene1
SSL -->|"api/Account<br/>api/Admin"| CMS
Player -.->|"Direct UDP/QUIC<br/>(only if NGINX on<br/>separate machine)"| Login
Player -.->|"Direct UDP/QUIC<br/>(only if NGINX on<br/>separate machine)"| World
Player -.->|"Direct UDP/QUIC<br/>(only if NGINX on<br/>separate machine)"| Scene1
Login --> PgBouncer
World --> PgBouncer
Scene1 --> PgBouncer
SceneN --> PgBouncer
PgBouncer --> PostgreSQL
IPFetch --> PostgreSQL
World --> Scene1
World --> SceneN
HealthMon -.->|"Monitor & restart"| Login
HealthMon -.->|"Monitor & restart"| World
HealthMon -.->|"Monitor & restart"| Scene1
HealthMon -.->|"Monitor & restart"| SceneN
HealthMon -.->|"Monitor & restart"| IPFetch
DiscordBot -->|"Chat API"| Scene1
Unity --> PatchGen
PatchGen --> Patcher
Patcher -->|"Serves patches"| Player
Player -->|"Downloads & applies"| Updater
License
This project is licensed under the MIT License. See LICENSE for details.