Skip to content

Backend Server

Summary: eternal-server is an ASP.NET Core 8.0 REST API backed by PostgreSQL 16, running as a Docker Compose stack alongside a Blazor admin dashboard. It provides account management, character CRUD, full persistence (save/load covering items, equipment, glyphs, per-item polymorphic state modules, quests, compendium, and world map), and game session management with heartbeat-based lifecycle. The persistence layer uses delete-and-insert inside a single transaction — every save replaces all character data atomically. Variable-shape data (item modifiers, per-item state modules, localizable text) is stored as JSONB for direct queryability. Sessions are kept alive by client heartbeats and automatically reaped when stale. The UE5 client communicates with it via UEternalApiSubsystem.

Table of Contents


Architecture Overview

UE5 Client (UEternalApiSubsystem)
        |
        | HTTP/JSON
        v
+-----------------------------------+
| Eternal.Server.Api                |
| (ASP.NET Core 8.0)                |
|                                   |
|  Controllers                      |
|  +-- AccountsController          |
|  +-- CharactersController        |
|  +-- PersistenceController       |
|  +-- SessionsController          |
|                                   |
|  Repositories (Scoped DI)         |
|  +-- AccountRepository           |
|  +-- CharacterRepository         |
|  +-- PersistenceRepository       |
|  +-- GameSessionRepository       |
|                                   |
|  SessionReaperService (hosted)    |
+-----------------------------------+
        |
        | EF Core + Npgsql
        v
+-----------------------------------+
| PostgreSQL 16                     |
| Database: eternal                 |
| 12 tables, snake_case columns     |
+-----------------------------------+

Project Structure

Project Purpose
Eternal.Server.Api Host entry point (Program.cs), DI configuration, Swagger, reaper registration
Eternal.Shared Controllers, repositories, DTOs, EternalDbContext, entity models
Eternal.Backend.Core Logging infrastructure (NLog)
Eternal.Core Shared plumbing: TaskResult<T>, HTTP helpers
Eternal.Dashboard Separate Blazor Server project — log viewer, read-only DB inspection
Eternal.Backend.Tests Test helpers, constants, mock serializer
Eternal.Integration.Tests xUnit integration suite (Account, Character, Persistence, Sessions, JSON contracts)

Key Design Decisions

Decision Rationale
EF Core + Npgsql for everything Single DbContext (EternalDbContext) manages all entities; no raw ADO.NET
EnsureCreatedAsync() instead of migrations Pre-prod policy — wipe + recreate schema when the model changes. No migration history to maintain.
Scoped DI for repositories One DbContext per request, proper disposal
Delete-and-insert saves Simpler than diffing — full snapshot replacement per save inside one transaction
JSONB for variable-shape data Item modifiers, per-item state modules, compendium FText — shapes with arrays or polymorphism don't map cleanly to relational columns but are still queryable via JSONB operators
camelCase JSON output, case-insensitive input [JsonPropertyName] attributes pin exact shapes for UE interop; deserializer accepts either case since UE ships PascalCase UPROPERTY names

Local Development

Prerequisites

  • .NET 8.0 SDK
  • Docker Desktop (for the full stack)

Docker Compose Stack

The full backend runs as three Docker containers via docker-compose.yml:

Service Container Port Purpose
PostgreSQL 16 eternal 5432 Database with persistent volume
API eternal-api 5065 REST API, Swagger, session reaper
Dashboard eternal-dashboard 5066 Blazor Server admin dashboard, reads API logs
cd /projects/eternal-server

# Start the full stack (build + run)
docker compose up -d --build

# Rebuild after code changes
docker compose up -d --build

# View logs
docker compose logs -f api

All services use restart: always — they survive reboots, crashes, and local dotnet test runs (Docker isolates the running binaries from local builds).

Services connect to PostgreSQL via Docker DNS (Host=postgres instead of Host=localhost), configured through environment variable overrides. The appsettings.json files still use localhost for running outside Docker.

Database Initialization

On startup, Program.cs calls EnsureCreatedAsync() (in Development mode) which auto-creates tables from the EF Core model. No manual migrations needed. After adding new entities, drop the schema and restart:

docker exec -i eternal psql -U testuser -d eternal -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose restart api

Database Schema

Entity Relationship

accounts
    |
    | 1:N (CASCADE DELETE)
    v
characters ──────────────────────────────────────┐
    |                                              |
    | 1:N (CASCADE DELETE on all)                  |
    v                                              v
persistence_items      inventory_slots       equipment_slots
    |
    | 1:N
    v
stone_plates
    |
    | 1:N
    v
glyph_placements

characters ──> quest_progress       (1:N)
characters ──> world_map_nodes      (1:N)
characters ──> domains              (1:N)
characters ──> compendium_entries   (1:N)

accounts ──> game_sessions          (1:N, optional — host_account_id nullable, SET NULL)

Tables

All persistence tables use composite primary keys keyed on character_id plus a natural identifier. No synthetic surrogate id columns. This matches how the data is loaded (every query filters by character_id) and keeps CASCADE deletes cheap.

accounts

Column Type Notes
account_id uuid PK
username text
email text
password text BCrypt hash via PasswordHasher<Account>
created timestamptz NOT NULL
updated timestamptz

characters

Column Type Notes
character_id uuid PK
account_id uuid FK → accounts (CASCADE)
player_class_template_id text Maps to UE5 class template ID
name text Display name
crafting_resource integer Currency
play_time real Seconds
save_version integer Client-authored save schema version
save_timestamp timestamptz Last save wall-clock time
current_node_id text UE5 FName — player's last world-map position
created timestamptz
updated timestamptz

persistence_items

Column Type Notes
instance_id uuid PK — UE5 FGuid, stable identity across save/load
character_id uuid FK → characters (CASCADE)
item_id text Template reference (e.g. Weapon_Sword_Flame) resolved by UE5 UItemRegistrySubsystem
stack_count integer Stack size
item_level integer Rolled item level
consumable_uses_left integer Remaining usages (0 for non-consumables)
modifiers jsonb Full FEquipmentModifiers object: {Implicits, Prefixes, Suffixes, ScalingModifiers} — consolidated into one column
state_modules jsonb Polymorphic per-instance state module wrappers — see State Modules
created_at timestamptz
updated_at timestamptz

inventory_slots

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
item_instance_id uuid PK part 2, FK → persistence_items (CASCADE)
top_left_index integer Top-left grid cell of the item's placement

equipment_slots

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
slot_tag text PK part 2 — gameplay tag (e.g. Equipment.Slot.RightHand)
item_instance_id uuid FK → persistence_items (CASCADE)

stone_plates

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
stone_plate_instance_id uuid PK part 2 — the stone plate's item instance id
slot_index integer Which of the character's stone-plate wall slots holds this plate

Stone plate rows carry the wall-slot assignment only. The stone plate item itself (name, icon, sockets) lives in persistence_items with the matching instance_id.

glyph_placements

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
stone_plate_instance_id uuid PK part 2 — the plate this glyph sits on
socket_index integer PK part 3 — socket position within the plate
glyph_item_instance_id uuid The glyph item occupying that socket

Glyph placements have no direct FK to stone_plates — the join is logical via (character_id, stone_plate_instance_id). Both tables cascade off characters.

quest_progress

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
quest_tag text PK part 2 — quest gameplay tag
status text Active, Completed, or Known

UE5 stores quest state as three FGameplayTagContainer fields (QuestTags, ActiveQuests, CompletedQuests). The server flattens these into individual rows: tags in CompletedQuests get status Completed, tags in ActiveQuests get Active, tags known but neither active nor completed get Known. LoadQuestProgress inverts the mapping on read.

world_map_nodes

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
node_id text PK part 2 — node identifier (UE5 FName)
state integer UE5 EWorldNodeState as uint8
is_visited boolean Maps to UE5 FWorldNodeSaveState.bVisited
clear_count integer Number of times cleared

domains

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
domain_tag text PK part 2 — domain gameplay tag
is_liberated boolean Default false

compendium_entries

Column Type Notes
character_id uuid PK part 1, FK → characters (CASCADE)
entry_id text PK part 2 — compendium entry identifier
title jsonb FText as JSON pass-through (UE5 ships multiple FText export formats — keep verbatim)
content jsonb FText as JSON pass-through
related_quest_tag text Associated quest tag (empty string if none)
required_tag text Prerequisite tag (empty string if none)
discovery_time timestamptz When the entry was discovered (nullable)
has_been_read boolean Whether the player has viewed the entry

game_sessions

Column Type Notes
session_id uuid PK
host_account_id uuid FK → accounts (SET NULL), nullable
host_address text Host IP address
port integer Host port
status text Open, Full, Closed
max_players integer Default 4
current_players integer Default 1
session_name text Display name
created_at timestamptz
updated_at timestamptz
last_heartbeat_at timestamptz Updated by heartbeat endpoint, used by reaper

Indexes on status (for listing open sessions) and host_account_id.


State Modules (JSONB Shape)

persistence_items.state_modules stores UE5's UItemObject::StateModules — the polymorphic per-instance runtime state pattern. The column is a JSONB array of type-tagged wrapper entries; the server treats the inner Data as opaque and passes it through as a JsonElement.

Casing convention:

Layer Case Why
Outer wrapper (StructName, Data) PascalCase The server DTO uses [JsonPropertyName] to force the UE UPROPERTY names on re-serialize, so @> containment queries match UE-style keys exactly
Inner Data fields (module's UPROPERTY contents) lowerCamelCase UE's FJsonObjectConverter lowercases the first character of every UPROPERTY name via StandardizeCase; server passes the object through verbatim

Example stored shape for a FRemnantItemState module:

[
  {
    "StructName": "RemnantItemState",
    "Data": {
      "schemaVersion": 1,
      "state": "Awakened",
      "echoMods": [{ "rolledModifier": {...}, "bIsRevealed": true, "bIsActive": true }],
      "activeDesire": {"taskTag": {"tagName": "Desire.Kill.Any"}, "progress": 4, "target": 4},
      "era": {"tagName": "Era.Debug"},
      "poolRef": "/Game/DataAssets/Items/Modifiers/RemnantItems.RemnantItems"
    }
  }
]

JSONB queries for trade validation, analytics, admin tooling, and batch migrations work out of the box:

-- All characters holding an awakened Remnant
SELECT character_id, instance_id FROM persistence_items
WHERE state_modules @> '[{"StructName":"RemnantItemState","Data":{"state":"Awakened"}}]'::jsonb;

-- Add a new field to every v1 RemnantItemState row with a default
UPDATE persistence_items
SET state_modules = jsonb_set(state_modules, '{0,Data,postAwakenBonus}', '0')
WHERE state_modules @> '[{"StructName":"RemnantItemState","Data":{"schemaVersion":1}}]'::jsonb;

Indexing: no index on state_modules today. A GIN index (USING gin (state_modules jsonb_path_ops)) lands when trade-validation load justifies it.

Typed access: opt-in and deferred. The backend keeps Data as generic JsonElement? until a specific capability (trade validation, analytics projection) needs a typed DTO for one module type. Other module types stay generic.

See Item State Modules for the UE-side pattern, lifecycle, replication, and serializer.


API Endpoints

Controllers use the exact route prefix declared in [Route(...)] — no /api prefix. All endpoints return TaskResult<T> (see Response Format).

Accounts (/accounts)

Method Route Request Body Response Notes
POST /accounts CreateAccountRequest Account BCrypt password hashing via PasswordHasher<Account>
POST /accounts/login LoginRequest Account Password verification
GET /accounts/{id:guid} Account
DELETE /accounts/{id:guid} Cascades to characters, items, sessions

Characters (/characters)

Method Route Request Body Response Notes
POST /characters CreateCharacterRequest Character
GET /characters/{characterId:guid} Character
GET /characters/account/{accountId:guid} Character[] All characters for account
PUT /characters/{characterId:guid} UpdateCharacterRequest Character
DELETE /characters/{characterId:guid} Cascades to all persistence tables

Persistence (/characters/{characterId:guid})

Method Route Request Body Response Notes
POST /characters/{characterId:guid}/save SaveCharacterRequest Full snapshot save
GET /characters/{characterId:guid}/load LoadCharacterResponse Full snapshot load

The {characterId} in the route must match the character ID in the request body — the controller validates this and returns 400 Bad Request on mismatch.

Sessions (/sessions)

Method Route Request Body Response Notes
POST /sessions CreateSessionRequest GameSession HostAccountId of Guid.Empty → null
GET /sessions GameSession[] Open sessions only, newest first
GET /sessions/{id:guid} GameSession
PUT /sessions/{id:guid} UpdateSessionRequest GameSession Partial update (status, player count)
PUT /sessions/{id:guid}/heartbeat Updates last_heartbeat_at
DELETE /sessions/{id:guid}

Sessions are short-lived — they exist while a game is active and are deleted when the host shuts down. USessionComponent on UE5's GameState handles registration and cleanup automatically.

Session Lifecycle & Heartbeat

Sessions follow a heartbeat-based lifecycle to handle crashes and ungraceful disconnects:

Host creates session (POST /sessions)
        |
        v
USessionComponent starts 30s heartbeat timer
        |
        +---> PUT /sessions/{id}/heartbeat (every 30s)
        |     Server updates last_heartbeat_at timestamp
        |
        v (on graceful shutdown)
USessionComponent::EndPlay() → DELETE /sessions/{id}

        v (on crash / ungraceful disconnect)
SessionReaperService (server-side background service)
        |
        +-- Sweeps every 60s
        +-- Deletes sessions where last_heartbeat_at > 2 minutes stale

This ensures the session list stays clean even when hosts crash, tests leak sessions, or network drops occur. The reaper runs as an ASP.NET Core BackgroundService using IServiceScopeFactory for scoped DbContext access.


Persistence Pipeline

Save Flow

UE5: UPersistenceComponent::RequestSave()
        |
        v
UE5: UPersistenceApiService::SaveCharacter(CharacterId, FCharacterSaveData)
        |
        | POST /characters/{id}/save
        v
Server: PersistenceController.Save()
        |
        | Validate route characterId == body characterId
        v
Server: PersistenceRepository.SaveCharacterAsync()
        |
        | BEGIN TRANSACTION
        |
        | 1. Upsert character row (player class, currency, play time, save_version,
        |    save_timestamp, current_node_id)
        | 2. ExecuteDeleteAsync() on every child table for this character:
        |    glyph_placements, stone_plates, equipment_slots, inventory_slots,
        |    persistence_items, quest_progress, compendium_entries,
        |    world_map_nodes, domains
        | 3. INSERT persistence_items (modifiers JSONB + state_modules JSONB)
        | 4. INSERT inventory_slots
        | 5. INSERT equipment_slots (tag name extracted from GameplayTagDto)
        | 6. INSERT stone_plates + glyph_placements (nested from DTO)
        | 7. INSERT quest_progress (flatten 3 tag containers → Active/Completed/Known rows)
        | 8. INSERT compendium_entries
        | 9. INSERT world_map_nodes
        |10. INSERT domains
        |
        | COMMIT (rollback on any exception)
        v
Server: Return TaskResult<Empty>

Load Flow

UE5: UPersistenceComponent::RequestLoad()
        |
        v
UE5: UPersistenceApiService::LoadCharacter(CharacterId)
        |
        | GET /characters/{id}/load
        v
Server: PersistenceController.Load()
        |
        v
Server: PersistenceRepository.LoadCharacterAsync()
        |
        | Sequential AsNoTracking queries (EF Core default):
        |   1. character row (null → return null response)
        |   2. persistence_items
        |   3. inventory_slots
        |   4. equipment_slots
        |   5. stone_plates
        |   6. glyph_placements
        |   7. quest_progress
        |   8. compendium_entries
        |   9. world_map_nodes
        |  10. domains
        |
        | Assemble SaveCharacterRequest DTO
        |   - Deserialize modifiers JSONB → JsonElement pass-through
        |   - Deserialize state_modules JSONB → List<ItemStateModuleEntryDto>
        |   - Group glyph_placements by stone_plate_instance_id, attach to plates
        |   - Reconstruct 3 quest tag containers from status column
        |   - Wrap slot_tag / domain_tag / related_quest_tag back into GameplayTagDto
        v
Server: Return TaskResult<LoadCharacterResponse>
        |
        v
UE5: Deserialize → FCharacterSaveData → apply to live game objects

Why Delete-and-Insert?

The save strategy deletes all existing data for a character and re-inserts everything fresh. This is simpler than computing diffs:

  • No need to track which items were added, removed, or modified
  • No stale data from items that were consumed or dropped
  • CASCADE DELETE on character_id handles all child tables in one statement
  • Entire operation is atomic within a single transaction
  • Trade-off: more I/O per save, but character data sets are small (dozens of items, not thousands)

Response Format

All endpoints return TaskResult<T>:

{
    "content": { ... },
    "isSuccessful": true,
    "message": ""
}
Field Type Notes
content T or null Response payload
isSuccessful bool Success indicator
message string Error details (empty on success)

UE5 maps this to FApiResponse:

TaskResult field FApiResponse field
isSuccessful bSuccess (combined with HTTP status)
content Content (TSharedPtr<FJsonObject>)
message ErrorMessage

DTO ↔ UE5 Struct Mapping

Persistence DTOs

Server DTO UE5 Struct Notes
SaveCharacterRequest FCharacterSaveData Top-level save payload
LoadCharacterResponse FCharacterSaveData Wraps save data for load response
ItemSaveDto FItemSaveState Per-item data + modifiers
InventorySlotDto FInventorySlotSaveState Slot index → item mapping
EquipmentSlotDto FEquipmentSlotSaveState Tag → item mapping
StonePlateDto FStonePlateSaveState Plate sockets on items
GlyphPlacementDto FGlyphPlacementSaveState Glyphs within plates
QuestSaveStateDto Three FGameplayTagContainer fields Flattened with status discriminator
CompendiumEntryDto FCompendiumEntry Lore/knowledge entries with FText fields
WorldMapProgressDto FWorldMapSaveState Nodes + domains + current node id
WorldMapNodeDto FWorldNodeSaveState Per-node state (int), visited, clear count
DomainSaveDto FDomainSaveState Per-domain liberation state
ItemStateModuleEntryDto FItemStateModuleSaveEntry Polymorphic state module wrapper (see State Modules)
GameplayTagDto FGameplayTag Tag name string
GameplayTagContainerDto FGameplayTagContainer Array of tag names

Account/Character DTOs

Server DTO UE5 Struct
CreateAccountRequest FCreateAccountRequest
LoginRequest FLoginRequest
AccountResponse FAccountResponse
CreateCharacterRequest FCreateCharacterApiRequest
CharacterResponse FCharacterApiResponse

Session DTOs

Server DTO UE5 Struct
CreateSessionRequest FCreateSessionRequest
UpdateSessionRequest FUpdateSessionRequest
GameSession FSessionResponse

JSONB Modifier Format

persistence_items.modifiers stores the full FEquipmentModifiers object in a single JSONB column — four named arrays of FRolledModifier entries:

{
  "implicits":   [ { "modifierDefinitionId": "crit_chance", "rolledValue": 12.5, "modifierTier": "Tier2", "source": "Drop" } ],
  "prefixes":    [ { "modifierDefinitionId": "increased_fire_damage", "rolledValue": 30.0, "modifierTier": "Tier3", "source": "Drop" } ],
  "suffixes":    [ ],
  "scalingModifiers": [ ]
}

The DTO (ItemSaveDto.Modifiers) is JsonElement? for pass-through — the server doesn't parse or validate the inner shape, it just round-trips whatever UE sends. Keys are UE-default lowerCamelCase.

source is ECraftingModifierSource as a string: Drop (loot roll), Crafted (crafting station), or Echo (Remnant state-module mod). The apply-time gate on the client reads this field to filter which modifiers spawn gameplay effects (Echo mods apply only when the owning state module flags them active).


Source References

All server source lives in a separate repository (eternal-server).

Topic File
DI + pipeline config Eternal.Server.Api/Program.cs
DbContext (single consolidated) Eternal.Shared/Database/EternalDbContext.cs
Entity models Eternal.Shared/Models/*.cs
All persistence DTOs Eternal.Shared/Dtos/PersistenceDtos.cs
Account endpoints Eternal.Shared/Controllers/AccountsController.cs
Character endpoints Eternal.Shared/Controllers/CharactersController.cs
Persistence endpoints Eternal.Shared/Controllers/PersistenceController.cs
Save/load transaction logic Eternal.Shared/Repositories/PersistenceRepository.cs
State-module serialize/deserialize helpers Eternal.Shared/Repositories/PersistenceRepository.csSerializeStateModules / DeserializeStateModules
Session endpoints Eternal.Shared/Controllers/SessionsController.cs
Session repository Eternal.Shared/Repositories/GameSessionRepository.cs
Session entity Eternal.Shared/Models/GameSession.cs
Session reaper background service Eternal.Server.Api/Services/SessionReaperService.cs
Generic response wrapper Eternal.Core/TaskResult.cs
Integration test suite Eternal.Integration.Tests/*.cs
Docker Compose (API + Dashboard + Postgres) docker-compose.yml


Recent Changes

Date Change Reason
2026-04-21 Doc refresh against live schema: consolidated modifiers column, composite PKs, current route prefixes (no /api), updated DTO/modifier shape Doc had drifted since initial 2026-03 write; aligned with schema as of eternal-server master HEAD
2026-04-21 state_modules JSONB column + ItemStateModuleEntryDto on ItemSaveDto Remote-persist UE's UItemObject::StateModules; first consumer is Remnant FRemnantItemState. Wrapper shape + outer-PascalCase / inner-camelCase convention documented.
2026-03-06 Dockerized full stack (API + Dashboard + PostgreSQL), session heartbeat/reaper, compendium persistence, Account→Character CASCADE FK Always-on infrastructure, stale session cleanup, lore persistence, referential integrity
2026-03-03 Session management: game_sessions table, SessionsController, GameSessionRepository "Game Room" multiplayer model — session registry for listen-server sessions
2026-03 Initial documentation Document eternal-server architecture alongside persistence layer work