FishMMO — Complete Feature List
Generated 2026-06-26 from the FishMMO-Dev monorepo. Updated 2026-08-15 against commit
630f975c.
Built on Unity 6000.3.2f1, FishNet, PostgreSQL, .NET 8.0, WebTransport (QUIC/HTTP3).Items marked scaffolded, planned, projected, or not yet wired are not shipped functionality — read those qualifiers literally.
Index
- FishMMO-AppHealthMonitor
- FishMMO-Art
- FishMMO-Auth
- FishMMO-CMS
- FishMMO-Database
- FishMMO-Dependencies
- FishMMO-DiscordBot
- FishMMO-Installer
- FishMMO-Logger
- FishMMO-Patcher
- FishMMO-Setup
- FishMMO-SharedUtility
- FishMMO-Unity — Client
- FishMMO-Unity — Server
- FishMMO-Unity — Shared
- FishMMO-WebServers
- FishMMO-WebTransport
FishMMO-AppHealthMonitor
Process supervisor daemon that launches, monitors, and auto-restarts FishMMO server executables.
- Process Liveness Monitoring — Verifies child processes are alive each check interval.
- TCP Port Health Check — TCP connect probe to confirm the monitored port is accepting connections.
- UDP Port Health Check — UDP send/receive probe to verify datagram delivery.
- WebSocket Health Check — Full WebSocket upgrade handshake probe. A generic capability of the supervisor (
WebSocketHealthChecker,PortType.WebSocket); FishMMO's own game servers are QUIC/UDP and are probed with the UDP checker. - CPU Threshold Monitoring — Samples per-process CPU% and triggers restart on sustained breach.
- Memory Threshold Monitoring — Samples per-process memory usage and triggers restart on sustained breach.
- Exponential Backoff Restarts — Failed processes restart with increasing delay (configurable initial → max, capped retries).
- Circuit Breaker — After N consecutive failures across launches, parks the application until manual intervention.
- Graceful Shutdown — Sends close signal to child process; force-kills if it doesn't exit within timeout.
- Interactive Console Commands —
start,stop,status,force-restart,force-kill,shutdown(aliasexit),help, registered throughCommandHandler/ConsoleCommand. - Headless Mode —
Headless: truein config disables stdin reads and starts monitoring immediately on launch (for systemd/Docker). Exits with a failure code when the headless monitoring cycle ends with all monitors exhausted. - Per-App Config Validation — Validates all settings at startup, rejects with precise error messages.
- Launch Delay Sequencing — Configurable per-app delay before launching the next application in sequence.
- Post-Launch Settle Delay — Pause after launch/restart before resuming probes (lets the process fully boot).
- systemd Integration — Handles both SIGTERM and SIGINT through the same graceful shutdown path. No
.servicefile is checked into the project; the README documents a reference unit inline, and FishMMO-Installer can generate and register one.
FishMMO-Art
Art and visual asset repository. Contains game art assets (models, textures, materials, animations, UI graphics) consumed by the Unity project. No code or configuration — purely creative assets.
FishMMO-Auth
Transport-agnostic .NET authentication library providing SRP-6a login, token auth, TOTP 2FA, and engine-independent authenticator cores. Split into three projects: FishMMO-AuthShared (DTOs, enums, crypto services, trackers), FishMMO-ClientAuth (ClientAuthenticatorCore), and FishMMO-ServerAuth (BaseAuthenticatorCore, SrpAuthenticatorCore, TokenAuthenticatorCore). All auth broadcast structs used by the Unity layer live in FishMMO-Unity/Assets/Scripts/Shared/Implementation/Network/Authentication/AuthenticationBroadcasts.cs.
Core / Protocol Contracts
- Bounded Concurrent Collections —
ArrivalOrderTracker<T>(O(1) insertion-order TTL),ExpiringKeyTracker<T>(debounce / rate-limit),LastSeenCacheTracker<TKey,TValue>(LRU-style last-seen cache). - Authentication DTOs — Engine-independent structs for all auth broadcast payloads.
- Auth Enums —
AccessLevel,AuthState,ClientAuthenticationResult. - Account Manager Interfaces —
IAccountManager<T>,ISrpAccountManager<T>,ITokenAccountManager<T>for auth-state storage and sweep.
Implementation / Authenticator Cores
- BaseAuthenticatorCore<TConnection> — Abstract server base: X25519 ECDH cookie challenge handshake pipeline, stale-auth TTL sweeps, per-IP and global handshake rate limiting, connection auth-state tracking.
- SrpAuthenticatorCore<TConnection> — LoginServer authenticator: bounded-channel SRP verify/proof workers, TOTP 2FA with per-username lockout, kick-request debouncing, per-IP/per-account rate limiting, auth token issuance.
- TokenAuthenticatorCore<TConnection> — World/Scene server authenticator: bounded-channel token auth worker, decrypt + verify + revocation-check pipeline, timing-equalization dummy-key path.
- ClientAuthenticatorCore — Full client-side auth state machine: SRP-6a + X25519 ECDH flow, cookie challenge echo, token auth path, key material cleanup.
Cryptographic Services
- HandshakeService — X25519 ECDH key agreement, stateless HMAC cookie challenge/verification with rollover, protocol version negotiation, IP normalization, key confirmation MACs, transcript hash binding with crypto-suite ID.
- SrpService — Encrypted SRP field handling with separated
SrpVerifyRequestBroadcast(client→server) /SrpVerifyResponseBroadcast(server→client) types. Registration encryption, TOTP payload encryption/decryption, deterministic fake-salt derivation (HMAC-SHA512) with startup-time charset/length validation. - TokenService — Full token pipeline: build → hash → encrypt → decrypt → partial-parse → verify with cross-check against pre-HMAC parsed IDs.
- CryptoHelper — Cryptographic backbone: HKDF, AES-GCM, HMAC-SHA256/SHA512, thread-safe
GcmNonceContext(shared across async workers viaInterlocked), X25519 ephemeral keypairs with small-order-point rejection, TOTP generation/validation with reflection-based OtpNet secret zeroization, recovery code PBKDF2 hashing (600K iterations, v2 envelope).
Security Features
- SRP-6a Authentication — Secure Remote Password protocol with encrypted verify/proof payloads and strict sequence ordering.
- Fake SRP Data Path — Deterministic per-username fake salt to reduce account-enumeration timing signal.
- TOTP Two-Factor Authentication — Per-username failure counting + lockout, semaphore-limited concurrency, recovery code hashing.
- Signed Auth Tokens — HMAC-SHA256 envelope with access level and expiration baked into verify flow.
- AES-GCM with AAD — All encrypted payloads bound to message type/version/sequence.
- Constant-Time Comparisons — All MAC/token checks use constant-time comparison.
- Secret Zeroization — Sensitive byte arrays cleared via
CryptographicOperations.ZeroMemory. - Per-IP Debounce —
ExpiringKeyTrackerat the handshake layer prevents cookie-spam attacks. - Global Handshake Rate Cap — Hard per-second limit across all connections.
- Token Revocation — Token hashes stored for revocation lookup; revocation check built into verify flow.
- Per-Account Login Lockout — Password guessing is limited per account (10 failures / 15 minutes), not merely per IP. The check runs at the proof step rather than at verify, deliberately, so the per-username fake-salt timing equalisation above is untouched and a locked account refuses identically to a wrong password — no enumeration oracle.
- Encrypted Two-Factor Recovery Payloads — The otpauth URI and recovery codes are written to disk under Argon2id (t=3, m=64 MiB, p=1) + AES-256-GCM, keyed from the account password, using the BouncyCastle assembly the client already references. The whole envelope header — magic, version, KDF parameters, salt, nonce — is the GCM AAD, so a cost-parameter downgrade fails the tag rather than being honoured. The password is genuinely in hand at write time (the setup broadcast arrives inside the same registration attempt), so no weaker key source was needed; a machine-bound key was rejected because it would not survive a reinstall, which is precisely when recovery codes matter. Migration from an existing plaintext file runs only from the one point holding a server-confirmed password — encrypting under a merely-typed one would succeed and silently destroy the codes — and the new envelope is written and verified before the plaintext is removed.
FishMMO-CMS
ASP.NET Core (net8.0) account-management web API — not a news/content CMS. Registers Swashbuckle and references FishMMO-ServerAuth and FishMMO-DB, and copies appsettings.CMS.json from FishMMO-Setup at build time.
Status: scaffolded, not implemented. Every controller action in this project is a stub — each one returns a placeholder and carries
// TODOcomments for the work it does not do. There is no database wiring, no authentication registration for the account endpoints, and no admin authorization on the admin endpoints. Nothing in this section is shipped functionality.
- AccountController (
api/Account) — Route stubs forPOST register,POST verify,POST change-password,POST 2fa/setup. TODOs cover SRP salt/verifier generation,IAccountServicepersistence, TOTP secret generation/encryption, recovery codes, and verification email delivery. - AdminController (
api/Admin) — Route stubs forGET accounts/search,POST accounts/{username}/ban,unban,access-level,revoke-tokens,reset-2fa,force-password-reset. Every action's first TODO is "Require admin authentication" — the endpoints are currently unauthenticated stubs. - appsettings.json Configuration —
CopyFishMMOConfigMSBuild target copiesFishMMO-Setup/Development/appsettings.CMS.json(and the Production variant when present) into the build output.
FishMMO-Database
Data-access library shared by all servers (Login/World/Scene), web services, and Unity builds. Centralizes EF Core DbContext, per-domain services, and monitoring.
Core Database Infrastructure
- IDatabase / Database — High-level orchestrator wrapping NpgsqlDbContextFactory + service registry. Consumed by all servers.
- IDatabaseServiceRegistry — Per-domain service lookup (
TryGet<TService>(out var svc)). - NpgsqlDbContext — EF Core DbContext with Npgsql PostgreSQL provider.
- NpgsqlDbContextFactory — Factory with connection interceptors driving ConnectionPoolMetrics + QueryPerformanceTracker.
- NpgsqlDbConfiguration — Builds connection string from
IConfiguration(Npgsql:*orConnectionStrings:NpgsqlConnection). - NpgsqlServiceRegistry — Wires all per-domain service implementations.
- AppSettings — Strongly-typed
appsettings.jsonbinder (Npgsql, QueryPerformanceTracking, Logging). DatabaseConfigurationHelper — Convenience helpers for IConfiguration builders. - DatabaseResult<T> — Uniform result envelope (
IsSuccess,ErrorCode,ErrorMessage,Data). - DatabaseErrorCodes — Stable error code enum returned via DatabaseResult.
- Layered Configuration —
appsettings.json→appsettings.{Environment}.json→ environment variables (with__nesting). - FISHMMO_ENVIRONMENT — Precedence-based environment selection (FISHMMO_ENVIRONMENT > DOTNET_ENVIRONMENT > ASPNETCORE_ENVIRONMENT).
- Schema Validation —
ValidateSchemaAsyncreports, without throwing, whether this database has applied every migration the entity model expects, andSchemaValidationResult.DescribeProblemnames the command that fixes it. Servers run it at startup concurrently with behaviour initialization and join it before the transport opens: pending migrations refuse startup (a server 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), while a check that could not run at all is a warning — unverified is not known-bad, and a failed diagnostic must not take down a server that is otherwise fine. Model drift is not detected: an entity changed with no migration generated leaves nothing pending and passes. A drift check lived here and never worked once — EF buildsModelSnapshot.Modelwith an empty convention set, so the relational model its differ needs is never attached and the comparison threw on every startup, and EF Core 5 exposes no supported way to rebuild it at runtime. It was removed rather than left reporting a failure forever (issue #162); the real fix is a CI check that scaffolds a migration and asserts it is empty. - Unit of Work —
IUnitOfWork/UnitOfWorkServicewrap a logical operation in one transaction; service calls made inside the scope reuse the ambient context.BeginAsyncis deliberately notasync: the ambient context lives in anAsyncLocal<T>, and the async state machine restores the execution context when the synchronous part of anasyncmethod completes, so a scope entered inside one is invisible to its caller. While it wasasync, every service call made "inside" a unit of work found no ambient context, created its own and committed independently — the unit of work then rolled back an empty transaction and reported success. Nothing had ever been atomic, and nothing said so. Splitting the synchronous scope entry out of the awaited transaction begin fixes it; re-merging the halves silently reintroduces it. Character creation, character deletion, character select and character load all believed they were atomic and were not. - Slot Versioning Contract — Documented on each item service. A container slot's occupancy is the row: vacating a slot hard-deletes it rather than flagging it, and the version-gated upsert may reclaim a soft-deleted row unconditionally (
WHERE deleted = TRUE OR EXCLUDED.version > version) because a deleted row holds no item and comparing versions against it is meaningless. The previous form soft-deleted and stamped the incoming version into the surviving row, and every ordinary item move passedlong.MaxValue"to ensure the delete succeeds" — leavingversion = 9223372036854775807behind, which no later write could exceed. Moving any item out of any inventory, bank or equipment slot once made that slot permanently unwritable for the life of the character, and everything later placed there was lost on relog. It needed no exploit and triggered in normal play. Rows already poisoned self-heal, because they are by definitiondeleted = TRUEand the reclaim clause takes them on the next write. uintBinding — Npgsql cannot bindSystem.UInt32at all, as a scalar or as an array; it throwsNotSupportedExceptionbefore the statement reaches the server, and the failure is recorded as a genericDATABASE_ERROR.BaseServicebinds raw parameters with no explicitNpgsqlDbType, so the CLR type is all Npgsql has to infer from. Everyuintreaching raw SQL is therefore projected tolong, and everyuint-backed column isbigint— solongbinds exactly. The batched path additionally cast::integer[], which does not truncate aboveint.MaxValuebut raises22003: integer out of range; it now casts::bigint[]to match the column. Fixing this once is not enough, because the batched and single-row writes are separate code. TheUNNESTbatch path was corrected first, which repaired character creation — while the single-rowPersistAsyncoverload, the oneCharacterInventorySystemcalls once per item on every equip and unequip, kept throwing. Both paths are now projected, acrossICharacterItemServiceandICharacterMailService, whoseSendMailAsynctook auint itemAttachmentAmountand so failed on every mail send — attachment or not, since the declared parameter type is what gets boxed.UInt32is the only unbindable CLR type in the data layer; theuint Versionfields onAccountEntity,AuthTokenEntityandLoginServerSigningKeyEntityare safe because they map to thexmin/xidsystem column as EF concurrency tokens and are never bound by hand.- UTC Timestamps — Every default and every raw-SQL write uses
timezone('UTC', CURRENT_TIMESTAMP)rather thanCURRENT_TIMESTAMP. The columns aretimestamp without time zonewhileCURRENT_TIMESTAMPis atimestamptz, so the bare form silently stored the session's local time for anything compared againstDateTime.UtcNow—last_pulsemost visibly, whose liveness query already compared in UTC.
Database Services (Npgsql/Services/)
- IAccountService — Account CRUD: create, fetch for login (SRP data), online status check, kick request persist, token hash persist, TOTP verify.
- Mail Attachments —
ICharacterMailServicecarries items on mail. An attachment is taken out of the sender's inventory into escrow when the mail is sent rather than referenced in place — a reference that is not removed is a duplication bug waiting to be found — andCharacterMailAttachmentDatarecords what a successful claim took off a mail, so the removal and the grant are one decision rather than two that can disagree. - Pet Persistence —
ICharacterPetService,ICharacterPetAttributeServiceandICharacterPetBuffServicepersist a summoned pet across sessions: which pet, its attribute values, and the buffs running on it. State is restored and staged onto the pet for application at spawn rather than applied to an entity that is not in the world yet. - ICharacterService — Character CRUD: save, load, delete, fetch by account, session claim/release (token-gated, with batched lease refresh and
FetchUnownedSessionsAsyncto name the claims a server has lost), inventory/equipment/bank/hotkey persist, and the persisted channel-switch cooldown —TryBeginChannelSwitchAsyncchecks and stamps in one statement and returns the timestamp it replaced, soRollbackChannelSwitchAsynccan restore it exactly when the transfer the claim was taken for does not happen. - IChatService — Chat message persistence and retrieval with channel, character, and server metadata.
- ILoginServerService — Login server registration, heartbeat pulses, signing key storage (AEAD-wrapped via deployment KEK).
- IWorldServerService — World server registration, heartbeat pulses, server listing, and operator lifecycle control (
SetLockedAsync,SetShutdownAsync,FetchControlStateAsync). Thelockedandshutdown_at_utccolumns are the authority: registration deliberately preserves them on conflict, andPulseAsyncreads them back (UPDATE … RETURNING) so the process adopts what an operator set rather than overwriting it. - ISceneServerService — Scene server registration, heartbeat pulses, pending scene queue, channel listing, and the same operator lifecycle control as the world server (
SetLockedAsync,SetShutdownAsync, read-back on pulse). A registration outlives a crash — it is only deleted on graceful shutdown — so callers judge liveness fromLastPulse, not from the row existing. - ICharacterItemService — One service over one
character_itemtable for every item a character owns, whatever container it is in. Inventory, equipment and bank were three slot-keyed tables and three services; a row is now keyed by the item's own id, withItemContainerType(Inventory/Equipment/Bank) andSlotas ordinary columns. That is what lets an item's identity survive a move between slots, a move between containers, and a relog — the propertyItem.IDand the attribute ledger both depend on (see the Item System). - IGuildService — Guild creation, membership, ranks, invitation persistence.
- IPartyService — Party creation, membership persistence.
- ICharacterFriendService — Friend list add/remove/query persistence. (Part of a wider per-character service family:
ICharacterAbilityService,ICharacterAchievementService,ICharacterArchetypeService,ICharacterAttributeService,ICharacterItemService,ICharacterBuffService,ICharacterFactionService,ICharacterGuildService,ICharacterHotkeyService,ICharacterItemCooldownService,ICharacterKnownAbilityService,ICharacterMailService,ICharacterPartyService,ICharacterPetService/ICharacterPetAttributeService/ICharacterPetBuffService,ICharacterQuestService,ICharacterSkillService.) - IKickRequestService — Kick request queue polling and processing.
- Auth & Deployment Services —
IAuthTokenService(token hash persist/revoke),ILoginServerSigningKeyService(AEAD-wrapped signing keys),ITwoFactorRecoveryCodeService,IConnectionTokenKeyService(one-time connection token keys for IPFetch),IDeploymentSecretService(database-stored deployment secrets, e.g. the signing-key KEK),IEmailQueueService(verification email queue),ISceneService(pending scene load/unload queue, scene-instance registry, availability and instance lookups, batched population pulses, the de-duplicatingEnqueueIfUnderOutstandingLimitAsync, the party-scopedEnqueueForPartyAsync(existence check and insert in one statement, so a party cannot create two instances at once; it blocks on the owning party as well as on member IDs, so a party whose opener has left cannot open a second copy of the dungeon its members are standing in) and its batched companionFetchCharacterInstancesAsync, the dungeon finder's browsableFetchJoinableInstancesAsync(public, non-full, enterable runs of one dungeon at one difficulty — including ones still Pending or Loading, because that is exactly the window in which a straggler is looking for the group that just opened it) andSetInstancePrivacyAsync(ownership re-asserted inside the UPDATE, so an authorisation that went stale between the caller's roster read and the write updates nothing instead of flipping another party's dungeon), and the two stale-row reapersDeleteStaleUnreadyAsync/DeleteByStaleSceneServersAsync),IGuildUpdateService/IPartyUpdateService(social update pumps).ICharacterPartyServiceadditionally answersFetchOnlineMemberIdsAsync— a party's members that currently hold a live session, joined against the character session state in one statement. A scene server knows which characters it hosts and nothing about the rest of the shard, so without it a party led by somebody who logged out is invisible to every server that could do something about it, and its members cannot invite, kick, promote, or close the instance they are holding open. - UnitOfWorkService — Ambient DbContext + transaction scope for multi-step atomic operations. Supports savepoints for nested atomicity inside a unit of work.
- BaseService Execution Wrappers —
ExecuteReadAsync,ExecuteWriteAsync,ExecuteTransactionAsyncwith retry logic, transient error classification (PostgreSQL error code mapping), and automatic SaveChanges. - BulkVersionConflictPolicy — How a batched, version-gated write treats a row that loses the version race, chosen per table because the right answer differs. Where the batch is one indivisible statement and a skipped row invalidates the rest — slot-addressed layouts like inventory, bank, equipment and hotkeys, where a half-applied move leaves items in two places at once — a conflict fails the batch. Where rows are independent facts, a row that loses to a newer write is correctly left alone and the rest proceed; for a batch spanning many characters, as the periodic save's does, the alternative would let one stale row discard everyone else's progress. Under either policy, more rows affected than supplied is thrown: that cannot happen to a correctly keyed statement and means the predicate matched something it should not have.
- BulkWriteResult — What a batched, version-gated write actually did, as distinct from whether it errored. Rows go missing between the caller's list and the database for two entirely different reasons and only one is benign, so
IPersistManyAction<T>.PersistAsyncreturns them separately rather than collapsing to a boolean:Filteredrows were never attempted — the character is deleted, a template is unresolvable, or two rows collided on the same key, none of which the database's state explains and all of which are worth surfacing — whileSupersededrows were attempted and lost the version race to something at least as new, which loses nothing and is routine under concurrency. - Convention Guards —
ApplyTimeCreatedConventionsskips entities with explicit defaults (prevents silent override ofQuestEntity'sDateTime.UnixEpoch).ApplyLogicalVersionConventionschecks for existing defaults before applying. - Npgsql Type Mapping —
List<int>properties natively map to PostgreSQLinteger[]columns;HasDefaultValueSql("'{}'")for empty array defaults.
Data Entities
- AccountData — Account credentials (SRP verifier, salt), email, 2FA state, verification status.
- CharacterData — Full character sheet: position, race, archetype, attributes, hotkeys, achievements, faction standings.
38b. CharacterItemData /character_item— One row per item, keyed by the item's own id, withItemContainerType(Inventory/Equipment/Bank) andSlotas ordinary columns. This replaced three slot-keyed tables (inventory, equipment, bank) and their three services with one table andICharacterItemService. Keying by slot meant an item had no identity of its own: moving it between slots or containers destroyed one row and created another, so nothing durable could be keyed by the item — which is exactly whatItem.IDand the attribute ledger'sModifierSource.Item(...)need. The single table also makes a cross-container move anUPDATEof two columns rather than a delete and an insert across two tables.
38c. CurrencyLedgerData — Append-only audit row per resolved currency movement. See the Currency System. - ChatData — Chat message with channel, content, character, server metadata.
- LoginServerData / WorldServerData / SceneServerData — Server registration and heartbeat entities.
- AuthTokenData — Token hash with expiration for revocation lookup.
- LoginServerSigningKeyData — AEAD-wrapped HMAC signing key per login server.
- KickRequestData — Admin-initiated kick request queue.
- SceneData — Pending scene load/unload requests.
- QuestData — Quest state persistence.
- TwoFactorRecoveryCodeData — Hashed 2FA recovery codes.
- IVersioned / VersionExtensions — Optimistic concurrency versioning on all entities.
Monitoring Infrastructure (Npgsql/Monitoring/)
- DatabaseHealthMonitor —
SELECT 1connectivity probe with Healthy/Degraded/Unhealthy classification. - ConnectionPoolMetrics — Runtime open connections, pool utilization %, driven by EF Core connection interceptors.
- DatabaseMetricsTracker — Success/failure/latency aggregates with summary reporting.
- QueryPerformanceTracker — Per-operation query performance with P95/P99 percentiles, slow query detection events, configurable tracking levels (None/Basic/Standard/Detailed/Full).
Unity Integration
- DatabaseHealthService — Unity MonoBehaviour wrapping the monitoring stack. Inspector-configurable health/pool/metrics check intervals. Exposes events for external alerting (Slack/PagerDuty). Context menu commands for manual health checks.
Exceptions
- DatabaseException — Typed database exception hierarchy:
DatabaseEntityNotFoundException,StaleStateException,DuplicateReplayException.
Database Migrator
- FishMMO-DB-Migrator — Standalone console tool for creating and applying EF Core migrations.
FishMMO-Dependencies
Centralised NuGet dependency library — single source of truth for third-party package versions across the entire solution.
- EF Core Stack (pinned 5.0.x) — EF Core, Abstractions, Relational, Design, Tools (all 5.0.17), EFCore.NamingConventions (snake_case), Npgsql 5.0.18 + Npgsql.EntityFrameworkCore.PostgreSQL 5.0.10. EF Core is intentionally pinned to 5.0.x for netstandard2.1 / Unity compatibility; the csproj carries an explicit warning that EF Core 5.0.x was compiled against older
Microsoft.Extensions.*assemblies, so mixing in 9.0.x surface APIs risksTypeLoadException/MissingMethodExceptionunder Unity's resolver — hard crashes on IL2CPP rather than warnings. - Microsoft.Extensions Stack (9.0.4) — Configuration (+ Json, Abstractions, Binder, EnvironmentVariables), DependencyInjection (+ Abstractions), Logging (+ Abstractions), Caching (Abstractions, Memory), Options, Primitives, Http (pinned to override the transitive 2.1.0 pulled by OpenAI), Bcl.AsyncInterfaces.
- Utility Libraries — srp 1.0.7 (SRP-6a), BouncyCastle.Cryptography 2.6.2, Otp.NET 1.4.1 (TOTP), HtmlAgilityPack, Humanizer, OpenAI, ZString, System.Collections.Immutable, ComponentModel.Annotations, DiagnosticSource, IO.Hashing (xxHash/Crc32/Crc64), Text.Json, Text.Encodings.Web, Threading.Channels, Runtime.CompilerServices.Unsafe.
- Redis Pins — StackExchange.Redis 2.8.0, Pipelines.Sockets.Unofficial 2.2.8, StackExchange.Redis.Extensions.Core 10.0.0 — transitively pulled by FishMMO-AuthShared, pinned here for solution-wide version consistency.
- FishMMO Sub-Library Project References — Builds and forwards FishMMO-AuthShared, FishMMO-ClientAuth, FishMMO-ServerAuth, FishMMO-DB, FishMMO-SharedUtility, and FishMMO-Logger so their DLLs land in Unity alongside the NuGet output.
- Post-Build DLL Copy — Output DLLs automatically copied to
../FishMMO-Unity/Assets/Dependencies/via theCopyDependenciesToUnityMSBuild target (cross-platform forward-slash paths). System DLLs excluded from copy to avoid Unity conflicts. - Stale DLL Sweep —
RemoveStaleDependenciesruns before the copy and clears the UnityAssets/Dependenciesfolder, so DLLs from removed NuGet packages do not linger.
FishMMO-DiscordBot
Standalone .NET 8 Discord bot that bridges in-game chat with a Discord guild.
- Game → Discord Chat Relay —
ChatPollingService(anIHostedServicetimer with aSemaphoreSlimreentrancy guard) polls the game database directly viaNpgsqlDbContextFactory, trackinglastProcessedChatId, and forwards new messages to the mapped Discord channels. There is no chat REST API in this path. Which channels may leave the game is an explicit allowlist (ChatRelayPolicy, configured underChatRelay:GameToDiscordChannels, defaulting to Say / World / Trade / Region). It previously excluded only the Discord channel itself and relayed everything else, so[Tell]whispers were republished to a public Discord channel with both character names and the full message body. Tell, Guild, Party, Discord and Command are additionally on aNeverRelayableset and are refused even if configuration names them, with an error logged — a config edit should not be able to start publishing private messages. - Discord → Game Chat Relay — Discord messages intercepted and pushed back to the game via
GameChatBridgeService. Author name and body are sanitised at the bridge and again server-side on relay (the chat table is shared; a row is not trusted because of where it came from), and Discord-sourced messages are no longer exempt from tab filtering.BridgeMessageMaxLengthwas 500/2000 against a client limit of 128, so every bridged message longer than 128 characters was being silently dropped by clients. - Account Linking —
link/unlinkcommands (LinkModule,AccountLinkingService,PendingLinkVerification): issues short-lived one-time codes redeemable in-game to link Discord ↔ FishMMO account. - Dynamic Channel Management — Creates/archives Discord channels in response to in-game events (party formed, guild created).
- Moderation Commands — Mute, unmute, ban, unban for the chat bridge (uses
BridgeBanService). - Admin Commands — Reload config, shutdown, diagnostics (owner/admin-only).
- Character Lookup — Query character info by name or Discord-linked account.
- Text Command Handling — All commands are Discord.Net text commands (
CommandService,ModuleBase<SocketCommandContext>,[Command("…")]).CommandHandlingServiceaccepts either a leading/character prefix or an @-mention. Note this is a message prefix, not a registered Discord application command — noInteractionServiceor slash-command registration exists in the project. ~34 commands across General, Admin, Moderation, Character, Link, Database, and CommandList modules (ping,help,commands,online,whois,inspect,getcharacter,getaccount,search,guild,channels,scenes,sceneservers,worldservers,status,kick,ban/unban,ban-bridge/unban-bridge,bridge-bans,mute-zone/unmute-zone,my-mutes,enable-cmd/disable-cmd,list-cmds,cmd-config,require-role/unrequire-role,cleanup,echo,link/unlink). - Rate Limiting — Per-user/per-channel sliding-window rate limiter to prevent spam from either side (
RateLimiterService). - Bridge Ban System — Tracks Discord users banned from the bridge; consulted before forwarding (
BridgeBanService). - Config File Watching —
BotConfigurationServicewatchesappsettings.jsonfor changes and propagates config at runtime. - Generic Host + DI — Built on
Microsoft.Extensions.Hosting; all services areIHostedServicewith full DI composition. - Database Read-Only Queries — Admin-gated database queries via
DatabaseModule. - Self-Documenting Help —
helpandcommandslist available commands, driven byCommandServicereflection over the registered modules (CommandListModule). Per-command enable/disable and role gating come fromCommandPermissionConfig.
FishMMO-Installer
Cross-platform .NET 8 console tool that automates the entire dependency and database installation pipeline. Supports interactive menu mode and CLI-driven non-interactive mode for headless/automated deployment.
Installation Targets
- Install DotNet EF Tool — Installs the
dotnet-efglobal tool for Entity Framework Core migrations. - Install ASP.NET Core Runtime — Installs the ASP.NET Core 8.0 runtime via package manager (Linux) or Hosting Bundle EXE (Windows). Dynamic URL resolution from .NET release metadata with hardcoded fallback.
- Install Visual Studio Build Tools — Windows-only C++ build tools for Unity IL2CPP compilation.
- Install PostgreSQL — Platform-native PostgreSQL installation (pacman, apt-get, dnf, yum, EnterpriseDB EXE).
- Install PgBouncer — PostgreSQL connection pooler installation and configuration (Linux systemd, Windows winget/choco).
- Install FishMMO Database — Creates PostgreSQL user, database, applies initial EF Core migration, grants permissions.
- Create New Database Migration — Generates and applies new EF Core migrations interactively.
- Grant User Permissions — Grants schema privileges to the FishMMO database user.
- Delete FishMMO Database — Destructive database teardown with typed confirmation (requires "DELETE").
- Install NGINX — Reverse proxy/SSL terminator installation and service registration (Linux systemd, Windows NSSM service).
- Deploy FishMMO nginx.conf — Atomically deploys the canonical nginx.conf with backup preservation and
nginx -tvalidation. - Install/Renew Let's Encrypt Certificate — SSL certificate provisioning via certbot (Linux) or win-acme (Windows), with staging mode support and automatic nginx.conf certificate path updates.
Interactive Menu
- Full Interactive Menu — Hierarchical menu system with numbered options, sub-menus per component group, and confirmation prompts.
CLI / Non-Interactive Mode
- CLI Argument Parser —
--help,--version,--component <name>,--non-interactive,--dry-run,--validate,--config <path>. Zero-arg invocation enters interactive menu (backward compatible). - Unattended Installation —
--non-interactive -f install-config.jsonruns a full dependency-ordered installation from a JSON manifest with no user prompts. - Single-Component Mode —
--component postgresqljumps directly to one component without navigating menus. - Dry-Run Mode —
--dry-runsimulates installation and prints what would happen without making changes. - Quickstart Template —
--quickstartshortcut for a recommended default installation profile.
Pre-Flight Checks
- Internet Connectivity Check — Probes dot.net in 10s before any download-dependent operation.
- Disk Space Check — Warns if less than 5 GB free on the target drive (Unity Editor + builds can consume 20+ GB).
- Memory Check — Reads
/proc/meminfoon Linux, warns if less than 2 GB RAM. - Admin/Sudo Access Check — Verifies passwordless sudo (Linux) or Administrator integrity level (Windows) before system-level installs.
- Port Conflict Detection — Checks ports 80, 443, 5432, 6432, 8000, 8080, 8090 for existing listeners before installing services.
Download Integrity & Progress
- SHA256 Checksum Verification — Every downloaded file verified against
checksums.json; corrupt/tampered files rejected. Already-downloaded files with valid checksums skip re-download. - Download Progress Bar — Console progress indicator with percentage and visual bar during large downloads.
- Dynamic .NET URL Resolution — Resolves the latest .NET SDK and ASP.NET runtime installer URLs from the .NET release metadata API; hardcoded constants as fallback.
Post-Install Validation
- Health Check Mode —
--validateruns checks against .NET SDK, ASP.NET runtime, PostgreSQL, NGINX, PgBouncer, systemd services, database connectivity, and disk space; prints a pass/fail report.
New Infrastructure Components
- Firewall Automation — Opens ports 80/tcp and 443/tcp via ufw or firewalld (Linux) or netsh (Windows). Menu option or
--component firewall. - Systemd Service Generation — Generates and registers systemd units for FishMMO ASP.NET web servers (fishmmo-ipfetch, fishmmo-patcher, fishmmo-webgl). Finds publish directories, generates
.servicefiles, runssystemctl enable --now. Menu option or--component systemd-services. - Dependency-Graph Orchestrator — Topological component ordering so dotnet-sdk installs before postgresql, postgresql before fishmmo-db, etc. Used by both non-interactive pipeline and single-component dispatch.
Security & Hardening
- Linux Config Hardening — Secure file permissions (
chmod 600), core dump disabling, ptrace hardening for production Linux deployments. - PostgreSQL Hardening — Rewrites
pg_hba.confto requirescram-sha-256on all TCP connections, setspassword_encryptionandlisten_addressesinpostgresql.conf, reloads viapg_reload_conf(). Idempotent via managed markers. - PgBouncer Configuration Generation — Generates
pgbouncer.ini(transaction pooling, scram-sha-256) anduserlist.txt(with SCRAM hash frompg_shadow) with secure file permissions. - Database Credentials File — Generates
/etc/fishmmo/db-secrets.env(systemdEnvironmentFile) and~/.config/fish/conf.d/fishmmo-secrets.fish(fish shell snippet) so database passwords never live in plain-text JSON. Application secrets (gate secret, KEK, connection token HMAC key) are stored in the database, not in env files. - AppSettings Secure Wizard — Interactive configuration wizard for all FishMMO components (Database, IPFetch, Patcher, WebGL, Discord Bot, CMS). Preserves unmanaged JSON keys across writes. Applies
chmod 600on all output files. - SecurityKeyInstaller — Generates CSPRNG keys (
RandomNumberGenerator.Fill, base64, round-trip validated) and writes them directly to the database over a superuserNpgsqlConnection, so no env file has to be copied between machines: the ClientGate secret and signing-key KEK intodeployment_secrets(client_gate_secret,signing_key_kek) and the connection token HMAC key intoconnection_token_keys(key_id='shared'). Superuser credentials come from the interactive prompt orFISHMMO_PG_SUPERUSER_PASSWORD. The matching client-side build constants (ClientApiSecret.generated.cs,CertificatePins.generated.cs,HostConfig.generated.cs) are generated separately from FishMMO Dashboard > Game Settings in the Unity Editor.
Build Automation
- Build All C# Projects — Discovers and builds all
.csprojfiles under the repo root with dependency-prioritized ordering (synchronous for low-priority projects, parallel for independent builds). Copies DLLs to Unity Dependencies. - Unity Build Automation — Headless Unity builds via
-batchmode -nographics -executeMethodfor Client/Server/Addressables. Resolves Unity executable path from environment variable, Unity Hub CLI, or filesystem probing. - Unity Hub + Editor Installation — Installs Unity Hub (Linux: apt/AUR, Windows: official installer) and Unity Editor versions with selectable build support modules via Unity Hub CLI.
Platform Support
- Cross-Platform — Windows 10/11 and Linux (Arch/CachyOS, Ubuntu/Debian, Fedora/RHEL).
- Package Manager Auto-Detection — pacman, apt-get, dnf, and yum auto-detected with appropriate update/install command templates.
- Platform Abstraction —
IPlatforminterface withWindowsPlatform/LinuxPlatformimplementations for shell command dispatch, privilege elevation, and command availability checks.
FishMMO-Logger
JSON-driven logging library used by all headless servers, the Discord bot, AppHealthMonitor, and Unity client builds.
- Static Log Facade —
Log.Info/Warn/Error/Debug/Trace/Critical(category, message)synchronous-friendly API. - Typed LogLevel Enum — Trace < Debug < Info < Warning < Error < Critical with per-sink filtering.
- Structured LogEntry — Immutable struct: timestamp, level, category, message, optional exception.
- File Sink with Rotation — Append-or-truncate file logging with byte-size-based rotation (timestamp-suffixed rollover).
- Email Sink via SMTP — Per-sink minimum level filtering (typically Error/Critical), TLS support.
- JSON Configuration — Single
logging.jsonfile with polymorphic{ Type, Config }entries. - Pluggable Sink Model —
ILogger+ILoggerConfiginterfaces; register custom sinks via factory before initialization. - Polymorphic Config Converter —
ILoggerConfigConverterfor System.Text.Json round-tripping of sink configs. - Console Formatter — ANSI / plain-text console formatting helpers.
- Unity Integration —
UnityLoggerBridge(captures Unity log callbacks into the facade, with anIsLoggingInternallyre-entrancy guard),UnityConsoleLoggersink, andUnityConsoleFormatter. These live in the Unity project underAssets/Scripts/Shared/Implementation/Bootstrap/Logging/, not in the FishMMO-Logger library itself, so the library stays engine-independent. - Async Shutdown —
Log.Shutdown()(asyncTask) drains and disposes all sinks gracefully. Bootstrap detachesUnityLoggerBridgebefore the async shutdown runs.
FishMMO-Patcher
Standalone .NET 8 updater (Updater/Program.cs, ~2,500 lines) that applies a versioned binary patch to a FishMMO client. Invoked by the launcher with -version=, -latestversion=, -pid=, -exe=.
- Single-Archive Patch Application — Applies exactly one archive per run:
Patches/{from}-{to}.zip, built from the-versionand-latestversionarguments. There is no patch chaining — if that specific archive is absent the updater logs the miss, restarts the client, and exits. - Patch Manifest Parsing — Reads
manifest.jsonfrom the ZIP intoPatchManifest(OldVersion,NewVersion,NewFiles,ModifiedFiles,DeletedFiles). - Binary Diff Application —
Patcher.Applyreconstructs each modified file from itsPatchDataEntryNamediff stream into a temp file. New files are verified againstNewHash(XxHash128) after extraction and deleted on mismatch. - Parallel File Operations — New and modified files processed concurrently via
Parallel.ForEachwith an exception bag that stops the loop on first failure. - Transactional Patching — Every replaced file copied to
.bakbefore the move; failure anywhere triggers a full rollback to the previous state. - Atomic File Replacement — Patched content written to unique temp files, then moved over originals in a finalization phase.
- Launcher Process Management — Terminates the launcher by PID before patching:
kill(SIGTERM)via alibcP/Invoke on Linux/macOS,Process.CloseMainWindow()on Windows, falling through to a forcedKill()on any path where the graceful request fails or is ignored. - Automatic Client Restart —
TryStartExecutableAndExitstarts the client executable on every exit path (success, failure, missing archive, already-current) and alwaysEnvironment.Exit(0)— the launcher treats a non-zero code as an updater failure. - Archive Lifecycle — The consumed archive is deleted on success so
Patches/does not accumulate; it is kept on failure so a retry does not require re-downloading. - Retry with Backoff —
TryDeleteFile/TryMoveFileretry with a fixed delay for transient file I/O errors before giving up. - Path Containment (zip-slip) — Every path built from a manifest entry passes through
Patch/PathContainment.csbefore it is touched: new files, modified files, deletions, both pre-create-directory passes, the patch-archive lookup, and theProcess.Starttarget. They previously used a barePath.Combine(WorkingDirectory, entry.RelativePath), so a hostile manifest could write — or delete — anywhere the process could reach. The checks layer NUL bytes, rooted/UNC/drive-relative forms, a segment scan on both separators (so..\is caught on Linux), trailing dot/space, alternate data streams, prefix-safe containment against a canonicalised root, and symlinked components, whichPath.GetFullPathdoes not follow. A rejected entry throws into the existing rollback so the whole patch fails, rather than being skipped — skipping would let a manifest selectively suppress files. Covered by 48 assertions including real symlinks and a regression proof of whatPath.Combinealone did. - Single-Writer Install Lock — A lock file under the install root makes one updater the only process working on it. Two updaters started against the same install — a double-click, a launcher that restarted, a scheduled check firing while a manual update runs — would otherwise interleave their file moves over the same tree, and the transactional rollback each one holds describes a state the other has already changed.
- Staging Directory + Append-Only Journal — Every file the patch displaces or creates is recorded in a journal inside the staging directory before the filesystem is touched, and the journal is
fsynced so it survives a power loss rather than sitting in a write cache. An updater that dies mid-apply leaves an install that is neither the old version nor the new one, and the.bakscheme alone cannot recover it because the process that knew what to restore is gone. The next updater to run replays the journal and puts the install back before attempting anything else. - Manifest Validation — Two classes of malformed archive are refused before any file is written: a manifest in which two entries write the same target path (the second silently overwrote the first, and the rollback then restored only one of them), and an archive whose manifest describes a different upgrade than the one requested — the archive is named
{from}-{to}.zip, but nothing had checked that its contents agreed, so a mis-named or substituted archive was applied to the wrong base version. - POSIX Permission Preservation — Permission bits are read from the original file and re-applied to the replacement, and a newly created file is detected as executable from its own content (ELF, Mach-O or shebang signature) rather than from its extension. On Linux and macOS a patched binary that lost its executable bit is an install that no longer starts, which the previous byte-identical replacement produced on every update.
- Bounded Parallelism — File work is parallelised to the machine's core count rather than unbounded, so a large patch does not saturate the I/O queue of the disk it is rewriting.
- Signed Version Manifests (Ed25519) — The patch server signs every
/latest_versionpayload and the client verifies before reading any field. The canonical form is the document with itssignaturevalue blanked, compared as received rather than re-serialised, so signer and verifier cannot disagree about key order or spacing. The previous construction was unsatisfiable: it appended the signature to the message being signed, which requires solvingsig = Sign(sk, stripped ‖ base64(sig))— a fixed point of a hash-driven function over a 64-byte value, roughly 2^256 work. It survived unnoticed because nothing had ever signed a manifest, so the verifier had never been handed a document meant to pass.ApiPinUpdateSidecarused the identical construction, meaning certificate pin updates could never have verified either; both now share the correctedEd25519ManifestVerifier. A verifier that cannot locate the field it is verifying now fails instead of falling back to a second canonical form.
FishMMO-Setup
Configuration templates and reference files for deployment environments.
nginx.conf — Reverse Proxy
- UDP Stream Proxy (L4) — Raw UDP forwarding for game ports 7770–7999 via
stream {}block. Auto-generated per-port configs viagen-fishmmo-stream-config.shwith atomic replacement andnginx -tvalidation. Zero-copy packet forwarding; no TLS termination at proxy. - HTTP/HTTPS Gateway (L7) — TLS 1.2/1.3 termination with Let's Encrypt certificates, HSTS (6 months + includeSubDomains), modern cipher suite (ECDHE+AESGCM:ECDHE+CHACHA20), OCSP stapling.
- Virtual Hosts —
play.fishmmo.com(WebGL client),api.fishmmo.com(IPFetch + Patcher),game.fishmmo.com(444-close — game traffic is UDP-only). Catch-all returns 444. - Rate Limiting —
limit_req_zoneper-endpoint: 10r/s API, 2r/s patch downloads, 30r/s WebGL.limit_conn_zoneper-IP: 20 conn WebGL, 10 conn API, 3 conn patch. HTTP 429 withRetry-After. - Security Headers — CSP (WebGL:
wasm-unsafe-eval,connect-src 'self' wss://game.fishmmo.com:* https://game.fishmmo.com:*),X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy,Access-Control-Allow-Originfor API. Browser WebTransport is permitted by thehttps://entry; thewss://entry is a leftover from the retired WebSocket transport and grants nothing that is still used. - Performance —
sendfile on,tcp_nopush on,tcp_nodelay on,gzip onwithgzip_proxied any(notoff),gzip_typestuned for text/wasm,keepalive_timeout 65s. - Hardening —
server_tokens off,client_max_body_size 64kglobally (raised from nginx's 1m default being too restrictive for POST; the patch download location overrides to0/ unlimited),client_body_timeout 10s,client_header_timeout 10s.
Server Configuration (.cfg files)
- LoginServer.cfg — ServerName, MaximumClients (4000), Address (127.0.0.1, all traffic via nginx), Port (7770), TLS
CertificatePath/PrivateKeyPathfor the server's own QUIC/TLS termination,AllowedOrigins(browser WebTransport CORS allow-list; empty = allow all, development only),ConnectionTokenHmacKeyBase64(left blank — keys load from theconnection_token_keystable), and SMTP config (Smtp:Host/Port/Username/Password/FromAddress/FromName/UseSsl, each overridable byFISHMMO_SMTP_*environment variables). - Login Queue Keys —
LoginQueueUpdateRateSeconds(2.0),LoginQueueMaxSize(500),LoginQueueAdmissionRatePerSecond(5.0),LoginQueueTimeoutSeconds(300) configureLoginQueueSystem. All server-authoritative — clients cannot request faster updates. - WorldServer.cfg — Port 7780, same Address/TLS/connection-token keys.
- SceneServer.cfg — Port 7790+, same Address/TLS/connection-token keys. Note
StaleSceneTimeout=5is present in all three .cfg templates, not only SceneServer. - IPv6 Reserved —
EnableIPv6/IPv6Addressare commented out in every template. IPv6 dual-stack is not supported at the native QUIC layer; IPv6 clients must arrive through an IPv6-enabled NGINX L4 proxy. - AutoVerifyAccounts —
truein Development (bypasses email verification at both account creation and login, flagged with an explicit do-not-copy-to-production warning),falsein Production so email verification is required.
Deploy Hooks (contracts, operator-supplied — not shipped in this repo)
Neither script exists under FishMMO-Setup/. nginx.conf and the deployment docs define the contract each must satisfy, and the root README states plainly that they are operator-supplied.
14. certbot-fishmmo.sh — Documented post-renewal deploy hook contract: copy Let's Encrypt certs to /etc/fishmmo/certs/, chmod 640, chown fishmmo:fishmmo, reload nginx, restart game servers via systemd (with SIGHUP fallback). Operator installs it to /usr/local/bin/.
15. gen-fishmmo-stream-config.sh — Documented generator contract for stream.d/*.conf across the game UDP port ranges, validated with nginx -t before atomic replacement. nginx.conf includes /etc/nginx/stream.d/*.conf and expects this generator at /usr/local/bin/.
Config Templates
- Per-Environment appsettings —
Development/andProduction/each holdappsettings.jsonplus per-component variants:appsettings.Database.json,appsettings.IpFetchServer.json,appsettings.Patcher.json,appsettings.WebGLServer.json,appsettings.DiscordBot.json,appsettings.AppHealthMonitor.json,appsettings.CMS.json. Component projects copy-and-rename these into their build output at build time. - Installer Manifests —
install-config.full.json,install-config.quickstart.json, andinstall-config.web.json(Development only) drive FishMMO-Installer’s non-interactive pipeline. - logging.json — Single shared FishMMO-Logger sink configuration.
Build System
- WebTransport Build — Per-platform scripts in
FishMMO-WebTransport/; there is nobuild_all.shmaster script.build_linux.sh(native CMake),build_windows.ps1/build_windows_schannel.ps1(native CMake on Windows),build_windows_cross.sh(Zig 0.13+ cross-compile from Linux — downloads the msquic NuGet package for the import library and runtime DLL, compiles withzig c++ -target x86_64-windows-gnu, links vialld-link --out-implib),build_macos.sh(must build on a Mac — msquic’s quictls dependency contains platform-specific assembly that cannot be cross-compiled), plusrebuild_only.*incremental helpers. - Cross-Platform Paths — Forward-slash paths in
.csprojfiles.$(Configuration)used directly (no redundantBuildConfigurationproperty).
FishMMO-SharedUtility
Pure C# / netstandard2.1 utility library — the lowest layer shared between Unity client and all .NET server projects.
Top-Level Utilities
- Authentication Validators — Username, password, character name, and email validation rules (shared by LoginServer and account creation). NFKC normalization for case-insensitive comparisons.
- CircularBuffer<T> — Thread-safe circular doubly-linked list with O(1) add/remove/pop/snapshot.
- Configuration — INI-style
.cfgfile handler with environment variable overrides (FISHMMO_CONFIG_*), thread-safe viaReaderWriterLockSlim, case-insensitive keys, typed getters.GetKeys(prefix)returns a snapshot of the stored names, taken under the read lock and copied rather than exposed as a live view — callers walk the result while callingSetandRemoveon the same instance, either of which would invalidate an enumerator over the dictionary. Environment overrides are deliberately excluded from it: those are a deployment mechanism for individual known keys, and a caller enumerating keys is asking what the file holds. - FastActivator<T> — Expression-tree compiled object factory (0–16 constructor args, faster than
Activator.CreateInstance). - MathHelper — Mathematical constants:
HalfPI,Tau. - RefWrapper<T> — Boxed reference wrapper for value types with implicit conversion.
- SetOnce<T> — Thread-safe write-once latch with lock-free reads and double-checked locking.
- IReference — Marker interface for reference-equality compared objects.
- CryptographicOperationsCompat — netstandard2.1 shim supplying
ZeroMemory/ fixed-time comparison primitives whereSystem.Security.Cryptography.CryptographicOperationsis unavailable.
Compression
- StringCompression — GZip compress/decompress for UTF-8 strings.
- DictionaryCompression — Compresses string dictionaries using a shared dictionary frame.
Extensions
- ArrayExtensions — Array manipulation helpers.
- IListExtensions — Binary search, swap, shuffle.
- StringExtensions — Case-insensitive contains, hex conversion, truncation.
- TypeExtensions — Assignable-from cache, type hierarchy utilities.
- RandomExtensions — Range pickers with deterministic seeding.
- EnumExtensions — Enum parsing and attribute helpers.
- DirectoryExtensions — Safe directory copy/cleanup.
- ProcessExtensions — Process management utilities.
- Primitive Bit Extensions — Byte, Short, Int, Long, Float bit-twiddling helpers.
FishMMO-Unity — Client
The player-facing Unity client (FishMMO.Client assembly, 175 .cs files).
Networking & Connectivity
- Multi-Server Connection Management — LoginServer → WorldServer → SceneServer transitions with state tracking via
ClientConnectionManager. - Reconnection with Exponential Backoff — Automatic reconnect attempts with configurable backoff (base 5s × 2^attempt × jitter, max 60s, 10 attempts). The loop is guaranteed to terminate:
TryReconnectchecks the attempt count and the stored world address together, so a retry with nothing to dial falls through to the give-up branch and raisesOnReconnectFailed(→QuitToLogin) instead of returning silently and leaving the client behind an overlay nothing would ever take down. The first retry after a deliberateScenedrop usesSceneHandoffReconnectDelay(0.25s, jittered) rather than the failure backoff, because a zone change, channel switch and cross-scene bind respawn are all implemented as handoffs. - Login-Server Discovery — Happy-Eyeballs multi-mirror probing via configurable API hosts with staggered probes (0.25s apart), 55s TTL cache, and one-time connection token relay.
- ServerConnectionType State — Tracks connection state: None, Login, World, Scene.
- Broadcast Sending — Centralized FishNet broadcast dispatch from the Client MonoBehaviour.
- WebTransport (QUIC/HTTP3) Transport — All platforms use WebTransport via
Multipass; NGINX L4 UDP stream proxy forwards raw QUIC to game servers. - Death Dialog —
UITKDeathDialogwith Respawn/Resurrect buttons. HandlesResurrectOfferBroadcastfor dynamic button visibility. Opens from replicated character state (CharacterFlags.IsDeadin the spawn payload) as well as fromDeathBroadcast, so logging in dead or transferring scenes dead surfaces it without depending on a message arriving after the world GUI scene has loaded. Actions are confirmed rather than assumed: the dialog stays up until the character is observed alive and re-arms itself if the server declines the request.
Authentication
- SRP-6a Client Login Flow — Full SRP-6a protocol: cookie challenge echo, key agreement, verify/proof, token-based reauth.
- Token-Based Reauthentication — Stored auth tokens for seamless World/Scene server transitions.
- Account Creation — Encrypted credential registration with validation.
- Account Email Verification — Verification code submission.
- TOTP / 2FA Support — Two-factor code submission and 2FA setup (QR code + recovery codes).
- Token Renewal & Revocation — Token refresh on login server and revocation on logout/shutdown.
Input System
- Unity Input System Integration —
PlayerInputControllermanages thePlayerControlsasset.PlayerControlsis static and outlives the component, but every handler registered against it is an instance method, so teardown unsubscribes unconditionally.Deinitializeused to return early whenCharacterwas already null — which is exactly what a despawn or a scene transfer produces — leaving the static action holding a delegate over a component destroyed moments later, one dead subscriber per character for the rest of the session. The resulting failure is easy to misread: movement is polled throughReadValueand keeps working, while everything routed through aperformedcallback — interact, jump, crouch, sprint — is the half that degrades. - Mouse Mode Management — Cursor visibility/lock state toggling.
- Input Binding Persistence — Binding overrides are saved to
InputBindingOverridesinConfiguration.cfgand loaded during the client's boot phase, not on world entry.PlayerControlsis created inert atBeforeSceneLoad— the asset exists and the saved overrides are applied, but no action map is enabled — so the Key Bindings tab has something to list from the login screen onwards while nothing in the world becomes live early. A blob 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 at all and the only route to "Reset All Keys" behind a panel that needs working input to reach. - Character Movement Input — Move, Look, Jump, Crouch, Sprint mapped to KCC replication data.
- Full Gameplay Bindings — Interact, Cancel, Chat, Inventory, Equipment, Abilities, Guild, Party, Friends, Achievements, Factions, Minimap, Menu, Toggle First-Person, ScrollWheel. Every panel toggle bound to a letter key is gated on input focus. Movement already gated on it and so did the hotkey bar, but the window toggles did not — so typing an ordinary sentence into chat opened the inventory on "i" and the guild panel on "g", and the panel that opened then took focus off the chat field, which is why the rest of the sentence went nowhere. The two symptoms are one bug.
- Right-Click Context Menus — Inspect, Add Friend, Invite to Party, Trade on player targets.
19a. Interactive Rebinding — Every binding in thePlayermap is rebindable from the Options panel, composite parts included. Four controls are reserved and none of them can be bound to anything: Escape cancels the prompt, Backspace clears the binding (leaving the action with nothing bound until something is), the left mouse button is excluded becausePerformInteractiveRebindingsuppresses the events it matches — with it eligible, the first click after starting a rebind, including the click meant to cancel it, was swallowed and bound to the action — and the keyboard's syntheticanyKey, which is a real bindableButtonControlthat actuates whenever any key does. Excludingbackspacedoes not excludeanyKey, so pressing Backspace left exactly one eligible candidate and two things followed: the rebind completed onto "Any Key", and because a candidate was found the event was marked handled, which stops the device state from updating at all — so the clear the panel advertises in two places could never fire. Escape and Backspace are polled in the panel's per-frame hook rather than routed through the operation: an excluded control is never offered to its callbacks, and a cancel control is not suppressed either (the operation breaks out of its candidate loop before setting the suppress flag), so Escape would otherwise cancel the rebind and then carry on into the game, whereCloseLastUIis bound to it — one press both cancelling the rebind and closing the settings window the player was still using.UITKControl.ConsumesEscapeis what makesUIManager.CloseNextabsorb the press instead. Candidates are restricted to the row's own device, resolved from the binding's control-scheme group rather than its path so it still holds for a row that has been cleared; without that, rebinding a "(Gamepad)" row with a key produced a keyboard binding sitting in the Gamepad control scheme — working, invisible, and still captioned Gamepad. The action being rebound is disabled for the duration, and re-enabled and the operation disposed before anything that can take time, so a prompt cannot leave an action dead in the world while it waits for an answer.
19b. Duplicate Bindings Refused — A rebind that would put two bindings on one control is undone and reported, naming the binding that already holds the key. What is restored is the override that was in force before that attempt — not a cleared override, which would drop the row back to the key the game shipped with and discard a rebind the player never asked to undo. Escape's authored overlap (Cancel + CloseLastUI + Menu) is exempt, but only while all three are still the shipped bindings: two of them dragged onto a key by hand is an ordinary collision. A per-row reset is checked the same way, so "restore" cannot be a hole in the rule; Reset All Keys is the unconditional way back, since the state it produces is the shipped one and cannot collide with itself.
Launcher
- HTML News Feed — Fetches launcher news via HtmlAgilityPack.
IHtmlContentFetcherstrips<script>/<style>and yields the parsed node rather than formatted text;UITKHtmlContentRendererbuilds aVisualElementtree from it, because UI Toolkit has no equivalent of TextMeshPro's<link>tag and a news link has to be an element that can receive a click. Traversal depth and output size are bounded against a hostile document, and every href is opened throughLauncherLinkPolicy, which allows only absolutehttp/https. When no feed is configured — including an unsubstitutedFISHMMO_SENTINEL_PLACEHOLDERbuild URL, which is treated the same as an empty one — or when the fetch fails, the pane shows a configurable built-in summary instead. It is not hidden: 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.
20a. Link Policy —LauncherLinkPolicyparses each href and permits only absolutehttp/httpsbefore it reachesApplication.OpenURL, which would otherwise invoke a registered protocol handler forjavascript:,file:, or any application-registered scheme. One shared implementation for both views on purpose: two copies of an allowlist drift, and a drifted allowlist is a vulnerability. - API Host Resolution — Randomised mirror selection from comma-separated host list with HTTPS enforcement (
ApiHostResolver). - Version Checking —
HttpPatchServerServicecallsGET /latest_version?from={clientVersion}and parseslatest_version,up_to_date,patch_available,sha256, andsizeintoPatchInfo. Unparseable version strings are rejected rather than thrown on. - Patch Download with SHA-256 Verification —
DownloadPatch(patchUrl, destination, expectedSha256, expectedTotalBytes, …)streams the archive and recomputes SHA-256 over the written file, failing the download on mismatch. Verification is skipped only when the server supplied no hash. The expected total comes from the version manifest rather than the response, so the player is shown a total before the first byte arrives and a truncated or chunked response cannot change what they were told the download would be.
23a. Transfer Statistics —DownloadStats/DownloadRateTrackerreport bytes transferred, expected total, current throughput and an ETA per progress callback, with the rate tracker reset per download so a retry does not inherit the previous attempt's history. Hash verification is reported as its own state: on a large patch it is seconds of work after the transfer has visibly finished, and without saying so the launcher sits at a full bar looking hung. - Persisted Launcher Settings —
LauncherSettingsgives the launcher typed access to its own options, stored in the sharedConfiguration.GlobalSettingsfile alongside the game's other settings rather than in a file of its own — one place for a player to edit, one file for support to ask for, and one thing to migrate. - Launcher State Machine —
LauncherState: LoadingNews, Connecting, CheckingVersion, DownloadingPatch, ApplyingPatch, ReadyToPlay, ClientAhead, ConnectionFailed, VersionCheckFailed, PatchDownloadFailed, UpdaterFailed, LaunchFailed, PatchUnavailable (out of date but no patch exists from this specific version — full reinstall required, retry cannot help), VersionError, ServerRejectedVersion (game server refused the client's game version). - Transient-State Watchdog —
TransientStateWatchdogcoroutine tracks a heartbeat across transient states (connecting/checking/downloading/applying) and recovers the UI if one stalls, so the player is never left with a dead button and no way to act. A separateLaunchWatchdogre-enables the Play button if the addressable scene load exceedslaunchWatchdogTimeoutSeconds(default 30s). - External Updater Launch —
IUpdaterLauncher/SystemUpdaterLauncherspawns the standalone Updater process, monitors exit, reports results. Applying a patch is a hand-off rather than an in-process step, because the updater cannot rewrite files the launcher is holding open: the launcher passes its own PID, the updater terminates it, patches, and restarts the client on every exit path. A non-zero updater exit code surfaces asUpdaterFailed. The resolved patch directory is passed explicitly as-patches=, rather than left for both sides to derive and agree only by convention — a disagreement is silent and loops forever. - UnityWebRequest Service — Shared MonoBehaviour for HTTP requests with retry, timeout, progress callbacks, custom certificate handling. Launcher API calls are HMAC-signed via
ClientApiSigner/ClientApiSecret(see Security below).
27a. UI Toolkit Launcher View —ILauncherViewdescribes the presentation surface in terms of intent (show this status) rather than widget manipulation, so all version-check and patch logic stays in one place instead of being coupled to a widget tree.UITKClientLauncheris the sole implementation; the uGUI adapter and its TextMeshPro converter were deleted with the Canvas layer, so a missing or wrongly-typed view assignment is now an error rather than a silent fallback. The view also owns its own dismissal: it watches forClientPostbootand hides itself when that scene arrives, independently of the launcher's own load callback, becauseAddressableLoadProcessorreturns early for a scene it already tracks and an editor session may have the scene open before Play is pressed — either of which would otherwise leave the launcher drawing over the login screen for the rest of the session.
27b. Launcher Settings —LauncherSettingsreads and writes the sharedConfiguration.GlobalSettingsstore (nothing had read a settings file at launcher time before): auto-update on/off, request timeout, retry count and delay, an absolute patch-directory override, and window size. Every getter clamps, because the file is plain text a player can edit and a timeout of0would otherwise be honoured literally. Window size is persisted shortly after a resize settles rather than at shutdown — the Updater terminates the launcher rather than closing it — and is clamped against the current display on restore.
27c. Install Size Probe —InstallSizeProbewalks the install on a thread-pool thread and caches the total, started only once the launcher is idle: doing it during the version check or a download would contend for disk with the thing the player is actually waiting on.
27d. Native Folder Picker —NativeFolderPickeropens the Windows shell folder dialog for the patch directory. Unity exposes no runtime folder picker, soIsSupportedis false elsewhere and callers hide the button rather than offering one that does nothing; the path text field remains the way to set a folder on every platform. Every failure returns null rather than throwing — this is reached from the screen that, if it breaks, leaves no way into the game.
Security
- TLS Certificate Pinning — SHA-256(SPKI) base64 pinning via BouncyCastle for UnityWebRequest. Constant-time pin comparison. Release builds fail-closed when pins are not configured.
- IL-Embedded Pin Configuration — Pins are compiled into the assembly from
CertificatePins.generated.cs, not loaded from StreamingAssets. Generated from the FishMMO Dashboard (FishMMO > FishMMO Dashboard, or Ctrl+Shift+D) > Game Settings panel, which also emitsClientApiSecret.generated.csandHostConfig.generated.cs. There is noFishMMO > Securitymenu — these are Dashboard panels, not menu items. - TOFU Mode — Development/editor builds allow empty pins (trust-on-first-use with loud warnings).
- Build-Time Validation —
IPreprocessBuildWithReportwarns on release builds without TLS pins (at least 2 required). - Dynamic Pin Update Scaffold —
IPinUpdateSidecarinterface for out-of-band signed manifest updates with UTC validity windows. - API Request Signing — HMAC-SHA256 with
X-FishMMO-Clientheader (v1.{ts}.{nonce}.{sig} format), 30s skew window, per-process nonce LRU cache.
UI Toolkit (UITK) Panels — Login Flow
- Loading Screen — Addressable-loaded transition images with progress bar. Visibility is driven by four independent flags — background Addressable loading, FishNet scene load/unload, an armed reconnect, and world entry — and comes down only when all four are clear, so no driver can pull the overlay out from under another. The world-entry flag is raised on
SceneLoginSuccess(before the scene load is even requested) and cleared only by the overlay's ownHide(); it covers the two gaps the other flags leave — between the FishNet scene load ending and the Addressable world preload starting, and between that preload draining and the character actually spawning — where the overlay used to drop over a half-built world for a full round trip each time.Client.DismissLoadingScreencallsHide()on the resolved control rather thanUIManager.Hide, which is a no-op unless the panel is visible and therefore skipped the flag clearing. - Reconnect Display — Reconnect attempt status during network interruptions. Skips the first attempt of a deliberate scene handoff (
ClientConnectionManager.IsSceneHandoffReconnect), so a routine teleport does not raise a "connection lost" panel over the loading overlay or force the mouse cursor back on; attempts past the first are a genuine failure and are shown. - Login Panel — Username/password, TOTP/2FA code, account verification code input.
- Register Panel — Username, password, email, age fields.
- Server Select — Available game server list.
- Character Select — Existing character display with create-new option.
- Character Create — Name input and appearance customization.
UI Toolkit (UITK) Panels — World / In-Game HUD
- Ability Book — Learned abilities with details.
- Cast Bar — Channeling/casting progress display.
- Ability Crafting — Ability-based item crafting UI.
- Achievement Window — Achievement tracking and completion display.
- Bank / Storage — Deposit/withdraw item interface. Like the inventory, the grid is sized from the container's slot count (100) rather than from how many slots hold something, and shares the same rebuild guarantee.
- Buff Container — Active buff/debuff icon management.
- Capture Point — Objective readout for a contested point: title, state, capture progress and current owner. Reads the same
CapturePointUpdateBroadcastthatClientInteractableStateSystemwrites onto the capture point component, deliberately as a separate concern — the system keeps the client's copy of the world correct for anything that inspects it (target frame, world labels) while this draws the transient readout, and neither depends on the other having run. It hides itself afterIDLE_HIDE_SECONDSof silence: a capture point emits an update only when something changes, so a quiet objective would otherwise leave a stale bar pinned to the screen for the rest of the session. - Chat Window — Message history, tabs, channel picker, input. The input row is sized with
min-heightrather than a fixed height: pinned to 26px with the field givenflex-grow, theTextField's inner text element resolved to 380x0 — correct colour, correct font size, no height — so every keystroke went in invisibly. The login fields differ only in this; they size to their own content and always rendered normally. A channel selector sits left of the input and names where a plain line goes, cycling through the six channels a player can actually send on (Tell is excluded because it needs a recipient typed with it; System and Discord are not player-sendable) and tinting itself with that channel's colour. It prefixes the channel's slash command on send, so anything already carrying a command — including non-channel ones like/leaveinstance— is left exactly as typed. Enter now releases the field as Escape already did: a focused text field is whatUIManager.InputControlHasFocusgates all player input on, so a field that kept focus after Enter left the player unable to move or interact until they thought to press Escape. That release is stamped with the frame it happened on, because Enter is bound to theChataction as well as to send, andInputAction.triggeredstays true for the whole frame — the per-frameEnableChatInputpoll would otherwise see a released field and a still-true trigger and focus it straight back, making the release depend on which of two same-priority MonoBehaviour updates Unity happened to run first. Trade wasColor.blackand Region wasColor.blue, both near-invisible on the dark chat ground rather than merely dim; they now carry enough lightness to be read there. The channel picker's list scrolls inside a panel capped against the viewport instead of running off the bottom of the screen, and clamps itself fully on-screen on its first geometry pass — it is placed at the click point, andActivateruns before layout, so its height cannot be measured any earlier. Its rename field commits on losing focus as well as on Enter: the panel dismisses itself as soon as the pointer leaves it, so Enter-only meant typing a name and then clicking a channel threw the edit away with no indication it had been ignored. Only an actual change commits, and Escape reverts the field and releases it, so there is still a gesture that abandons an edit — without one, every way of leaving the field, including backing out of the panel, would save a half-typed name. - Container — World container panel for chests, crates and wardrobes. A pure view of server state, for the same reason the corpse loot panel is one: a container in the world is not private, so the item a player is looking at may already have been taken by somebody else standing at the same chest. Nothing here removes a row — a click sends a request naming a slot index, the row is marked as waiting, and what changes the display is the server sending the contents back.
ContainerOpenBroadcastserves as both the open message and the refresh, so there is exactly one code path for "here is what is in the box" and a client that missed an update converges on the next one rather than compounding the error. - Crosshair — Reticle display for targeting.
- Dungeon Finder — Browse and join open runs of a dungeon, or start your own. The panel describes the dungeon (name, artwork and description, resolved client-side from a
DungeonTemplatethe open message names by ID, so opening it costs oneinton the wire), offers one tab per difficulty that dungeon declares, renders that difficulty's rules generated from its own values rather than from a hand-written blurb — so the panel can never describe a ruleset the server is not enforcing — and lists the instances currently joinable at it. The list is requested per difficulty and only when the panel opens, when a tab changes, or when Refresh is pressed; it never polls, because a finder that refreshed on a timer would turn every open panel on the shard into standing database load for a list nobody is necessarily reading. Refresh is disabled locally for the same interval the server debounces the request, so the ordinary case never meets the server's limit at all, and an unanswered request times out into a message rather than leaving the panel saying "Looking for open dungeons…" for as long as it stays open. A reply for an entrance or a tab the player has since left is discarded — both are reachable by walking to another entrance or clicking a second tab mid-flight, and drawing a late reply would present another dungeon's runs under the heading being looked at. Join and Open both close on send: the request is one-shot — on success the server drops the connection and the client re-routes, and on refusal it answers withSceneTransferRefusedBroadcast, which raises its own dialog — so leaving the panel up only invites a second click that the server's ingress guard rejects as a duplicate. An "Open to others" toggle decides whether a new run is listed for strangers. - Equipment Window — Equipped items and character stats. Accepts press-and-drag as well as click-to-pick-up/click-to-drop; releasing over the slot the drag started from is treated as a click, not a drop, so click-to-pick-up still works.
- Faction Standings — Reputation display. The value label carries its own dark scrim sized to the number instead of stretching across the whole bar. It has no single background to contrast against — the fill covers only the standing's share while the label spanned the full width — so white measured 1.37:1 on the positive green, 1.72:1 on the neutral sky blue and 4.00:1 on the negative red, all under the 4.5:1 needed, and 18.8:1 on the empty track. Darkening the text only inverts which half is unreadable, and no per-standing tint can fix a label that crosses two backgrounds at once.
- Friend List — Online/offline status.
- Gathering Progress — Progress bar for a gathering interaction, driven by
GatheringNodeAction's broadcast rather than timed locally, so the bar and the server's notion of when the node yields cannot drift apart. - Guild Management — Members, ranks, info. The footer draws only the actions that can do anything: Create and Leave are decided by membership, and Invite additionally answers to the same
GuildPermissions.Invitethe server enforces. All three used to be shown at all times, so a player with no guild was offered two actions with nothing to invite anyone to and nothing to leave. - Hotkey Bar — Action bar with ability slots. Accepts a drag released onto a slot, not only a completed click-to-pick-up. Occupancy is tracked separately from the icon, because a null sprite meant three different things — an empty slot, a slot bound to something whose template has no icon, and a binding whose target is gone — and only the first should draw nothing. Change detection compares occupancy as well as sprite: a slot going from empty to bound-but-iconless has a null sprite on both sides and would otherwise report "unchanged" and never draw.
- Instance Management — Which dungeon the character is in, at what difficulty, how long it has left, who else is in it, and who leads it. Leadership is the owning party's leader, not whoever opened the run: an instance belongs to a party, the party is what survives its creator leaving or logging out, and reading leadership from the party means it moves the moment the party's does — a promotion shows up on the panel's next refresh with nothing here needing to know a promotion happened. A run opened by an ungrouped character has no party, and there its owner is its leader; that is the only case where the two differ and also the one where they cannot disagree. The leader may remove others and may hide the run from the dungeon finder; everyone may leave, and everyone sees the visibility, because whether strangers can walk into their run is something every member has an interest in knowing. Membership changes without this client being told — someone leaves, someone is removed — and there is no push channel for it, so the panel refreshes on a timer while visible rather than showing a roster that quietly goes stale, and the remaining time counts down locally between refreshes so the clock moves without a message per second. The roster is ordered by character ID: the server builds it by walking a dictionary, whose order is undefined and changes as characters come and go, and a list that reshuffles under the cursor is how a Remove lands on the wrong person. Remove is drawn only where it can act — leader, and not the viewer's own row, since removing yourself is Leave and has different rules. Nothing is removed locally on click: the server answers a successful removal with a fresh roster, so a refused one cannot leave the panel disagreeing with the server.
ViewerIsLeaderarrives from the server and only decides what is drawn; every action is re-authorised against the instance's owning party when it arrives, because a drawn control is not an authorisation and the broadcast can be sent without one. The visibility toggle guards against its own writes being read back as clicks —Toggle.valueraises a change event whether a person or the code set it, and the panel rewrites the toggle from every server reply, so without the guard each reply would send a request that produced another reply. - Inventory / Bag Window — Item grid display, sized from the container's slot count (32) rather than from how many slots hold something: an empty slot is what the player drops an item onto and what shows them the room they have. The grid rebuilds itself whenever it is smaller than the container — which covers a grid built before the character had one, and elements orphaned by
UIDocumentre-cloning the UXML. Supports press-and-drag, and right-click to equip: the destination comes from the item's own template, which is what makes a single right-click meaningful, since a breastplate has exactly one slot it can go to. (Right-click previously calledInventoryController.Activate, whose body is a log line and a commented-out call with no matching server handler, so it silently did nothing.) Occupancy for the capacity readout is counted from the controller, not from the sprite array — a sprite records whether an item has an icon, not whether a slot holds an item, so the totals read low and drifted as icons resolved. - Lore Window — Displays a lore object's text on interaction. The abilities, ability events and items a lore object grants are applied server-side and idempotently — the window is the presentation, not the grant, so closing it or missing the broadcast cannot cost the unlock.
- Mailbox — Read, send and delete mail, and claim attachments. Attachment claims are a pure request: nothing about an attachment's identity or value comes from the client, which names only the mail and the slot, and the server resolves what that is and removes it from the mail before granting it. An item attached to an outgoing mail is taken out of the sender's inventory into escrow at send time, so it cannot be duplicated by attaching it and then trading it, and is returned through the normal grant path if the send fails.
- Main Menu — Settings, logout, quit.
- Merchant Buy/Sell — NPC vendor interface.
- Minimap — Live overhead render centred on the character, drawn by
UITKMinimap. The overheadCamerais kept disabled and rendered by hand at a cappedFramesPerSecond(30) rather than left enabled: an enabled camera renders its target every frame for as long as it exists, whether or not anything is looking at the result. It is the panel that is always present, so it also drivesClientMapSystem.Tickfor both maps.
65b. World Map —UITKMapshows the whole scene from an image baked at edit time. Two panels, one subsystem: both draw through the sameUITKMapViewelement and read the sameClientMapSystem, so anything pinned, revealed or filtered on one appears identically on the other. A second fog grid would be a second quarter-megabyte that has to be revealed in step with the first, and a second overhead camera a second full scene render per frame.
65c. Authored Map Data —WorldMapDefinition(Shared) holds a scene's bounds, baked image, region labels and landmarks. Scene authors dropMapRegionLabelandMapPointOfInterestcomponents — both gizmo-drawing, neither present at runtime — and FishMMO → World Map → Bake Maps harvests them, derives bounds from the scene's boundaries and terrain, photographs the scene from overhead, and registers the image as an addressable. None of it is required: with no definition at all, bounds fall back toSceneBoundaryviaMapBoundsResolver, the minimap renders normally, and the world map draws markers and fog over a flat background. The bake needs a graphics device; under-nographicseverything except the photograph is still written.
65d. Map Markers — Put an object on the map with aMapMarker: 16MapMarkerTypes (party/guild/friendly/neutral/hostile player, NPC, vendor, quest giver, trainer, service, resource, enemy, interactable, teleporter, landmark, note) and aMapMarkerVisibilityrule (Always,SelfOnly,PartyOrGuild,Detection,Discovered).MapMarkerRegistryis the runtime index.
65e. The Map Is Not a Radar —MapMarkerFilterdraws in three tiers. Self, party and guild are exact and continuous — the group already shares positions through the party frames, so the map is not the leak. World fixtures are exact because their positions are public and fixed. Everyone else is drawn only inside a 20 m detection radius, refreshed at 1 Hz, snapped to a 4 m grid, and never labelled — so the value the UI receives is already stale and coarse, and the detection radius is smaller thanObserverStreamingPolicy.MinimumRange. The honest client's map is strictly less informative than the network stream it is drawn from.MinimapCameraRendererre-applies the camera's entire configuration on every render, so a widened field of view survives at most one frame — and widening it only reveals terrain, which is public. A client that edits its own memory defeats all of this; the point is that doing so gains nothing, because the map never held anything better.
65f. Party/Guild Membership Tracking —MapRelationshipTrackerkeeps its own hash sets fed by the party and guild broadcast events. Neither controller exposes its roster — both are event streams — and reading the other character's controller does not work: those are filled from broadcasts sent to their owner, so on this client a peer's copy is empty. That failure would have been silent.
65g. Fog of War —FogOfWarMapstores coverage per cell, not an explored bit: 255 for never visited down to 0 for fully explored, one byte per 16 m² of world. A bit per cell is a quarter of the memory and produces a hard checkerboard edge, because the smallest unit of reveal becomes a whole cell; coverage lets a reveal write a radial falloff, so the boundary is a soft ring at the edge of sight. Reveal only ever lowers a value — fog does not come back.
65h. Local-Only Map Persistence —FogOfWarStorewrites one signed, gzipped file per character per scene under<install>/Cartography/<characterID>/, andMapNoteStorewrites the character's pinned notes beside it as one plain-text record per line. Neither ever crosses the network. Exploration changes several times a second and is worth nothing to anybody else; sending it would mean a write path, a table, a migration and a sync broadcast for data whose entire purpose is to decide which pixels are dark. The HMAC makes tampering detectable, not impossible — the key ships in the client — so a mismatched file is discarded loudly rather than trusted.
65i. Cartography Seam — Every map feature that scales with skill (reveal radius, world-map zoom range, label detail tier, note capacity, minimap resolution, coordinates, grid) reads its tier fromCartography/ICartographyProvider. The profession does not exist yet, so the provider is absent and the seam answers with the maximum tier — players get the full map rather than a crippled one. When it lands, experience must be awarded server-side from positions the server already receives, never from anything read out of the local fog file.
65j. Cross-Panel Item Operation Locking —ItemOperationTrackeris one shared record of which item slots are waiting on the server, used by the inventory, bank and equipment panels together. An item operation almost never involves one panel: equipping starts in the inventory and finishes in the equipment window, a bank deposit starts in one grid and lands in another, and the panel that sends the request is frequently not the panel that owns the slot which must be locked while it is in flight. A per-panel lock table cannot express that, so the lock set is shared andItemSlotPendingSetkeys it by container and slot. - NPC Dialogue — Conversation window.
- Options / Settings — Five tabs: Display (resolution, refresh rate, fullscreen mode, quality level, brightness, frame-rate limit, VSync), Audio (Master volume and mute-when-unfocused — the other five channels are stored and applied but not offered, because nothing in the client owns an
AudioSourceyet and a slider that saves perfectly while changing nothing audible is worse than a missing one), Gameplay (ShowDamage,ShowHeals,ShowAchievementCompletion,IgnorePartyInvites,IgnoreGuildInvites), Key Bindings, and UI (interface scale, window snap grid, layout reset, the ten themeable colours, and shareable UI profiles). Every row that belongs to a list is generated from the table that defines it rather than authored per GameObject — the gameplay set was previously configured entirely in the scene, which is how all five were lost when the panel was rebuilt. The panel applies nothing at start-up: it used to be the only code that applied VSync, brightness and the frame-rate cap, from its ownOnStarting, and the panel ships closed — so a player who had capped their frame rate got the bootstrap default every session until they visited the menu. Display settings are staged rather than live: Apply commits and arms a 12s countdown, Keep is the only thing that writes them to the file, and 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. Three of the colours were removed rather than kept:TooltipTitle,TooltipValueandTooltipStathad no element to land on — a theme colour reaches the screen by being written onto elements carrying a USS class, no element in the client carriesfish-tooltip__titleorfish-tooltip__stat, andTooltipValuenever had a class at all, so all three were editable controls that changed nothing. Three more were renamed to match what they actually paint: "Panel Background" wasPrimary, which paints the header and footer bars, while the panel body isBackground— so the two obvious choices each changed the other one's surface. Two toggles that had been storing a value nobody read are now wired: the achievement toggle wroteShowAchievementswhile its only consumer readShowAchievementCompletion, and the ignore-invite toggles had no consumer at all — those now decline the invitation rather than dropping it, because a silently discarded invite leaves the inviter on a prompt that never resolves and the server holding one that blocks the next.
67a. UI Profiles — The UI tab saves the window layout, interface scale, snap grid and colour scheme to<install root>/UIProfiles/<name>.cfgand loads one back, so a player can hand their arrangement to somebody else as a plain text file. Deliberately notConfiguration.cfg, which also holds the API host, launcher state and this machine's display mode — none of it meaningful elsewhere and some of it actively wrong there.Configuration.cfgstays the source of truth: loading a profile writes its keys into the global store and saves, and nothing reads a profile at runtime, so a profile 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, since merging somebody else's layout over yours produces an arrangement neither has ever seen. Panel positions are collected from the stored configuration keys rather than from the panels currently registered, because the Options panel is reachable from the login screen where the world's forty-odd windows do not exist yet. Names are validated rather than sanitised — a name silently rewritten is not the one the player looks for — and every value is re-validated on load.
67b. Interface Scale — A 0.75–1.5 multiplier applied by dividing the sharedPanelSettingsreference resolution, which is the scale knob underScaleWithScreenSize;PanelSettings.scaleonly has an effect underConstantPixelSizeand would have done nothing. BecausePanelSettingsis a project asset, the authored reference resolution is captured before the first change and restored when Editor play mode ends, so running the client once at a non-default scale cannot leave the asset modified in source control. - Party List — A roster of groups rather than of lines. Each member is one horizontal band: name and a leader badge, the three resource bars stacked so their relative depths compare at a glance, per-encounter damage and healing, and the member's buffs and debuffs on two strips to the right of the bars. Reading down a column compares the party on one axis; reading across a band tells you everything about one player. The column strip earns its place here more than in most lists — three unlabelled bars of different colours are a stack of rectangles.
- A member in another zone is drawn as a greyscale facade, not blanked and not hidden. The server sends live state only for the members sharing your scene, so a roster member absent from the payload is somewhere the local scene server cannot see; the row keeps its place and its last known values but stops being drawn in the colours that would claim they are current. Every colour is restated rather than the row merely faded — a dim red bar still reads as "that player is nearly dead right now". Their buff countdowns freeze, because a duration ticking down under the facade would be the one part of it still claiming to be live. It takes two consecutive missing payloads, so neither a member who has just joined nor a single dropped message puts the facade up.
- Two sources feed the model and they are not equal. The roster broadcast is rebuilt from the party database rows — authoritative about who is in the party and what rank they hold, and carrying a health figure that is whatever the member logged in with. The vitals broadcast is pushed from the scene server's in-memory controllers and is authoritative about everything that moves. Each field is taken from exactly one of them; the roster's health is only ever a seed, since applying it unconditionally made every bar jump back to its login value once per party update and be corrected a fraction of a second later. The local player's own bars are refreshed every frame from their own controller, because a once-a-second push is the right rate for five other people and far too slow for your own health bar.
- Leadership changes are announced from the roster payload, client-side. Leadership can now move without anybody asking it to — the server hands it on when the holder has been gone long enough — and a party that quietly acquires a new leader deserves to say so. Driven from the payload rather than sent by the server because every member receives the same payload wherever they are; a server-side announcement could only reach the members the promoting scene server happens to host, telling half a party something the other half never heard. It fires only on a rank that moved, so joining a party is not mistaken for a leadership change.
- Buff icons are pooled across every row, so a party of six trading buffs allocates nothing once the pool has grown to the busiest moment so far, and a payload whose effect set is unchanged re-bases the durations in place rather than rebuilding the strips — that churn also stranded open tooltips, since
PointerLeavenever fires for an element that has been removed. The countdown skips writes that would not change the rounded percentage, which is the difference between a few style writes a second and a few thousand. - As with the guild panel, the footer is drawn against membership rather than shown unconditionally —
OnButtonCreatePartyalready refuses unless the party ID is unset, and Leave and Invite unless it is set, so showing all three at once offered two buttons guaranteed to be no-ops. Invite additionally requires leader rank, matching what the server enforces:OnServerPartyInviteBroadcastReceiveddrops a non-leader's invite without a reply, so offering the button to an ordinary member produces a prompt, a broadcast, and no visible result.
- Pet Control — Summon, dismiss, pet abilities.
- Resource Bars — Health bar, mana bar, stamina bar, anchored top-left. Two independent faults had made all three invisible. They were laid out at
left: 24px/bottom: 96–40px, which is inside the chat panel — authored atleft: 24px, bottom: 40px, 420x280, with an opaque.fish-panelbackground — so they were positioned correctly the whole time and drawn underneath it. Separately, every bar isposition: absoluteand so contributes nothing to its parent's content size, leaving the document root with no size of its own to derive from; each bar was then positioned against a zero-sized containing block. The root is stretched to the panel explicitly, and the bars moved to the free top-left corner. Panels whose content is an ordinary flex child (chat, factions) never hit the second fault. - Scene Channel Picker — Lists the other instances ("channels") of the open-world scene the character is standing in, with each one's population, and asks the server to move the character to the one the player picks. Opened from the game menu (Escape → Channels); distinct from the chat window's channel picker, which selects a chat channel and has nothing to do with scene instances. Every open asks the server for a fresh list rather than rendering a cached one — a channel list is a population snapshot that goes stale in seconds, and the server has no push channel to correct it — and the wait is bounded by an 8s timeout, because the ingress guard drops a debounced or already-in-flight list request without replying. Rows are ordered by scene row ID so the numbering the player reads is stable across refreshes: every channel of a scene shares that scene's name, so position is the only thing distinguishing them, and an unordered list would make "Channel 2" a different destination each time. The channel the character is already on is marked and not clickable, since the server refuses that switch and the panel closes on send. Rows respond to a click rather than to a pointer press: UI Toolkit raises a click only when press and release both land on the element, which is what stops a drag begun on a row — the obvious way to scroll a list longer than the panel — from committing the player to leaving the world. A switch is confirmed before it is sent, for the reason the game menu confirms its own two destructive actions: the scene server releases the character and drops the connection, there is no undo, and the rows it is chosen from are near-identical entries a few pixels apart. Sits at the
Popuptier rather thanSettings: panels sharing a tier fall back to scene load order, Options lives inClientPrebootwhile this lives inClientWorldGUI, and in UI Toolkit the loser of that ordering receives no pointer events at all. - Shrine — Feedback line for a shrine's effect. The heal and buff are applied by
ShrineActionon the server; this exists so the player is told what happened, since a shrine that silently restores health is indistinguishable from one that did nothing. - Target Frame — Target name, health, buffs/debuffs. A health bar is shown only when the target actually has a health resource — a portal or a signpost is worth targeting and naming, and an empty bar reads as a dead one.
64a. Context Menu — Right-click actions on another player (Inspect, Add Friend, Invite to Party, Trade). Entries are plain elements built at open time, so the panel has no scene dependencies that can go missing. Placement converts the pointer from screen pixels into panel points and then clamps, because a menu opened near an edge would otherwise render partly off-screen with its last entries unreachable.
64b. Inspect — Another player's name and equipment, read straight from their in-memory character; all of it is already synchronised to observers viaWritePayload/ReadPayload, so there is no server round trip. Slots reuse the shared.fish-slotclasses and carry tooltips, so inspected gear reads the same as your own. - Death Dialog — Respawn or resurrect choice. Sits at the
Modaltier and is deliberately not Escape-closable, since the player has to pick one.
UI Toolkit Panel Lifecycle
UITKControl is the UI Toolkit analogue of UIControl, and implements the same contract:
OnStartingruns against a populated visual tree, not atAwake.UIDocumentallocatesrootVisualElementup front but only clones the UXML into it during its ownOnEnable— after every component'sAwake— soAwakesaw a real but empty root: everyQ<>returned null, was cached as null, and was never re-resolved, leaving controls that looked initialised and were wired to nothing. A panel that starts hidden has itsUIDocumentdisabled and no tree at all, so the retry is a coroutine, notUpdate: Unity dispatches magic methods to the most-derived declaration, and a baseUpdatewould be shadowed for exactly the panels that need it.- Cached elements are re-resolved when the tree is rebuilt. Hiding a panel disables its
UIDocumentand re-showing clones the UXML afresh, so every element cached inOnStartingpoints into a discarded tree — writes go nowhere and the panel shows whatever the UXML declares.Showcompares the root's identity and re-runs initialisation when it has changed. OnAfterShowwrites per-open content, because "mutate then Show" silently does not work. Enabling theUIDocumentmakes it clone the UXML afresh, so the familiar shape — set the label, thenShow()— writes into a tree that is discarded microseconds later and the player sees whatever the UXML declares. This produced blank dialogs, empty tooltips, empty selector and context lists, an invisible drag icon, guild/party/friend rosters that emptied themselves after one close, an inventory that blanked for the rest of the session, an empty character list on every login, and a world list that could not be selected from until Refresh was pressed. The last two are worth naming because both panels were already doing this correctly for their status text —pendingStatusis re-applied on every show — and it was only the rows that still cachedVisualElements across a hide. The world list also showed why the bug hides: pressing Refresh appeared to fix it, because by then the panel was visible andShow()returned early without re-cloning the tree.Showtherefore ends by callingOnAfterShow, which runs against the tree the player will actually see. Note that it is not sufficient on its own: on a panel's very first openhasStartedis still false, soReinitializeIfTreeReplacedbails and onlyOnAfterStartingruns — panels write their per-open content from both hooks. Panels holding rows in a dictionary rebuild it from model state rather than cachingVisualElements across a hide, the same split the roster panels use.Hide()is deliberately not virtual; overrideHide(bool).Hide()only forwards toHide(IsAlwaysOpen), so while both were virtual an override on the parameterless form was bypassed by every caller that used the bool overload — and quit-to-login is one of them. That cost a hang:UITKLoadingScreenoverrodeHide(), its teardown never ran on quit-to-login, and the overlay sat over the login screen forever with no exit but Alt+F4. The colour picker, the dropdown and the drag object each lost their cleanup the same way, silently. With one overridable form there is no wrong choice to make.- A focused
Buttonis not a focused text field.IsInputFieldFocusedgates player movement, and it used to matchelement is TextElement— butButtonandLabelboth derive fromTextElementandButtonis focusable by default, so clicking any button in any panel reported text-input focus permanently and left movement, camera and hotkeys dead with no recovery. It now matchesTextFieldor the USS classunity-base-text-field, which isTextInputBaseField<T>.ussClassNameand therefore coversIntegerFieldand every other text input without naming them. OnAfterStartingre-applies state that arrived first. World entry callsUIManager.SetCharacterfor every control at once, which for a panel that starts hidden lands before any element exists.UITKCharacterControlre-applies the character so both orders converge, pairing Pre with Post so a rebuild cannot stack duplicate event subscriptions.ReleasesCursorandCloseOnEscapeare separate flags. They were briefly merged, becausePlayerInputControllerused "is anything Escape-closable" as its test for whether to keep the cursor free — so a panel that released the cursor without registering for Escape had it taken straight back. That proxy was the fault, not the separation:UIManager.AnyCursorReleasingVisible()now answers the real question directly. The distinction matters because the two genuinely differ — a confirm dialog needs the cursor but must not be dismissable with Escape, since the point of it is that the player chooses. Escape is bound to several actions at once, soUIManager.ClosedThisFramestops a handler that would reopen a panel the same press just closed.- Panels are layered by tier, and raise within their tier on click. Every panel is its own
UIDocumentsharing onePanelSettings, and UI Toolkit orders those by sorting order alone — panels left at the same value fall back to scene load order, which put Options (inClientPreboot) permanently behind Login (inClientLoginGUI) and unable to receive a click.UITKPanelLayerassigns each panel a tier (WorldOverlay,Hud,Window,Menu,Settings,Popup,Modal,Tooltip,Drag,System), declared in code so a new panel inheritsWindowrather than silently defaulting to zero. Clicking a panel raises it within its tier, restoring the uGUI click-to-front behaviour while keeping a modal above a window no matter what was clicked last, and re-registers it as the next panel Escape closes.
Editor tooling. FishMMO → UI Toolkit → Validate Panels checks that every UXML imports and instantiates to a non-empty tree, that every USS imports, that each panel loads FishMMO-Theme.uss first, and that no stylesheet carries a keyword cursor rule — those are editor-only, and at runtime UI Toolkit logs "Runtime cursors other than the default cursor need to be defined using a texture" every frame the pointer is over the element, naming no file. FishMMO → UI Toolkit → Render Panel Previews mounts each panel on the project's real PanelSettings, renders it through a RenderTexture at the reference resolution, and writes a PNG per panel to Assets/UITKValidationImages/. It runs as an EditorApplication.update state machine rather than a loop, because a panel only lays out between editor frames and a single-call loop would capture forty-odd identical blank images.
The migration-era Wire Unwired Panels Into Open Scene tool has been removed: its whole job was copying visibility flags from a legacy panel onto its replacement, which is meaningless now that no legacy panels exist.
Theme and Layering
The whole UI draws from FishMMO-Theme.uss: 55 tokens and 137 selectors, with the palette sampled from the project logo art rather than invented — the fish body is #00364E, its eye #0073C0, the wordmark runs #0073C0 into #58B3F1, and the plate behind it falls to #001012. No colour literal appears in any panel stylesheet; every one references a token, so a retheme cannot miss a value hiding in a panel. Text colour reaches labels through .fish-label, which the UXML applies and C# does not — so a label built in code carries only the class its builder gives it, and nothing in the theme colours a bare Label. Any row class assembled at runtime therefore has to declare its own color, or it renders in UI Toolkit's near-black default: against the --abyss-800 panel ground that measures 1.12:1, which is not merely mis-styled but invisible, and reads as an empty list with no error anywhere to explain it. character-row__* and server-row__* set theirs explicitly, using --abyss-100 for the name and --abyss-200 ("secondary text") for the line beneath it — not --abyss-300 ("muted text"), which measures 3.36:1 and misses the 4.5:1 wanted for 12px body text where those lines carry the combat-logout warning and world availability.
UI Toolkit has no box-shadow and no USS gradients, so depth is built two ways and both are deliberate: surfaces stack through the token ramp (window ground, panel body, raised surface, slot), and per-side borders do the bevelling — a light top edge over a dark bottom edge reads as raised, the reverse as inset. Buttons are raised and invert on press; slots and bar tracks are inset, so an item sits in a socket and a fill sits in a channel.
The theme carries the shared component vocabulary the panels would otherwise each improvise: list rows with hover and a leading accent rail, column captions, badges, presence dots, section headers, empty-state text, bar labels, well surfaces, button variants (primary, danger, ghost), and themed inputs, toggles, sliders and dropdowns.
Note on cursors. USS
cursorkeywords are editor-only. A runtime panel can only change the cursor from a texture, and a keyword rule makes both UIElements and the EventSystem log "Runtime cursors other than the default cursor need to be defined using a texture" on every frame the pointer is over the element, naming no file. The theme therefore carries no cursor rules, andValidate Panelsfails the build on any that reappear.
Shared UI Components
Every panel that disables a control while awaiting a server reply arms PendingReplyGuard, a shared watchdog armed and cleared by the same methods that disable and re-enable the control, and refreshed by any intermediate progress from the server. On expiry it re-enables the control and reports it without tearing anything down, so a late reply is still handled normally. Used by login, register, character create and character select; deliberately not by server select, whose lock spans a multi-hop journey with its own queue feedback. The login panels also refresh the guard on every LoginQueuePositionBroadcast, because a queue wait is the one place a login legitimately outlasts the 30s deadline — without it the panel announced that the server had not responded while the queue dialog was still counting down beside it.
Both login panels additionally report a connection that stopped without the server ever sending an authentication result. The login server closes the transport with no message for its pre-authentication rejections (unverifiable connection token, unsupported protocol version, oversized handshake field, tripped handshake rate limit), and narrating those to an unauthenticated peer would hand an attacker a probe oracle — so the client is the only party that can explain them. If any ClientAuthenticationResult arrived first, its specific message stands; if none did, the panel says the connection was closed before it answered. Client.QuitToLogin covers the mirror case, staging an Unspecified disconnect notice when a client that already holds a session token loses the login server.
The shared dialogs refuse rather than hijack. UITKDialogBox, UITKDialogInputBox and UITKSelector share one request contract (UITKCallbackDialog): a second Open while a request is on screen returns false, leaves the live dialog untouched, and answers the refused request immediately through its own onCancel; live text updates go through SetText. The previous behaviour — silently replacing the message and the callbacks — meant a timed guild or party invite could land on top of an open confirm dialog, so Yes answered a different question than the one the player had read. Exactly one callback fires on every exit path (accept, cancel, empty accept, Escape, Hide(), Hide(false), OnDestroy, and a refused Open), all funnelled through one Resolve with a re-entrancy guard, and callbacks are unarmed and nulled before the answer is invoked so a stale caller cannot stay armed on a shared singleton. The corollary for callers: a message that must always be seen cannot use a bare Open, because it may be refused — LoginNotice.Show queues instead, collapsing duplicates, and is what the server-busy and scene-transfer-refused notices use.
A missing icon is not a missing item. UITKItemIcon is the single decision every item surface goes through — the inventory, the bank, the equipment window, the hotkey bar and the drag ghost — because "no icon" and "no item" used to be drawn identically. Each surface wrote backgroundImage from Template.Icon and cleared it when that was null, so an item whose art is not in yet occupied a slot that rendered completely empty: nothing on screen invited the player to press, drag or right-click it, and every interaction built on the slot was undiscoverable. A null icon now paints a .fish-icon--placeholder square at the element's existing size — 48x48 on the drag ghost, the slot inset everywhere else — so the class sets no size of its own and a surface that resizes its slots keeps working untouched. It is a USS class rather than a substitute sprite deliberately: a sprite would have to be authored, imported and referenced from five call sites, and would be one more asset able to go missing in exactly the situation this handles. It reads as a blank plate rather than an error state, because a project whose art is not in yet would otherwise be covered in warning markers and a missing icon is not a fault the player can act on.
Cursor-anchored widgets share their geometry. UITKScreenSpace carries the screen→panel conversion and edge clamping for the tooltip, the dropdown, the context menu and the drag icon, because the same two mistakes had been made independently in each. The first is the Y axis: the Input System reports the pointer with Y measured from the bottom of the screen while UI Toolkit lays out from the top, so a raw Mouse.current.position mirrors the widget about the horizontal centre — hover near the top, and the tooltip appears near the bottom. The second is measurement: an element's resolvedStyle size is NaN until the layout pass after it is added or re-cloned, so a clamp computed at positioning time has nothing to clamp against and silently does nothing. The helper reports whether it could clamp, and the caller defers a single re-clamp to the next GeometryChangedEvent when it could not. Sizes are compared against the container's contentRect rather than Screen.width/Screen.height, because PanelSettings scales the panel against a reference resolution and at any other resolution those two spaces differ.
- Dialog Box — Modal informational dialog. Also serves as the shared wait dialog for both connection queues (login admission and World → Scene routing), live-updating its text in place via
SetTextand offering a single Close action that leaves the queue. It takes the cursor but is deliberately not closable with Escape: the point of a confirm dialog is that the player chooses. - Input Dialog Box — Modal dialog with text input field. Takes an optional
maskedflag that renders the field as a password entry; it is applied inApplyRequestagainst the live tree (setting it inOpenwould write to the treeShowis about to discard) and reset on close, so a masked prompt cannot leave the shared dialog masked for the next caller asking for ordinary text. Added for the two-factor recovery unlock, which needs the account password and would otherwise have displayed it in clear text. - Color Picker — Color selection control.
- Custom Dropdown — Dropdown control.
- Selector / Grid — Item picking grid.
- Drag Object — Draggable UI elements. The reference decides whether anything is being carried — not the icon. The per-frame teardown also cancelled on a null sprite, so an item whose template has no icon armed a drag on pointer-down and had it torn down on the very next frame: the panels reported a clean start and the release then found nothing, so dragging appeared simply not to be implemented. On a project whose item art is not in yet that is every item, which took click-to-pick-up, click-to-drop and press-and-drag with it. An item with no art is still an item; it now drags under a placeholder ghost.
- Tooltip — Item/ability information popup on hover.
- UI Theming Engine —
UITKThemeparses the player's thirteen configurable colours andUITKThemeManagerapplies them to every registered panel, replacing the Canvas-crawlingUITheme/CanvasCrawlerpair. The storage format is unchanged —{Name}ColorR/G/B/Abytes — so a configuration file written by the old client still themes the new UI. Overrides are written as inline styles, because UI Toolkit exposes no runtime API for setting a custom property and a StyleSheet cannot be authored at runtime; the consequence is that an override applies to an element's resting appearance while its:hoverand:activerules keep coming from the stylesheet, which is exactly the limitation the Canvas crawler had for the same reason. - UI Manager — Static registry for all UI Toolkit panels with Show/Hide/Toggle/TryGetTK, close-on-escape stack, focus tracking and character injection.
Show(name)warns and lists what is registered when no control answers to the name, rather than returning as though it had worked: the name is authored in a scene and the string is authored in code, so the two drift apart silently and the result looks identical to a panel that rendered nothing. It briefly held a second, parallel registry for the Canvas panels; every lookup checked that one first and fell through withelse if, so wherever a panel existed in both — twenty-four of them in the world scene — the Canvas panel won and its UI Toolkit twin was unreachable through any generic entry point. That half is gone with the Canvas layer, and with it the whole class of shadowing.
3D World-Space Effects
- World Label System — Object-pooled world-anchored labels for damage numbers, heal numbers, achievement popups and nameplates. UI Toolkit has no world-space render mode, so a label is no longer a renderer sitting in the scene:
WorldLabelis position-plus-text, andUITKWorldLabelLayerprojects each one onto a screen-space panel every frame throughRuntimePanelUtils.CameraTransformWorldToPanel. Two behaviours of real 3D text are reproduced rather than dropped — perspective scaling, so a world-unit font size still shrinks with distance and callers keep passing the sizes they always did, and depth ordering, so a near label paints over a far one, which UI Toolkit does not get for free without a depth buffer. What is not reproduced by default is occlusion by scene geometry;OccludeBehindGeometryrestores it at the cost of one linecast per visible label per frame. - Visual Effects — 10 configurable effects: FadeIn, FadeOut, FloatUp, FloatRandom, Bounce, Pulse, ScaleUp, ScaleDown, Wave, Shake.
- Billboard Component — Makes GameObjects always face the camera (nameplates, health bars).
- Cinematic Camera — Camera movement along Unity Spline paths with LookAt target and user skip.
- Floating Labels — Damage, heal, achievement, and region name labels in world space.
Scene Management
- Addressable-Based Scene Loading — Scene preloading/postloading with progress tracking.
- Template Cache Population — Static permanent addressable loading.
- Fog Transitions — Scene fog changes during world transitions.
ClientFogManagerextracted for SRP compliance. - World Scene Tracking / Unloading — Client-side world scene lifecycle management.
- Postload Scene Lifecycle — Reloads on quit-to-login, unloads on entering game world.
- Death Broadcast Handler —
DeathBroadcastregistered on client for reconnect-while-dead death dialog re-display.
Naming & Resolution
- ClientNamingSystem — ID-to-name and name-to-ID resolution for characters, guilds, pets with server queries and disk persistence (GZip binary).
WebGL Support
- Browser Key Interception — Prevents default browser actions (F12, Ctrl+W) during gameplay via JavaScript interop (
Assets/Scripts/Client/WebGL/WebGL.jslib). - WebGL Quit — Calls a JavaScript quit function via
Client.jslib.
Settings and Client Boot Phase
- Single Settings Owner —
ClientSettingscreates and loadsConfiguration.GlobalSettings, names every configuration key exactly once, clamps everything read out of it, and owns the one debounced write that puts it back on disk. The store used to be created lazily by whichever of two unrelated places asked for it first —LauncherSettings.EnsureLoadedin the launcher scene, and the Options panel the first time a player opened it — and the Options panel ships closed, so in a client started past the launcher neither ran: key binding overrides were skipped without a word (LoadBindingOverridesreturns early on a null store), panel positions were never restored, and the theme was built from nothing. Every one of those looked like a setting that had not saved rather than one that had never been read. Keys live in one place because a key is a string shared between the control that writes it and the code that applies it, and the two are always in different files — which is how the achievement toggle came to writeShowAchievementswhile its only consumer readShowAchievementCompletion. Every read clamps because the file is plain text a player can edit and a crash can truncate, and the values reachRenderSettings.ambientLight,Screen.fullScreenModeandAudioListener.volume, none of which validate what they are given. - Two-Phase Boot Load — The store is loaded at
RuntimeInitializeLoadType.BeforeSceneLoad, ahead of every scene'sAwake, so the first panel to register already has settings to read. The settings are applied fromMainBootstrapSystem.OnApplyClientBootSettings, raised during client preload immediately after that system installs its boot-time frame-rate cap and forcesvSyncCountto 0. The ordering is the whole point: those two lines are a default for a client with no preference, and anything applied before them is silently overwritten by them, which is indistinguishable from the setting not saving. A hook rather than a direct call because the applier lives inFishMMO.Client, whichFishMMO.Sharedcannot reference; anAfterSceneLoadbackstop covers scenes with no bootstrap system, and applying is idempotent either way. - Display Settings —
ClientDisplaySettingsowns which modes the hardware offers, which one was saved, and how to put one into effect, shared by the boot phase and the Options panel. A saved resolution used to be applied by nothing — not at boot, and not on opening Options, which deliberately only stages — so the only path that ever applied it was pressing Apply and then Keep. A saved mode the display no longer reports is refused rather than approximated, since the countdown that protects a bad mode in the panel does not exist at boot. Brightness drives bothRenderSettings.ambientLightandambientIntensity, because which one is read depends on the scene:ambientLightis consulted only underAmbientMode.Flat, and every world scene is authoredAmbientMode.Skybox, where it is ignored outright — so a slider that wrote onlyambientLightdid nothing anywhere the player actually plays. It appeared to work while testing because the login and preboot scenes are authored Flat. It is an ambient control and not an exposure one; a true gamma control needs a URP Volume with a Color Adjustments override. Either way it is re-applied on everysceneLoaded: ambient is per-scene state baked into whichever scene is active, and the client loads several, so a brightness set once survived only until the first load. Quality levels are stored by name, because levels can be reordered between builds and an index silently selects a different one; re-applying a level also re-applies the player's VSync, sinceSetQualityLevelinstalls that level's own authoredvSyncCount. Every quality and VSync write routes throughApplyQualityLevel/ApplyVSyncso that pairing cannot be forgotten at one call site — and so the editor safeguard runs first:QualitySettingsis a project asset and a value written into it at runtime stays written, so running the client once leftm_CurrentQualityand the active level'svSyncCountmodified in source control, describing whatever the last person to press Play had saved in their ownConfiguration.cfg. The authored values are captured before anything writes — atBeforeSceneLoad, ahead of the bootstrap system's ownvSyncCount = 0— and restored on play-mode exit, mirroring whatUITKPanelScalealready does forPanelSettings. A fresh install keeps the boot-time menu cap of 60 FPS rather than jumping to the display's fastest mode:ResolveSavedFrameRatereturnsBootstrapTargetFrameRatewhen no preference is stored, which is what makes that constant mean something instead of being overwritten microseconds after it is set. - Audio Settings —
ClientAudioSettingsholds one level perAudioChannel(Master, Music, Effects, Ambient, Interface, Voice), persisted, applied at boot, and readable synchronously by anything that plays a sound.PlayableChannelsis the subset the options panel offers, and it currently contains Master alone: the other five have no consumer, so listing them would put sliders on screen that save perfectly and change nothing. The model stays whole so that wiring up an audio system later is adding entries to that one array. Not anAudioMixer: a mixer has to exist as an asset with a matching exposed parameter per group, and a parameter set before the mixer has loaded is silently dropped — this survives having no audio asset loaded at all, which is the state the client boots in. Master is the one level with somewhere to go on its own and is applied toAudioListener.volume;EffectiveVolume(channel)deliberately does not fold it in again, since applying it twice would square it. Levels are stored as the slider position and applied as its square, because loudness is not linear in amplitude and a slider at half travel that halves the amplitude sounds far quieter than half. Mute-when-unfocused is applied on top of Master rather than by writing zero into it, so alt-tabbing cannot destroy the saved level; the focus signal comes from a hiddenDontDestroyOnLoadwatcher, becauseOnApplicationFocusonly reaches aMonoBehaviourand a watcher living in a scene would stop reporting exactly when the player has alt-tabbed away. - Configuration Key Enumeration —
Configuration.GetKeys(prefix)returns a snapshot of the stored names, taken under the read lock. Added for the UI profile writer, which has to collect everyUI.Panel.*entry: enumerating the panels currently registered instead would save an almost-empty "layout" when the Options panel is opened from the login screen, where the world's windows do not exist yet — and loading that back would reset every world window the player had arranged. - Culture-Invariant Storage —
Configurationformats every value withCultureInfo.InvariantCultureand givesfloat/doublethe round-trip ("R") format. It previously usedvalue.ToString()— the current culture — while every reader parses invariantly, so on any machine whose locale writes a comma as the decimal separator0.75fwas stored as"0,75"and read back as 75, the comma accepted as a digit-group separator. Interface scale, brightness, every audio volume and every window position round-tripped to roughly a hundred times their value and were then clamped to whatever bound the reader enforced — a setting that looked like it had not saved when in fact it had saved and been misread. Float parsing usesNumberStyles.Floatso a legacy comma value is rejected and falls back rather than absorbed. Separately, every typed getter now returns the caller's default when a stored value is present but unreadable, instead of the type's: a truncated write or a hand edit otherwise meant zero brightness, zero volume, or a toggle reading off that ships on. - One Debounced Write —
Configuration.Save()serialises and rewrites the whole file, so every change coalesces onto a 0.75s quiet period. There is exactly one pending write in the client.UITKPanelPositionsused to keep a second flag and a second deadline over the same file with a different interval, so a player who dragged a window and changed a setting in the same breath got two full serialisations of identical content, each timer could fire inside the other's quiet period, and which change reached disk first depended on which subsystem had last been touched.LauncherSettingswas a third writer that calledSave()on the store directly, bypassing both the editor guard and the WebGL sync. All of them now request and flush throughClientSettings. - Write Pump —
ClientSettingsPumpis a hiddenDontDestroyOnLoadcomponent that drives the debounce and forces the owed write out on focus loss, pause, quit and destroy. The debounce used to be pumped fromUITKControl.Update, which made a guarantee about the player's settings depend on at least one panel being alive to make it — a scene without panels silently stopped the clock on a write that was already owed, with nothing to report it. It also cost forty-odd redundant calls per frame to read a single bool. Flushing on focus loss matters most in a browser, whereOnApplicationQuitdoes not run when a tab is closed. - WebGL Settings Persistence — WebGL was excluded from the write alongside the editor, so a browser client applied every setting correctly and persisted none of them.
Constants.GetWorkingDirectory()already resolves toApplication.persistentDataPaththere, which is a real writable filesystem — it is just an Emscripten IDBFS mount, and a write reaches IndexedDB only once the mount is synced. Unity persists automatically on file close only when the page passesautoSyncPersistentDataPath: truetocreateUnityInstance(), and this project ships the stock PWA template, which does not — soWebGLPersistentData.Sync()queues the persist explicitly after every save and after a UI profile is written or deleted, and compiles to an empty method everywhere else.
FishMMO-Unity — Server
The headless server (FishMMO.Server assembly, 211 .cs files). Three server types — Login, World, Scene — launched from one GameServer executable.
Core Server Infrastructure
- Server Composition Root —
ServerMonoBehaviour orchestrates CoreServer, Database, NetworkWrapper, AddressProvider, AccountManager, BehaviourRegistry, DataContainerRegistry. - Config File Loading —
FileServerConfigurationloads/saves.cfgfiles with typed getters and defaults. - Server Lifecycle Events —
IServerEventswith delegates for LoginServer/WorldServer/SceneServer initialization. - Periodic Callback System —
IPeriodicUpdateSystemfor registering/unregistering configurable-interval per-frame callbacks. - Server Behaviour System — ScriptableObject-derived modular server behaviours with unified InitializeOnce/Deinitialize lifecycle.
- Server Component Registry — Multi-interface lookup registry for all server components.
- Runtime Data Containers — Typed runtime data containers (
RuntimeDataContainer) withRuntimeDataContainerFactoryandRuntimeDataContainerRegistry; behaviours declare required containers via[RequiresDataContainer]. Per-system containers follow the<System>SystemRuntimeData/I<System>SystemRuntimeDatanaming convention — e.g.PartySystemRuntimeData/IPartySystemRuntimeData,GuildSystemRuntimeData,CharacterSystemRuntimeData,ChatSystemRuntimeData,WorldSceneSystemRuntimeData,NamingSystemRuntimeData. Shared infrastructure containers (MainThreadQueueData,AsyncWorkerData) are similarly split per system via marker interfaces such asIGuildSystemMainThreadQueueDataso systems do not collide on one registry slot. - Main Thread Queue — Thread-safe main-thread action queue for marshalling async worker results to the Unity thread. Each queued action is invoked in isolation and the drain buffer is cleared in a
finally, so one throwing action cannot discard the rest of a batch — for a request/response handler the queued action is the reply, and the actions in a batch belong to unrelated connections. Capacity rejection is counted and rate-limit warned; callers holding state across the hand-off check the return value. - Async Work Pool — Centralized bounded async work queue (
AsyncWorkerData) with backpressure, replacing fire-and-forget_ = SomeAsync(...)across every server system. Work runs concurrently under a semaphore (maxConcurrency, sized well under the database connection pool), and FIFO ordering is preserved only where it is promised — per entity key, through per-key continuation chains that retire when they empty, so different keys never block each other. It was previously N channels each with one sequential loop, which made every worker a head-of-line queue: one item waiting on something slow stalled every unrelated item hashed to the same channel, and the waits are long — a main-thread dispatch blocks for up to 30s when that queue is not draining, a session claim backs off across five attempts, any database call can stall. Eight such items halted every save, session release, scene status write and routing decision on the process at once, while the thread pool sat idle, because none of them were using a thread; they were awaiting. Nothing ever executes on the calling thread — awaiting a free semaphore completes synchronously, so work would otherwise run inline on Unity's main thread up to its first real await. AnentityKeyof 0 means unordered; the old hash mapped it to a single channel, quietly serializing every caller that passed a default id.Clear()stops accepting new work but leaves the accepted backlog to drain: its only caller is the shutdown path, immediately before the drain, so "discard everything pending" only ever threw away the saves and session releases the behaviours had just enqueued — and a dropped release leaves the character Online until its lease expires, which the fail-closed duplicate-login gate turns into a two-minute lockout from every server. The drain itself is clamped to whatever remains of the shutdown budget. - IngressGuard — Per-connection, per-operation debounce and in-flight guard to prevent duplicate/replay/DoS attacks (ConcurrentDictionary-backed, bounded, periodic sweep). The two are tracked separately and swept on separate horizons: a debounce entry is reclaimed on the configured TTL but only while nothing is in flight for that key, and an in-flight marker is reclaimed only after
InFlightStaleAfter(5 minutes) as a backstop against a missingEnd(). Sweeping them together meant any operation still running when its debounce entry aged out — a database stall is enough — silently lost its lock, so a duplicate could start and the first completion then released the second one's marker. - FishNet Network Wrapper — Clean abstraction over FishNet NetworkManager: broadcast registration, transport config (bind address/port/maxClients forwarded to each WebTransport child in Multipass), TLS certificate configuration, authenticator attachment, coroutine hosting.
- Server Type Selection — Server type determined by command-line arg (
LOGIN,WORLD, orSCENE). - Address Resolution —
ServerAddressProviderresolves IPv4/IPv6 from transport with optional overrides. - Physics Ticker — Unity MonoBehaviour ticking a PhysicsScene at server fixed timestep (per-scene physics).
- Window Title Metrics — Updates server window title with connection/character counts.
- Server Launcher — Bootstrap system preloading addressables and loading server scenes based on CLI args.
Authentication (All Server Types)
- BaseServerAuthenticator — Abstract MonoBehaviour bridging FishNet transport to engine-independent
BaseAuthenticatorCore. Handles: handshake routing, cookie challenges, rate-limit key resolution, main-thread action queue. - ServerAuthenticator (SRP) — LoginServer SRP-6a authenticator: SRP verify/proof, TOTP/recovery code verification, token issuance, kick request processing.
- TokenServerAuthenticator — World/Scene token authenticator: decrypt + verify + revocation check, one-retry with linear backoff for DB blips.
- Signing Key KEK Provider — Static utility loading AES-256 KEK from the
deployment_secretsdatabase table (keysigning_key_kek), building 8-byte AAD bound to LoginServer ID, wrapping/unwrapping HMAC signing keys. No environment variable or .cfg file fallback. - Account Managers —
AccountManager,SrpAccountManager,TokenAccountManagerwrapping FishMMO-Auth cores for Unity/FishNet. - Handshake Rate-Limit Window Lifecycle — A disconnect clears only the
conn:{ClientId}key, so a recycled ClientId (FishNet reuses them) does not inherit a stale 100 ms block. The IP-keyed window deliberately survives the connection: it is a property of the address, and clearing it on disconnect would let any client reset its own per-IP handshake budget by reconnecting. The stopped path never resolves an address-derived key either, because the transport has already dropped its id mapping and the lookup would make FishNet logTransportIdData could not be foundon every disconnect. Login-queue admission still clears unconditionally — there the server is inviting a re-handshake on a live connection. requireTokenRealIp— Serialized onTokenServerAuthenticator, default on: an auth token must carry a verified real client IP. Correct behind the L4 proxy, whereconn.GetAddress()is the proxy's loopback for every client and the token-embedded IP is the only key the handshake limiter can use. Disable only for a direct-connect deployment: the Login Server recovers a real IP solely from an IPFetch-issued connection token, and that token is optional at the handshake, so a stack without the proxy issues valid tokens carrying no IP — with the requirement on, every player is refused at world entry.
LoginServer Features
- Login Server Registration — Registers server in DB, generates/rotates HMAC signing keys (AEAD-wrapped via KEK), derives TOTP master key. Periodic heartbeat pulses.
- Account Creation System — Per-IP rate limiting with
ExpiringKeyTracker, per-IP block after N failures, global hourly account creation cap (DoS shield), per-username verification failure lockout (60 min after 5 failures). AES-256-GCM encrypted credential decryption, mandatory TOTP 2FA setup with encrypted secret storage, recovery code generation/hashing, encrypted otpauth URI delivery. - Character Create System — Template-validated character creation with starting equipment/abilities/hotkeys initialization,
MaxCharactersper account enforcement. - Character Select System — Character listing, selection, and deletion for player accounts.
- Server Select System — World server list provisioning from database.
- Login Queue System —
LoginQueueSystem(ServerBehaviour) holds a FIFO queue, backed byArrivalOrderTracker<TKey>for O(1) add/remove by connection, for clients arriving while the server is at authentication capacity. Queued clients stay connected at the QUIC layer and receiveLoginQueuePositionBroadcastposition updates everyLoginQueueUpdateRateSeconds; on reaching position 0 the client re-initiates the handshake (after 0-1s of jitter, so a drained queue does not arrive at the SRP channel in lockstep) and proceeds through normal auth. That retry runs on the connection the client has been holding open, so it resets the per-connection crypto state viaClientAuthenticatorCore.OnRehandshakeRequired()— notOnDisconnected(), which would also clear the credentials SRP has not consumed yet and make the re-handshake disconnect itself. The queued connection is exempted from the handshake-timeout sweep byIsConnectionAwaitingQueueAdmission(covering the admitted-but-not-yet-re-handshaked window viarecentlyAdmitted, 15s TTL), and its handshake rate-limit window is cleared on enqueue so the server-invited retry cannot trip it. Admission is rate-smoothed viaLoginQueueAdmissionRatePerSecondso newly admitted clients cannot immediately re-saturate auth capacity. Clients beyondLoginQueueMaxSizeare rejected withClientAuthenticationResult.ServerBusy(ServerBusyBroadcast) rather than queued; clients exceedingLoginQueueTimeoutSecondsreceive position -1 and are disconnected. All parameters are server-authoritative.
WorldServer Features
- World Server Registration — DB registration, periodic heartbeat with character count.
- World Server Authenticator — Token auth with per-account login debounce, server-lock check, combined admission gate (DB connection count + recently admitted usernames burst prevention), selected-character validation.
- World Scene System — Open world and instanced scene routing, connection authentication, instance lookup with debounce and TTL caching, waiting queue management with TTL purge, DB updates. Routing keys every map by scene row ID rather than by the hosting process's scene-manager handle (see Scene instance identity), so two scene servers hosting the same scene are never collapsed into one entry. Open-world routing accepts only
SceneType.OpenWorldrows —FetchAvailableAsyncselects on world, name, capacity andReadyand says nothing about type, so without the filter aGrouprow for a scene also reachable as a teleporter'sToSceneor a character'sBindScenewould drop the player into somebody else's private instance. Two periodic reapers keep the routing pool honest:DeleteStaleUnreadyAsyncremoves rows that never reachedReady(nothing else does — aLoadingrow orphaned by a scene server that died between dequeue and load still hasscene_server_id = 0, so that server's own restart cleanup never matches it), andDeleteByStaleSceneServersAsyncremovesReadyrows whose host has stopped pulsing, then clears the routing caches so a cache hit cannot keep sending players to them. Clients held in the routing queue receiveWorldSceneQueuePositionBroadcasteveryqueuePositionUpdateRateSeconds, carrying their 1-based position within their own scene or instance group, the group size, an estimated wait derived from how many connections the last routing pass actually placed, and aWorldSceneQueueReason(Capacity,SceneLoading,CombatLogoutBody). Position semantics and channel selection mirrorLoginQueuePositionBroadcast:>0waiting (Unreliable, corrected by the next sweep),0routed and-1abandoned (both Reliable, as one-shot transitions). Each reason carries its own bound —waitingQueueTtlSecondsfor capacity,× SceneLoadWaitTtlMultiplierwhile a scene instance is still loading, andCombatLogoutRoutingGraceSecondsfor a character whose combat-logout body only one instance can hand back. Connections are ranked across the whole group but notified only after waiting longer than one full routing cycle, so a healthy login never sees the dialog. The wait-queue TTL measures the total wait: the arrival stamp survives re-queue cycles and is cleared only by a terminal outcome (routed, purged, disconnected). - Kick Request System — Periodic DB polling for admin-initiated kicks, player disconnection via main-thread marshalling. Kicks are delivered with
DisconnectWithNotice(AdministrativeKick, terminal: true)rather thanNetworkConnection.Kick, which FishNet does not relay: the player is told they were disconnected by an administrator, and theTerminalflag stops the client's reconnect loop from spending ten attempts on a server that will refuse it again. Stale requests are skipped by comparing the account's last successful login against the request timestamp.
SceneServer Features
Scene Server Registration — DB registration, periodic heartbeat pulses with scene character counts.
Scene Loading/Unloading — FishNet SceneManager orchestration, pending scene queue processing from DB, stale scene cleanup. Scene instances are identified across processes by their
scenesrow ID; the local scene-manager handle is kept only for the two places that need it (SceneInstanceByHandlefor unload callbacks, and the scene manager itself) and is never persisted or sent to another process.SetReadyAsyncandPulseAsyncaddress the row the server actually dequeued rather than "the oldest loading row with this name", so two concurrent loads of one scene cannot stamp their host onto each other's row — which for an instanced scene handed a character the instance created for somebody else, becausecharacter_idstays with the row. A scene server dequeues from a global pending queue and therefore hosts scenes for every world server, so world-scoped bookkeeping (population maps, cache keys, failed-load kicks) is keyed by world server ID as well as scene name. A load that completes after its request has gone — the pending-scene timeout swept it, or the row turned out to be unusable — has its scene unloaded rather than abandoned: such a scene is in no registry, so it is never pulsed, never detected as stale and never unloaded, and it stayed fully simulated for the life of the process. Instances are additionally bounded byMaxInstanceLifetimeMinutes(120 by default), measured from the scene row's creation so the queue and the load count toward it; the idle sweep only ever reclaims an empty scene, so an occupied instance had no upper bound at all. Occupants are warned at 10 / 5 / 1 minutes and the instance is then closed. Closing is distinct from unloading:CloseInstancereturns everyone inside to the open world through the ordinary leave-instance path — announced while they still belong to the instance so its population is debited correctly, saved, released and re-routed — and finalises any combat-logout bodies standing in it, which have no connection and would otherwise have their session claims stranded when the scene was destroyed under them.Character System — Full lifecycle: loading from DB, spawning, periodic saves (configurable interval), despawning, disconnect cleanup. Session ownership with claim/release and lease refresh. Teleportation, out-of-bounds checks, death/respawn. Three watchdogs bound the scene-entry path end to end so a stalled load cannot present as a hang: residency (
CharacterResidencyTimeout, 60s — armed on the auth callback, cleared once the character reaches a mapping table), scene handshake (SceneLoadHandshakeTimeout, 90s — armed onWaitingSceneLoadCharacters, cleared byClientValidatedSceneBroadcast), and transfer (TransferDisconnectGrace, 15s). The per-account auth-callback rate limit disconnects withRateLimitedrather than returning silently, because that callback is the only entry point to a character load. Every per-connection watchdog map is cleared inOnDeinitialize, since the behaviour is aScriptableObjectwhose fields outlive an editor play-session restart while FishNet reissuesClientIds from zero. The scene handshake itself is order-independent:ClientValidatedSceneBroadcastneeds both the client's start-scene acknowledgement (raised once per connection, one round trip after authentication — before the character load has even started) and the character reachingWaitingSceneLoadCharacters(several database round trips later), and the server does not control which arrives first.startScenesAckedClientIdsrecords the acknowledgement instead of acting on it, and whichever half lands second callsValidateSceneAndAcknowledge; exactly one does, because both run on the main thread. Driving the handshake off the event alone made world entry a race that disconnected a healthy login withCharacterUnavailablewhenever the acknowledgement won — which, under any database latency, is the common case.Combat-Logout Linger — A character whose owner disconnects mid-combat keeps its body in the world, targetable and killable, for up to
combatLogoutLingerSeconds(60s) instead of being despawned — so closing the client is not a free escape from a losing fight.TryBeginCombatLingerremoves ownership (taking the object out ofconnection.Objectsbefore FishNet's disconnect cleanup despawns it), cancels any in-flight ability (a lingering body is still ticked, andAbilityController.OnReplicatewould otherwise re-assertIsHeldand let a cast complete on behalf of a player who cannot aim or stop it), sets the persistedIsCombatLoggedflag, and re-adds the body to its scene instance's population count so the scene is neither unloaded as empty nor advertised as having free capacity. The session claim is held for the whole linger, so no other server can load a second copy. The linger ends on combat ending, on death, or at a hardExpiresUtcdeadline — the last of which stops an attacker pinning a body indefinitely by chipping at it.AnyOnlineAsyncskipsIsCombatLoggedcharacters so the owner can log back in and reclaim the body viaTryReattachLingeringCharacter, which carries the existing token through rather than releasing and re-claiming. Bodies are included in the periodic save (AppendLingeringCharacterSnapshots), paired with the claim this server holds so the writes stay ownership-gated: without that they were persisted only at the two ends of the linger, and a scene server that died in between restored the character at full health, refunding the fight it had already lost. A body is reachable by everything that ends a character's presence, not only by its own timer: an administrative kick ends the linger (a body has no connection, so the kick found nothing and silently skipped exactly the case where the character is hardest to load, while the body went on holding its claim), and closing the instance it stands in finalises it too. On shutdown the claims this server holds are snapshotted before the lingers are finalised, becauseFinalizeCombatLingermoves each token out ofSessionTokensand hands the release to the async pool — which drains on a bounded budget, so on a slow database that release could be dropped, and a dropped release is not a small loss: the fail-closed duplicate-login gate reads the character as still online and refuses the player for the full two-minute lease.Character Inventory System — Item moves, swaps, splits across inventory, equipment, and bank containers. Persists changes to DB.
Equipment System — Equip/unequip with slot validation.
Bank System — Bank/storage slot management.
Chat System — Local (proximity), World, Party, Guild, and Private (whisper/tell) chat channels. Lock-free incoming queue with O(1) size counter. Batch DB persistence. Token-bucket rate limiting. Region chat is delivered to the sender's actual scene instance, taken from the spawned object, not resolved by scene name — scene stacking means several instances share a name, and inside an instance
SceneNamestill names the open-world scene the character will return to. The sender is resolved fromConnectionCharactersrather thanconn.FirstObject, so a command's authorisation is never decided from a network-deserialised payload.Slash Command Routing —
ChatHelper.GetCommandAndTrimreturns the command including its leading slash, which is how every registration is keyed (/leaveinstance,/gi,/pi,/w,/guild, …). Lookups are case-insensitive. Registrations are removed on teardown and the channel map is reset, because the registry is static and holds delegates bound toScriptableObjectbehaviours that do not survive a play-session restart. A repeated slash command is exempt from the duplicate-message filter on both client and server — repeating one is the normal response to a command refused for a reason that has since cleared.Guild System — Creation, membership, invitations (TTL-expiring and identity-bound), periodic update pump, character connect/disconnect tracking, MOTD and notice, an activity log (
guild_log), member notes, applications, and per-rank permission flags.- Ranks are guild-owned rows, not a fixed enum.
GuildRankused to be an enum compared directly at every permission site; a guild now owns its rank rows, names them, and carries a permission flag set per rank, while the wire carries the ladder position (RankOrder) and the client resolves the name. All 30 read sites were migrated — the 9 real permission decisions became flag checks and the 21 carriage sites follow the storage. Legacy guilds are seeded lazily on first touch (ON CONFLICT DO NOTHING) with Leader → all flags, Officer → exactly the set the old>= Officercomparisons granted, Member → none, so no migration script runs and no membership row is rewritten (rank_orderis the oldrankbyte). - Escalation guards, each covered by a test: promoting above yourself (previously absent — a direct escalation), acting on someone at or above your rank, granting a permission you do not hold, editing your own or a superior rank, top-seat entry and exit via re-rank, non-existent destination ranks, and the
EditRankssoft-lock — the one permission whose absence prevents its own restoration. - Three defects surfaced by the migration rather than caused by it: succession on leader-leave was literally
Rank == Officerand now promotes the most senior remaining member into the leaver's own seat (a constant would have demoted leadership to rank 3 in a 5-rank guild); transfer demoted the outgoing leader to a hardcodedOfficer, skipping intermediate ranks and leaving members senior to their own leader; and MOTD and notice shared one inseparable "officer or better" gate, now two flags. - Player-authored guild text does not parse rich text. Notices, rank names and member notes render with
enableRichTextdisabled — with it on, a<size=500>in a notice was obeyed rather than displayed. - Status: protocol, server handlers, permission gating and persistence are complete and tested (129/129 permission assertions, 16/16 SQL scenarios). There is no rank-editor or recruitment UI yet — create/edit/delete-rank, apply, and browse are reachable only programmatically. That is the largest outstanding piece of the guild work.
- Ranks are guild-owned rows, not a fixed enum.
Party System — Creation, membership, invitations (TTL-expiring and identity-bound), periodic update pump, character connect/disconnect tracking, and a live vitals push for the members sharing a scene.
- Exactly one leader, maintained convergently rather than by event handlers. Leaving, being kicked, being dropped on arrival at the wrong world server, joining an instance and the periodic pump all route through one rule, so there is one answer to "who leads this party" instead of five that can drift. It repairs in both directions: a party with none cannot fix itself (promoting somebody requires a leader, and so do invite, kick and closing the instance the party is holding open), and a party with two looks healthy to every check that merely asks whether a leader exists while two people can each kick the other. The successor is the lowest character ID, never a random pick — two scene servers repairing the same party in the same second must reach the same answer, or each undoes the other; a pure function of the roster cannot disagree with itself.
- A leader who is not logged in anywhere is also a broken party, and that is invisible from the roster's shape. The scene server asks the database who actually holds a live session — the same definition the account checks use, so a lapsed lease does not count and a character running out a combat-logout timer does not either — and moves the rank to somebody who does. This is what makes leadership impossible to leave stuck: it is a question about the state as it stands rather than a handler hung off an event, so it is right however the state arose, including when a scene server died without running a single disconnect handler and the only signal is the leases it held expiring.
- Absence is confirmed twice, far enough apart that a scene load cannot span it. Moving between scene servers — walking through a teleporter, or leading the party into the dungeon it just opened — releases the character's session on the way out and re-claims it on arrival, and for that whole gap the database reports the leader exactly as it reports somebody who quit. Acting on one observation would take the rank off every leader who used a door. The first sighting starts a clock and schedules the second; arriving cancels it.
- Every party mutation is serialised per party. Leaving, kicking, promoting, accepting an invitation, joining an instance and the repair each claim the party first. Optimistic concurrency cannot cover this on its own: a promotion and a departure touch different rows, so both writes succeed and it is the two decisions that conflict — a leader who promotes somebody and leaves in the same breath produces two leaders, and nothing that counts leaders afterwards sees a problem. The claim is process-local by design; what makes the cross-server case safe is that every write is version-gated and the repair converges, so two servers that disagree produce a state that is wrong for one pass and right afterwards.
- Leader-only actions are re-verified against the database after their async hop. The broadcast handlers gate on
IPartyController.Rank, which is a copy the update pump refreshes — correct most of the time and stale for up to a pump interval after any rank change, including one the same player just caused. Without the re-read, a leader who promotes somebody and then immediately promotes or kicks again is acting on a rank they gave away a moment earlier. - Membership is never created where one already exists. The row is keyed by character, so persisting over one is a move rather than an insert — it takes somebody out of a party whose remaining members are never told, because nothing marks that party as updated. Accepting an invitation and joining an instance both check first, and connecting refuses to re-create a membership deleted while the character was offline.
- Live vitals for the members sharing your scene — health, mana and stamina, per-encounter damage and healing, and the server-filtered buff and debuff list — pushed on the update pump from the in-memory controllers rather than from the party database row, which is written on connect and disconnect and at no other time. Grouped by Unity scene, not by scene server: one scene server hosts every zone and dungeon instance it owns, and members of a party are routinely spread across them. The payload is complete for its scene, so a roster member missing from it is somewhere else — no separate presence message, and no way for the two to disagree. Buff durations are re-based to the moment the payload is built, because the observed list they come from is only pushed when the buff set changes and a twenty-minute buff's figure can be very old.
- Per-encounter damage and heal meters, defined purely by activity: every contribution refreshes an idle timer, and once it lapses the accumulator is discarded so the next hit opens a fresh encounter. Deliberately not hooked into the damage controller's combat window — that window is refreshed by being attacked as well as by attacking, so a player who has done nothing but take hits would keep a stale rate on everyone's frame. Credit resolves to the controlling player, so a hunter fighting through a pet reads as having done it.
- Tuning lives on the scene server's
PartySystemasset rather than inConfiguration.cfg, because it is per-scene-server behaviour rather than per-deployment identity. The ones an operator is likely to reach for:transferLeadershipOnDisconnect(the whole absent-leader mechanism; off reverts to structural repair only),leadershipAbsenceGraceSeconds(must comfortably exceed the slowest scene load on the shard, or leading your party into a dungeon costs you the rank),partyUpdateClockSkewAllowanceSeconds(how far the update watermark trails real time — the rows are timestamped by the scene server that wrote them and compared against a mark stamped by the one reading, so this must exceed the clock skew between them),encounterTimeoutSeconds(how long the damage and heal meters hold a fight open), andmaxVitalsBuffsPerMember(a bound on the vitals payload, which is built per member and sent per member).
Friend System — Add/remove friends with validation, online status tracking,
MaxFriendsenforcement.Achievement System — Progress tracking, completion events, reward delivery.
Quest System — Event handling, auto-progression, reward delivery, DB persistence.
Pet System — Summoning, following, staying, releasing, AI initialization, death handling, and persistence across sessions: which pet is out, its attribute values and the buffs running on it are saved and restored. Restored state is staged onto the
PetasPetPersistedStateand applied at spawn rather than written to an entity that is not in the world yet — an attribute set on an unspawned pet is set on nothing. Navigation is consumed throughIAINavigationrather than the fullIAIController, so the pet system depends only on the movement contract it actually uses. The invariants the system relies on but that nothing enforces at compile time — prefab wiring, component presence — are asserted byPetSystemAssetTestsinstead of being discovered at runtime by a designer.Hotkey System — Player hotkey configuration for abilities/items with ingress debounce protection.
Naming System — Character/guild ID ↔ name resolution with bounded TTL caches and negative caching.
Interactable System — NPC interaction, merchant purchases (items/abilities), ability crafting, world containers, server-authoritative dialogues (ECA-driven), dungeon finder entrance, mailbox (send/receive/delete mail). A titled interactable renders its title into the character's world guild label, which is optional — it is assigned from a prefab's label object and a character without one leaves it null — so the write is guarded; unguarded, every titled NPC lacking the label logged a
NullReferenceExceptionout ofAwake. Instance membership is reported and managed throughRequestInstanceDetailsBroadcast/InstanceDetailsBroadcastandInstanceKickBroadcast, handled beside the leave-instance path on the character system — every operation is about the characters standing in a scene rather than about creating or resolving one. Leadership is the owning party's leader: the scene row records both the character an instance was created for and the party that owns it, and the party is the durable half — it survives its creator leaving, logging out, or handing leadership on, and leadership moves with the party rather than needing to be transferred separately. An ungrouped character's run has no party, and there the owner is the leader. A removal is re-authorised against that party, and the target is checked to be in the same instance — a character ID is a global identity, so without that check a leader could name anyone on the scene server and have them thrown out of a dungeon they were not in. The removal itself is the ordinary leave-instance transfer rather than a disconnect: the target is announced out so the population is debited, put back where it entered from, saved, released and re-routed. Disconnecting instead would drop them to a loading screen with the instance flag still set, so the world server would route them straight back into the instance they were just removed from. Removal is immediate by design — deferring it until the target is out of combat would let a target who does not want to go stay indefinitely by staying in a fight. The mailbox validates on every operation that the requesting character is standing at the mailbox its request names, and carries attachments: an item attached to an outgoing mail is removed from the sender's inventory into escrow at send time — attaching without removing is a duplication bug waiting to be found — and returned through the ordinary grant path, not to the slot it came from, if the send fails, because that slot may no longer be free. A claim names only the mail and the slot; the item's identity and value are resolved server-side and removed from the mail before anything is granted, so nothing about what a player receives comes from their own request. Subject and body are length-capped. The dungeon finder is three separately-authorised requests rather than one:DungeonFinderListBroadcastbrowses the runs open at one difficulty,DungeonFinderCreateBroadcastopens a new one, andDungeonFinderJoinBroadcastjoins somebody else's.DungeonFinderBroadcastis now purely the server's message opening the panel and is not accepted from a client at all. Listing has its own ingress-guard operation and a 2-second debounce, so browsing cannot debounce the attempt to enter that follows it — the two are debounced at rates two orders of magnitude apart, and sharing a key would let the cheap one lock out the expensive one. Every exit from the list handler replies, including the refusals and the empty lists, because the panel disables its list while a request is outstanding and a silent return would leave it inert for the rest of its life. Listing is deliberately not gated on character state: reading a list is not a move, and a player in combat who cannot see it can see it by walking ten metres away. It resolves what the character or its party already holds with a single batched query (FetchCharacterInstancesAsync), which returns only rows that can still be entered (Ready,PendingorLoading— aFailedrow is treated as absent, since routing to one cost a full disconnect and, because nothing deleted it, made that dungeon permanently unenterable for that character) and asks about every dungeon rather than only the one requested. That one query decides between the three outcomes: join the instance already held, refuse because a different one is held, or create. It matches on the owning party as well as on member IDs, which is what closes the re-entry lockout: an instance is recorded against the character who opened it, so a party whose opener had since left it — or logged out and been dropped from it — could no longer resolve the dungeon its members were standing in, saw nothing, opened a second copy and split the group. Matching on party ID finds it regardless of who created it, which is also what lets a member walk out to the entrance and walk straight back in. It replaced a round trip per party member plus one for the requester. A party may hold one instance at a time, not one per dungeon — scoped to the scene name, a party could open a live copy of every dungeon on the shard by entering one and walking out, each abandoned copy holding a full physics scene and a scene row until its own idle timeout expired. Holding a different one is refused withPartyInstanceExists. Creation goes throughEnqueueForPartyAsync, which folds the existence check into the insert so it is one statement: the search and the create are otherwise separated by an await, and every member of a party clicking the same entrance runs that sequence simultaneously on per-character workers and possibly on different scene servers — each finding no instance, each creating its own, splitting the party across separate copies of the dungeon in exactly the situation the party search exists for. The losers of that race insert nothing and join the winner. Solo characters use the same guarded insert with a list of just themselves, so the rule is enforced by the database for everyone. An instance created for an entry that then falls through — the player is pulled into combat while the database work runs — is markedFailedrather than left Pending, because a row nobody entered would otherwise lock the whole party out of every dungeon until the world server's stale-row sweep removed it. Joining validates the named instance against what the finder would actually have offered — the right dungeon, on this world server, public, enterable, not full — rather than trusting it, because a row ID is a small integer and the panel is not the only thing that can send the message; every one of those checks refuses with the sameInstanceUnavailable, so an ID cannot be probed to learn whether a particular instance exists. Joining another group's run joins their party, before the transfer rather than after: the transfer is a disconnect and a reroute, so there is no "after" on this server, and a character arriving inside the instance without having joined would be a stranger in somebody's run with no leader able to remove them and no way for the finder to resolve the instance as theirs on a later visit. It is refused for a character who is already in a party with anybody else (AlreadyInParty) — silently dissolving a real group would be a much larger act than the click that caused it, and if the joiner led that group it would hand it to somebody else without asking — while a character alone in a party of their own is simply released from it. Privacy is a lock on the front door rather than on the instance: a member of the owning party still gets in, which is what keeps re-entry working for a run that has been closed to strangers. Instance population is capped at the difficulty's own limit, or the scene'sMaxClientswhere the difficulty declares none: a full instance is refused withDestinationFullrather than falling through to "create a new one", which would silently split a party from the instance it was trying to join. Entry is gated byCanActOrMoveon both the request and the authoritative re-check after the async database work, and the hand-off is announced viaBeginDeliberateTransferso the disconnect that performs it is not mistaken for a combat logout.Faction System — Faction relationship management.
Scene Channel System — Open-world channel listing and channel switching. The list aggregates every
Ready,OpenWorldinstance of the character's scene on its world server — including instances hosted by other scene servers — viaISceneService.FetchAvailableAsync, resolving each host's address through a TTL cache keyed by world server ID and scene name (a scene server serves every world server, so a key of scene name alone served one world's channel list to another world's players). Switching is gated byCanActOrMove, so it is refused while dead, teleporting, frozen, mid-load or in combat — a channel switch is otherwise a cleaner escape than a teleporter, landing the player on an instance their attacker is not in. The destination is re-validated against the database and the character's state re-checked on the authoritative side before the transfer commits, and the transfer itself is handed toICharacterSystem.BeginChannelTransferso the departure is announced (and the scene population debited) while the character still belongs to the instance it is leaving. Every refusal is named throughSceneTransferRefusedBroadcastrather than returning silently, and "gone" is distinguished from "full" by reading the target row directly —FetchAvailableAsyncfilters on capacity, so a channel that filled up is simply absent from the list and was reported as no longer available, telling the player to refresh when the honest answer was to pick another. The channel-switch cooldown is claimed against the character row (the only cooldown that survives a switch, since the switch itself ends the connection the per-connection one was keyed to) and is reversible: it has to be taken before the transfer, because it is the last thing that can refuse the request, but the transfer can still fail afterwards — so every post-claim failure restores the exact previous timestamp rather than charging the player a full cooldown for a switch they were refused.ChannelAddress.Portis left at zero on the wire: a switch is never a direct dial, so a client has no use for it, and sending it published which scene servers host which instances to every player who opened the picker. The unsolicited per-login channel-list push is off by default (sendChannelListOnCharacterLoad) because the client asks for one when the player opens the picker — left on, every login paid two database queries for a snapshot that would be replaced before anyone could act on it. Client side, see Scene Channel Picker.Persisted Channel-Switch Cooldown — The rate limit lives on the character row (
characters.last_channel_switch_utc), claimed atomically byICharacterService.TryBeginChannelSwitchAsyncin a single check-and-stamp statement. A switch is implemented as a disconnect, so the client returns through the world server on a fresh connection id and quite possibly to a different scene server: any cooldown held per connection — or even per process — is erased by the very action it is meant to limit, and only ever delayed retries after a switch that had already been refused. The per-connection dictionary is retained purely as a cheap pre-check so a spamming client does not reach the database. The claim is taken after the destination is validated, so a player is not put on cooldown for asking about a channel that turned out to be full or gone.Leave Instance —
RequestLeaveInstanceBroadcastand the/leaveinstance(/exitinstance) chat commands remove a character from instanced content and return it to the open world at its recordedLastWorldPosition. This is a system-level guarantee rather than content data: a dungeon is expected to provide an exit teleporter, but a character bound to an instance is routed back into it on every login, so a dungeon authored without a reachable exit would otherwise strand its occupants permanently. Available as a command as well as a broadcast precisely so the escape hatch does not depend on a client UI having been authored. Gated byCanActOrMovelike every other voluntary transfer, so it is not a combat escape, and it performs the same ordered release (announce, rebind, save, release, disconnect) as the bind-point respawn.
Operator Control
- Server Lock (drain) —
world_servers.lockedandscene_servers.lockedare the authority for whether a server accepts new arrivals; each process reads its own row back on every pulse and adopts it, and registration preserves the column so a restart does not silently undo a maintenance lock. A locked world refuses logins except for accounts aboveAccessLevel.Player, so locking a world for maintenance does not lock out the people doing it. A locked scene server stops dequeuing scene-load requests and is skipped by the world server's open-world routing (IsSceneServerRoutable). Instance routing deliberately ignores the lock — a character bound to a dungeon can only go to the one server hosting it, so refusing there would evict them from their instance rather than drain them. Players already online are unaffected either way. - Scheduled Maintenance Shutdown —
shutdown_at_utc(absolute UTC, so every process counts down to the same instant) schedules a world or scene server to stop, and locks it in the same statement. Scene servers read the control state of every world they host scenes for, warn the affected players as the countdown crosses 15m/10m/5m/2m/1m/30s/10s, then disconnect them with a terminalServerMaintenancenotice one tick before the process stops so the notice actually flushes. Cancelling a shutdown deliberately does not unlock: halting a shutdown and reopening to players are separate decisions. A scene server clears its own consumed shutdown and lock as it exits, so an automatic restart does not stop again immediately. - In-Game
/adminCommands —status,lockserver/unlockserver,shutdown <seconds>/stopshutdown,lockscene/unlockscene,shutdownscene <seconds>/stopshutdownscene, all requiringAccessLevel.Admin. Registered as a single/admincommand with sub-command dispatch, so one access check covers every operation and no sub-command can be added that forgets to be gated. Commands write the database row and return; each process adopts it on its next pulse, which is how one command typed on one scene server reaches the world server and every other scene server under it. - Per-Command Access Levels —
ChatHelperregisters each slash command with a minimumAccessLevel, enforced against the character's own level as loaded from its database row (never from anything the client sends). A command the sender may not run is consumed rather than rejected: it is neither executed nor echoed to a channel, and the response is indistinguishable from an unknown command, so command names cannot be probed. Every refusal is logged with the character and account that attempted it.
Server Authority & Security
- CharacterStateValidation — Centralized static validation gate for all broadcast handlers.
CanAct()rejects dead, teleporting, incapacitated and unloaded characters. Incapacitation isCharacterIncapacitation.IsIncapacitated— frozen or stunned or mesmerized. OnlyIsFrozenused to be tested here:IsStunnedandIsMesmerizedwere set by the crowd-control buff templates and read by no code anywhere, so a stunned player could still craft, trade, use hotkeys and activate abilities through every handler that funnels throughCanAct, unless a template author had separately attached a condition asset.CanActOrMove()additionally rejects in-combat characters.TryGetPlayerAndValidate(conn, out player)canonical pattern resolves player from connection and validates in one call. Called at entry of every state-mutating broadcast handler. - Comprehensive CanAct Coverage — All server-side broadcast handlers validated: CharacterInventory (6 ops), Bank (2 ops), Equipment (2 ops), Quest (3 ops), Guild (7 ops), Party (7 ops), Friend (2 ops), Pet (4 ops), Hotkey (2 ops), Chat, Interactable. Movement pipeline also gated server-side in KCCPlayer.OnReplicate.
- Per-Account Rate Limiting — Auth callback rate limit keyed by account name (not ClientId), preventing multi-connection bypass. Separate per-connection rate limit for scene unload broadcasts.
- Respawn/Resurrect IngressGuard — Per-operation IngressGuard (2s debounce) on respawn-at-bind-point and resurrect-accept handlers. Prevents spam and concurrent-operation races.
- TCP/TLS Transport Encryption — All network traffic encrypted at transport layer.
62a. Item operations are one transaction, guarded by session ownership. A move, swap, deposit or withdraw touches two containers; those used to be three ordered but independent transactions, so a crash or a disconnect between them destroyed or duplicated the item. Each operation now runs as
Begin → AssertOwnership → TryClaimSequence → steps → Commit, rolling the lot back on any failure, and equip/unequip carry their attribute rows inside the same unit. The invariant is stated in code: the in-memory containers are authoritative and the database is a replica converging on them — memory is never rolled back, and divergence is repaired by a reconcile snapshot. Ownership is asserted withSELECT … FOR NO KEY UPDATEon the character row inside the transaction, so a competing claim blocks rather than races, and every batch quotes the session triple held when the mutation happened.TryClaimSequenceis an indivisible test-and-claim under that row lock, replacing an ordering check that had been evaluated outside the transaction and was a genuine item-resurrection hole. 62b. One-time dialogue choices are refused server-side. The "already chosen" test lived only in the client panel, so the server re-set the bit and re-ranOnSelectActionsfor everyDialogueChoiceBroadcastit received without ever readingChoicesMade— a patched client or a replayed packet could re-collect a one-time reward immediately, needing no restart, transfer or cache eviction. The mask is now both persisted (character_dialogue_choices, a write-through cache hydrated on character load) and enforced at the point of selection; persisting it alone would have stored the evidence of the exploit rather than preventing it. 62c. Ability crafting and hotkeys are validated against what the character actually has. The per-abilityAdditionalEventSlotslimit was applied only by the client while the server capped at a global 32, so a hacked client could stack 32 events onto a zero-slot ability; the server now enforces the per-ability limit and allows at most oneAbilityTypeOverride. Hotkey ability bindings were being validated against template IDs viaKnowsAbilitywhile the client sends instance IDs truncated toint.
Observer LOD System
- HashGrid Spatial Partitioning — FishNet
HashGridcomponent on the SceneServer scene's NetworkManager (_accuracy: 70). O(1) hash-based proximity: objects in the same or adjacent grid cells are "nearby."_gridAxesis serialized as2=XZ, which is the correct plane for a horizontal-plane world. (An earlier revision of this document reported it asXY; that has been corrected.) - Global Observer Conditions — The scene's
ObserverManager_defaultConditionslist holds exactly two assets: FishNet's stockSceneCondition(never observe cross-scene) andGridCondition(spatial hash pre-filter), applied to allNetworkObjects. - Tiered Distance Conditions (wired) — Four
DistanceConditionScriptableObjects live inAssets/Settings/ObserverConditions/:PlayerDistanceCondition(100m,_hideDistancePercent0.1),MonsterDistanceCondition(50m, 0.15),InteractableDistanceCondition(30m, 0.1),WorldItemDistanceCondition(15m, 0.2). The three playable character prefabs now referencePlayerDistanceConditionon theirNetworkObserver, and the monster/interactable/world-item conditions are applied to their respective prefabs. (An earlier revision reported these as authored but unreferenced.) - Per-Observer Streaming Budget —
ObserverBudgetCondition(referenced by the SceneServer scene) plusObserverStreamingRegistry/ObserverStreamingPolicydecide, per observer, a density-scaled range and a cap on how many characters may stream at full rate. Condition assets are cloned per object, so one object's state cannot leak into another's decision. - Distance-Scaled Transform Rate —
NetworkTransformDistanceLodshapes theNetworkTransformsend interval per observer by distance, so a distant peer costs a fraction of a near one without being culled outright. - Bandwidth Reduction (projected) — With the full observer condition setup applied, per-client observer bandwidth is projected to drop from ~256 KB/s to ~43 KB/s (83% reduction), and server aggregate outbound from ~25.6 MB/s to ~4.3 MB/s for 100 players + 100 NPCs. These remain design targets;
PredictionBandwidthBenchmarkTestsandObserverChannelCostTestsmeasure the per-field and per-channel costs that feed them.
FishMMO-Unity — Shared
The shared entity and logic layer (FishMMO.Shared assembly, 585 .cs files). Used by both client and server, containing all entity definitions, the ECA trigger system, templates, network broadcasts, and prediction pipeline.
Character System
- ICharacter / IPlayerCharacter Interfaces — Root character contracts: ID, name, transform, collider, network object, prediction manager, observers, flags, behaviours, triggers.
- BaseCharacter — Abstract NetworkBehaviour implementing ICharacter: behaviour registry, bitwise flag management, ECA trigger invocation, race model instantiation (Addressable), client character dictionary.
- PlayerCharacter — Concrete player class requiring 13+ behaviour components (attribute, target, cooldown, inventory, equipment, bank, ability, achievement, buff, quest, damage, guild, party, friend, faction controllers). KCC movement, chat anti-spam token bucket.
- CharacterBehaviour — Abstract base for modular behaviour components: InitializeOnce, OnStartCharacter, OnStopCharacter lifecycle.
- CharacterFlags — Bitwise state flags: Idle, IsMoving, IsRunning, IsCrouching, IsSwimming, IsTeleporting, IsFrozen, IsStunned, IsMesmerized, IsInInstance, IsLoaded, IsDead, IsInCombat, IsCombatLogged.
IsInCombatis transient and is stripped on both save and load;IsCombatLoggedis the one combat-related flag that is persisted, because it is what lets the login path tell "this account is playing elsewhere" (which must block a second login) from "this account owns a body running out its combat-logout timer" (which must not, or the player could never reclaim it).IPlayerCharacter.IsInInstance()additionally requiresInstanceSceneNameto be set, so the flag alone does not make a character instanced — the name is resolved from the instance's scene row during the load. - Combat State System — Tick-aligned combat timer on
CharacterDamageController. Enters combat on dealing damage, taking damage, or healing an in-combat ally. Auto-clears after configurable duration (default 600 ticks / 20s at 30Hz) of inactivity.EnterCombat()safe to call repeatedly — refreshes expiry.IsInCombatflag cleared on death and network reset. Combat state prevents teleportation (combat-escape prevention). - Combat Contributions & Loot Rights —
CharacterDamageControllertracks who contributed to a kill, byCombatContributionKind, and resolves credit through to the owner rather than the actor: a player who fought entirely through their pet earned nothing, because a pet is an NPC carrying none of the triggers quest objectives, achievements and faction standing are driven from, andFactionControllerrefuses adjustments for an NPC in any case. The contribution set is cleared when combat lapses, so leaving combat expires loot rights in both directions — reusing the combat window means tag expiry and combat state can never disagree, and tagging a creature for one point of damage no longer entitles the tagger to loot it half an hour later when somebody else finally kills it. - Combat-Escape Prevention — Teleport is blocked while the
IsInCombatflag is active. Movement is not — players move freely during combat; only teleportation is restricted. The movement gate inKCCPlayer.OnReplicateis split deliberately:- Predicted on both peers — incapacitation (
IsFrozen/IsStunned/IsMesmerized, viaCharacterIncapacitation) and death. Both are things the owner already knows, so it was simply never asked; gating them server-side only meant a stunned owner carried on predicting movement while the server refused it, and the reconcile snapped the character back every tick for the stun's whole duration. What the player saw was rubber-banding rather than a stun. - Server-only —
IsTeleportingandIsLoaded, which are server bookkeeping a client cannot evaluate.IsLoadedrides the spawn payload and reads as permanently true on a client. - Death is tested on the replicated health value, never on
CharacterFlags.IsDead: flags ride the spawn payload and are never re-synced, so a client's copy is stale from its first death onward and gating on it would freeze the owner permanently after one death.
- Predicted on both peers — incapacitation (
ECA Trigger System (Entity-Component-Action)
The data-driven trigger/action pipeline powering abilities, quests, dialogue, interactables, and game events.
- Trigger System Core —
TriggerScriptableObjects withTargetSelector+Conditions+OnConditionsMetActions+OnConditionsNotMetActions. Fault isolation (throwing actions caught/logged). - EventData — Typed event context container: Initiator, Target, TargetCharacter, RNG, ConditionFilter. Supports typed sub-payloads, forking, merging.
- Polymorphic Serialization — All actions/conditions/selectors use
[SerializeReference]+[SubclassSelector]for designer-authored Inspector workflows.
ECA Actions (52 implementations)
- Combat Actions — ApplyDamage, ApplyHeal, ApplyRevive, ApplyBuff, ApplyDispel, ConsumeResource, Interrupt, KnockbackHit.
- Ability Actions — AbilityApplyArea, AbilityApplyTarget, AbilityForkHit, AbilityHitCount, AbilityMoveTransform, AbilityPierceHit, AbilitySpawnMultiply.
- Item Actions — EquipItem, UnequipItem, GiveItem, RemoveItem. (Equip/Unequip
#if UNITY_SERVERguarded — persistent state mutations never run during prediction replay.) - Quest Actions — AcceptQuest, AbandonQuest, AdvanceQuestObjective, CompleteQuest, FailQuest, TurnInQuest.
- Interactable Actions — Bindstone, GatheringNode, LoreObject, NPCLookAtInteractor, PickupWorldItem, SendAbilityCrafterBroadcast, SendBankerBroadcast, SendContainerOpenBroadcast, SendDungeonFinderBroadcast, SendMailboxBroadcast, SendMerchantBroadcast, SendQuestOffer, Shrine, Switch, Teleport.
BindstoneActionrefuses to bind inside an instance —BindScene/BindPositionare consumed by the respawn-at-bind path, which hands the character to open-world routing, so aBindScenenaming a dungeon is a trap the character carries until it binds elsewhere — and matches the bindstone's scene by handle, since scene stacking means several instances of one scene are loaded at once and share a name. - Region Actions — ApplyRegionAttribute, ApplyRegionBuff, ChangeFog, ChangeSkybox, DisplayRegionName, PlayRegionAudio.
- Utility Actions — AchievementIncrement, AddFaction, ClearTarget, DestroyObject, DisplayDialogue, PlayFX. (DestroyObject
#if UNITY_SERVERguarded. PlayFX suppresses on a replayed tick viaIsReplayTick— a rollback re-runs the tick and would otherwise spawn the effect again per replay. ClearTarget still gates onTickEventData.IsReplicateTick.)
ECA Conditions (30 implementations)
There is no separate composite/AND-OR condition type. Every BaseCondition carries a ConditionTargetCombine Combine field (All/Any, default All) governing how per-target results aggregate, plus a universal Invert flag the framework applies in Check() — derived classes must not apply it themselves.
19. Combat/Attribute Conditions — HasResource, HasRequiredAttribute, HasBuff, HasCooldown, HitCount, IsCharacterAlive, IsImmortal.
20. Equipment/Inventory Conditions — CanEquipItem, HasEquippedItem, CanUseItem, HasInventoryItem, HasInventorySpace, HasBankItem, HasBankSpace.
21. Social/Progression Conditions — HasGuild, HasParty, HasFaction, TargetAlliance, IsArchetype, IsRace, HasPet.
22. Quest Conditions — CanAcceptQuest, HasQuest, QuestObjectiveComplete, QuestStatus.
23. Achievement Conditions — AchievementCompleted.
24. Presence/Controller Conditions — HasTarget, IsCharacterNPC, HasAttributeController, HasBankController.
ECA Target Selectors (13 types)
All derive from the abstract TargetSelector base (Conditions list + SelectTargets(EventData)). Selectors are attachable on the Trigger itself and, optionally, per-condition and per-action for additional fan-out.
25. Basic — EventTargetSelector, InitiatorTargetSelector, NearestTargetSelector, FurthestTargetSelector, RandomTargetSelector, AllCharactersTargetSelector.
26. Spatial — AreaTargetSelector, ConeTargetSelector, LineTargetSelector, ChainTargetSelector.
27. Hierarchy — ChildrenTargetSelector.
28. Named/Tagged — NamedSceneObjectTargetSelector, TaggedSceneObjectTargetSelector.
ECA Value Providers (10 types)
28b. ConstantValue, ConstantFloatValue, RandomRangeValue, RandomRangeFloatValue, StatScaledValue, StatScaledFloatValue, DamageAmountValue, HealAmountValue, FactionAmountValue, QuestObjectiveAmountValue.
Item System
- Item Template Hierarchy —
BaseItemTemplate→ConsumableTemplate/EquippableItemTemplate→ concrete: Potion, Scroll, Armor, Weapon. - Runtime Item —
Itemwith optionalItemEquippable,ItemStackable,ItemGeneratorcomponents. - Item Generation —
ItemGeneratorusingDeterministicRNGfor seed-based stat rolls (AttackPower, AttackSpeed, ArmorBonus + random attributes from databases). - Item Attributes — Template-driven attribute system with min/max values linked to CharacterAttributeTemplates.
- Item Containers —
IItemContainerwith slot locking, stacking, swapping.InventoryController,EquipmentController,BankControllerimplementations. - Item Slots — Head, Chest, Shoulders, Hands, Legs, Feet, Back, Primary, Secondary, Accessory (10 slots). Every member of
ItemSlotis explicitly numbered, and must stay that way. Item templates are ScriptableObjects and serialize the enum as its integer, so the number is the contract — not the name and not the position. InsertingShouldersat index 2 once renumbered every slot belowHandsin every already-authored asset: leggings became shoulders, boots became legs, a sword became feet and a shield became back. Nothing errored, because each value was still a valid slot — the items simply equipped to the wrong part of the body, and the only symptom was a sword on someone's feet. Add new slots at the end with the next free number; never insert, never reorder, and never reuse the number of a slot that has been removed.FishMMO > Validate > Equipment Item Slotscross-checks the authored assets against this.
Currency System
34b. Character Currency — CharacterCurrency is a currency balance carried as a character attribute, so it flows through the same persistence, reconcile and modifier machinery as every other stat rather than needing its own sync path.
34c. Currency Ledger, Not Escrow — CurrencyLedger writes one row after a movement has already resolved and its deduction has been persisted, so the outcome is known at write time and a row is never revisited. It is deliberately not an escrow: an escrow holds funds across an in-flight transaction so an interrupted one can be recovered, which requires the hold and the balance deduction to commit together — and they cannot here, because the deduction goes through the in-memory attribute controller and an asynchronous persistence queue rather than a transaction this code can enlist in. The ledger is an audit record, and calling it an escrow would imply a recovery guarantee it does not provide.
Ability System
- Ability Templates —
BaseAbilityTemplate→AbilityTemplate/PetAbilityTemplatewith ActivationTime, LifeTime, Speed, Cooldown, Price, RequiresTarget, HitCount. - ECA Ability Events — OnTick, OnHit, OnPreSpawn, OnSpawn, OnDestroy — each with configurable ECA triggers.
- Ability Activation State Machine — Resource cost validation via
IResourceCostconditions, activation queuing, consumable support, network sync. - AbilityObject — Networked GameObject for projectiles/AoE with lifetime, collision, tick handling, and snapshot reconciliation.
- Ability Knowledge System — Learned abilities, base abilities, ability events, event subset tracking.
- Cooldown System — Tick-based immutable
CooldownInstancewith reconcile snapshots, static events for add/update/remove.
40b. Swept Hit Resolution —AbilityObjectSweepcovers the segment an ability object travelled each tick: an overlap at the start (a cast cannot see what it begins inside of) plus a cast along the segment, ordered by distance with an identity tiebreak. Hits are deduped per body for the object's whole life, so a stationary field cannot drain its hit count into one victim.
40c. Honest Hit Feedback — The caster draws a predicted damage label at the tick it predicted; the server's coalesced report settles it (PredictedCombatEvents.TryConfirm), and anything unsettled after a one-second window greys out. A hit the caster already predicted is absorbed by the object's own dedupe when the authoritative echo arrives, so nothing is drawn twice. Only the server and the caster's own client resolve hits; a third-party observer is told viaAbilityObjectHitBroadcast. 40d. Deterministic Container IDs —AbilityContainerAllocatorallocates the ids spawned objects are keyed by, so a predicted object and its authoritative counterpart agree on identity and a rollback can name exactly what to destroy.
40e. Owner-Only Spawn Reconciliation —PredictedAbilityStateHistoryrecords what this client's simulation left behind for each tick, and the reconcile for tick T is compared against the history entry for T rather than against live state that has moved on. Three signals are distinguished because they mean different things: a seed mismatch (the client's roll diverged), theDeniedflag (the server refused the activation — authoritative and independent of RNG, since a rejection can precede any seed advance), andNoSpawn(the server demonstrably spawned nothing that tick while the client did). A denied activation refunds itself, because the cooldown table and resource state ride the same reconcile.
40f. Detached Object Snapshots — When a caster disconnects with ability objects still in flight,AbilityObjectSnapshot+SnapshotCharacter+SnapshotAttributeControllerstand in for it: identity, the object's own transform as the character's, and a read-only clone of the attribute sheet so stat-scaled damage still resolves. Everything else degrades gracefully rather than throwing.
Buff/Debuff System
- Runtime Buff — Tick-based timing (ExpiryTick, NextTickTick), stack count, cumulative tick multiplier.
- Buff Template Types — AttributeBuff (flat stat modifier), AttributeTickBuff (per-tick modifier), ResourceTickBuff (DoT/HoT), StateBuff (stun/freeze/mesmerize), CompositeBuff.
- Buff Reconciliation —
BuffReconcileEntryfor deterministic rollback in the prediction pipeline.
43b. Absolute Stack Accounting — Each buff hook states the whole contribution for the stack count that will be in effect —(1 + Stacks) × Value— rather than adding a delta. Apply and remove are therefore exact inverses at every stack count by construction, and any sequence of stack changes ending at the same count leaves the same value.
Character Attribute System
- Three-Tier Value System — baseValue + formulaModifier + externalModifier = finalValue. Parent/child dependency graph with formula propagation.
44b. Attributed-Modifier Ledger —externalModifieris the sum of named contributions rather than an anonymous running total. Every contributor writes throughSetSource(ModifierSource(Kind, Id, Index), value)— Item, Buff, Region, DungeonScaling, NpcBonus — so a contribution can be restated idempotently and released by contributor withClearSourceGroup, without the apply and release halves having to agree on an index scheme forever. The server's total is installed as anAuthoritativeresidual over whatever the peer has already attributed, which is what lets the owner predict an equip or a buff locally and still converge on the server's number rather than double it. - Resource Attributes —
CharacterResourceAttributeextends with currentValue (health/mana/stamina), clamping, regeneration. - Attribute Formulas — Flat bonus and percentage bonus formulas with dependency tracking.
- Propagation Batching — Deferred notifications with suppression for replay performance.
- Tick-Driven Regeneration — A 1 second pulse delivering a share of the amount authored against a 5 second window, so the pulse got finer without getting stronger, plus a 1 second consumption lockout that suppresses regen right after a spend. Carries a per-tick monotonic guard against double-advance under replay.
48b. Pooled-Character Safety —RestoreTemplateBaselinereleases contributors withClearAllModifierSources(), neverSetModifierDirect(0). The latter installs a residual of minus the attributed sum — a total of zero today, and the previous occupant's items and buffs still sitting in the ledger for whoever recycles the object next.
48c. Owner and Observer Get Different Shapes — The owner's spawn payload carries base values only and builds its external modifier from the buff and equipment restores that follow, completing it on the first reconcile's authoritative residual. Observers receive base plus the server's total, because they apply no contributions locally and could not reconstruct the region, dungeon-scaling and NPC-bonus terms in any case. Sending the total to the owner as well was a double-apply by construction. - Damage System —
CharacterDamageController: damage, healing, kill, resurrection, combat state management with full ECA trigger invocation. Client+server deterministic prediction (Damage/Heal/Revive run on both sides; Kill server-only for non-deterministic side effects). Healer enters combat when healing an in-combat ally. 49b. Combat Event Coalescing —CombatEventCoalescermerges every hit within one tick sharing a(source, kind, damage type)into a single report, carrying an occurrence count so the caster can settle each of the labels it predicted rather than leaving a volley to grey out. A full table folds into an anonymous bucket so the total still reaches the client. - Damage Types & Resistances —
DamageAttributeTemplate(physical, fire, frost, etc.) andResistanceAttributeTemplatepairing. - Death System — Player death shows dialog with Respawn/Resurrect options. NPC corpse decay timer (configurable per spawner).
ResurrectOfferBroadcast/ResurrectAcceptBroadcast/RespawnAtBindPointBroadcast/DeathBroadcast. Reconnect-while-dead re-shows death dialog. - Revive —
Revive(ICharacter, int)works on dead characters (unlike Heal). FiresOnResurrectedstatic event, resets death animation, fires ECA resurrect triggers.
Client-Side Prediction Pipeline
Pipeline structure
- Unified Prediction Controller —
CharacterPredictionControllerdiscovers allIPredictableControllercomponents, stable-sorts by Order with a deterministic type-name tiebreaker, and drives a single FishNet Prediction V2 pipeline. One[Replicate]/[Reconcile]pair perNetworkObject, which is what avoids FishNet's multi-behaviour prediction conflicts. - Participating Subsystems — KCC movement (Order 80), BuffController (85), CooldownController (90), EquipmentController (93), CharacterAttributeController (95), AbilityController (100). The order encodes real data dependencies: buffs and equipment settle their attribute contributions before the authoritative total that subsumes them is installed, which is what keeps the ledger's residual correct within a single reconcile pass.
- Type-Safe Ticks —
PredictionTickcan only be produced from a replicate input, so the compiler refuses a rawTimeManager.LocalTickwhere a prediction-domain tick is required.
55b. Tick Domain Separation — A replicate's tick is the owning client's unsynchronised counter, not the server's. Anything indexing server-domain state (lag-comp anchors above all) resolves throughLagCompensationTick.ServerTickDomain. Buff and cooldown ticks that arrive in a spawn payload are translated into the replicate domain on the first replicate rather than being trusted as-is, and the controller publishes tick snapshots so consumers that run before its own tick callback do not read the previous tick's value.
55c. Input Authority Is Not Ownership —HasInputAuthorityanswers "who writes this character's input this tick?" A monster is server-owned with no owning connection; a pet is owned by the summoner's connection yet driven entirely by a server-sideAIController. Gating onIsOwnerleft monsters with nobody producing input at all.
55d. Empty Queue Is Not Zero Input — When the replicate queue is exhausted FishNet runs the body with a default struct rather than skipping the tick. Controllers treat that as "no new input", not "input zero": held casts keep theirIsHeldstate, and the view offset latches only from a replicate carrying real input (ReplicateState.Created)._dropExcessiveReplicates: 1is a correctness setting, not a tuning one.
Input and quantisation
55e. Input, Not State — CharacterReplicateData carries only input. Movement axes ride a single signed byte each (MoveAxisCompression), aim rides a packed uint (AimDirectionCompression: 16 bits yaw, 16 bits pitch).
55f. Quantise Before Predicting — The producer writes the quantised value into the input struct itself, not just onto the wire. This is input to a deterministic simulation, so the owner must commit to the value the wire can carry — otherwise it predicts with one direction while the server and observers simulate with the decoded one, and every cast diverges by the quantisation error.
55g. Fixed-Point Encode/Decode — Encode(Decode(x)) == x. Poles pin yaw to zero, pitch uses Atan2 rather than the numerically flat Asin, and cos(pitch) is clamped non-negative. This matters beyond aim: ground-normal reconcile deltas derive their baseline by re-encoding, so any round-trip disagreement is added straight onto the result.
55h. AimDirection Replaced CameraRotation — Nothing ever read the roll; movement and the ability trace both take the same forward. The quaternion carried a degree of freedom no consumer used and that could not be represented exactly. The aim ORIGIN is not sent either — it is derived from the motor on whichever peer needs it, so it cannot drift from the capsule it originates at.
Wire format and robustness
- Delta Compression —
CharacterReconcileDataDeltaSerializer(12 of 16 bitmask bits in use),CharacterAttributeResourceStateSerializer, and the KCC motor-state and platform delta serializers. Arrays (Cooldowns,Buffs,Equipment,Attributes) use index-delta compression with aReferenceEqualsshortcut that skips per-element comparison entirely for an unchanged cached snapshot.
56b. Loss-Detecting Delta Chain — FishNet's scalar delta primitives are difference-based, so a payload is only decodable by a peer holding the baseline the writer used. A one-byteSequence, stamped when the reconcile is actually written (not when it is created — the send is skipped when no resends remain), lets the reader requireprev + 1and reject anything else. A lost datagram costs "no correction until the next snapshot" instead of "a wrong correction for up to a second".
56c. Absolute Snapshots for Baseline-less Peers — AFullSerializeis routed through the absolute serializer rather than forced through the difference-based one, so a peer with no baseline (a late-joining observer) decodes it correctly. FishNet emits one about once per second, so it doubles as a periodic resync that repairs drift rather than letting it accumulate.
56d. Length-Framed Spawn Payloads — EveryNetworkBehaviouron an object shares one unframed buffer, so a reader that stops early leaves every behaviour after it reading from the wrong offset. All four predicted controllers frame their block with a byte count, validate the declared length against what the reader actually holds, and seek to the end of their own frame on every defensive abort and on the success path.
56e. Two Payload Shapes —PayloadVisibilitychooses an owner or observer shape per connection, and the shape flag travels in the stream rather than being re-derived at read time. Observers receive the server's authoritative totals; owners receive base values and build the rest from their own predicted state. Owner-only state — generator internals, full cooldown tables, inventory internals — never reaches an observer.
56f. Reconcile Delta Efficiency — Measured byPredictionBandwidthBenchmarkTestson every run, so a new field's cost is visible when it lands:
| Struct | Scenario | Full | Delta | Saving |
|---|---|---|---|---|
CharacterReconcileData |
idle | 199 B | 4 B | 94.7% |
CharacterReconcileData |
walking | 199 B | 28 B | 83.0% |
CharacterReconcileData |
combat | 203 B | 71 B | 62.8% |
CharacterReplicateData |
idle | 11 B | 1 B | 88.2% |
CharacterReplicateData |
walking | 11 B | 6 B | 43.9% |
CharacterReplicateData |
casting | 13 B | 7 B | 44.9% |
Movement
56g. Moving Platform Prediction — KCCPlatform is a separately predicted NetworkObject with its own [Replicate]/[Reconcile] pair and its own delta serializers (KCCPlatformDeltaSerializers). A character standing on one carries CurrentPlatformID in its motor state, so the platform's motion and the rider's own prediction reconcile independently instead of fighting over one transform.
56h. Third-Person Camera — KCCCamera is a pure view concern and is deliberately not replicated. Only the quantised aim direction it produces enters the replicate data; the camera's own position and roll are reconstructible locally and were a degree of freedom no consumer read.
Determinism
- Deterministic RNG — xoshiro128** with full 128-bit state captured in reconcile data, not just the 32-bit output. The seed alone cannot reconstruct the generator, so without the state words a single mismatch permanently desynchronised it and every later activation mismatched too. All prediction-path code uses
DeterministicRNG— zeroUnityEngine.RandomorSystem.Random. - Shared Speed Enforcement —
MaxAllowedSpeed = SprintSpeed × 3.0f(KCCController) runs identically on client and server in shared code. No server-only branches. - Motor PhysicsScene Init —
KCCPlayerinitialises the motor'sPhysicsScenefrom its GameObject's scene, so client collision queries match the server's. A scene server hosts many scenes and the default one holds none of these colliders. - Deterministic Ability Math —
System.Math.Ceiling(double)replacesMathf.CeilToInt(float)for activation and cooldown tick rounding, preventing x86/ARM one-tick mismatches.
60b. Replay-Safe Side Effects — A reconcile replays every tick since the correction. Deterministic state mutation re-runs; observable side effects do not. Buff and cooldown controllers suppress event/ECA/FX dispatch on a replayed tick, attribute notifications are suppressed and discarded rather than queued, regeneration carries a per-tick monotonic guard, andPlayFXActiondeclines on a replayed tick outright.
Physics and hit resolution
- Physics Queries Are Server-Only, Not Replay-Gated — Every ECA target selector (
Area,Cone,Line,Chain,Nearest,Furthest,Random,AllCharacters) and every physics action (AbilityApplyAreaAction,AbilityApplyHitscanAction,ApplyThreatAction) gates on the peer viaEcaAuthority.IsServer, because a physics query is not reproducible across peers. This replaced anIsReplicateTickguard that also suppressed the server — the server's own spawn and self-target dispatches carry replicate ticks too, so an area effect wired toOnSpawnused to run on no peer at all.
61b. Fixed Selection Pipeline — Every capping selector runsquery → grow while full → resolve hit root → rank by distance with identity tiebreak → dedupe by body → cap, in that order. Capping the raw overlap orders an arbitrary subset chosen by the broadphase; deduping after the cap counts colliders rather than bodies.MaxHits <= 0means uncapped everywhere.
61c. Lag Compensation — Hits resolve against where the caster's client saw its peers.CharacterPositionHistoryrecords one pose per tick into a ring sized frommaximumRewindMilliseconds(500 ms, the designed worst case);LagCompensationRegistry.Rewinddisplaces every character in the scene to that pose for the duration of one query and restores afterwards, refusing nested scopes and restoring even when the body throws. The client contributes the one term the server cannot derive — its full round trip plus its interpolation buffer, asViewOffsetTicks+ a 1/256-tick fraction — and the server adds its own replicate queue depth. Every latency term cancels exactly;LagCompensationClosedLoopTestspins that identity across a spread of round trips.
61d. One Scope Per Selection — Query, distance ranking, per-body dedupe and the cap all happen inside the rewind, and results are materialised before anything is yielded. Ranking outside the scope mixes a rewound world with a live one; yielding inside it would run the damage pipeline against a world hundreds of milliseconds stale.
61e. Rewind Is Bounded, Not Trusted — The client-supplied offset is a claim: capped byMaximumCompensationTicksbefore it reaches the history, then clamped by the history to its oldest recorded sample. A tick thousands out is refused rather than clamped — that is a tick-domain error, not a latency claim, and clamping it would hand back a real-looking pose for a tick nobody recorded.
61f. Who Resolves Hits — The server (inside a rewind) and the caster's own client (whose world already is that rewound one). A third-party observer resolves nothing and is told, viaAbilityObjectHitBroadcast— its world is interpolated against its own latency, so it would invent hits nothing would ever correct.
61g. Knockback Is Reconciled, Not Fought — The impulse is written tomotor.BaseVelocity, a field of theKinematicCharacterMotorStatethe reconcile already carries. The victim's owner receives it and replays forward from it, so there is no apply/erase/re-apply stutter. The attacker sees a cosmetic lean immediately on a child transform theNetworkTransformdoes not touch, and the two compose.
Observer synchronisation
- Observers Do Not Simulate Their Peers — State forwarding is deliberately off for playable characters. Position arrives via
NetworkTransform; resources, attributes, buffs, equipment, ability casts, ability hits and ability end-of-life each have an explicit broadcast (never an RPC), sent to the observer set except the owner viaObserverBroadcastScope.
62b. Late Joiners Reconstruct the Same State — Every one of those broadcasts has an observer-shaped form in the spawn payload, so a client that arrives mid-fight holds what a continuous observer holds. Pinned byObserverSynchronizationProofTestsandLateJoinerReplayTests.
62c. Change-Driven Push, Not Per-Tick —ObservedResourcePushSchedulerpushes resources on a 6-tick interval in combat and 12 out of it, and only when something actually changed. Buff pushes are a structural diff against what the observers were last sent.
62d. No Duplicate Transport —ApplyObserverTransportModesilences theNetworkTransformonly when prediction genuinely moves the character (aKCCPlayeris present) and state forwarding is on. An NPC runs the same pipeline but is moved by a NavMeshAgent, so itsMotorStateis default every tick and silencing its transform would freeze it for every observer while it carried on fighting.
62e. Honest Client Feedback — The caster draws a predicted damage/heal label at the tick it predicted; the server's coalesced report settles it (PredictedCombatEvents.TryConfirm, which settles as many predictions as the report claims occurrences), and anything unsettled after a one-second window greys out. A hit the caster already predicted is absorbed by the ability object's own per-body dedupe when the authoritative echo arrives, so nothing is drawn twice.
AI System (NPC)
- State Machine —
BaseAIStatesubclasses: Idle, Wander, Patrol, ReturnHome, Retreat, GetBehind, Orbit, PetIdle, and the attacking family (BaseAttackingStateplus Melee/Ranged/Caster/Pet presets and the Healer/Defender/Rogue subclasses), plusAggressionStateandBossScript. - Archetypes Are Data —
AIArchetypeTemplateis one asset that is a whole brain: states, personality, ability rotation, behaviour tree, threat tuning and LOD profile. Assign it toAIController.Archetypeand every other slot fills itself in at spawn.Validate()reports configurations that spawn and then quietly misbehave. 16 archetypes ship (10 enemy, 6 pet). - Shared Combat Decision —
AICombatDecision.Planis a pure function over plain floats that every attacking state runs. Melee, archer, caster, defender and rogue behaviour fall out of four serialized numbers, so an archetype's behaviour is assertable in an EditMode test without a scene. - Tick-Driven Brain — The AI runs on the FishNet
TimeManagertick, notUpdate.AiTickRate(default 8 Hz) is rounded to a whole divisor of the 30 Hz network tick so the brain is phase-locked and its rate does not move with server load. Elapsed time per tick is computed, not measured. - Level of Detail — Four distance tiers with per-tier intervals counted in AI ticks: Active (full pipeline), Nearby (no BT/boss/sweep; combat entry is event-driven), Far (movement only), Dormant (wake-up check). Three profiles ship, including a responsive one for pets.
- Combat Slots —
AICombatSlotsgives each attacker on a shared target its own angular slot, with ring capacity derived from agent geometry and a staggered second rank for overflow. Unity's local avoidance stops agents overlapping but has no say in where they are going; several NPCs sent to one point shove each other around it regardless of tuning. - Movement Correctness —
AIController.MovementreportsComplete/Partial/Failed/Throttledrather than assuming success. Unity returns a partial path to an unreachable destination instead of failing, so an NPC that only checks arrival distance stops at the near side of an obstacle and reports success. Includes widening NavMesh sampling, stuck detection, and escalating recovery that warps only after visibly failing to walk out. - Group Combat —
NPCGroupwith roles and pack tactics (Surround, Flank, FocusFire, Kite) that assign each member a distinct orbit angle and ring radius. - Boss Mechanics —
BossPhase,BossScript,BossTimedMechanic. - Behavior Tree —
AIBehaviorTreeofAIBehaviorNodes:AISelector,AISequence,AIInverter,AIRepeater,AICompositeNode,AIConditionNode, plus game-specific leavesAIHasTargetNode,AIIsDeadNode,AIGroupInCombatNode,AIAdoptGroupTargetNode,AIStateTransitionNode. The editor refuses cyclic connections and the runtime carries a depth guard, so a hand-edited asset degrades to a failed evaluation instead of a stack overflow that terminates the server. - Deterministic RNG — Seeded per-NPC for reproducible behavior, now paired with tick-driven timing so when a roll is drawn is reproducible too.
- Ability Rotation —
AIAbilityRotationfor condition-driven combat ability selection, evaluated before the default scorer. - Combat Personality —
AICombatPersonalitystyles: Balanced, Aggressive, Defensive, Cautious, Berserker, Pathetic, Determined, Rampaging. Targeting modes: Threat, Random, Weakest, Nearest. A Pathetic personality is guaranteed a retreat threshold even if the field is left at zero; Rampaging forces random re-targeting so it cannot be held by threat. Per-intent ability weights (damage, heal, control, debuff, buff, threat) steer which half of a shared spellbook an archetype reaches for, without naming a single ability.
Interactable System
- Dungeon Difficulties — A dungeon declares its own difficulties on a
DungeonTemplate, as a list rather than an enum: there is no global set of difficulty levels, because dungeons do not agree on how many they have or what they mean — a short introductory dungeon may offer one, a raid five, with the top one banning resurrection outright. An instance records the index it was opened at, meaningful only alongside the dungeon that defined the list, so entries are appended rather than inserted once a dungeon is live. ADungeonDifficultyDefinitionis deliberately grouped into requirements (minimum party size, checked against the roster so a dungeon demanding a group cannot be started by whichever member arrives first and then finished alone; a capacity override), detriments (a resource multiplier applied to NPC health, a list of named attribute scalars for anything else, deaths allowed per character, whether resurrection is permitted, a lifetime override) and benefits (loot quantity and currency multipliers). Damage is scaled through named attribute templates rather than a fixed "enemy damage" field, because there is no built-in notion of which attribute represents damage — that is a decision each build makes when it authors its attribute templates — and naming them also makes the rules summary say what actually changes.
The rules a player agrees to are generated from those values by BuildRulesSummary, never hand-written, so a difficulty made harder without being paid for shows exactly that in the panel, and a note left stale by a later balance change cannot misrepresent what the server will do. An index the dungeon no longer offers is refused, never clamped: clamping would quietly enter a player into a ruleset they did not choose, and on a dungeon whose top difficulty ends a run on the first death that is not a rounding error.
The rules reach the things that spawn inside a run through DungeonDifficultyRegistry, a scene-handle-keyed map the scene server publishes as it finishes loading each scene and withdraws as the scene unloads. An NPC waking up in a dungeon knows only the Unity scene it is in — not which row asked for it, not which party owns it — and it cannot ask the server systems, which live in an assembly it does not reference. Withdrawal is unconditional and happens before anything else on unload, because Unity reuses scene handles: an entry that outlived its scene is not merely stale, it is another dungeon's rules waiting to be applied to whatever loads at that handle next. NPCs are scaled after their spawner and prefab bonuses, so a zone that varies its NPCs keeps that variation on every difficulty, and a resource's current value is raised with its maximum — scaling only the ceiling would spawn every enemy in a hard dungeon already wounded, in exact proportion to how much harder it was supposed to be. Loot multipliers scale the amounts rolled and never the drop chances: scaling chance would change what a table can produce, so a rare line meant to be rare everywhere would stop being rare on the hardest difficulty. A limited-lives rule removes only the character who died — ending a group's run over one member's mistake would also make a hardcore run something any one member could end for everybody — and the count is a property of the attempt, cleared on every route out of the instance so returning starts over.
- 16 Interactable Types — All derive from
Interactable : NetworkBehaviour, IInteractable, ISpawnable:AbilityCrafter,Banker,Bindstone,CapturePoint,Container,DialogueInteractable,DungeonEntrance,GatheringNode,LoreObject,Mailbox,Merchant,QuestInteractable,Shrine,Switch,Teleporter,WorldItem. Behaviour is authored as ScriptableObject templates rather than per-instance fields —CapturePointTemplate(capture time, ownership rules),ContainerTemplate,GatheringNodeTemplate(loot table, remaining uses),LoreObjectTemplate(text, and the abilities, ability events and items it unlocks),ShrineTemplate(heal health, mana or both, plus an optional buff) — so a designer configures one asset and reuses it across every placement instead of re-entering the numbers per instance. - Switch Targets — A
Switchexecutes against anything implementingISwitchTarget, so what a lever does is authored rather than special-cased:SwitchTargetMoverslides and/or rotates a transform between a closed and an open pose (a door, a portcullis, a drawbridge, a moving platform) andSwitchTargetObjectenables and disables a set of GameObjects. The switch knows only the interface, so a new mechanism is a new component rather than a change to the switch. - Server-Authoritative Interactable State — Interactables that change the scene rather than opening a window — a capture point's ownership and progress, a gathering node's remaining uses, a world item that has been taken, a switch that has been thrown — are applied on the client by
ClientInteractableStateSystemfrom the server's own broadcast. It is deliberately separate from the panels that display them: the system keeps the client's copy of the world correct for everything that inspects it (target frames, world labels, whether the thing is still interactable at all), while a panel draws the transient readout, and neither depends on the other having run. - Base Interaction — ECA-Authored, No Handler Plugins —
InteractionRange3.5u default,INTERACT_RATE_LIMITof 60ms (overridable per type viaInteractRateLimit). Behaviour is authored entirely as aList<Trigger> OnInteractTriggerson the interactable prefab and fired viaIInteractable.ExecuteOnInteract(EventData). There is no server-side handler-plugin architecture — no handler interface, no registration attribute, and no handler initializer exists anywhere in the codebase. - Server-Side Validation — The server's
InteractableSystemvalidates the scene, runsValidateSceneObjectagainst the character's scene handle, resolves theIInteractablecomponent, checksCanInteract()(which coversInRange()and the rate limit), then invokesExecuteOnInteractwith aPlayerInteractionEventData— all inside anIngressGuard. - Capture Points — PvP capture points with state machine (
CapturePointTemplate,ObjectiveState). - Dialogue Trees —
DialogueTemplatewithDialogueNode/DialogueChoice, server-authoritative session management with choice bitmasks. - Gathering Nodes — Harvesting with
GatheringDropdrop tables, cooldowns, remaining uses (GatheringNodeTemplate). - Merchant Tabs — Categorized merchant inventory tabs (
MerchantTabType,MerchantTemplate).
Faction System
- Faction Standing — Per-faction integer standing with Allied/Neutral/Hostile classification.
- Faction Matrices — Template-driven faction relationship matrices with editor tooling.
Quest System
- Quest Lifecycle — Inactive → Active → Complete → TurnedIn / Failed.
- Objective Tracking — Per-objective progress with required amounts.
- Attribute Requirements — Pre-requisite attribute checks before acceptance.
Social Systems
- Friends — Friend list management with online status.
- Guilds — Membership, invites, ranks, join/leave ECA triggers.
- Parties — Creation, invites, member tracking, leader ranks. A party belongs to one world server, recorded on the party row at creation. Characters are global on purpose — the same character can be played on any world server so friends can play together wherever they are — but a party is replicated between scene servers through the party update pump, and that pump is scoped to a world server. A membership carried across would be updated by pumps that cannot see each other: rosters that never converge, invitations to instances that do not exist on the other side, and a leader nobody can reach. So a character arriving on a different world server is dropped from its party during load, before any of it is applied — dropped rather than migrated, since the party is still live where it belongs and pulling it across would take its other members with it. A party that cannot be read is left alone: a transient database fault is not evidence that a character has changed shard.
Joining a dungeon instance somebody else opened also joins their party, without an invitation. That is not an invitation bypass — the only caller is the dungeon finder, and it reaches it only after establishing that the party has published a joinable instance, which is an explicit and revocable offer by its leader — and it enforces the same size limit the invitation path does, so a full party's run simply cannot be joined. Members already on the scene server are told immediately rather than waiting for the pump, because the people most likely to be watching their party frame when somebody joins their dungeon are the ones standing in it. The join also repairs a leaderless party: a departure and a join race each other, and if the leader left in that window the transfer chose from a roster read before the joiner's row landed, leaving a party with members and no leader — a state nothing would ever repair on its own, since promotion needs a leader to perform it. The repair promotes deterministically by lowest character ID, so two servers repairing the same party concurrently choose the same member and the second write is a no-op rather than a second leader.
World System
- World Scene Details — Per-scene configuration: max clients, spawn/respawn positions, teleporters, boundaries.
- Day/Night Cycle — Configurable cycle durations, skybox transitions, object activation/deactivation, material alpha fading, ECA triggers for day/night transitions.
- Spawner System — Linear/Random/Weighted spawning with respawn conditions (OR/AND), initial/max counts, pooling (
ObjectSpawner). NPC corpse decay with per-spawner override. Re-rolled attributes on each spawn. - Deterministic Memory Footprint —
ObjectSpawnerPoolreservesMaxSpawnCount + PrewarmHeadroominstances of every prefab a spawner can select, at scene load, de-duplicated across spawners sharing a prefab. Entities are cached and recycled through FishNet's pool rather than destroyed; the pre-warm converts a heap that grew as players explored into a one-time load cost you can plan capacity against. - Per-Spawner Entity Overrides —
NPCSpawnableSettingsrolls attributes, AI archetype, additional or replacement abilities, faction, corpse decay and a random uniform scale.ItemSpawnableSettingscarries a weighted roll table. One prefab serves a zone's worth of variants, which matters for memory as well as content: a duplicated prefab is a second pool bucket and a second fixed slice of the budget. - Teleporter System — Cross-scene and same-scene teleportation with cached destinations.
- Region System — Zone definitions for area effects (fog, skybox, audio, buffs, attributes, region name display), driven as server-authoritative ECA triggers:
Regionis aNetworkBehaviourcarryingOnRegionEnter/OnRegionStay/OnRegionExittrigger lists.RegionMembershipresolves ownership when regions nest so only the innermost region owns a character, andRegionGeometryholds the authored shape.
100b. Region Attribute Contributions Are Released, Not Negated —ApplyRegionAttributeActionwrites through the attributed ledger underModifierSource.Region(regionObjectID, entryIndex), so a stay trigger firing every tick restates one contribution rather than accumulating one per tick, leaving a region releases every entry it wrote whatever index it used, and two overlapping regions cannot release each other's. - Scene Boundaries — Terrain and custom boundary definitions.
Character Appearance & Visual Equipment
- Modular Character System — One shared humanoid skeleton, one Animator, one animation library for all races and equipment.
- Body Region System — Body mesh split into 6 hideable regions (Head, Torso, Arms, Hands, Legs, Feet).
BodyVisibilityManagerwith per-slot reference counting for overlapping equipment hides. - Character Customization — Bone scaling for Height, ArmLength, LegLength, TorsoLength, ShoulderWidth, HeadScale. Race presets (Human/Dwarf/Elf). Blend shapes for Weight, MuscleMass, ChestSize, WaistSize.
- Equipment Visuals —
EquipmentVisualControllerwith persistent renderer pool (no Instantiate/Destroy spam). Loads prefabs via Addressables, extracts mesh + materials, binds to skeleton viaSkeletonBinder.BindMeshKeepParent. A template with no model assigned is treated as "no mesh", not as an error: an unassignedAssetReferencestill serializes as an object with an emptym_AssetGUID, so it passes a null check and then throwsInvalidKeyExceptionout ofLoadAssetAsync— surfacing as an unhandled exception in the player's console on equipping an ordinary item. The reference is tested withRuntimeKeyIsValid()as well as for null, which routes it to the "no mesh configured" warning that already existed to describe exactly that case. - Weapon Attachment — Weapons as
MeshRendererchildren of bone transforms (RightHand, LeftHand). Follow animations automatically. Scale-independent from body proportions. - Equipment Mesh Variations —
EquippableItemTemplate.EquipmentMesheslist with seed-based selection viaModelPools/ModelSeed. - SkeletonBinder — Bone name matching with caching. Generation-based cache invalidation for instance ID recycling safety.
- Animation System —
CharacterAnimationControllerwith Speed, IsGrounded, IsCrouching, Jump, Attack, Block, Roll, Cast, Death, RootMotion. FishNetNetworkAnimatorintegration. - Ability Animation —
TriggerAbilityAnimationmapsAbilityTypeto animation: Physical→Attack, Magic→Cast, Block→SetBlocking, Roll→TriggerRoll. Death animation suppresses all other state.
AI Threat System
- Threat Table —
AggressionControllerwith damage, healing, resource expenditure threat. Configurable weights per category. - Vulnerability Scoring — Low-health targets (<30%) get 1.5x threat multiplier. Low-mana targets (<20%) get 1.3x multiplier. AI intelligently pressures weakened enemies.
- Replay-Safe Events —
AggressionState.IsSpawnedAndAuthoritative()guard prevents threat double-counting during client-side prediction replay. - Object-Pooled Aggression Entries — Stack-based pool for
AggressionEntryto avoid per-event allocations. - Single-Dispatch Routing —
AggressionDispatcherholds one global subscription for the whole process and routes damage by dictionary lookup on the defender, so a hit costs O(1) rather than one delegate invocation per NPC alive. Heal and kill walk a list and skip anyone whose table is empty with a field read. - Tick-Derived Staleness Clock — Decay and expiry share one clock advanced only by
Tick, so "stale" means seconds of AI time without an event. Wall-clock expiry disagreed with tick-driven decay whenever LOD throttled an NPC or the server hitched. - Taunts and Threat Abilities —
ApplyTauntActionandApplyThreatActionare ECA actions attachable to ability events. The taunt guarantees top threat rather than adding a flat bonus a long fight has already outgrown, and can force an immediate target switch.ApplyThreatActionis the caller that finally givesResourceWeightmeaning.
Network Broadcasts (30+ types)
- Auth — Authentication request/response, token sync.
- Character — Character data, abilities, achievements, archetype, factions, friends, guild, party, pet, quest, hotkeys.
- Inventory — Inventory, equipment, bank slot sync.
- Character Create/Select — Creation request/result, character details, delete.
- Chat — Chat messages with 10 channels (Say, World, Region, Party, Guild, Tell, Trade, System, Command, Discord).
- Interactable — Interactable state sync.
- Naming — Name reservation/release, ID ↔ name resolution.
- Scene — Scene loading, transitions, channel addresses (
ChannelAddressidentifies a channel by itsscenes.id, never by a process-local handle), scene-routing queue positions (WorldSceneQueuePositionBroadcastwith aWorldSceneQueueReason), voluntary-transfer refusals (SceneTransferRefusedBroadcastwith aSceneTransferRefusalReason:DestinationUnavailable,DestinationFull,CharacterStateChanged,OnCooldown,PartyInstanceExists,ServerError,AlreadyAtDestination,RequirementsNotMet,InstanceUnavailable,AlreadyInParty), and the leave-instance request. Both voluntary transfers — a channel switch and a dungeon entrance — finish validating asynchronously after the client has already closed its own UI, so every refusal is named rather than returning silently; a silent refusal is indistinguishable from a lost request, and the obvious response (clicking again) is what the cooldown then swallows. - Server Select — Server list and connection info.
Bootstrap & Tools
- Bootstrap System — Multi-environment asset/scene preloading (Editor, Standalone, WebGL), version management, graceful shutdown. Each phase enqueues its work and completes on its own
batch.Completedsignal rather than a shared global event. - Addressable Integration —
AddressableLoadProcessorfor async prefab/sprite/mesh/scene loading with caching. - Per-Caller Load Batches —
BeginProcessQueue()returns anAddressableLoadBatchclaiming exactly the items that caller enqueued, with its ownCompletedevent,Progressedevent,TotalItems/CompletedItems/Progress, andFailedItems/HasFailures. This replaces completion signalling through the processor's globalOnProgressUpdatemulticast delegate, which reported "done" to every bootstrap system and loading screen whenever any drain finished and could double-invoke subscribers that resubscribed during dispatch.OnProgressUpdateremains as a display-only progress feed. A batch counts an item finished whether it succeeded, failed, or was dropped — failures surface viaFailedItemsinstead of withholding completion and stalling boot. Handlers subscribing after completion are invoked immediately, so a fully-cached batch that completes insideBeginProcessQueuecannot be missed. - Template Caching —
CachedScriptableObjectwith database-wide lookup and Addressable icon/mesh loading. - DeterministicRNG — Reproducible random number generator for networked determinism.
- SerializableDictionary / SerializableHashSet — Unity-serializable generic collections with custom property drawers.
- Version Management —
VersionBuilderwithVersionConfigScriptableObject; increments major/minor/patch, writesversion.txtat build time.
Editor Tools
- FishMMO Dashboard — The single editor hub (
FishMMO > FishMMO Dashboard, Ctrl+Shift+D), a UI Toolkit window whose panels are Build & Version, Categories, Game Settings, Inspector, and Patcher. Most FishMMO editor workflows are panels inside this window, not separate menu items. Backed by the custom build tool suite: AddressableManager, BuildConfigurator, BuildExecutor, LinkerGenerator.BuildExecutoradditionally performs two post-build copies:CopyRemoteAddressablesToBuildstagesServerData/[BuildTarget]/bundles into the built player'sStreamingAssets/ServerData/[BuildTarget]/for server builds (soDynamicAddressableLoadPathSystemcan load them overfile://), andCopyUpdaterToBuildcopies the standalone Updater executable and its runtime dependencies into standalone client builds — without it the launcher'sConstants.Configuration.UpdaterExecutablelookup fails and players are stranded on an unpatchable version. Both are skipped for build types that do not need them (server/WebGL for the updater). - Patch Generator —
PatchGeneratorWindow(EditorWindow) for creating delta patches between builds with manifest generation, surfaced through the Dashboard's Patcher panel (FishMMODashboard.Patcher.cs). It has no menu item of its own. - Addressables Dashboard — Analysis, build, categorization, and tree view for addressable assets. Menu:
FishMMO > Addressables Dashboard. - Behavior Tree Editor — Visual editor for NPC behaviour trees. Menu:
FishMMO > Behavior Tree Editor(spelled "Behavior"), or the Open button on a tree selected in the FishMMO Dashboard. Refuses connections that would make the tree cyclic. - AI Prefab Tooling —
FishMMO > AI > Repair NPC Prefabs For Combatadds the ability-pipeline components and enables prediction;Audit NPC Prefabsreports prefabs that cannot fight and why;Validate Archetypesreports archetypes whose configuration cannot behave as described;Audit Ability Intentsreports what the AI derives each ability to do;Organize AI AssetsandRe-serialize AI Assetsmaintain the canonical asset layout. - Network Timing Validator —
FishMMO > Validate Network Timingconfirms every scene'sNetworkManageragrees on tick rate. FishNet does not synchronise it —SetTickRatesays so explicitly — and a mismatch does not throw or refuse the connection, it just makes the client simulate on a different timeline and present as latency. - Dialogue Tree Editor — Visual editor for NPC dialogue trees. Menu:
FishMMO > Dialogue Tree Editor. - World Scene Details Cache Builder — Builds cached world scene details at edit time. Menu:
FishMMO > Rebuild World Scene Details. - Custom Property Drawers —
[ShowReadonly],[SubclassSelector],[TemplateReference], serializable dictionary drawers. - Build Option Toggles —
FishMMO > Build > Build Type(Client/Server),> OS Target(Windows x64 / Linux x64 / WebGL), and> Environment(Development/Production/Enable Local Directory), fromBuildEnvironmentOptions.csandWorkingEnvironmentOptions.cs. These set build options only — they do not run builds; builds execute from the Dashboard's Build & Version panel. - Security Assembly Filter — Editor-only assembly filtering for security-sensitive code.
- Version Menu —
FishMMO > Version > Increment Major/Minor/PatchdrivesVersionBuilder. - QuickStart Scene Menu —
FishMMO > QuickStart > …opens Main Bootstrap, Client Preboot/Postboot/Launcher, and Login/World/Scene Server scenes directly, ordered by priority. - Script Compilation Menu —
FishMMO > Script Compilation > …toggles Auto Refresh and selects recompile behaviour while in Play Mode (Recompile After Finished Playing / Recompile And Continue Playing / Stop Playing And Recompile). - Equipment Slot Validator —
FishMMO > Validate > Equipment Item Slotsreports equippable templates whoseItemSlotdisagrees with the folder they are filed under. It exists because a slot mismatch has no symptom a developer would recognise: templates serialize the slot as an integer, so renumbering the enum silently changes what every already-authored asset means, nothing throws, and every value is still a legal slot. The folder is the cross-check because it independently records what a human meant when they filed the asset — two statements of the same fact are what make the drift detectable at all. A mismatch is reported, not corrected: a template deliberately filed somewhere that does not match its slot is legitimate, and the tool cannot tell that apart from a mistake. Templates not filed under a slot-named folder are counted and skipped. - AddressablesPlayModeSceneHandleFix — Editor-only workaround for the "Attempting to use an invalid operation handle" exception Addressables throws from its own Play Mode teardown.
AddressablesImpl.Dispose()releases each scene handle twice (once fromm_resultToHandle, once fromm_SceneInstances) with noIsValid()guard. Subscribes from[InitializeOnLoadMethod]so it runs ahead of the Addressables package's own handler, which our runtime shutdown path cannot do.
FishMMO-WebTransport
The native transport every platform connects over: WebTransport-over-HTTP/3 (QUIC), wrapping Microsoft msquic v2.5.9 and exposing a C ABI that the C# FishNet transport plugin P/Invokes.
Transport Core
- QUIC Server and Client —
server.cpp(listener, connection array, broadcast) andclient.cpp(connection, polling, deferred shutdown), over a ref-counted per-connectionsessionthat owns its streams and datagrams. - Two Channels, Two QUIC Primitives — Channel 0 maps to QUIC bidirectional streams (reliable, ordered); Channel 1 maps to QUIC DATAGRAM frames (unreliable). FishNet's reliable and unreliable channels therefore cost what they say they cost, rather than both riding one ordered stream where a lost packet would head-of-line block state updates behind it.
- Length-Delimited Stream Framing — Every application message on a stream carries a QUIC varint length (RFC 9000 §16). A stream delivers bytes, not messages, and a peer's writes may be coalesced or split arbitrarily, so without a length prefix a reader cannot tell where one message ends. This is a wire-format contract: peers built before it cannot interoperate with peers built after it — deploy both ends together.
- Browser and Native on One Port — The server auto-detects the peer from the first byte of the
initial stream (
0x00→ HTTP/3 browser client, anything else → raw QUIC native client) and handles both transparently. Browser sessions additionally carry theWEBTRANSPORT_STREAMheader once per data stream and encode datagrams as HTTP/3 Datagrams (RFC 9297) with a Quarter Stream ID varint; native peers exchange bare payloads. - HTTP/3 Handshake —
http3.cppimplements the SETTINGS exchange, the extended CONNECT that establishes a WebTransport session, and the QPACK encoding it needs. - Thread-Safe Datagram Ring —
datagram_queueis a lock-light ring buffer, because datagrams arrive on msquic's callback threads and are drained on Unity's main thread.
Build
- Static msquic, Straight Into the Plugin Folder — CMake fetches msquic from source and links
it statically, writing the artifact directly into
FishMMO-Unity/Assets/Plugins/FishNet/Plugins/WebTransport/Plugins/{platform}/. There is no copy step to forget. - Per-Platform Scripts, No Master Build —
build_linux.sh,build_windows.ps1/build_windows_schannel.ps1(native),build_windows_cross.sh(Zig 0.13+ cross-compile from Linux, using the msquic NuGet import library andlld-link --out-implib), andbuild_macos.sh— which must run on a Mac, because msquic's quictls dependency contains platform-specific assembly that cannot be cross-compiled.rebuild_only.*are the incremental helpers.
FishMMO-WebServers
ASP.NET Core web services providing client-facing HTTP APIs.
IPFetchASP.NET (Login Server Discovery)
- Login Server Discovery API —
GET /loginserver(LoginServerController) returns available login server ports from the database, cached inIMemoryCachewith a 60s TTL plus jitter so a server pulled from rotation ages out quickly. Empty results are deliberately not cached, so a re-registering server is not masked by a 404 for the full window. - Stateless Connection Token — Each response carries a token for real-IP recovery across the L4 UDP proxy (which loses the client IP). Format
base64url(payload).base64url(hmac)wherepayload = [keyId ':'] realIp '|' expiryUnixSecondsandhmac = HMAC-SHA256(sharedKey, payload); the client echoes it in its firstClientHandshakeand the Login Server verifies the HMAC — no database round-trip. Expiry is 60 seconds. It is HMAC-signed, not a hashed one-time value. The optionalkeyIdprefix lets multi-region game servers pick the right verification key; the signing key is registered in theconnection_token_keystable as the sole discovery source. Keys shorter than 32 bytes are rejected at request time with a 500. - ClientGate — Validates the
X-FishMMO-ClientHMAC-SHA256 header with multi-key rotation, a 30-second skew window (MaxSkewSeconds, re-checked post-HMAC), a 100,000-entry nonce cache (NonceCacheCapacity) with oldest-quarter eviction on overflow, and canonicalization that collapses repeated slashes and rejects traversal segments before signing. - Port Safety —
WebServer:HttpPortis read as a string (accepting both"8080"and8080in JSON) and validated withint.TryParseplus a 1–65535 range check; a malformed value throws at startup rather than silently falling back. Matches Patcher and WebGLServer behaviour. Kestrel binds viaListenLocalhostwith no TLS — termination is NGINX's job. - CORS Defaults to Deny — The
Publicpolicy readsCors:AllowedOrigins; when unset it emits noAccess-Control-Allow-Originand logs a warning, denying cross-origin browser requests. NativeUnityWebRequestclients ignore CORS entirely and the WebGL build is loaded same-origin, so operators must opt in explicitly for genuine cross-origin browser access. - Forwarded Headers, Single Hop —
X-Forwarded-For/X-Forwarded-Protohonoured withForwardLimit = 1, since NGINX is the only trusted proxy; extra values would be attacker-controlled and would break per-IP rate limiting. - PascalCase JSON —
PropertyNamingPolicy/DictionaryKeyPolicyset to null so Unity'sJsonUtility(exact-name matching) can deserialize responses without client-side rewriting.
PatcherASP.NET (Patch Delivery)
- Latest Version Endpoint —
GET/HEAD /latest_version?from={clientVersion}. Withoutfromit returns{ latest_version }; withfromit returnsup_to_date: true, orpatch_available: falsewhen no archive bridges that specific version pair, orpatch_available: truewith the patch'ssha256andsize. - Version Response Caching & Integrity — Sets a weak
ETag(derived from the patch hash / response shape) andCache-Control: public, max-age=30; honoursIf-None-Match(comma-separated list, any match) with304 Not Modified. AddsX-FishMMO-Version-Signature, an HMAC over the canonicallatest_version=…content so a compromised endpoint cannot silently substitute a patch hash. - Patch Download Endpoint —
GET /{version}serves patch ZIP files with range request support,ReparsePointsymlink rejection at serve time, and strongETag/Cache-Control: public, max-age=3600, immutableon the artifact. Returns204 No Contentwhen the requesting client is already on the latest version. - ClientGate — Same HMAC request signing validation as IPFetch (
UseFishMMOClientGate, with/healthzexempted). - Sliding-Window Rate Limiting — Patch downloads limited to 6 permits/minute via a sliding-window partition (
[EnableRateLimiting("PatchDownload")]), behind a global token-bucket limiter partitioned by client IP. - Content-Addressed Patch Index —
PatchVersionServicescans the configured patches directory at startup and exposes an immutable, indexed view of what is available. Each entry carries the file's fully-resolved absolute path and a SHA-256 content hash, which the client verifies after downloading — so a truncated or tampered archive is refused before the updater is ever handed it, rather than failing part-way through rewriting an install. - Symlink Protection —
PatchVersionServicereindex skipsFileAttributes.ReparsePointfiles to prevent hash disclosure via symlinks. - Semantic Versioning —
VersionConfigwith full SemVer 2.0.0 parsing, comparison operators, andIComparable<VersionConfig>.
WebGLServerASP.NET (WebGL Static Server)
- WebGL Build Serving — Serves Unity WebGL builds as static files (HTML, JS, WASM,
.unityweb,.data) with correct MIME types andX-Content-Type-Options: nosniff. - Response Compression —
AddResponseCompressionmiddleware withapplication/wasmandapplication/octet-streamMIME types for bandwidth reduction on large WASM builds (20–50 MB). - Cross-Origin Isolation — CSP headers configured for
wasm-unsafe-evaland WebTransportconnect-srctogame.fishmmo.com:*. - ClientGate — Intentionally absent. Browsers cannot add custom headers to static resource requests, so HMAC request signing is not possible for WebGL. Rate limiting and CORS provide the security boundary.
End of FishMMO Feature List