Skip to main content

Autoscaling with ODIN Fleet, AWS GameLift and Unreal Engine

The goal of this guide is to explain how to manage the amount of deployed ODIN Fleet compute instances dynamically, exemplary by using AWS FlexMatch and GameLift in combination with the ODIN Fleet API. The automated scaling is based on the amount of players that want to play the game. By doing this, you can utilize ODIN Fleet for on-demand dedicated game server scaling, while continuing to rely on GameLift for matchmaking and session orchestration.

Requirements

  • A dedicated Unreal Engine game server integrated with AWS GameLift Anywhere
  • An Unreal Engine game client
  • A backend service (e.g., Firebase Cloud Functions, Node.js, etc.)
  • Access to ODIN Fleet and a configured Project, Server Config and autoscaled Deployment

If you need assistance with the basic setup, refer to the ODIN Fleet and AWS GameLift Anywhere integration guide and the ODIN Fleet and AWS GameLift FlexMatch integration guide.

Overview of the steps in this guide

  1. Configure the ODIN Fleet REST API in the backend
  2. Configure ODIN Fleet autoscaling
  3. Add backend integration endpoints
  4. Update your Unreal Engine game server code to handle session states

Step 1: Configure the ODIN Fleet REST API

To deploy and manage ODIN Fleet servers dynamically, we use the ODIN Fleet REST API. You can generate an SDK in your preferred programming language using the OpenAPI specification. In this example, we use a TypeScript SDK generated from the OpenAPI spec to integrate it into our backend service.

First, specify your configuration parameters and authenticate by passing your access token in the header.

const FleetApi = require("odin-fleet-api"); // Your generated SDK package

const OdinAccessToken = "<your-access-token>";

let defaultConfig = FleetApi.DefaultConfig;

var headers = {
Authorization: `Bearer ${OdinAccessToken}`,
};

var config = {
basePath: defaultConfig.basePath,
headers: { ...defaultConfig.headers, ...headers },
fetchApi: defaultConfig.fetchApi,
middleware: defaultConfig.middleware,
queryParamsStringify: defaultConfig.queryParamsStringify,
username: defaultConfig.username,
password: defaultConfig.password,
accessToken: OdinAccessToken,
credentials: defaultConfig.credentials,
apiKey: defaultConfig.apiKey,
};

This config is used to create several needed API classes.


Step 2: Configure ODIN Fleet autoscaling

To use the ODIN Fleet autoscaler, create a deployment and enable autoscaling for it. Configure the minimum, maximum, and buffer values according to the expected server demand.

Autoscaling settings in Create Deployment

  • Minimum: The minimum number of server instances that should remain running.
  • Maximum: The maximum number of server instances that may run at the same time.
  • Buffer: The number of unallocated server instances that should remain ready to accept new game sessions. If this value is set to 0, the autoscaler does not maintain any spare instances. This means that it will not start another instance when all currently running instances are allocated, even if the configured maximum has not yet been reached.

For more information about the native autoscaler and its configuration options, refer to the ODIN Fleet Autoscaler guide.

Managed FlexMatch and GameLift remain responsible for session placement in this integration. GameLift selects an available server process and invokes OnStartGameSession on that process. The selected server then reports its own Fleet service as allocated, allowing the Fleet autoscaler to restore the configured buffer.

Do not also call Fleet's deployment-level allocation endpoint in this Managed FlexMatch flow. It selects a ready Fleet service independently and could therefore reserve a different service than the process selected by GameLift.

To report the selected server's lifecycle states to ODIN Fleet, use the DockerServiceAutoscalingApi provided by the ODIN Fleet API.

async function setServerStatus(serverID, status) {
const dockerAutoscalingApi = new FleetApi.DockerServiceAutoscalingApi(config);

switch (status) {
case "ready":
await dockerAutoscalingApi.dockerServicesAutoscalingReady({
dockerService: serverID,
});
break;
case "allocated":
await dockerAutoscalingApi.dockerServicesAutoscalingAllocate({
dockerService: serverID,
});
break;
case "shutdown":
await dockerAutoscalingApi.dockerServicesAutoscalingShutdown({
dockerService: serverID,
});
break;
default:
throw new Error(`Unsupported autoscaling status: ${status}`);
}
}

The game server must trigger these operations whenever its lifecycle state changes. It can call the ODIN Fleet API directly through HTTPS requests or report its state to an external backend service. This guide uses the backend-service approach.


Step 3: Add backend integration endpoints

To allow the game server to report its lifecycle state, create three HTTP endpoints in your backend service. Each endpoint forwards the corresponding state change to the ODIN Fleet API.

exports.SetServerActive = onRequest(
{ region: GCloudRegion },
async (req, res) => {
if (req.body.server_id === undefined) {
return res.status(400).send("Missing server_id");
}
await ServerAPI.setServerStatus(req.body.server_id, "ready");
return res.status(204).send();
},
);

exports.SetServerUsed = onRequest(
{ region: GCloudRegion },
async (req, res) => {
if (req.body.server_id === undefined) {
return res.status(400).send("Missing server_id");
}
await ServerAPI.setServerStatus(req.body.server_id, "allocated");
return res.status(204).send();
},
);

exports.SetServerShutdown = onRequest(
{ region: GCloudRegion },
async (req, res) => {
if (req.body.server_id === undefined) {
return res.status(400).send("Missing server_id");
}
await ServerAPI.setServerStatus(req.body.server_id, "shutdown");
return res.status(204).send();
},
);
  • SetServerActive: Called when GameLift initialization is complete, marking the server as ready.
  • SetServerUsed: Called when GameLift starts a game session on this process, reporting the selected instance as allocated.
  • SetServerShutdown: Called when GameLift requests process termination. Fleet removes the instance and reconciles the autoscaled capacity.

Step 4: Unreal Engine game server updates

Finally, update the Unreal Engine dedicated server code to communicate these lifecycle states back to our backend.

void AOdinFleetGameMode::InitGameLift()
{
#if WITH_GAMELIFT
UE_LOG(GameServerLog, Log, TEXT("Game Lift initialized"));
FGameLiftServerSDKModule* GameLiftServerSdkModule = &FModuleManager::LoadModuleChecked<FGameLiftServerSDKModule>(FName("GameLiftServerSDK"));
Service_Id = FPlatformMisc::GetEnvironmentVariable(TEXT("SERVICE_ID"));

FServerParameters ServerParameters;
bool bIsAnywhereActive = false;

FGameLiftGenericOutcome InitSdkOutcome = GameLiftServerSdkModule->InitSDK();
if (InitSdkOutcome.IsSuccess())
{
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_GREEN);
UE_LOG(GameServerLog, Log, TEXT("GameLift InitSDK succeeded!"));
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_NONE);
}else
{
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_RED);
UE_LOG(GameServerLog, Log, TEXT("ERROR: InitSDK failed : ("));
FGameLiftError GameLiftError = InitSdkOutcome.GetError();
UE_LOG(GameServerLog, Log, TEXT("ERROR: %s"), *GameLiftError.m_errorMessage);
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_NONE);
return;
}
ProcessParameters = MakeShared<FProcessParameters>();

ProcessParameters->OnStartGameSession.BindLambda([=,this](Aws::GameLift::Server::Model::GameSession InGameSession)
{

FString GameSessionId = FString(InGameSession.GetGameSessionId());
UE_LOG(GameServerLog, Log, TEXT("GameSession Initializing: %s"), *GameSessionId);
GameLiftServerSdkModule->ActivateGameSession();
UGLBSServiceConnector::SetServerAsUsed(this->Service_Id); //on gamesession creation, set the status of the server
});
ProcessParameters->OnUpdateGameSession.BindLambda([=](Aws::GameLift::Server::Model::UpdateGameSession InGameSession)
{
UE_LOG(GameServerLog, Log, TEXT("Game SessionUpdating"));
Aws::GameLift::Server::Model::UpdateReason c = InGameSession.GetUpdateReason();
Aws::GameLift::Server::Model::GameSession r = InGameSession.GetGameSession();
return;
});
ProcessParameters->OnTerminate.BindLambda([=,this]()
{
UE_LOG(GameServerLog, Log, TEXT("Game Server Process is terminating"));
FGameLiftGenericOutcome processEndingOutcome = GameLiftServerSdkModule->ProcessEnding();

FGameLiftGenericOutcome destroyOutcome = GameLiftServerSdkModule->Destroy();
if (processEndingOutcome.IsSuccess() && destroyOutcome.IsSuccess())
{
UE_LOG(GameServerLog, Log, TEXT("Server process ending successfully"));
UGLBSServiceConnector::ShutdownServer(this->Service_Id); //shutdown the server
//FGenericPlatformMisc::RequestExit(false);
}else{
if (!processEndingOutcome.IsSuccess()) {
const FGameLiftError& error = processEndingOutcome.GetError();
UE_LOG(GameServerLog, Error, TEXT("ProcessEnding() failed. Error: %s"),
error.m_errorMessage.IsEmpty() ? TEXT("Unknown error") : *error.m_errorMessage);
}
if (!destroyOutcome.IsSuccess()) {
const FGameLiftError& error = destroyOutcome.GetError();
UE_LOG(GameServerLog, Error, TEXT("Destroy() failed. Error: %s"),
error.m_errorMessage.IsEmpty() ? TEXT("Unknown error") : *error.m_errorMessage);
}
}
});


ProcessParameters->OnHealthCheck.BindLambda([=]()
{
UE_LOG(GameServerLog, Log, TEXT("Performing Health Check"));
return true;
});


ProcessParameters->port = FURL::UrlConfig.DefaultPort;


TArray<FString> CommandLineTokens;
TArray<FString> CommandLineSwitches;

FCommandLine::Parse(FCommandLine::Get(),CommandLineTokens,CommandLineSwitches);

for (FString Switch : CommandLineSwitches)
{
FString Key;
FString Value;

if (Switch.Split("=",&Key,&Value))
{
UE_LOG(GameServerLog, Log, TEXT("KEY: %s"), *Key);
UE_LOG(GameServerLog, Log, TEXT("VALUE: %s"), *Value);
if (Key.Equals("extport"))
{
UE_LOG(GameServerLog, Log, TEXT("EXTPORT EXIST"));
ProcessParameters->port = FCString::Atoi(*Value);
}
}
}
if (UNetDriver* Driver = GetWorld()->GetNetDriver())
{
TSharedPtr<const FInternetAddr> LocalAddr = Driver->GetLocalAddr();

if (LocalAddr.IsValid())
{
UE_LOG(GameServerLog, Log, TEXT("PORT %i!"),LocalAddr->GetPort());
}
}

TArray<FString> LogFiles;
LogFiles.Add(TEXT("OdinFleet/Saved/Logs/server.log"));
ProcessParameters->logParameters = LogFiles;

UE_LOG(GameServerLog, Log, TEXT("Calling Process Ready..."));

FGameLiftGenericOutcome ProcessReadyOutcome = GameLiftServerSdkModule->ProcessReady(*ProcessParameters);

if (ProcessReadyOutcome.IsSuccess())
{
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_GREEN);
UE_LOG(GameServerLog, Log, TEXT("Process Ready!"));
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_NONE);
UGLBSServiceConnector::SetServerAsActive(Service_Id); //Set the server as available for gamesessions
}
else
{
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_RED);
UE_LOG(GameServerLog, Log, TEXT("ERROR: Process Ready Failed!"));
FGameLiftError ProcessReadyError = ProcessReadyOutcome.GetError();
UE_LOG(GameServerLog, Log, TEXT("ERROR: %s"), *ProcessReadyError.m_errorMessage);
UE_LOG(GameServerLog, SetColor, TEXT("%s"), COLOR_NONE);
}
UE_LOG(GameServerLog, Log, TEXT("InitGameLift completed!"));
#endif
}

GameLift invokes OnStartGameSession on the server process it selected for the session. SetServerAsUsed reports that exact process, identified by its Fleet SERVICE_ID, as allocated. This synchronizes Fleet's capacity state without running a second, independent allocation.

Implement your backend communication HTTPS requests in C++. Make sure the URIs map perfectly to the endpoints defined in Step 3.

void UGLBSServiceConnector::SetServerAsActive(FString ServerID)
{
TSharedPtr<FJsonObject> JsonData = MakeShared<FJsonObject>();
JsonData->SetStringField(TEXT("server_id"), ServerID);
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = GetPostRequest("<your-backend-service-endpoint>/SetServerActive", JsonData);
Request->ProcessRequest();
}

void UGLBSServiceConnector::SetServerAsUsed(FString ServerID)
{
TSharedPtr<FJsonObject> JsonData = MakeShared<FJsonObject>();
JsonData->SetStringField(TEXT("server_id"), ServerID);
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = GetPostRequest("<your-backend-service-endpoint>/SetServerUsed", JsonData);
Request->ProcessRequest();
}

void UGLBSServiceConnector::ShutdownServer(FString ServerID)
{
TSharedPtr<FJsonObject> JsonData = MakeShared<FJsonObject>();
JsonData->SetStringField(TEXT("server_id"), ServerID);
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = GetPostRequest("<your-backend-service-endpoint>/SetServerShutdown", JsonData);
Request->ProcessRequest();
}

Conclusion

With this integration, Managed FlexMatch and GameLift handle matchmaking and session placement, while the native ODIN Fleet autoscaler maintains instance capacity from the server's lifecycle reports. Fleet autoscaling itself remains independent of GameLift and can also be integrated with other matchmaking systems.

If you need a custom matchmaking solution, please contact us!