Table of Contents

FishMMO — Complete Feature List

View this file on GitHub

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

Process supervisor daemon that launches, monitors, and auto-restarts FishMMO server executables.

  1. Process Liveness Monitoring — Verifies child processes are alive each check interval.
  2. TCP Port Health Check — TCP connect probe to confirm the monitored port is accepting connections.
  3. UDP Port Health Check — UDP send/receive probe to verify datagram delivery.
  4. 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.
  5. CPU Threshold Monitoring — Samples per-process CPU% and triggers restart on sustained breach.
  6. Memory Threshold Monitoring — Samples per-process memory usage and triggers restart on sustained breach.
  7. Exponential Backoff Restarts — Failed processes restart with increasing delay (configurable initial → max, capped retries).
  8. Circuit Breaker — After N consecutive failures across launches, parks the application until manual intervention.
  9. Graceful Shutdown — Sends close signal to child process; force-kills if it doesn't exit within timeout.
  10. Interactive Console Commandsstart, stop, status, force-restart, force-kill, shutdown (alias exit), help, registered through CommandHandler/ConsoleCommand.
  11. Headless ModeHeadless: true in 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.
  12. Per-App Config Validation — Validates all settings at startup, rejects with precise error messages.
  13. Launch Delay Sequencing — Configurable per-app delay before launching the next application in sequence.
  14. Post-Launch Settle Delay — Pause after launch/restart before resuming probes (lets the process fully boot).
  15. systemd Integration — Handles both SIGTERM and SIGINT through the same graceful shutdown path. No .service file 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

  1. Bounded Concurrent CollectionsArrivalOrderTracker<T> (O(1) insertion-order TTL), ExpiringKeyTracker<T> (debounce / rate-limit), LastSeenCacheTracker<TKey,TValue> (LRU-style last-seen cache).
  2. Authentication DTOs — Engine-independent structs for all auth broadcast payloads.
  3. Auth EnumsAccessLevel, AuthState, ClientAuthenticationResult.
  4. Account Manager InterfacesIAccountManager<T>, ISrpAccountManager<T>, ITokenAccountManager<T> for auth-state storage and sweep.

Implementation / Authenticator Cores

  1. 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.
  2. 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.
  3. TokenAuthenticatorCore<TConnection> — World/Scene server authenticator: bounded-channel token auth worker, decrypt + verify + revocation-check pipeline, timing-equalization dummy-key path.
  4. ClientAuthenticatorCore — Full client-side auth state machine: SRP-6a + X25519 ECDH flow, cookie challenge echo, token auth path, key material cleanup.

Cryptographic Services

  1. 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.
  2. 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.
  3. TokenService — Full token pipeline: build → hash → encrypt → decrypt → partial-parse → verify with cross-check against pre-HMAC parsed IDs.
  4. CryptoHelper — Cryptographic backbone: HKDF, AES-GCM, HMAC-SHA256/SHA512, thread-safe GcmNonceContext (shared across async workers via Interlocked), 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

  1. SRP-6a Authentication — Secure Remote Password protocol with encrypted verify/proof payloads and strict sequence ordering.
  2. Fake SRP Data Path — Deterministic per-username fake salt to reduce account-enumeration timing signal.
  3. TOTP Two-Factor Authentication — Per-username failure counting + lockout, semaphore-limited concurrency, recovery code hashing.
  4. Signed Auth Tokens — HMAC-SHA256 envelope with access level and expiration baked into verify flow.
  5. AES-GCM with AAD — All encrypted payloads bound to message type/version/sequence.
  6. Constant-Time Comparisons — All MAC/token checks use constant-time comparison.
  7. Secret Zeroization — Sensitive byte arrays cleared via CryptographicOperations.ZeroMemory.
  8. Per-IP DebounceExpiringKeyTracker at the handshake layer prevents cookie-spam attacks.
  9. Global Handshake Rate Cap — Hard per-second limit across all connections.
  10. Token Revocation — Token hashes stored for revocation lookup; revocation check built into verify flow.
  11. 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.
  12. 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 APInot 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 // TODO comments 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.

  1. AccountController (api/Account) — Route stubs for POST register, POST verify, POST change-password, POST 2fa/setup. TODOs cover SRP salt/verifier generation, IAccountService persistence, TOTP secret generation/encryption, recovery codes, and verification email delivery.
  2. AdminController (api/Admin) — Route stubs for GET 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.
  3. appsettings.json ConfigurationCopyFishMMOConfig MSBuild target copies FishMMO-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

  1. IDatabase / Database — High-level orchestrator wrapping NpgsqlDbContextFactory + service registry. Consumed by all servers.
  2. IDatabaseServiceRegistry — Per-domain service lookup (TryGet<TService>(out var svc)).
  3. NpgsqlDbContext — EF Core DbContext with Npgsql PostgreSQL provider.
  4. NpgsqlDbContextFactory — Factory with connection interceptors driving ConnectionPoolMetrics + QueryPerformanceTracker.
  5. NpgsqlDbConfiguration — Builds connection string from IConfiguration (Npgsql:* or ConnectionStrings:NpgsqlConnection).
  6. NpgsqlServiceRegistry — Wires all per-domain service implementations.
  7. AppSettings — Strongly-typed appsettings.json binder (Npgsql, QueryPerformanceTracking, Logging). DatabaseConfigurationHelper — Convenience helpers for IConfiguration builders.
  8. DatabaseResult<T> — Uniform result envelope (IsSuccess, ErrorCode, ErrorMessage, Data).
  9. DatabaseErrorCodes — Stable error code enum returned via DatabaseResult.
  10. Layered Configurationappsettings.jsonappsettings.{Environment}.json → environment variables (with __ nesting).
  11. FISHMMO_ENVIRONMENT — Precedence-based environment selection (FISHMMO_ENVIRONMENT > DOTNET_ENVIRONMENT > ASPNETCORE_ENVIRONMENT).
  12. Schema ValidationValidateSchemaAsync reports, without throwing, whether this database has applied every migration the entity model expects, and SchemaValidationResult.DescribeProblem names 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 builds ModelSnapshot.Model with 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.
  13. Unit of WorkIUnitOfWork / UnitOfWorkService wrap a logical operation in one transaction; service calls made inside the scope reuse the ambient context. BeginAsync is deliberately not async: the ambient context lives in an AsyncLocal<T>, and the async state machine restores the execution context when the synchronous part of an async method completes, so a scope entered inside one is invisible to its caller. While it was async, 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.
  14. 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 passed long.MaxValue "to ensure the delete succeeds" — leaving version = 9223372036854775807 behind, 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 definition deleted = TRUE and the reclaim clause takes them on the next write.
  15. uint BindingNpgsql cannot bind System.UInt32 at all, as a scalar or as an array; it throws NotSupportedException before the statement reaches the server, and the failure is recorded as a generic DATABASE_ERROR. BaseService binds raw parameters with no explicit NpgsqlDbType, so the CLR type is all Npgsql has to infer from. Every uint reaching raw SQL is therefore projected to long, and every uint-backed column is bigint — so long binds exactly. The batched path additionally cast ::integer[], which does not truncate above int.MaxValue but raises 22003: 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. The UNNEST batch path was corrected first, which repaired character creation — while the single-row PersistAsync overload, the one CharacterInventorySystem calls once per item on every equip and unequip, kept throwing. Both paths are now projected, across ICharacterItemService and ICharacterMailService, whose SendMailAsync took a uint itemAttachmentAmount and so failed on every mail send — attachment or not, since the declared parameter type is what gets boxed. UInt32 is the only unbindable CLR type in the data layer; the uint Version fields on AccountEntity, AuthTokenEntity and LoginServerSigningKeyEntity are safe because they map to the xmin/xid system column as EF concurrency tokens and are never bound by hand.
  16. UTC Timestamps — Every default and every raw-SQL write uses timezone('UTC', CURRENT_TIMESTAMP) rather than CURRENT_TIMESTAMP. The columns are timestamp without time zone while CURRENT_TIMESTAMP is a timestamptz, so the bare form silently stored the session's local time for anything compared against DateTime.UtcNowlast_pulse most visibly, whose liveness query already compared in UTC.

Database Services (Npgsql/Services/)

  1. IAccountService — Account CRUD: create, fetch for login (SRP data), online status check, kick request persist, token hash persist, TOTP verify.
  2. Mail AttachmentsICharacterMailService carries 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 — and CharacterMailAttachmentData records what a successful claim took off a mail, so the removal and the grant are one decision rather than two that can disagree.
  3. Pet PersistenceICharacterPetService, ICharacterPetAttributeService and ICharacterPetBuffService persist 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.
  4. ICharacterService — Character CRUD: save, load, delete, fetch by account, session claim/release (token-gated, with batched lease refresh and FetchUnownedSessionsAsync to name the claims a server has lost), inventory/equipment/bank/hotkey persist, and the persisted channel-switch cooldown — TryBeginChannelSwitchAsync checks and stamps in one statement and returns the timestamp it replaced, so RollbackChannelSwitchAsync can restore it exactly when the transfer the claim was taken for does not happen.
  5. IChatService — Chat message persistence and retrieval with channel, character, and server metadata.
  6. ILoginServerService — Login server registration, heartbeat pulses, signing key storage (AEAD-wrapped via deployment KEK).
  7. IWorldServerService — World server registration, heartbeat pulses, server listing, and operator lifecycle control (SetLockedAsync, SetShutdownAsync, FetchControlStateAsync). The locked and shutdown_at_utc columns are the authority: registration deliberately preserves them on conflict, and PulseAsync reads them back (UPDATE … RETURNING) so the process adopts what an operator set rather than overwriting it.
  8. 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 from LastPulse, not from the row existing.
  9. ICharacterItemServiceOne service over one character_item table 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, with ItemContainerType (Inventory / Equipment / Bank) and Slot as ordinary columns. That is what lets an item's identity survive a move between slots, a move between containers, and a relog — the property Item.ID and the attribute ledger both depend on (see the Item System).
  10. IGuildService — Guild creation, membership, ranks, invitation persistence.
  11. IPartyService — Party creation, membership persistence.
  12. 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.)
  13. IKickRequestService — Kick request queue polling and processing.
  14. Auth & Deployment ServicesIAuthTokenService (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-duplicating EnqueueIfUnderOutstandingLimitAsync, the party-scoped EnqueueForPartyAsync (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 companion FetchCharacterInstancesAsync, the dungeon finder's browsable FetchJoinableInstancesAsync (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) and SetInstancePrivacyAsync (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 reapers DeleteStaleUnreadyAsync / DeleteByStaleSceneServersAsync), IGuildUpdateService / IPartyUpdateService (social update pumps). ICharacterPartyService additionally answers FetchOnlineMemberIdsAsync — 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.
  15. UnitOfWorkService — Ambient DbContext + transaction scope for multi-step atomic operations. Supports savepoints for nested atomicity inside a unit of work.
  16. BaseService Execution WrappersExecuteReadAsync, ExecuteWriteAsync, ExecuteTransactionAsync with retry logic, transient error classification (PostgreSQL error code mapping), and automatic SaveChanges.
  17. 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.
  18. 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>.PersistAsync returns them separately rather than collapsing to a boolean: Filtered rows 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 — while Superseded rows were attempted and lost the version race to something at least as new, which loses nothing and is routine under concurrency.
  19. Convention GuardsApplyTimeCreatedConventions skips entities with explicit defaults (prevents silent override of QuestEntity's DateTime.UnixEpoch). ApplyLogicalVersionConventions checks for existing defaults before applying.
  20. Npgsql Type MappingList<int> properties natively map to PostgreSQL integer[] columns; HasDefaultValueSql("'{}'") for empty array defaults.

Data Entities

  1. AccountData — Account credentials (SRP verifier, salt), email, 2FA state, verification status.
  2. CharacterData — Full character sheet: position, race, archetype, attributes, hotkeys, achievements, faction standings.
    38b. CharacterItemData / character_itemOne row per item, keyed by the item's own id, with ItemContainerType (Inventory / Equipment / Bank) and Slot as ordinary columns. This replaced three slot-keyed tables (inventory, equipment, bank) and their three services with one table and ICharacterItemService. 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 what Item.ID and the attribute ledger's ModifierSource.Item(...) need. The single table also makes a cross-container move an UPDATE of 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.
  3. ChatData — Chat message with channel, content, character, server metadata.
  4. LoginServerData / WorldServerData / SceneServerData — Server registration and heartbeat entities.
  5. AuthTokenData — Token hash with expiration for revocation lookup.
  6. LoginServerSigningKeyData — AEAD-wrapped HMAC signing key per login server.
  7. KickRequestData — Admin-initiated kick request queue.
  8. SceneData — Pending scene load/unload requests.
  9. QuestData — Quest state persistence.
  10. TwoFactorRecoveryCodeData — Hashed 2FA recovery codes.
  11. IVersioned / VersionExtensions — Optimistic concurrency versioning on all entities.

Monitoring Infrastructure (Npgsql/Monitoring/)

  1. DatabaseHealthMonitorSELECT 1 connectivity probe with Healthy/Degraded/Unhealthy classification.
  2. ConnectionPoolMetrics — Runtime open connections, pool utilization %, driven by EF Core connection interceptors.
  3. DatabaseMetricsTracker — Success/failure/latency aggregates with summary reporting.
  4. QueryPerformanceTracker — Per-operation query performance with P95/P99 percentiles, slow query detection events, configurable tracking levels (None/Basic/Standard/Detailed/Full).

Unity Integration

  1. 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

  1. DatabaseException — Typed database exception hierarchy: DatabaseEntityNotFoundException, StaleStateException, DuplicateReplayException.

Database Migrator

  1. 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.

  1. 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 risks TypeLoadException / MissingMethodException under Unity's resolver — hard crashes on IL2CPP rather than warnings.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. Post-Build DLL Copy — Output DLLs automatically copied to ../FishMMO-Unity/Assets/Dependencies/ via the CopyDependenciesToUnity MSBuild target (cross-platform forward-slash paths). System DLLs excluded from copy to avoid Unity conflicts.
  7. Stale DLL SweepRemoveStaleDependencies runs before the copy and clears the Unity Assets/Dependencies folder, 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.

  1. Game → Discord Chat RelayChatPollingService (an IHostedService timer with a SemaphoreSlim reentrancy guard) polls the game database directly via NpgsqlDbContextFactory, tracking lastProcessedChatId, 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 under ChatRelay: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 a NeverRelayable set and are refused even if configuration names them, with an error logged — a config edit should not be able to start publishing private messages.
  2. 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. BridgeMessageMaxLength was 500/2000 against a client limit of 128, so every bridged message longer than 128 characters was being silently dropped by clients.
  3. Account Linkinglink / unlink commands (LinkModule, AccountLinkingService, PendingLinkVerification): issues short-lived one-time codes redeemable in-game to link Discord ↔ FishMMO account.
  4. Dynamic Channel Management — Creates/archives Discord channels in response to in-game events (party formed, guild created).
  5. Moderation Commands — Mute, unmute, ban, unban for the chat bridge (uses BridgeBanService).
  6. Admin Commands — Reload config, shutdown, diagnostics (owner/admin-only).
  7. Character Lookup — Query character info by name or Discord-linked account.
  8. Text Command Handling — All commands are Discord.Net text commands (CommandService, ModuleBase<SocketCommandContext>, [Command("…")]). CommandHandlingService accepts either a leading / character prefix or an @-mention. Note this is a message prefix, not a registered Discord application command — no InteractionService or 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).
  9. Rate Limiting — Per-user/per-channel sliding-window rate limiter to prevent spam from either side (RateLimiterService).
  10. Bridge Ban System — Tracks Discord users banned from the bridge; consulted before forwarding (BridgeBanService).
  11. Config File WatchingBotConfigurationService watches appsettings.json for changes and propagates config at runtime.
  12. Generic Host + DI — Built on Microsoft.Extensions.Hosting; all services are IHostedService with full DI composition.
  13. Database Read-Only Queries — Admin-gated database queries via DatabaseModule.
  14. Self-Documenting Helphelp and commands list available commands, driven by CommandService reflection over the registered modules (CommandListModule). Per-command enable/disable and role gating come from CommandPermissionConfig.

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

  1. Install DotNet EF Tool — Installs the dotnet-ef global tool for Entity Framework Core migrations.
  2. 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.
  3. Install Visual Studio Build Tools — Windows-only C++ build tools for Unity IL2CPP compilation.
  4. Install PostgreSQL — Platform-native PostgreSQL installation (pacman, apt-get, dnf, yum, EnterpriseDB EXE).
  5. Install PgBouncer — PostgreSQL connection pooler installation and configuration (Linux systemd, Windows winget/choco).
  6. Install FishMMO Database — Creates PostgreSQL user, database, applies initial EF Core migration, grants permissions.
  7. Create New Database Migration — Generates and applies new EF Core migrations interactively.
  8. Grant User Permissions — Grants schema privileges to the FishMMO database user.
  9. Delete FishMMO Database — Destructive database teardown with typed confirmation (requires "DELETE").
  10. Install NGINX — Reverse proxy/SSL terminator installation and service registration (Linux systemd, Windows NSSM service).
  11. Deploy FishMMO nginx.conf — Atomically deploys the canonical nginx.conf with backup preservation and nginx -t validation.
  12. 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

  1. Full Interactive Menu — Hierarchical menu system with numbered options, sub-menus per component group, and confirmation prompts.

CLI / Non-Interactive Mode

  1. CLI Argument Parser--help, --version, --component <name>, --non-interactive, --dry-run, --validate, --config <path>. Zero-arg invocation enters interactive menu (backward compatible).
  2. Unattended Installation--non-interactive -f install-config.json runs a full dependency-ordered installation from a JSON manifest with no user prompts.
  3. Single-Component Mode--component postgresql jumps directly to one component without navigating menus.
  4. Dry-Run Mode--dry-run simulates installation and prints what would happen without making changes.
  5. Quickstart Template--quickstart shortcut for a recommended default installation profile.

Pre-Flight Checks

  1. Internet Connectivity Check — Probes dot.net in 10s before any download-dependent operation.
  2. Disk Space Check — Warns if less than 5 GB free on the target drive (Unity Editor + builds can consume 20+ GB).
  3. Memory Check — Reads /proc/meminfo on Linux, warns if less than 2 GB RAM.
  4. Admin/Sudo Access Check — Verifies passwordless sudo (Linux) or Administrator integrity level (Windows) before system-level installs.
  5. Port Conflict Detection — Checks ports 80, 443, 5432, 6432, 8000, 8080, 8090 for existing listeners before installing services.

Download Integrity & Progress

  1. SHA256 Checksum Verification — Every downloaded file verified against checksums.json; corrupt/tampered files rejected. Already-downloaded files with valid checksums skip re-download.
  2. Download Progress Bar — Console progress indicator with percentage and visual bar during large downloads.
  3. 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

  1. Health Check Mode--validate runs 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

  1. Firewall Automation — Opens ports 80/tcp and 443/tcp via ufw or firewalld (Linux) or netsh (Windows). Menu option or --component firewall.
  2. Systemd Service Generation — Generates and registers systemd units for FishMMO ASP.NET web servers (fishmmo-ipfetch, fishmmo-patcher, fishmmo-webgl). Finds publish directories, generates .service files, runs systemctl enable --now. Menu option or --component systemd-services.
  3. 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

  1. Linux Config Hardening — Secure file permissions (chmod 600), core dump disabling, ptrace hardening for production Linux deployments.
  2. PostgreSQL Hardening — Rewrites pg_hba.conf to require scram-sha-256 on all TCP connections, sets password_encryption and listen_addresses in postgresql.conf, reloads via pg_reload_conf(). Idempotent via managed markers.
  3. PgBouncer Configuration Generation — Generates pgbouncer.ini (transaction pooling, scram-sha-256) and userlist.txt (with SCRAM hash from pg_shadow) with secure file permissions.
  4. Database Credentials File — Generates /etc/fishmmo/db-secrets.env (systemd EnvironmentFile) 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.
  5. 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 600 on all output files.
  6. SecurityKeyInstaller — Generates CSPRNG keys (RandomNumberGenerator.Fill, base64, round-trip validated) and writes them directly to the database over a superuser NpgsqlConnection, so no env file has to be copied between machines: the ClientGate secret and signing-key KEK into deployment_secrets (client_gate_secret, signing_key_kek) and the connection token HMAC key into connection_token_keys (key_id='shared'). Superuser credentials come from the interactive prompt or FISHMMO_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

  1. Build All C# Projects — Discovers and builds all .csproj files under the repo root with dependency-prioritized ordering (synchronous for low-priority projects, parallel for independent builds). Copies DLLs to Unity Dependencies.
  2. Unity Build Automation — Headless Unity builds via -batchmode -nographics -executeMethod for Client/Server/Addressables. Resolves Unity executable path from environment variable, Unity Hub CLI, or filesystem probing.
  3. 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

  1. Cross-Platform — Windows 10/11 and Linux (Arch/CachyOS, Ubuntu/Debian, Fedora/RHEL).
  2. Package Manager Auto-Detection — pacman, apt-get, dnf, and yum auto-detected with appropriate update/install command templates.
  3. Platform AbstractionIPlatform interface with WindowsPlatform / LinuxPlatform implementations 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.

  1. Static Log FacadeLog.Info/Warn/Error/Debug/Trace/Critical(category, message) synchronous-friendly API.
  2. Typed LogLevel Enum — Trace < Debug < Info < Warning < Error < Critical with per-sink filtering.
  3. Structured LogEntry — Immutable struct: timestamp, level, category, message, optional exception.
  4. File Sink with Rotation — Append-or-truncate file logging with byte-size-based rotation (timestamp-suffixed rollover).
  5. Email Sink via SMTP — Per-sink minimum level filtering (typically Error/Critical), TLS support.
  6. JSON Configuration — Single logging.json file with polymorphic { Type, Config } entries.
  7. Pluggable Sink ModelILogger + ILoggerConfig interfaces; register custom sinks via factory before initialization.
  8. Polymorphic Config ConverterILoggerConfigConverter for System.Text.Json round-tripping of sink configs.
  9. Console Formatter — ANSI / plain-text console formatting helpers.
  10. Unity IntegrationUnityLoggerBridge (captures Unity log callbacks into the facade, with an IsLoggingInternally re-entrancy guard), UnityConsoleLogger sink, and UnityConsoleFormatter. These live in the Unity project under Assets/Scripts/Shared/Implementation/Bootstrap/Logging/, not in the FishMMO-Logger library itself, so the library stays engine-independent.
  11. Async ShutdownLog.Shutdown() (async Task) drains and disposes all sinks gracefully. Bootstrap detaches UnityLoggerBridge before 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=.

  1. Single-Archive Patch Application — Applies exactly one archive per run: Patches/{from}-{to}.zip, built from the -version and -latestversion arguments. There is no patch chaining — if that specific archive is absent the updater logs the miss, restarts the client, and exits.
  2. Patch Manifest Parsing — Reads manifest.json from the ZIP into PatchManifest (OldVersion, NewVersion, NewFiles, ModifiedFiles, DeletedFiles).
  3. Binary Diff ApplicationPatcher.Apply reconstructs each modified file from its PatchDataEntryName diff stream into a temp file. New files are verified against NewHash (XxHash128) after extraction and deleted on mismatch.
  4. Parallel File Operations — New and modified files processed concurrently via Parallel.ForEach with an exception bag that stops the loop on first failure.
  5. Transactional Patching — Every replaced file copied to .bak before the move; failure anywhere triggers a full rollback to the previous state.
  6. Atomic File Replacement — Patched content written to unique temp files, then moved over originals in a finalization phase.
  7. Launcher Process Management — Terminates the launcher by PID before patching: kill(SIGTERM) via a libc P/Invoke on Linux/macOS, Process.CloseMainWindow() on Windows, falling through to a forced Kill() on any path where the graceful request fails or is ignored.
  8. Automatic Client RestartTryStartExecutableAndExit starts the client executable on every exit path (success, failure, missing archive, already-current) and always Environment.Exit(0) — the launcher treats a non-zero code as an updater failure.
  9. 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.
  10. Retry with BackoffTryDeleteFile / TryMoveFile retry with a fixed delay for transient file I/O errors before giving up.
  11. Path Containment (zip-slip) — Every path built from a manifest entry passes through Patch/PathContainment.cs before it is touched: new files, modified files, deletions, both pre-create-directory passes, the patch-archive lookup, and the Process.Start target. They previously used a bare Path.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, which Path.GetFullPath does 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 what Path.Combine alone did.
  12. 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.
  13. 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 .bak scheme 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.
  14. 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.
  15. 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.
  16. 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.
  17. Signed Version Manifests (Ed25519) — The patch server signs every /latest_version payload and the client verifies before reading any field. The canonical form is the document with its signature value 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 solving sig = 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. ApiPinUpdateSidecar used the identical construction, meaning certificate pin updates could never have verified either; both now share the corrected Ed25519ManifestVerifier. 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

  1. UDP Stream Proxy (L4) — Raw UDP forwarding for game ports 7770–7999 via stream {} block. Auto-generated per-port configs via gen-fishmmo-stream-config.sh with atomic replacement and nginx -t validation. Zero-copy packet forwarding; no TLS termination at proxy.
  2. 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.
  3. Virtual Hostsplay.fishmmo.com (WebGL client), api.fishmmo.com (IPFetch + Patcher), game.fishmmo.com (444-close — game traffic is UDP-only). Catch-all returns 444.
  4. Rate Limitinglimit_req_zone per-endpoint: 10r/s API, 2r/s patch downloads, 30r/s WebGL. limit_conn_zone per-IP: 20 conn WebGL, 10 conn API, 3 conn patch. HTTP 429 with Retry-After.
  5. 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-Origin for API. Browser WebTransport is permitted by the https:// entry; the wss:// entry is a leftover from the retired WebSocket transport and grants nothing that is still used.
  6. Performancesendfile on, tcp_nopush on, tcp_nodelay on, gzip on with gzip_proxied any (not off), gzip_types tuned for text/wasm, keepalive_timeout 65s.
  7. Hardeningserver_tokens off, client_max_body_size 64k globally (raised from nginx's 1m default being too restrictive for POST; the patch download location overrides to 0 / unlimited), client_body_timeout 10s, client_header_timeout 10s.

Server Configuration (.cfg files)

  1. LoginServer.cfg — ServerName, MaximumClients (4000), Address (127.0.0.1, all traffic via nginx), Port (7770), TLS CertificatePath/PrivateKeyPath for the server's own QUIC/TLS termination, AllowedOrigins (browser WebTransport CORS allow-list; empty = allow all, development only), ConnectionTokenHmacKeyBase64 (left blank — keys load from the connection_token_keys table), and SMTP config (Smtp:Host/Port/Username/Password/FromAddress/FromName/UseSsl, each overridable by FISHMMO_SMTP_* environment variables).
  2. Login Queue KeysLoginQueueUpdateRateSeconds (2.0), LoginQueueMaxSize (500), LoginQueueAdmissionRatePerSecond (5.0), LoginQueueTimeoutSeconds (300) configure LoginQueueSystem. All server-authoritative — clients cannot request faster updates.
  3. WorldServer.cfg — Port 7780, same Address/TLS/connection-token keys.
  4. SceneServer.cfg — Port 7790+, same Address/TLS/connection-token keys. Note StaleSceneTimeout=5 is present in all three .cfg templates, not only SceneServer.
  5. IPv6 ReservedEnableIPv6 / IPv6Address are 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.
  6. AutoVerifyAccountstrue in Development (bypasses email verification at both account creation and login, flagged with an explicit do-not-copy-to-production warning), false in 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

  1. Per-Environment appsettingsDevelopment/ and Production/ each hold appsettings.json plus 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.
  2. Installer Manifestsinstall-config.full.json, install-config.quickstart.json, and install-config.web.json (Development only) drive FishMMO-Installer’s non-interactive pipeline.
  3. logging.json — Single shared FishMMO-Logger sink configuration.

Build System

  1. WebTransport Build — Per-platform scripts in FishMMO-WebTransport/; there is no build_all.sh master 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 with zig c++ -target x86_64-windows-gnu, links via lld-link --out-implib), build_macos.sh (must build on a Mac — msquic’s quictls dependency contains platform-specific assembly that cannot be cross-compiled), plus rebuild_only.* incremental helpers.
  2. Cross-Platform Paths — Forward-slash paths in .csproj files. $(Configuration) used directly (no redundant BuildConfiguration property).

FishMMO-SharedUtility

Pure C# / netstandard2.1 utility library — the lowest layer shared between Unity client and all .NET server projects.

Top-Level Utilities

  1. Authentication Validators — Username, password, character name, and email validation rules (shared by LoginServer and account creation). NFKC normalization for case-insensitive comparisons.
  2. CircularBuffer<T> — Thread-safe circular doubly-linked list with O(1) add/remove/pop/snapshot.
  3. Configuration — INI-style .cfg file handler with environment variable overrides (FISHMMO_CONFIG_*), thread-safe via ReaderWriterLockSlim, 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 calling Set and Remove on 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.
  4. FastActivator<T> — Expression-tree compiled object factory (0–16 constructor args, faster than Activator.CreateInstance).
  5. MathHelper — Mathematical constants: HalfPI, Tau.
  6. RefWrapper<T> — Boxed reference wrapper for value types with implicit conversion.
  7. SetOnce<T> — Thread-safe write-once latch with lock-free reads and double-checked locking.
  8. IReference — Marker interface for reference-equality compared objects.
  9. CryptographicOperationsCompat — netstandard2.1 shim supplying ZeroMemory / fixed-time comparison primitives where System.Security.Cryptography.CryptographicOperations is unavailable.

Compression

  1. StringCompression — GZip compress/decompress for UTF-8 strings.
  2. DictionaryCompression — Compresses string dictionaries using a shared dictionary frame.

Extensions

  1. ArrayExtensions — Array manipulation helpers.
  2. IListExtensions — Binary search, swap, shuffle.
  3. StringExtensions — Case-insensitive contains, hex conversion, truncation.
  4. TypeExtensions — Assignable-from cache, type hierarchy utilities.
  5. RandomExtensions — Range pickers with deterministic seeding.
  6. EnumExtensions — Enum parsing and attribute helpers.
  7. DirectoryExtensions — Safe directory copy/cleanup.
  8. ProcessExtensions — Process management utilities.
  9. 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

  1. Multi-Server Connection Management — LoginServer → WorldServer → SceneServer transitions with state tracking via ClientConnectionManager.
  2. 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: TryReconnect checks the attempt count and the stored world address together, so a retry with nothing to dial falls through to the give-up branch and raises OnReconnectFailed (→ QuitToLogin) instead of returning silently and leaving the client behind an overlay nothing would ever take down. The first retry after a deliberate Scene drop uses SceneHandoffReconnectDelay (0.25s, jittered) rather than the failure backoff, because a zone change, channel switch and cross-scene bind respawn are all implemented as handoffs.
  3. 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.
  4. ServerConnectionType State — Tracks connection state: None, Login, World, Scene.
  5. Broadcast Sending — Centralized FishNet broadcast dispatch from the Client MonoBehaviour.
  6. WebTransport (QUIC/HTTP3) Transport — All platforms use WebTransport via Multipass; NGINX L4 UDP stream proxy forwards raw QUIC to game servers.
  7. Death DialogUITKDeathDialog with Respawn/Resurrect buttons. Handles ResurrectOfferBroadcast for dynamic button visibility. Opens from replicated character state (CharacterFlags.IsDead in the spawn payload) as well as from DeathBroadcast, 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

  1. SRP-6a Client Login Flow — Full SRP-6a protocol: cookie challenge echo, key agreement, verify/proof, token-based reauth.
  2. Token-Based Reauthentication — Stored auth tokens for seamless World/Scene server transitions.
  3. Account Creation — Encrypted credential registration with validation.
  4. Account Email Verification — Verification code submission.
  5. TOTP / 2FA Support — Two-factor code submission and 2FA setup (QR code + recovery codes).
  6. Token Renewal & Revocation — Token refresh on login server and revocation on logout/shutdown.

Input System

  1. Unity Input System IntegrationPlayerInputController manages the PlayerControls asset. PlayerControls is static and outlives the component, but every handler registered against it is an instance method, so teardown unsubscribes unconditionally. Deinitialize used to return early when Character was 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 through ReadValue and keeps working, while everything routed through a performed callback — interact, jump, crouch, sprint — is the half that degrades.
  2. Mouse Mode Management — Cursor visibility/lock state toggling.
  3. Input Binding Persistence — Binding overrides are saved to InputBindingOverrides in Configuration.cfg and loaded during the client's boot phase, not on world entry. PlayerControls is created inert at BeforeSceneLoad — 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.
  4. Character Movement Input — Move, Look, Jump, Crouch, Sprint mapped to KCC replication data.
  5. 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.
  6. Right-Click Context Menus — Inspect, Add Friend, Invite to Party, Trade on player targets.
    19a. Interactive Rebinding — Every binding in the Player map 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 because PerformInteractiveRebinding suppresses 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 synthetic anyKey, which is a real bindable ButtonControl that actuates whenever any key does. Excluding backspace does not exclude anyKey, 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, where CloseLastUI is bound to it — one press both cancelling the rebind and closing the settings window the player was still using. UITKControl.ConsumesEscape is what makes UIManager.CloseNext absorb 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

  1. HTML News Feed — Fetches launcher news via HtmlAgilityPack. IHtmlContentFetcher strips <script>/<style> and yields the parsed node rather than formatted text; UITKHtmlContentRenderer builds a VisualElement tree 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 through LauncherLinkPolicy, which allows only absolute http/https. When no feed is configured — including an unsubstituted FISHMMO_SENTINEL_PLACEHOLDER build 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 PolicyLauncherLinkPolicy parses each href and permits only absolute http/https before it reaches Application.OpenURL, which would otherwise invoke a registered protocol handler for javascript:, 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.
  2. API Host Resolution — Randomised mirror selection from comma-separated host list with HTTPS enforcement (ApiHostResolver).
  3. Version CheckingHttpPatchServerService calls GET /latest_version?from={clientVersion} and parses latest_version, up_to_date, patch_available, sha256, and size into PatchInfo. Unparseable version strings are rejected rather than thrown on.
  4. Patch Download with SHA-256 VerificationDownloadPatch(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 StatisticsDownloadStats / DownloadRateTracker report 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.
  5. Persisted Launcher SettingsLauncherSettings gives the launcher typed access to its own options, stored in the shared Configuration.GlobalSettings file 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.
  6. Launcher State MachineLauncherState: 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).
  7. Transient-State WatchdogTransientStateWatchdog coroutine 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 separate LaunchWatchdog re-enables the Play button if the addressable scene load exceeds launchWatchdogTimeoutSeconds (default 30s).
  8. External Updater LaunchIUpdaterLauncher / SystemUpdaterLauncher spawns 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 as UpdaterFailed. 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.
  9. 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 ViewILauncherView describes 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. UITKClientLauncher is 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 for ClientPostboot and hides itself when that scene arrives, independently of the launcher's own load callback, because AddressableLoadProcessor returns 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 SettingsLauncherSettings reads and writes the shared Configuration.GlobalSettings store (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 of 0 would 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 ProbeInstallSizeProbe walks 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 PickerNativeFolderPicker opens the Windows shell folder dialog for the patch directory. Unity exposes no runtime folder picker, so IsSupported is 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

  1. 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.
  2. 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 emits ClientApiSecret.generated.cs and HostConfig.generated.cs. There is no FishMMO > Security menu — these are Dashboard panels, not menu items.
  3. TOFU Mode — Development/editor builds allow empty pins (trust-on-first-use with loud warnings).
  4. Build-Time ValidationIPreprocessBuildWithReport warns on release builds without TLS pins (at least 2 required).
  5. Dynamic Pin Update ScaffoldIPinUpdateSidecar interface for out-of-band signed manifest updates with UTC validity windows.
  6. API Request Signing — HMAC-SHA256 with X-FishMMO-Client header (v1.{ts}.{nonce}.{sig} format), 30s skew window, per-process nonce LRU cache.

UI Toolkit (UITK) Panels — Login Flow

  1. 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 own Hide(); 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.DismissLoadingScreen calls Hide() on the resolved control rather than UIManager.Hide, which is a no-op unless the panel is visible and therefore skipped the flag clearing.
  2. 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.
  3. Login Panel — Username/password, TOTP/2FA code, account verification code input.
  4. Register Panel — Username, password, email, age fields.
  5. Server Select — Available game server list.
  6. Character Select — Existing character display with create-new option.
  7. Character Create — Name input and appearance customization.

UI Toolkit (UITK) Panels — World / In-Game HUD

  1. Ability Book — Learned abilities with details.
  2. Cast Bar — Channeling/casting progress display.
  3. Ability Crafting — Ability-based item crafting UI.
  4. Achievement Window — Achievement tracking and completion display.
  5. 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.
  6. Buff Container — Active buff/debuff icon management.
  7. Capture Point — Objective readout for a contested point: title, state, capture progress and current owner. Reads the same CapturePointUpdateBroadcast that ClientInteractableStateSystem writes 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 after IDLE_HIDE_SECONDS of 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.
  8. Chat Window — Message history, tabs, channel picker, input. The input row is sized with min-height rather than a fixed height: pinned to 26px with the field given flex-grow, the TextField'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 what UIManager.InputControlHasFocus gates 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 the Chat action as well as to send, and InputAction.triggered stays true for the whole frame — the per-frame EnableChatInput poll 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 was Color.black and Region was Color.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, and Activate runs 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.
  9. 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. ContainerOpenBroadcast serves 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.
  10. Crosshair — Reticle display for targeting.
  11. 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 DungeonTemplate the open message names by ID, so opening it costs one int on 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 with SceneTransferRefusedBroadcast, 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.
  12. 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.
  13. 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.
  14. Friend List — Online/offline status.
  15. 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.
  16. 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.Invite the 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.
  17. 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.
  18. 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. ViewerIsLeader arrives 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.value raises 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.
  19. 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 UIDocument re-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 called InventoryController.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.
  20. 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.
  21. 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.
  22. Main Menu — Settings, logout, quit.
  23. Merchant Buy/Sell — NPC vendor interface.
  24. Minimap — Live overhead render centred on the character, drawn by UITKMinimap. The overhead Camera is kept disabled and rendered by hand at a capped FramesPerSecond (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 drives ClientMapSystem.Tick for both maps.
    65b. World MapUITKMap shows the whole scene from an image baked at edit time. Two panels, one subsystem: both draw through the same UITKMapView element and read the same ClientMapSystem, 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 DataWorldMapDefinition (Shared) holds a scene's bounds, baked image, region labels and landmarks. Scene authors drop MapRegionLabel and MapPointOfInterest components — 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 to SceneBoundary via MapBoundsResolver, the minimap renders normally, and the world map draws markers and fog over a flat background. The bake needs a graphics device; under -nographics everything except the photograph is still written.
    65d. Map Markers — Put an object on the map with a MapMarker: 16 MapMarkerTypes (party/guild/friendly/neutral/hostile player, NPC, vendor, quest giver, trainer, service, resource, enemy, interactable, teleporter, landmark, note) and a MapMarkerVisibility rule (Always, SelfOnly, PartyOrGuild, Detection, Discovered). MapMarkerRegistry is the runtime index.
    65e. The Map Is Not a RadarMapMarkerFilter draws 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 than ObserverStreamingPolicy.MinimumRange. The honest client's map is strictly less informative than the network stream it is drawn from. MinimapCameraRenderer re-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 TrackingMapRelationshipTracker keeps 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 WarFogOfWarMap stores 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 PersistenceFogOfWarStore writes one signed, gzipped file per character per scene under <install>/Cartography/<characterID>/, and MapNoteStore writes 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 from Cartography/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 LockingItemOperationTracker is 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 and ItemSlotPendingSet keys it by container and slot.
  25. NPC Dialogue — Conversation window.
  26. 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 AudioSource yet 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 own OnStarting, 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, TooltipValue and TooltipStat had 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 carries fish-tooltip__title or fish-tooltip__stat, and TooltipValue never 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" was Primary, which paints the header and footer bars, while the panel body is Background — 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 wrote ShowAchievements while its only consumer read ShowAchievementCompletion, 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>.cfg and loads one back, so a player can hand their arrangement to somebody else as a plain text file. Deliberately not Configuration.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.cfg stays 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 shared PanelSettings reference resolution, which is the scale knob under ScaleWithScreenSize; PanelSettings.scale only has an effect under ConstantPixelSize and would have done nothing. Because PanelSettings is 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.
  27. 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 PointerLeave never 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 — OnButtonCreateParty already 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: OnServerPartyInviteBroadcastReceived drops 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.
  28. Pet Control — Summon, dismiss, pet abilities.
  29. 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 at left: 24px, bottom: 40px, 420x280, with an opaque .fish-panel background — so they were positioned correctly the whole time and drawn underneath it. Separately, every bar is position: absolute and 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.
  30. 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 Popup tier rather than Settings: panels sharing a tier fall back to scene load order, Options lives in ClientPreboot while this lives in ClientWorldGUI, and in UI Toolkit the loser of that ordering receives no pointer events at all.
  31. Shrine — Feedback line for a shrine's effect. The heal and buff are applied by ShrineAction on 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.
  32. 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 via WritePayload/ReadPayload, so there is no server round trip. Slots reuse the shared .fish-slot classes and carry tooltips, so inspected gear reads the same as your own.
  33. Death Dialog — Respawn or resurrect choice. Sits at the Modal tier 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:

  • OnStarting runs against a populated visual tree, not at Awake. UIDocument allocates rootVisualElement up front but only clones the UXML into it during its own OnEnable — after every component's Awake — so Awake saw a real but empty root: every Q<> 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 its UIDocument disabled and no tree at all, so the retry is a coroutine, not Update: Unity dispatches magic methods to the most-derived declaration, and a base Update would be shadowed for exactly the panels that need it.
  • Cached elements are re-resolved when the tree is rebuilt. Hiding a panel disables its UIDocument and re-showing clones the UXML afresh, so every element cached in OnStarting points into a discarded tree — writes go nowhere and the panel shows whatever the UXML declares. Show compares the root's identity and re-runs initialisation when it has changed.
  • OnAfterShow writes per-open content, because "mutate then Show" silently does not work. Enabling the UIDocument makes it clone the UXML afresh, so the familiar shape — set the label, then Show() — 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 — pendingStatus is re-applied on every show — and it was only the rows that still cached VisualElements 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 and Show() returned early without re-cloning the tree. Show therefore ends by calling OnAfterShow, 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 open hasStarted is still false, so ReinitializeIfTreeReplaced bails and only OnAfterStarting runs — panels write their per-open content from both hooks. Panels holding rows in a dictionary rebuild it from model state rather than caching VisualElements across a hide, the same split the roster panels use.
  • Hide() is deliberately not virtual; override Hide(bool). Hide() only forwards to Hide(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: UITKLoadingScreen overrode Hide(), 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 Button is not a focused text field. IsInputFieldFocused gates player movement, and it used to match element is TextElement — but Button and Label both derive from TextElement and Button is 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 matches TextField or the USS class unity-base-text-field, which is TextInputBaseField<T>.ussClassName and therefore covers IntegerField and every other text input without naming them.
  • OnAfterStarting re-applies state that arrived first. World entry calls UIManager.SetCharacter for every control at once, which for a panel that starts hidden lands before any element exists. UITKCharacterControl re-applies the character so both orders converge, pairing Pre with Post so a rebuild cannot stack duplicate event subscriptions.
  • ReleasesCursor and CloseOnEscape are separate flags. They were briefly merged, because PlayerInputController used "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, so UIManager.ClosedThisFrame stops 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 UIDocument sharing one PanelSettings, and UI Toolkit orders those by sorting order alone — panels left at the same value fall back to scene load order, which put Options (in ClientPreboot) permanently behind Login (in ClientLoginGUI) and unable to receive a click. UITKPanelLayer assigns each panel a tier (WorldOverlay, Hud, Window, Menu, Settings, Popup, Modal, Tooltip, Drag, System), declared in code so a new panel inherits Window rather 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 cursor keywords 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, and Validate Panels fails 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.

  1. 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 SetText and 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.
  2. Input Dialog Box — Modal dialog with text input field. Takes an optional masked flag that renders the field as a password entry; it is applied in ApplyRequest against the live tree (setting it in Open would write to the tree Show is 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.
  3. Color Picker — Color selection control.
  4. Custom Dropdown — Dropdown control.
  5. Selector / Grid — Item picking grid.
  6. 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.
  7. Tooltip — Item/ability information popup on hover.
  8. UI Theming EngineUITKTheme parses the player's thirteen configurable colours and UITKThemeManager applies them to every registered panel, replacing the Canvas-crawling UITheme/CanvasCrawler pair. The storage format is unchanged — {Name}ColorR/G/B/A bytes — 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 :hover and :active rules keep coming from the stylesheet, which is exactly the limitation the Canvas crawler had for the same reason.
  9. 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 with else 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

  1. 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: WorldLabel is position-plus-text, and UITKWorldLabelLayer projects each one onto a screen-space panel every frame through RuntimePanelUtils.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; OccludeBehindGeometry restores it at the cost of one linecast per visible label per frame.
  2. Visual Effects — 10 configurable effects: FadeIn, FadeOut, FloatUp, FloatRandom, Bounce, Pulse, ScaleUp, ScaleDown, Wave, Shake.
  3. Billboard Component — Makes GameObjects always face the camera (nameplates, health bars).
  4. Cinematic Camera — Camera movement along Unity Spline paths with LookAt target and user skip.
  5. Floating Labels — Damage, heal, achievement, and region name labels in world space.

Scene Management

  1. Addressable-Based Scene Loading — Scene preloading/postloading with progress tracking.
  2. Template Cache Population — Static permanent addressable loading.
  3. Fog Transitions — Scene fog changes during world transitions. ClientFogManager extracted for SRP compliance.
  4. World Scene Tracking / Unloading — Client-side world scene lifecycle management.
  5. Postload Scene Lifecycle — Reloads on quit-to-login, unloads on entering game world.
  6. Death Broadcast HandlerDeathBroadcast registered on client for reconnect-while-dead death dialog re-display.

Naming & Resolution

  1. ClientNamingSystem — ID-to-name and name-to-ID resolution for characters, guilds, pets with server queries and disk persistence (GZip binary).

WebGL Support

  1. Browser Key Interception — Prevents default browser actions (F12, Ctrl+W) during gameplay via JavaScript interop (Assets/Scripts/Client/WebGL/WebGL.jslib).
  2. WebGL Quit — Calls a JavaScript quit function via Client.jslib.

Settings and Client Boot Phase

  1. Single Settings OwnerClientSettings creates and loads Configuration.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.EnsureLoaded in 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 (LoadBindingOverrides returns 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 write ShowAchievements while its only consumer read ShowAchievementCompletion. Every read clamps because the file is plain text a player can edit and a crash can truncate, and the values reach RenderSettings.ambientLight, Screen.fullScreenMode and AudioListener.volume, none of which validate what they are given.
  2. Two-Phase Boot Load — The store is loaded at RuntimeInitializeLoadType.BeforeSceneLoad, ahead of every scene's Awake, so the first panel to register already has settings to read. The settings are applied from MainBootstrapSystem.OnApplyClientBootSettings, raised during client preload immediately after that system installs its boot-time frame-rate cap and forces vSyncCount to 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 in FishMMO.Client, which FishMMO.Shared cannot reference; an AfterSceneLoad backstop covers scenes with no bootstrap system, and applying is idempotent either way.
  3. Display SettingsClientDisplaySettings owns 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 both RenderSettings.ambientLight and ambientIntensity, because which one is read depends on the scene: ambientLight is consulted only under AmbientMode.Flat, and every world scene is authored AmbientMode.Skybox, where it is ignored outright — so a slider that wrote only ambientLight did 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 every sceneLoaded: 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, since SetQualityLevel installs that level's own authored vSyncCount. Every quality and VSync write routes through ApplyQualityLevel/ApplyVSync so that pairing cannot be forgotten at one call site — and so the editor safeguard runs first: QualitySettings is a project asset and a value written into it at runtime stays written, so running the client once left m_CurrentQuality and the active level's vSyncCount modified in source control, describing whatever the last person to press Play had saved in their own Configuration.cfg. The authored values are captured before anything writes — at BeforeSceneLoad, ahead of the bootstrap system's own vSyncCount = 0 — and restored on play-mode exit, mirroring what UITKPanelScale already does for PanelSettings. A fresh install keeps the boot-time menu cap of 60 FPS rather than jumping to the display's fastest mode: ResolveSavedFrameRate returns BootstrapTargetFrameRate when no preference is stored, which is what makes that constant mean something instead of being overwritten microseconds after it is set.
  4. Audio SettingsClientAudioSettings holds one level per AudioChannel (Master, Music, Effects, Ambient, Interface, Voice), persisted, applied at boot, and readable synchronously by anything that plays a sound. PlayableChannels is 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 an AudioMixer: 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 to AudioListener.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 hidden DontDestroyOnLoad watcher, because OnApplicationFocus only reaches a MonoBehaviour and a watcher living in a scene would stop reporting exactly when the player has alt-tabbed away.
  5. Configuration Key EnumerationConfiguration.GetKeys(prefix) returns a snapshot of the stored names, taken under the read lock. Added for the UI profile writer, which has to collect every UI.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.
  6. Culture-Invariant StorageConfiguration formats every value with CultureInfo.InvariantCulture and gives float/double the round-trip ("R") format. It previously used value.ToString() — the current culture — while every reader parses invariantly, so on any machine whose locale writes a comma as the decimal separator 0.75f was 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 uses NumberStyles.Float so 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.
  7. One Debounced WriteConfiguration.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. UITKPanelPositions used 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. LauncherSettings was a third writer that called Save() on the store directly, bypassing both the editor guard and the WebGL sync. All of them now request and flush through ClientSettings.
  8. Write PumpClientSettingsPump is a hidden DontDestroyOnLoad component that drives the debounce and forces the owed write out on focus loss, pause, quit and destroy. The debounce used to be pumped from UITKControl.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, where OnApplicationQuit does not run when a tab is closed.
  9. 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 to Application.persistentDataPath there, 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 passes autoSyncPersistentDataPath: true to createUnityInstance(), and this project ships the stock PWA template, which does not — so WebGLPersistentData.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

  1. Server Composition RootServer MonoBehaviour orchestrates CoreServer, Database, NetworkWrapper, AddressProvider, AccountManager, BehaviourRegistry, DataContainerRegistry.
  2. Config File LoadingFileServerConfiguration loads/saves .cfg files with typed getters and defaults.
  3. Server Lifecycle EventsIServerEvents with delegates for LoginServer/WorldServer/SceneServer initialization.
  4. Periodic Callback SystemIPeriodicUpdateSystem for registering/unregistering configurable-interval per-frame callbacks.
  5. Server Behaviour System — ScriptableObject-derived modular server behaviours with unified InitializeOnce/Deinitialize lifecycle.
  6. Server Component Registry — Multi-interface lookup registry for all server components.
  7. Runtime Data Containers — Typed runtime data containers (RuntimeDataContainer) with RuntimeDataContainerFactory and RuntimeDataContainerRegistry; behaviours declare required containers via [RequiresDataContainer]. Per-system containers follow the <System>SystemRuntimeData / I<System>SystemRuntimeData naming convention — e.g. PartySystemRuntimeData/IPartySystemRuntimeData, GuildSystemRuntimeData, CharacterSystemRuntimeData, ChatSystemRuntimeData, WorldSceneSystemRuntimeData, NamingSystemRuntimeData. Shared infrastructure containers (MainThreadQueueData, AsyncWorkerData) are similarly split per system via marker interfaces such as IGuildSystemMainThreadQueueData so systems do not collide on one registry slot.
  8. 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.
  9. 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. An entityKey of 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.
  10. 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 missing End(). 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.
  11. 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.
  12. Server Type Selection — Server type determined by command-line arg (LOGIN, WORLD, or SCENE).
  13. Address ResolutionServerAddressProvider resolves IPv4/IPv6 from transport with optional overrides.
  14. Physics Ticker — Unity MonoBehaviour ticking a PhysicsScene at server fixed timestep (per-scene physics).
  15. Window Title Metrics — Updates server window title with connection/character counts.
  16. Server Launcher — Bootstrap system preloading addressables and loading server scenes based on CLI args.

Authentication (All Server Types)

  1. BaseServerAuthenticator — Abstract MonoBehaviour bridging FishNet transport to engine-independent BaseAuthenticatorCore. Handles: handshake routing, cookie challenges, rate-limit key resolution, main-thread action queue.
  2. ServerAuthenticator (SRP) — LoginServer SRP-6a authenticator: SRP verify/proof, TOTP/recovery code verification, token issuance, kick request processing.
  3. TokenServerAuthenticator — World/Scene token authenticator: decrypt + verify + revocation check, one-retry with linear backoff for DB blips.
  4. Signing Key KEK Provider — Static utility loading AES-256 KEK from the deployment_secrets database table (key signing_key_kek), building 8-byte AAD bound to LoginServer ID, wrapping/unwrapping HMAC signing keys. No environment variable or .cfg file fallback.
  5. Account ManagersAccountManager, SrpAccountManager, TokenAccountManager wrapping FishMMO-Auth cores for Unity/FishNet.
  6. 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 log TransportIdData could not be found on every disconnect. Login-queue admission still clears unconditionally — there the server is inviting a re-handshake on a live connection.
  7. requireTokenRealIp — Serialized on TokenServerAuthenticator, default on: an auth token must carry a verified real client IP. Correct behind the L4 proxy, where conn.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

  1. Login Server Registration — Registers server in DB, generates/rotates HMAC signing keys (AEAD-wrapped via KEK), derives TOTP master key. Periodic heartbeat pulses.
  2. 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.
  3. Character Create System — Template-validated character creation with starting equipment/abilities/hotkeys initialization, MaxCharacters per account enforcement.
  4. Character Select System — Character listing, selection, and deletion for player accounts.
  5. Server Select System — World server list provisioning from database.
  6. Login Queue SystemLoginQueueSystem (ServerBehaviour) holds a FIFO queue, backed by ArrivalOrderTracker<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 receive LoginQueuePositionBroadcast position updates every LoginQueueUpdateRateSeconds; 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 via ClientAuthenticatorCore.OnRehandshakeRequired()not OnDisconnected(), 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 by IsConnectionAwaitingQueueAdmission (covering the admitted-but-not-yet-re-handshaked window via recentlyAdmitted, 15s TTL), and its handshake rate-limit window is cleared on enqueue so the server-invited retry cannot trip it. Admission is rate-smoothed via LoginQueueAdmissionRatePerSecond so newly admitted clients cannot immediately re-saturate auth capacity. Clients beyond LoginQueueMaxSize are rejected with ClientAuthenticationResult.ServerBusy (ServerBusyBroadcast) rather than queued; clients exceeding LoginQueueTimeoutSeconds receive position -1 and are disconnected. All parameters are server-authoritative.

WorldServer Features

  1. World Server Registration — DB registration, periodic heartbeat with character count.
  2. 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.
  3. 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.OpenWorld rows — FetchAvailableAsync selects on world, name, capacity and Ready and says nothing about type, so without the filter a Group row for a scene also reachable as a teleporter's ToScene or a character's BindScene would drop the player into somebody else's private instance. Two periodic reapers keep the routing pool honest: DeleteStaleUnreadyAsync removes rows that never reached Ready (nothing else does — a Loading row orphaned by a scene server that died between dequeue and load still has scene_server_id = 0, so that server's own restart cleanup never matches it), and DeleteByStaleSceneServersAsync removes Ready rows 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 receive WorldSceneQueuePositionBroadcast every queuePositionUpdateRateSeconds, 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 a WorldSceneQueueReason (Capacity, SceneLoading, CombatLogoutBody). Position semantics and channel selection mirror LoginQueuePositionBroadcast: >0 waiting (Unreliable, corrected by the next sweep), 0 routed and -1 abandoned (both Reliable, as one-shot transitions). Each reason carries its own bound — waitingQueueTtlSeconds for capacity, × SceneLoadWaitTtlMultiplier while a scene instance is still loading, and CombatLogoutRoutingGraceSeconds for 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).
  4. 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 than NetworkConnection.Kick, which FishNet does not relay: the player is told they were disconnected by an administrator, and the Terminal flag 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

  1. Scene Server Registration — DB registration, periodic heartbeat pulses with scene character counts.

  2. Scene Loading/Unloading — FishNet SceneManager orchestration, pending scene queue processing from DB, stale scene cleanup. Scene instances are identified across processes by their scenes row ID; the local scene-manager handle is kept only for the two places that need it (SceneInstanceByHandle for unload callbacks, and the scene manager itself) and is never persisted or sent to another process. SetReadyAsync and PulseAsync address 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, because character_id stays 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 by MaxInstanceLifetimeMinutes (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: CloseInstance returns 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.

  3. 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 on WaitingSceneLoadCharacters, cleared by ClientValidatedSceneBroadcast), and transfer (TransferDisconnectGrace, 15s). The per-account auth-callback rate limit disconnects with RateLimited rather than returning silently, because that callback is the only entry point to a character load. Every per-connection watchdog map is cleared in OnDeinitialize, since the behaviour is a ScriptableObject whose fields outlive an editor play-session restart while FishNet reissues ClientIds from zero. The scene handshake itself is order-independent: ClientValidatedSceneBroadcast needs 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 reaching WaitingSceneLoadCharacters (several database round trips later), and the server does not control which arrives first. startScenesAckedClientIds records the acknowledgement instead of acting on it, and whichever half lands second calls ValidateSceneAndAcknowledge; 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 with CharacterUnavailable whenever the acknowledgement won — which, under any database latency, is the common case.

  4. 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. TryBeginCombatLinger removes ownership (taking the object out of connection.Objects before FishNet's disconnect cleanup despawns it), cancels any in-flight ability (a lingering body is still ticked, and AbilityController.OnReplicate would otherwise re-assert IsHeld and let a cast complete on behalf of a player who cannot aim or stop it), sets the persisted IsCombatLogged flag, 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 hard ExpiresUtc deadline — the last of which stops an attacker pinning a body indefinitely by chipping at it. AnyOnlineAsync skips IsCombatLogged characters so the owner can log back in and reclaim the body via TryReattachLingeringCharacter, 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, because FinalizeCombatLinger moves each token out of SessionTokens and 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.

  5. Character Inventory System — Item moves, swaps, splits across inventory, equipment, and bank containers. Persists changes to DB.

  6. Equipment System — Equip/unequip with slot validation.

  7. Bank System — Bank/storage slot management.

  8. 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 SceneName still names the open-world scene the character will return to. The sender is resolved from ConnectionCharacters rather than conn.FirstObject, so a command's authorisation is never decided from a network-deserialised payload.

  9. Slash Command RoutingChatHelper.GetCommandAndTrim returns 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 to ScriptableObject behaviours 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.

  10. 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. GuildRank used 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 >= Officer comparisons granted, Member → none, so no migration script runs and no membership row is rewritten (rank_order is the old rank byte).
    • 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 EditRanks soft-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 == Officer and 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 hardcoded Officer, 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 enableRichText disabled — 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.
  11. 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 PartySystem asset rather than in Configuration.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), and maxVitalsBuffsPerMember (a bound on the vitals payload, which is built per member and sent per member).
  12. Friend System — Add/remove friends with validation, online status tracking, MaxFriends enforcement.

  13. Achievement System — Progress tracking, completion events, reward delivery.

  14. Quest System — Event handling, auto-progression, reward delivery, DB persistence.

  15. 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 Pet as PetPersistedState and 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 through IAINavigation rather than the full IAIController, 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 by PetSystemAssetTests instead of being discovered at runtime by a designer.

  16. Hotkey System — Player hotkey configuration for abilities/items with ingress debounce protection.

  17. Naming System — Character/guild ID ↔ name resolution with bounded TTL caches and negative caching.

  18. 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 NullReferenceException out of Awake. Instance membership is reported and managed through RequestInstanceDetailsBroadcast / InstanceDetailsBroadcast and InstanceKickBroadcast, 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: DungeonFinderListBroadcast browses the runs open at one difficulty, DungeonFinderCreateBroadcast opens a new one, and DungeonFinderJoinBroadcast joins somebody else's. DungeonFinderBroadcast is 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, Pending or Loading — a Failed row 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 with PartyInstanceExists. Creation goes through EnqueueForPartyAsync, 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 marked Failed rather 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 same InstanceUnavailable, 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's MaxClients where the difficulty declares none: a full instance is refused with DestinationFull rather 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 by CanActOrMove on both the request and the authoritative re-check after the async database work, and the hand-off is announced via BeginDeliberateTransfer so the disconnect that performs it is not mistaken for a combat logout.

  19. Faction System — Faction relationship management.

  20. Scene Channel System — Open-world channel listing and channel switching. The list aggregates every Ready, OpenWorld instance of the character's scene on its world server — including instances hosted by other scene servers — via ISceneService.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 by CanActOrMove, 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 to ICharacterSystem.BeginChannelTransfer so the departure is announced (and the scene population debited) while the character still belongs to the instance it is leaving. Every refusal is named through SceneTransferRefusedBroadcast rather than returning silently, and "gone" is distinguished from "full" by reading the target row directly — FetchAvailableAsync filters 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.Port is 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.

  21. Persisted Channel-Switch Cooldown — The rate limit lives on the character row (characters.last_channel_switch_utc), claimed atomically by ICharacterService.TryBeginChannelSwitchAsync in 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.

  22. Leave InstanceRequestLeaveInstanceBroadcast and the /leaveinstance (/exitinstance) chat commands remove a character from instanced content and return it to the open world at its recorded LastWorldPosition. 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 by CanActOrMove like 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

  1. Server Lock (drain)world_servers.locked and scene_servers.locked are 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 above AccessLevel.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.
  2. Scheduled Maintenance Shutdownshutdown_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 terminal ServerMaintenance notice 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.
  3. In-Game /admin Commandsstatus, lockserver/unlockserver, shutdown <seconds>/stopshutdown, lockscene/unlockscene, shutdownscene <seconds>/stopshutdownscene, all requiring AccessLevel.Admin. Registered as a single /admin command 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.
  4. Per-Command Access LevelsChatHelper registers each slash command with a minimum AccessLevel, 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

  1. CharacterStateValidation — Centralized static validation gate for all broadcast handlers. CanAct() rejects dead, teleporting, incapacitated and unloaded characters. Incapacitation is CharacterIncapacitation.IsIncapacitated — frozen or stunned or mesmerized. Only IsFrozen used to be tested here: IsStunned and IsMesmerized were 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 through CanAct, 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.
  2. 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.
  3. 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.
  4. Respawn/Resurrect IngressGuard — Per-operation IngressGuard (2s debounce) on respawn-at-bind-point and resurrect-accept handlers. Prevents spam and concurrent-operation races.
  5. 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 with SELECT … FOR NO KEY UPDATE on 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. TryClaimSequence is 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-ran OnSelectActions for every DialogueChoiceBroadcast it received without ever reading ChoicesMade — 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-ability AdditionalEventSlots limit 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 one AbilityTypeOverride. Hotkey ability bindings were being validated against template IDs via KnowsAbility while the client sends instance IDs truncated to int.

Observer LOD System

  1. HashGrid Spatial Partitioning — FishNet HashGrid component on the SceneServer scene's NetworkManager (_accuracy: 70). O(1) hash-based proximity: objects in the same or adjacent grid cells are "nearby." _gridAxes is serialized as 2 = XZ, which is the correct plane for a horizontal-plane world. (An earlier revision of this document reported it as XY; that has been corrected.)
  2. Global Observer Conditions — The scene's ObserverManager _defaultConditions list holds exactly two assets: FishNet's stock SceneCondition (never observe cross-scene) and GridCondition (spatial hash pre-filter), applied to all NetworkObjects.
  3. Tiered Distance Conditions (wired) — Four DistanceCondition ScriptableObjects live in Assets/Settings/ObserverConditions/: PlayerDistanceCondition (100m, _hideDistancePercent 0.1), MonsterDistanceCondition (50m, 0.15), InteractableDistanceCondition (30m, 0.1), WorldItemDistanceCondition (15m, 0.2). The three playable character prefabs now reference PlayerDistanceCondition on their NetworkObserver, and the monster/interactable/world-item conditions are applied to their respective prefabs. (An earlier revision reported these as authored but unreferenced.)
  4. Per-Observer Streaming BudgetObserverBudgetCondition (referenced by the SceneServer scene) plus ObserverStreamingRegistry / ObserverStreamingPolicy decide, 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.
  5. Distance-Scaled Transform RateNetworkTransformDistanceLod shapes the NetworkTransform send interval per observer by distance, so a distant peer costs a fraction of a near one without being culled outright.
  6. 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; PredictionBandwidthBenchmarkTests and ObserverChannelCostTests measure 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

  1. ICharacter / IPlayerCharacter Interfaces — Root character contracts: ID, name, transform, collider, network object, prediction manager, observers, flags, behaviours, triggers.
  2. BaseCharacter — Abstract NetworkBehaviour implementing ICharacter: behaviour registry, bitwise flag management, ECA trigger invocation, race model instantiation (Addressable), client character dictionary.
  3. 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.
  4. CharacterBehaviour — Abstract base for modular behaviour components: InitializeOnce, OnStartCharacter, OnStopCharacter lifecycle.
  5. CharacterFlags — Bitwise state flags: Idle, IsMoving, IsRunning, IsCrouching, IsSwimming, IsTeleporting, IsFrozen, IsStunned, IsMesmerized, IsInInstance, IsLoaded, IsDead, IsInCombat, IsCombatLogged. IsInCombat is transient and is stripped on both save and load; IsCombatLogged is 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 requires InstanceSceneName to 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.
  6. 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. IsInCombat flag cleared on death and network reset. Combat state prevents teleportation (combat-escape prevention).
  7. Combat Contributions & Loot RightsCharacterDamageController tracks who contributed to a kill, by CombatContributionKind, 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, and FactionController refuses 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.
  8. Combat-Escape Prevention — Teleport is blocked while the IsInCombat flag is active. Movement is not — players move freely during combat; only teleportation is restricted. The movement gate in KCCPlayer.OnReplicate is split deliberately:
    • Predicted on both peers — incapacitation (IsFrozen / IsStunned / IsMesmerized, via CharacterIncapacitation) 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-onlyIsTeleporting and IsLoaded, which are server bookkeeping a client cannot evaluate. IsLoaded rides 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.

ECA Trigger System (Entity-Component-Action)

The data-driven trigger/action pipeline powering abilities, quests, dialogue, interactables, and game events.

  1. Trigger System CoreTrigger ScriptableObjects with TargetSelector + Conditions + OnConditionsMetActions + OnConditionsNotMetActions. Fault isolation (throwing actions caught/logged).
  2. EventData — Typed event context container: Initiator, Target, TargetCharacter, RNG, ConditionFilter. Supports typed sub-payloads, forking, merging.
  3. Polymorphic Serialization — All actions/conditions/selectors use [SerializeReference] + [SubclassSelector] for designer-authored Inspector workflows.

ECA Actions (52 implementations)

  1. Combat Actions — ApplyDamage, ApplyHeal, ApplyRevive, ApplyBuff, ApplyDispel, ConsumeResource, Interrupt, KnockbackHit.
  2. Ability Actions — AbilityApplyArea, AbilityApplyTarget, AbilityForkHit, AbilityHitCount, AbilityMoveTransform, AbilityPierceHit, AbilitySpawnMultiply.
  3. Item Actions — EquipItem, UnequipItem, GiveItem, RemoveItem. (Equip/Unequip #if UNITY_SERVER guarded — persistent state mutations never run during prediction replay.)
  4. Quest Actions — AcceptQuest, AbandonQuest, AdvanceQuestObjective, CompleteQuest, FailQuest, TurnInQuest.
  5. Interactable Actions — Bindstone, GatheringNode, LoreObject, NPCLookAtInteractor, PickupWorldItem, SendAbilityCrafterBroadcast, SendBankerBroadcast, SendContainerOpenBroadcast, SendDungeonFinderBroadcast, SendMailboxBroadcast, SendMerchantBroadcast, SendQuestOffer, Shrine, Switch, Teleport. BindstoneAction refuses to bind inside an instance — BindScene/BindPosition are consumed by the respawn-at-bind path, which hands the character to open-world routing, so a BindScene naming 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.
  6. Region Actions — ApplyRegionAttribute, ApplyRegionBuff, ChangeFog, ChangeSkybox, DisplayRegionName, PlayRegionAudio.
  7. Utility Actions — AchievementIncrement, AddFaction, ClearTarget, DestroyObject, DisplayDialogue, PlayFX. (DestroyObject #if UNITY_SERVER guarded. PlayFX suppresses on a replayed tick via IsReplayTick — a rollback re-runs the tick and would otherwise spawn the effect again per replay. ClearTarget still gates on TickEventData.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. BasicEventTargetSelector, InitiatorTargetSelector, NearestTargetSelector, FurthestTargetSelector, RandomTargetSelector, AllCharactersTargetSelector.
26. SpatialAreaTargetSelector, ConeTargetSelector, LineTargetSelector, ChainTargetSelector.
27. HierarchyChildrenTargetSelector.
28. Named/TaggedNamedSceneObjectTargetSelector, TaggedSceneObjectTargetSelector.

ECA Value Providers (10 types)

28b. ConstantValue, ConstantFloatValue, RandomRangeValue, RandomRangeFloatValue, StatScaledValue, StatScaledFloatValue, DamageAmountValue, HealAmountValue, FactionAmountValue, QuestObjectiveAmountValue.

Item System

  1. Item Template HierarchyBaseItemTemplateConsumableTemplate / EquippableItemTemplate → concrete: Potion, Scroll, Armor, Weapon.
  2. Runtime ItemItem with optional ItemEquippable, ItemStackable, ItemGenerator components.
  3. Item GenerationItemGenerator using DeterministicRNG for seed-based stat rolls (AttackPower, AttackSpeed, ArmorBonus + random attributes from databases).
  4. Item Attributes — Template-driven attribute system with min/max values linked to CharacterAttributeTemplates.
  5. Item ContainersIItemContainer with slot locking, stacking, swapping. InventoryController, EquipmentController, BankController implementations.
  6. Item Slots — Head, Chest, Shoulders, Hands, Legs, Feet, Back, Primary, Secondary, Accessory (10 slots). Every member of ItemSlot is 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. Inserting Shoulders at index 2 once renumbered every slot below Hands in 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 Slots cross-checks the authored assets against this.

Currency System

34b. Character CurrencyCharacterCurrency 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 EscrowCurrencyLedger 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

  1. Ability TemplatesBaseAbilityTemplateAbilityTemplate / PetAbilityTemplate with ActivationTime, LifeTime, Speed, Cooldown, Price, RequiresTarget, HitCount.
  2. ECA Ability Events — OnTick, OnHit, OnPreSpawn, OnSpawn, OnDestroy — each with configurable ECA triggers.
  3. Ability Activation State Machine — Resource cost validation via IResourceCost conditions, activation queuing, consumable support, network sync.
  4. AbilityObject — Networked GameObject for projectiles/AoE with lifetime, collision, tick handling, and snapshot reconciliation.
  5. Ability Knowledge System — Learned abilities, base abilities, ability events, event subset tracking.
  6. Cooldown System — Tick-based immutable CooldownInstance with reconcile snapshots, static events for add/update/remove.
    40b. Swept Hit ResolutionAbilityObjectSweep covers 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 via AbilityObjectHitBroadcast. 40d. Deterministic Container IDsAbilityContainerAllocator allocates 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 ReconciliationPredictedAbilityStateHistory records 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), the Denied flag (the server refused the activation — authoritative and independent of RNG, since a rejection can precede any seed advance), and NoSpawn (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 + SnapshotAttributeController stand 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

  1. Runtime Buff — Tick-based timing (ExpiryTick, NextTickTick), stack count, cumulative tick multiplier.
  2. Buff Template Types — AttributeBuff (flat stat modifier), AttributeTickBuff (per-tick modifier), ResourceTickBuff (DoT/HoT), StateBuff (stun/freeze/mesmerize), CompositeBuff.
  3. Buff ReconciliationBuffReconcileEntry for 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

  1. Three-Tier Value System — baseValue + formulaModifier + externalModifier = finalValue. Parent/child dependency graph with formula propagation.
    44b. Attributed-Modifier LedgerexternalModifier is the sum of named contributions rather than an anonymous running total. Every contributor writes through SetSource(ModifierSource(Kind, Id, Index), value) — Item, Buff, Region, DungeonScaling, NpcBonus — so a contribution can be restated idempotently and released by contributor with ClearSourceGroup, without the apply and release halves having to agree on an index scheme forever. The server's total is installed as an Authoritative residual 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.
  2. Resource AttributesCharacterResourceAttribute extends with currentValue (health/mana/stamina), clamping, regeneration.
  3. Attribute Formulas — Flat bonus and percentage bonus formulas with dependency tracking.
  4. Propagation Batching — Deferred notifications with suppression for replay performance.
  5. 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 SafetyRestoreTemplateBaseline releases contributors with ClearAllModifierSources(), never SetModifierDirect(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.
  6. Damage SystemCharacterDamageController: 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 CoalescingCombatEventCoalescer merges 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.
  7. Damage Types & ResistancesDamageAttributeTemplate (physical, fire, frost, etc.) and ResistanceAttributeTemplate pairing.
  8. 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.
  9. ReviveRevive(ICharacter, int) works on dead characters (unlike Heal). Fires OnResurrected static event, resets death animation, fires ECA resurrect triggers.

Client-Side Prediction Pipeline

Pipeline structure

  1. Unified Prediction ControllerCharacterPredictionController discovers all IPredictableController components, stable-sorts by Order with a deterministic type-name tiebreaker, and drives a single FishNet Prediction V2 pipeline. One [Replicate]/[Reconcile] pair per NetworkObject, which is what avoids FishNet's multi-behaviour prediction conflicts.
  2. 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.
  3. Type-Safe TicksPredictionTick can only be produced from a replicate input, so the compiler refuses a raw TimeManager.LocalTick where 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 through LagCompensationTick.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 OwnershipHasInputAuthority answers "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-side AIController. Gating on IsOwner left 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 their IsHeld state, and the view offset latches only from a replicate carrying real input (ReplicateState.Created). _dropExcessiveReplicates: 1 is a correctness setting, not a tuning one.

Input and quantisation

55e. Input, Not StateCharacterReplicateData 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/DecodeEncode(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

  1. Delta CompressionCharacterReconcileDataDeltaSerializer (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 a ReferenceEquals shortcut 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-byte Sequence, stamped when the reconcile is actually written (not when it is created — the send is skipped when no resends remain), lets the reader require prev + 1 and 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 — A FullSerialize is 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 — Every NetworkBehaviour on 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 ShapesPayloadVisibility chooses 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 by PredictionBandwidthBenchmarkTests on 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 PredictionKCCPlatform 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 CameraKCCCamera 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

  1. 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 — zero UnityEngine.Random or System.Random.
  2. Shared Speed EnforcementMaxAllowedSpeed = SprintSpeed × 3.0f (KCCController) runs identically on client and server in shared code. No server-only branches.
  3. Motor PhysicsScene InitKCCPlayer initialises the motor's PhysicsScene from 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.
  4. Deterministic Ability MathSystem.Math.Ceiling(double) replaces Mathf.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, and PlayFXAction declines on a replayed tick outright.

Physics and hit resolution

  1. 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 via EcaAuthority.IsServer, because a physics query is not reproducible across peers. This replaced an IsReplicateTick guard that also suppressed the server — the server's own spawn and self-target dispatches carry replicate ticks too, so an area effect wired to OnSpawn used to run on no peer at all.
    61b. Fixed Selection Pipeline — Every capping selector runs query → 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 <= 0 means uncapped everywhere.
    61c. Lag Compensation — Hits resolve against where the caster's client saw its peers. CharacterPositionHistory records one pose per tick into a ring sized from maximumRewindMilliseconds (500 ms, the designed worst case); LagCompensationRegistry.Rewind displaces 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, as ViewOffsetTicks + a 1/256-tick fraction — and the server adds its own replicate queue depth. Every latency term cancels exactly; LagCompensationClosedLoopTests pins 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 by MaximumCompensationTicks before 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, via AbilityObjectHitBroadcast — 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 to motor.BaseVelocity, a field of the KinematicCharacterMotorState the 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 the NetworkTransform does not touch, and the two compose.

Observer synchronisation

  1. 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 via ObserverBroadcastScope.
    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 by ObserverSynchronizationProofTests and LateJoinerReplayTests.
    62c. Change-Driven Push, Not Per-TickObservedResourcePushScheduler pushes 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 TransportApplyObserverTransportMode silences the NetworkTransform only when prediction genuinely moves the character (a KCCPlayer is present) and state forwarding is on. An NPC runs the same pipeline but is moved by a NavMeshAgent, so its MotorState is 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)

  1. State MachineBaseAIState subclasses: Idle, Wander, Patrol, ReturnHome, Retreat, GetBehind, Orbit, PetIdle, and the attacking family (BaseAttackingState plus Melee/Ranged/Caster/Pet presets and the Healer/Defender/Rogue subclasses), plus AggressionState and BossScript.
  2. Archetypes Are DataAIArchetypeTemplate is one asset that is a whole brain: states, personality, ability rotation, behaviour tree, threat tuning and LOD profile. Assign it to AIController.Archetype and every other slot fills itself in at spawn. Validate() reports configurations that spawn and then quietly misbehave. 16 archetypes ship (10 enemy, 6 pet).
  3. Shared Combat DecisionAICombatDecision.Plan is 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.
  4. Tick-Driven Brain — The AI runs on the FishNet TimeManager tick, not Update. 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.
  5. 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.
  6. Combat SlotsAICombatSlots gives 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.
  7. Movement CorrectnessAIController.Movement reports Complete / Partial / Failed / Throttled rather 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.
  8. Group CombatNPCGroup with roles and pack tactics (Surround, Flank, FocusFire, Kite) that assign each member a distinct orbit angle and ring radius.
  9. Boss MechanicsBossPhase, BossScript, BossTimedMechanic.
  10. Behavior TreeAIBehaviorTree of AIBehaviorNodes: AISelector, AISequence, AIInverter, AIRepeater, AICompositeNode, AIConditionNode, plus game-specific leaves AIHasTargetNode, 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.
  11. Deterministic RNG — Seeded per-NPC for reproducible behavior, now paired with tick-driven timing so when a roll is drawn is reproducible too.
  12. Ability RotationAIAbilityRotation for condition-driven combat ability selection, evaluated before the default scorer.
  13. Combat PersonalityAICombatPersonality styles: 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

  1. 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. A DungeonDifficultyDefinition is 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.

  1. 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.
  2. Switch Targets — A Switch executes against anything implementing ISwitchTarget, so what a lever does is authored rather than special-cased: SwitchTargetMover slides and/or rotates a transform between a closed and an open pose (a door, a portcullis, a drawbridge, a moving platform) and SwitchTargetObject enables 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.
  3. 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 ClientInteractableStateSystem from 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.
  4. Base Interaction — ECA-Authored, No Handler PluginsInteractionRange 3.5u default, INTERACT_RATE_LIMIT of 60ms (overridable per type via InteractRateLimit). Behaviour is authored entirely as a List<Trigger> OnInteractTriggers on the interactable prefab and fired via IInteractable.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.
  5. Server-Side Validation — The server's InteractableSystem validates the scene, runs ValidateSceneObject against the character's scene handle, resolves the IInteractable component, checks CanInteract() (which covers InRange() and the rate limit), then invokes ExecuteOnInteract with a PlayerInteractionEventData — all inside an IngressGuard.
  6. Capture Points — PvP capture points with state machine (CapturePointTemplate, ObjectiveState).
  7. Dialogue TreesDialogueTemplate with DialogueNode/DialogueChoice, server-authoritative session management with choice bitmasks.
  8. Gathering Nodes — Harvesting with GatheringDrop drop tables, cooldowns, remaining uses (GatheringNodeTemplate).
  9. Merchant Tabs — Categorized merchant inventory tabs (MerchantTabType, MerchantTemplate).

Faction System

  1. Faction Standing — Per-faction integer standing with Allied/Neutral/Hostile classification.
  2. Faction Matrices — Template-driven faction relationship matrices with editor tooling.

Quest System

  1. Quest Lifecycle — Inactive → Active → Complete → TurnedIn / Failed.
  2. Objective Tracking — Per-objective progress with required amounts.
  3. Attribute Requirements — Pre-requisite attribute checks before acceptance.

Social Systems

  1. Friends — Friend list management with online status.
  2. Guilds — Membership, invites, ranks, join/leave ECA triggers.
  3. 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

  1. World Scene Details — Per-scene configuration: max clients, spawn/respawn positions, teleporters, boundaries.
  2. Day/Night Cycle — Configurable cycle durations, skybox transitions, object activation/deactivation, material alpha fading, ECA triggers for day/night transitions.
  3. 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.
  4. Deterministic Memory FootprintObjectSpawnerPool reserves MaxSpawnCount + PrewarmHeadroom instances 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.
  5. Per-Spawner Entity OverridesNPCSpawnableSettings rolls attributes, AI archetype, additional or replacement abilities, faction, corpse decay and a random uniform scale. ItemSpawnableSettings carries 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.
  6. Teleporter System — Cross-scene and same-scene teleportation with cached destinations.
  7. Region System — Zone definitions for area effects (fog, skybox, audio, buffs, attributes, region name display), driven as server-authoritative ECA triggers: Region is a NetworkBehaviour carrying OnRegionEnter / OnRegionStay / OnRegionExit trigger lists. RegionMembership resolves ownership when regions nest so only the innermost region owns a character, and RegionGeometry holds the authored shape.
    100b. Region Attribute Contributions Are Released, Not NegatedApplyRegionAttributeAction writes through the attributed ledger under ModifierSource.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.
  8. Scene Boundaries — Terrain and custom boundary definitions.

Character Appearance & Visual Equipment

  1. Modular Character System — One shared humanoid skeleton, one Animator, one animation library for all races and equipment.
  2. Body Region System — Body mesh split into 6 hideable regions (Head, Torso, Arms, Hands, Legs, Feet). BodyVisibilityManager with per-slot reference counting for overlapping equipment hides.
  3. Character Customization — Bone scaling for Height, ArmLength, LegLength, TorsoLength, ShoulderWidth, HeadScale. Race presets (Human/Dwarf/Elf). Blend shapes for Weight, MuscleMass, ChestSize, WaistSize.
  4. Equipment VisualsEquipmentVisualController with persistent renderer pool (no Instantiate/Destroy spam). Loads prefabs via Addressables, extracts mesh + materials, binds to skeleton via SkeletonBinder.BindMeshKeepParent. A template with no model assigned is treated as "no mesh", not as an error: an unassigned AssetReference still serializes as an object with an empty m_AssetGUID, so it passes a null check and then throws InvalidKeyException out of LoadAssetAsync — surfacing as an unhandled exception in the player's console on equipping an ordinary item. The reference is tested with RuntimeKeyIsValid() as well as for null, which routes it to the "no mesh configured" warning that already existed to describe exactly that case.
  5. Weapon Attachment — Weapons as MeshRenderer children of bone transforms (RightHand, LeftHand). Follow animations automatically. Scale-independent from body proportions.
  6. Equipment Mesh VariationsEquippableItemTemplate.EquipmentMeshes list with seed-based selection via ModelPools/ModelSeed.
  7. SkeletonBinder — Bone name matching with caching. Generation-based cache invalidation for instance ID recycling safety.
  8. Animation SystemCharacterAnimationController with Speed, IsGrounded, IsCrouching, Jump, Attack, Block, Roll, Cast, Death, RootMotion. FishNet NetworkAnimator integration.
  9. Ability AnimationTriggerAbilityAnimation maps AbilityType to animation: Physical→Attack, Magic→Cast, Block→SetBlocking, Roll→TriggerRoll. Death animation suppresses all other state.

AI Threat System

  1. Threat TableAggressionController with damage, healing, resource expenditure threat. Configurable weights per category.
  2. Vulnerability Scoring — Low-health targets (<30%) get 1.5x threat multiplier. Low-mana targets (<20%) get 1.3x multiplier. AI intelligently pressures weakened enemies.
  3. Replay-Safe EventsAggressionState.IsSpawnedAndAuthoritative() guard prevents threat double-counting during client-side prediction replay.
  4. Object-Pooled Aggression Entries — Stack-based pool for AggressionEntry to avoid per-event allocations.
  5. Single-Dispatch RoutingAggressionDispatcher holds 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.
  6. 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.
  7. Taunts and Threat AbilitiesApplyTauntAction and ApplyThreatAction are 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. ApplyThreatAction is the caller that finally gives ResourceWeight meaning.

Network Broadcasts (30+ types)

  1. Auth — Authentication request/response, token sync.
  2. Character — Character data, abilities, achievements, archetype, factions, friends, guild, party, pet, quest, hotkeys.
  3. Inventory — Inventory, equipment, bank slot sync.
  4. Character Create/Select — Creation request/result, character details, delete.
  5. Chat — Chat messages with 10 channels (Say, World, Region, Party, Guild, Tell, Trade, System, Command, Discord).
  6. Interactable — Interactable state sync.
  7. Naming — Name reservation/release, ID ↔ name resolution.
  8. Scene — Scene loading, transitions, channel addresses (ChannelAddress identifies a channel by its scenes.id, never by a process-local handle), scene-routing queue positions (WorldSceneQueuePositionBroadcast with a WorldSceneQueueReason), voluntary-transfer refusals (SceneTransferRefusedBroadcast with a SceneTransferRefusalReason: 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.
  9. Server Select — Server list and connection info.

Bootstrap & Tools

  1. 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.Completed signal rather than a shared global event.
  2. Addressable IntegrationAddressableLoadProcessor for async prefab/sprite/mesh/scene loading with caching.
  3. Per-Caller Load BatchesBeginProcessQueue() returns an AddressableLoadBatch claiming exactly the items that caller enqueued, with its own Completed event, Progressed event, TotalItems/CompletedItems/Progress, and FailedItems/HasFailures. This replaces completion signalling through the processor's global OnProgressUpdate multicast delegate, which reported "done" to every bootstrap system and loading screen whenever any drain finished and could double-invoke subscribers that resubscribed during dispatch. OnProgressUpdate remains as a display-only progress feed. A batch counts an item finished whether it succeeded, failed, or was dropped — failures surface via FailedItems instead of withholding completion and stalling boot. Handlers subscribing after completion are invoked immediately, so a fully-cached batch that completes inside BeginProcessQueue cannot be missed.
  4. Template CachingCachedScriptableObject with database-wide lookup and Addressable icon/mesh loading.
  5. DeterministicRNG — Reproducible random number generator for networked determinism.
  6. SerializableDictionary / SerializableHashSet — Unity-serializable generic collections with custom property drawers.
  7. Version ManagementVersionBuilder with VersionConfig ScriptableObject; increments major/minor/patch, writes version.txt at build time.

Editor Tools

  1. 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. BuildExecutor additionally performs two post-build copies: CopyRemoteAddressablesToBuild stages ServerData/[BuildTarget]/ bundles into the built player's StreamingAssets/ServerData/[BuildTarget]/ for server builds (so DynamicAddressableLoadPathSystem can load them over file://), and CopyUpdaterToBuild copies the standalone Updater executable and its runtime dependencies into standalone client builds — without it the launcher's Constants.Configuration.UpdaterExecutable lookup 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).
  2. Patch GeneratorPatchGeneratorWindow (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.
  3. Addressables Dashboard — Analysis, build, categorization, and tree view for addressable assets. Menu: FishMMO > Addressables Dashboard.
  4. 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.
  5. AI Prefab ToolingFishMMO > AI > Repair NPC Prefabs For Combat adds the ability-pipeline components and enables prediction; Audit NPC Prefabs reports prefabs that cannot fight and why; Validate Archetypes reports archetypes whose configuration cannot behave as described; Audit Ability Intents reports what the AI derives each ability to do; Organize AI Assets and Re-serialize AI Assets maintain the canonical asset layout.
  6. Network Timing ValidatorFishMMO > Validate Network Timing confirms every scene's NetworkManager agrees on tick rate. FishNet does not synchronise it — SetTickRate says 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.
  7. Dialogue Tree Editor — Visual editor for NPC dialogue trees. Menu: FishMMO > Dialogue Tree Editor.
  8. World Scene Details Cache Builder — Builds cached world scene details at edit time. Menu: FishMMO > Rebuild World Scene Details.
  9. Custom Property Drawers[ShowReadonly], [SubclassSelector], [TemplateReference], serializable dictionary drawers.
  10. Build Option TogglesFishMMO > Build > Build Type (Client/Server), > OS Target (Windows x64 / Linux x64 / WebGL), and > Environment (Development/Production/Enable Local Directory), from BuildEnvironmentOptions.cs and WorkingEnvironmentOptions.cs. These set build options only — they do not run builds; builds execute from the Dashboard's Build & Version panel.
  11. Security Assembly Filter — Editor-only assembly filtering for security-sensitive code.
  12. Version MenuFishMMO > Version > Increment Major/Minor/Patch drives VersionBuilder.
  13. QuickStart Scene MenuFishMMO > QuickStart > … opens Main Bootstrap, Client Preboot/Postboot/Launcher, and Login/World/Scene Server scenes directly, ordered by priority.
  14. Script Compilation MenuFishMMO > 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).
  15. Equipment Slot ValidatorFishMMO > Validate > Equipment Item Slots reports equippable templates whose ItemSlot disagrees 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.
  16. 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 from m_resultToHandle, once from m_SceneInstances) with no IsValid() 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

  1. QUIC Server and Clientserver.cpp (listener, connection array, broadcast) and client.cpp (connection, polling, deferred shutdown), over a ref-counted per-connection session that owns its streams and datagrams.
  2. 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.
  3. 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.
  4. 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 the WEBTRANSPORT_STREAM header once per data stream and encode datagrams as HTTP/3 Datagrams (RFC 9297) with a Quarter Stream ID varint; native peers exchange bare payloads.
  5. HTTP/3 Handshakehttp3.cpp implements the SETTINGS exchange, the extended CONNECT that establishes a WebTransport session, and the QPACK encoding it needs.
  6. Thread-Safe Datagram Ringdatagram_queue is a lock-light ring buffer, because datagrams arrive on msquic's callback threads and are drained on Unity's main thread.

Build

  1. 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.
  2. Per-Platform Scripts, No Master Buildbuild_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 and lld-link --out-implib), and build_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)

  1. Login Server Discovery APIGET /loginserver (LoginServerController) returns available login server ports from the database, cached in IMemoryCache with 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.
  2. 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) where payload = [keyId ':'] realIp '|' expiryUnixSeconds and hmac = HMAC-SHA256(sharedKey, payload); the client echoes it in its first ClientHandshake and 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 optional keyId prefix lets multi-region game servers pick the right verification key; the signing key is registered in the connection_token_keys table as the sole discovery source. Keys shorter than 32 bytes are rejected at request time with a 500.
  3. ClientGate — Validates the X-FishMMO-Client HMAC-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.
  4. Port SafetyWebServer:HttpPort is read as a string (accepting both "8080" and 8080 in JSON) and validated with int.TryParse plus a 1–65535 range check; a malformed value throws at startup rather than silently falling back. Matches Patcher and WebGLServer behaviour. Kestrel binds via ListenLocalhost with no TLS — termination is NGINX's job.
  5. CORS Defaults to Deny — The Public policy reads Cors:AllowedOrigins; when unset it emits no Access-Control-Allow-Origin and logs a warning, denying cross-origin browser requests. Native UnityWebRequest clients ignore CORS entirely and the WebGL build is loaded same-origin, so operators must opt in explicitly for genuine cross-origin browser access.
  6. Forwarded Headers, Single HopX-Forwarded-For / X-Forwarded-Proto honoured with ForwardLimit = 1, since NGINX is the only trusted proxy; extra values would be attacker-controlled and would break per-IP rate limiting.
  7. PascalCase JSONPropertyNamingPolicy/DictionaryKeyPolicy set to null so Unity's JsonUtility (exact-name matching) can deserialize responses without client-side rewriting.

PatcherASP.NET (Patch Delivery)

  1. Latest Version EndpointGET/HEAD /latest_version?from={clientVersion}. Without from it returns { latest_version }; with from it returns up_to_date: true, or patch_available: false when no archive bridges that specific version pair, or patch_available: true with the patch's sha256 and size.
  2. Version Response Caching & Integrity — Sets a weak ETag (derived from the patch hash / response shape) and Cache-Control: public, max-age=30; honours If-None-Match (comma-separated list, any match) with 304 Not Modified. Adds X-FishMMO-Version-Signature, an HMAC over the canonical latest_version=… content so a compromised endpoint cannot silently substitute a patch hash.
  3. Patch Download EndpointGET /{version} serves patch ZIP files with range request support, ReparsePoint symlink rejection at serve time, and strong ETag/Cache-Control: public, max-age=3600, immutable on the artifact. Returns 204 No Content when the requesting client is already on the latest version.
  4. ClientGate — Same HMAC request signing validation as IPFetch (UseFishMMOClientGate, with /healthz exempted).
  5. 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.
  6. Content-Addressed Patch IndexPatchVersionService scans 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.
  7. Symlink ProtectionPatchVersionService reindex skips FileAttributes.ReparsePoint files to prevent hash disclosure via symlinks.
  8. Semantic VersioningVersionConfig with full SemVer 2.0.0 parsing, comparison operators, and IComparable<VersionConfig>.

WebGLServerASP.NET (WebGL Static Server)

  1. WebGL Build Serving — Serves Unity WebGL builds as static files (HTML, JS, WASM, .unityweb, .data) with correct MIME types and X-Content-Type-Options: nosniff.
  2. Response CompressionAddResponseCompression middleware with application/wasm and application/octet-stream MIME types for bandwidth reduction on large WASM builds (20–50 MB).
  3. Cross-Origin Isolation — CSP headers configured for wasm-unsafe-eval and WebTransport connect-src to game.fishmmo.com:*.
  4. 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