Skip to content

API Layer

Summary: The API layer uses UEternalApiSubsystem (a GameInstance subsystem) to manage backend communication. Four service classes — UAccountApiService, UCharacterApiService, UPersistenceApiService, and USessionApiService — inherit shared HTTP/JSON boilerplate from UBaseApiService. All requests use per-request callbacks (FApiResponseDelegate) to avoid delegate collision. Data serializes automatically via FJsonObjectConverter.

Table of Contents


Why This Architecture

Design Goals

The API layer is designed around three principles:

  1. Non-Blocking — HTTP requests run asynchronously; game thread never waits
  2. Per-Request Isolation — Each call gets its own callback, no delegate collision
  3. Minimal Boilerplate — Services inherit HTTP/JSON handling from a shared base class

Previous Architecture (Removed)

Old New Why
UApiManager singleton with AddToRoot() UEternalApiSubsystem (GameInstanceSubsystem) Proper UE5 lifecycle, no manual GC management
UAccountService / UCharacterService (standalone) UAccountApiService / UCharacterApiService / UPersistenceApiService inheriting UBaseApiService Shared HTTP boilerplate, consistent patterns
DECLARE_DYNAMIC_MULTICAST_DELEGATE per endpoint Per-request FApiResponseDelegate (TDelegate) Concurrent requests no longer collide
Manual ToJsonObject() per struct FJsonObjectConverter auto-serialization in base class Zero per-struct serialization code

Service Separation

Service Responsibility
UAccountApiService User authentication, account management
UCharacterApiService Character CRUD operations
UPersistenceApiService Save/load complete character state
USessionApiService Game session CRUD (create, list, join, update, close)

API Architecture

+------------------+
|   Game Systems   |
+------------------+
        |
        v
+---------------------------+
| UEternalApiSubsystem      |  <-- GameInstanceSubsystem (auto-lifecycle)
| - BaseUrl                 |
| - SetBaseUrl() / GetBaseUrl()
+---------------------------+
        |
        +--------------------+--------------------+--------------------+
        |                    |                    |                    |
        v                    v                    v                    v
+------------------+  +------------------+  +--------------------+  +------------------+
| UAccountApi      |  | UCharacterApi    |  | UPersistenceApi    |  | USessionApi      |
| Service          |  | Service          |  | Service            |  | Service          |
+------------------+  +------------------+  +--------------------+  +------------------+
        |                    |                    |                    |
        +--------------------+--------------------+--------------------+
        |
        v
+---------------------------+
| UBaseApiService            |  <-- Shared HTTP/JSON boilerplate
| - SendGet/Post/Put/Delete |
| - SerializeToJson<T>()    |
| - DeserializeContent<T>() |
+---------------------------+
        |
        v
+---------------------------+
|       FHttpModule         |
|     (Unreal HTTP)         |
+---------------------------+
        |
        v
+---------------------------+
|      Backend API          |
|   (REST JSON Endpoints)   |
+---------------------------+

Subsystem Lifecycle

GameInstance created
    |
    v
UEternalApiSubsystem::Initialize()
    |
    +-- Set default BaseUrl ("http://localhost:5065/")
    +-- Resolve ServerUrl (see configuration hierarchy below)
    +-- Create AccountApiService, CharacterApiService, PersistenceApiService, SessionApiService
    +-- Each service gets SetOwningSubsystem(this) for BaseUrl access
    |
    v
(Game session active — services available via GetSubsystem<UEternalApiSubsystem>())
    |
    v
UEternalApiSubsystem::Deinitialize()
    |
    +-- Null all service pointers (GC handles cleanup)

ServerUrl Configuration

The base URL for all API requests is resolved at startup with a three-level fallback:

1. Command-line override: -ServerUrl=https://...
   |
   | (not present?)
   v
2. Config file: UPersistenceDevSettings::ServerUrl
   (reads from DefaultGame.ini in ALL builds, not just editor)
   |
   | (empty?)
   v
3. Hardcoded default: http://localhost:5065/
Source When it applies Example
-ServerUrl= CLI arg Packaged builds, EGS launch options, testing -ServerUrl=https://api.eternal-server.net/
DefaultGame.ini All builds (editor + packaged) ServerUrl="https://api.eternal-server.net/"
Hardcoded default Local development fallback http://localhost:5065/

This hierarchy allows remote testers to use a Cloudflare Tunnel URL by default (baked into DefaultGame.ini), while developers can override to localhost or any other URL via command line.


Request Lifecycle

Complete Flow

1. Game code calls service method
   Api->GetPersistenceService()->SaveCharacter(Id, Data, Callback)
        |
        v
2. Service calls base class helper
   SendPost("characters/{id}/save", SaveData, Callback)
        |
        v
3. UBaseApiService::SendRequest()
   +-- Create TSharedRef<IHttpRequest>
   +-- Set URL: GetBaseUrl() + Endpoint
   +-- Set headers (Content-Type, User-Agent)
   +-- SerializeToJson<T>(Body) → SetContentAsString()
   +-- Bind OnHttpResponseReceived with per-request callback
   +-- ProcessRequest() (async)
        |
        v (later, on game thread)
4. UBaseApiService::OnHttpResponseReceived()
   +-- Check bWasSuccessful, Response validity
   +-- Parse JSON from response body
   +-- Build FApiResponse (StatusCode, bSuccess, Content, ErrorMessage)
   +-- Callback.ExecuteIfBound(ApiResponse)
        |
        v
5. Caller's lambda/delegate receives FApiResponse
   +-- Check bSuccess
   +-- DeserializeContent<T>(Response, OutStruct) for typed data

Request Configuration

Setting Value Purpose
User-Agent X-UnrealEngine-Agent Identify client
Content-Type application/json JSON body format
Thread Safety ESPMode::ThreadSafe Safe async access
Base URL http://localhost:5065/ Default, overridable via SetBaseUrl()

Service Contracts

Account Service

Endpoint Method Request Response
accounts POST FCreateAccountRequest FAccountResponse
accounts/login POST FLoginRequest FAccountResponse

Character Service

Endpoint Method Request Response
characters POST FCreateCharacterApiRequest FCharacterApiResponse
characters/{id} GET Path param FCharacterApiResponse
characters/account/{id} GET Path param TArray<FCharacterApiResponse>

Persistence Service

Endpoint Method Request Response
characters/{id}/save POST FCharacterSaveData Success/error
characters/{id}/load GET Path param FLoadCharacterResponse (wraps FCharacterSaveData)

FItemSaveState payload (inside FCharacterSaveData.Items)

Field Type Notes
InstanceId FGuid Matches UItemObject::InstanceId
ItemID FString Template ref — rebuilt via UItemRegistrySubsystem
StackCount int32 Current stack
ItemLevel int32 From FEquipmentFragment, 0 for non-equipment
Modifiers FEquipmentModifiers Implicit/prefix/suffix/scaling arrays (JSONB server-side)
ConsumableUsagesLeft int32 -1 when not a consumable
StateModules TArray<FItemStateModuleSaveEntry> Polymorphic per-instance runtime state

FItemStateModuleSaveEntry shape

Each entry wraps one state module attached to the item:

Field Type Notes
StructName FString Type tag, e.g. "RemnantItemState". Resolved on load via FindFirstObject<UScriptStruct>.
Data FJsonObjectWrapper Nested JSON object carrying the module's UPROPERTY fields — NOT a text-export string. FJsonObjectConverter special-cases FJsonObjectWrapper to serialize as a first-class JSON object.

Backend receives structured JSON, not opaque blobs. The wrapper shape enables server-side trade validation, analytics, and SQL migrations on state-module fields. See Item State Modules and Backend Server.

Session Service

Endpoint Method Request Response
sessions POST FCreateSessionRequest FSessionResponse
sessions GET TArray<FSessionResponse>
sessions/{id} GET Path param FSessionResponse
sessions/{id} PUT FUpdateSessionRequest FSessionResponse
sessions/{id}/heartbeat PUT Success/error
sessions/{id} DELETE Path param Success/error

Request Struct Fields

FCreateAccountRequest

Field Type Notes
AccountId FGuid Client-generated, auto-serialized as string
Email FString User email
Username FString Display name
Password FString Plaintext (HTTPS required)

FLoginRequest

Field Type
Email FString
Password FString

FCreateCharacterApiRequest

Field Type Notes
AccountId FGuid Parent account reference
CharacterId FGuid Client-generated ID
PlayerClassTemplateId FString Class selection
Name FString Character name

Response Struct Fields

FAccountResponse

Field Type
AccountId FGuid
Username FString
Email FString
Created FDateTime
Updated FDateTime

FCharacterApiResponse

Field Type
AccountId FGuid
CharacterId FGuid
PlayerClassTemplateId FString
Name FString
CraftingResource int32
PlayTime float

FApiResponse (generic wrapper returned to all callers)

Field Type Notes
bSuccess bool True for 2xx status codes AND body-level isSuccessful
StatusCode int32 HTTP status code
ErrorMessage FString Error details (empty on success)
Content TSharedPtr<FJsonObject> Parsed JSON for further deserialization

Data Serialization

Automatic Serialization

All request/response structs use UPROPERTY() fields and are serialized/deserialized automatically by FJsonObjectConverter in UBaseApiService:

Serialize (outgoing):
    USTRUCT with UPROPERTY fields
        → FJsonObjectConverter::UStructToJsonObjectString()
        → FString JSON body

Deserialize (incoming):
    FApiResponse::Content (TSharedPtr<FJsonObject>)
        → FJsonObjectConverter::JsonObjectToUStruct<T>()
        → Typed USTRUCT

GUID Format

All FGuid values serialize to strings using EGuidFormats::DigitsWithHyphens:

FGuid: 12345678-1234-1234-1234-123456789012
JSON: "12345678-1234-1234-1234-123456789012"

Caller-Side Deserialization

Services return FApiResponse with raw JSON. Callers deserialize into typed structs:

FApiResponseDelegate::CreateLambda([](const FApiResponse& Response)
{
    FCharacterApiResponse Character;
    if (Response.bSuccess && UBaseApiService::DeserializeContent(Response, Character))
    {
        // Use Character.Name, Character.CharacterId, etc.
    }
});

Error Handling

Three-Level Validation

All error handling is centralized in UBaseApiService::OnHttpResponseReceived():

Level 1: Network
    bWasSuccessful? Response valid?
        |
        | fail → FApiResponse{bSuccess=false, ErrorMessage="HTTP request failed"}
        | pass
        v
Level 2: Parse
    JSON valid? FJsonObject created?
        |
        | fail → FApiResponse{bSuccess=false, ErrorMessage="Failed to parse JSON response"}
        | pass
        v
Level 3: HTTP Status
    StatusCode 2xx?
        |
        | fail → Extract "message"/"Message" from JSON, or "HTTP {code}"
        | pass
        v
Level 4: Body-Level Success (TaskResult<T> wrapper)
    JSON field "isSuccessful" present?
        |
        | yes → Use its value (backend may return 200 with isSuccessful=false)
        | no  → Trust HTTP status
        v
    Extract "content"/"Content" object, or use root JSON
        v
    FApiResponse{bSuccess, StatusCode, Content, ErrorMessage}

Callback Signature

All service methods accept the same callback type:

DECLARE_DELEGATE_OneParam(FApiResponseDelegate, const FApiResponse&);

Per-request binding ensures concurrent calls never collide — each HTTP request captures its own FApiResponseDelegate by value.


Async Patterns

Per-Request Callback (Key Pattern)

// Old pattern (REMOVED) — one delegate for ALL callers:
OnCreateAccountResponseDelegate.Broadcast(bSuccess, Data, Error);
// Problem: if two systems call CreateAccount concurrently, both get both responses

// New pattern — each call gets its own callback:
Api->GetAccountService()->CreateAccount(Request,
    FApiResponseDelegate::CreateLambda([this](const FApiResponse& Response)
    {
        // Only THIS request's response arrives here
    }));

Thread Safety

Concern Solution
Request creation ESPMode::ThreadSafe shared pointer
Callback execution Main game thread (Unreal HTTP guarantee)
Service access GameInstanceSubsystem (automatic lifecycle)
BaseUrl access Services hold OwningSubsystem pointer → GetBaseUrl()

Source References

API Subsystem

  • UEternalApiSubsystemSource/ProjectEternal/Public/API/EternalApiSubsystem.h
  • Initialization — Source/ProjectEternal/Private/API/EternalApiSubsystem.cpp

Base Service

  • UBaseApiServiceSource/ProjectEternal/Public/API/Services/BaseApiService.h
  • HTTP/JSON handling — Source/ProjectEternal/Private/API/Services/BaseApiService.cpp

Services

  • UAccountApiServiceSource/ProjectEternal/Public/API/Services/AccountApiService.h
  • UCharacterApiServiceSource/ProjectEternal/Public/API/Services/CharacterApiService.h
  • UPersistenceApiServiceSource/ProjectEternal/Public/API/Services/PersistenceApiService.h
  • USessionApiServiceSource/ProjectEternal/Public/API/Services/SessionApiService.h

Data Models

  • FApiResponse, request/response structs — Source/ProjectEternal/Public/API/Types/ApiTypes.h
  • FCharacterSaveData (persistence payload) — Source/ProjectEternal/Public/Persistence/Types/PersistenceTypes.h


Recent Changes

| Date | Change | Reason | | 2026-04 | FItemSaveState.StateModules carrying polymorphic per-instance runtime state as wrapper-JSON entries | Remnant state (and future mechanics like Corruption) round-trip through save/load with the backend able to query into the payload via JSONB operators — not opaque text-export | | 2026-03-06 | Added ServerUrl configuration hierarchy (CLI → INI → default), session heartbeat endpoint (SendHeartbeat) | Remote tester support via Cloudflare Tunnel, session lifecycle management | | 2026-03-03 | Added USessionApiService for game session CRUD; BaseApiService now checks body-level isSuccessful from TaskResult<T> wrapper; default base URL corrected to http://localhost:5065/ | Session management system for multiplayer "Game Room" model | | 2026-03-02 | API architecture refactored: singleton UApiManagerUEternalApiSubsystem (subsystem pattern); broadcast delegates → per-request FApiResponseDelegate callbacks; standalone services → UBaseApiService inheritance pattern | Eliminates concurrent request collision; proper UE5 lifecycle; improved composition | | - | Initial documentation | Document API layer architecture |


Future Considerations

Enhancement Benefit Complexity
Authentication Headers JWT/Bearer token support Low
Request Retry Logic Handle transient failures Medium
Request Cancellation Cancel in-flight requests Low
Response Caching Reduce repeated GET calls Medium
Offline Queue Buffer requests when offline High