Unreal Engine 5.8

The Unreal plugin

WorldPlane's game side is two Unreal Engine 5.8 plugins: WorldPlane, which every game needs, and WorldPlaneGAS, for games that use the Gameplay Ability System. The plugin knows nothing about any particular game: your game takes part through settings, a few hooks and components, and keeps its own rules in its own code.

What you need#

  • Unreal Engine 5.8. A dedicated server target needs a C++ project and an engine built from source, as for any Unreal dedicated server.
  • The Linux cross-compile toolchain for your engine version, to package the Linux server WorldPlane runs (LINUX_MULTIARCH_ROOT set).
  • An active WorldPlane subscription: subscribers download the plugins from the panel.
  • The worldplane CLI, to check the project and push builds (see CLI). Everything it does can also be done in the panel or the API.

Installing the plugins#

The download holds two plugin folders. Copy them into your project's Plugins folder, next to the .uproject:

MyGame/
  MyGame.uproject
  Plugins/
    WorldPlane/        WorldPlane.uplugin, Source/, Binaries/
    WorldPlaneGAS/     WorldPlaneGAS.uplugin, Source/   (only with the Gameplay Ability System)
  Source/
  Config/

Then enable them in the .uproject, or in the editor's Edit → Plugins (both are under Networking, marked experimental):

"Plugins": [
  { "Name": "WorldPlane", "Enabled": true },
  { "Name": "WorldPlaneGAS", "Enabled": true }
]
  • WorldPlane has three runtime modules: WorldPlaneCore (settings, entities, coordinator, telemetry), WorldPlaneIris (handoff, ghosts, owner calls) and WorldPlaneProxy (the gateway). It enables the engine plugins it needs: Iris, MultiServerReplication and OnlineSubsystemUtils.
  • WorldPlaneGAS is off by default. It needs WorldPlane and GameplayAbilities, which it enables.
  • The server library. WorldPlane loads a native library, wp_runtime, from Plugins/WorldPlane/Binaries/ThirdParty/WPRuntime: Win64/wp_runtime.dll for editors and Windows servers, Linux/x86_64-unknown-linux-gnu/libwp_runtime.so for Linux servers. It is staged into packaged servers by itself. Client targets build without it. If the file is missing, a server or editor build stops with WorldPlane: ... is missing.

Regenerate project files and build the editor target once, so the plugin's modules compile with your project.

Project setup#

Four engine settings and one module line make a project ready. worldplane check --fix makes these changes for you (see checking a project).

Config/DefaultEngine.ini#

[ConsoleVariables]
net.Iris.UseIrisReplication=1
net.EnableSupportForNoPawnConnection=1
net.SubObjects.DefaultUseSubObjectReplicationList=1

[/Script/IrisCore.ObjectReplicationBridgeConfig]
NetRefHandleGeneratorClass=/Script/WorldPlaneIris.WPNetRefHandleGenerator

[/Script/Engine.Engine]
LocalPlayerClassName=/Script/WorldPlaneCore.WPLocalPlayer
SettingWhy
net.Iris.UseIrisReplication=1WorldPlane replicates through Iris. Under the legacy replication, each server's objects clash in the gateway.
NetRefHandleGeneratorClassAn entity must have the same network handle on every server, or clients see it respawn at each handoff. WorldPlane's generator makes handles deterministic; nothing to write.
net.EnableSupportForNoPawnConnection=1The gateway joins every server with every player, and only one server has the player's pawn.
net.SubObjects.DefaultUseSubObjectReplicationList=1Iris replicates subobjects through registered lists.
LocalPlayerClassNameOn the gateway, each login it makes to a server gets the zone's session key and the options forwarded from the client's own login (ForwardedLoginOptions). A game with its own local player class calls WorldPlane::GetGatewayLoginOptions from it instead.

Iris support in your modules#

Every module that declares replicated FFastArraySerializer properties, third-party plugins included, calls SetupIrisSupport in its Build.cs:

public MyGame(ReadOnlyTargetRules Target) : base(Target)
{
    // ...
    SetupIrisSupport(Target);
}

Project Settings → Plugins → WorldPlane#

The plugin's settings are saved in Config/DefaultGame.ini under [/Script/WorldPlaneCore.WPSettings]. The defaults suit most games; the ones you are most likely to touch:

SettingDefaultWhat it does
Scopelocal/sandbox/devThe zone this server belongs to, tenant/project/environment. A coordinator refuses a server of another scope. Hosted zones pass -WPScope, which overrides it.
EntityClassespawns, player controllers, PlayerStatesActors of these classes get a UWPEntityComponent on zone servers, which lets them move between servers as themselves. A class with its own entity component, in C++ or Blueprint, keeps it.
NonEntityClassesemptySubclasses that stay on the server that spawned them, such as a turret. The actor tag WPNotEntity does the same.
PlayerHysteresis, NpcHysteresis1000 cm, 500 cmHow far past a border a player or NPC goes before it is handed to the next server.
MinResidenceSeconds3 sHow long an entity stays on a server it just arrived at before it can be handed on.
PlacedSpawnerClassesemptyPlaced actors that spawn entities (a camp that tops up its creatures). Each runs on one server of the zone, and on another when that one stops. The tag WPSpawner does the same.
PlacedActorsKeptEverywhereemptyPlaced replicated actors every server keeps, such as level managers. Other placed replicated actors exist only on the server whose area holds them. The tag WPKeepEverywhere does the same.
ZoneMaxPlayers1000Zone servers raise their game session's MaxPlayers to at least this, since every player in the zone has a connection on every server.
ForwardedLoginOptionstokenClient login options the gateway passes on when it joins the player to its first server, such as your game's login token.
bRequirePlayerTokenoffRequires a WorldPlane player token on each join (see player accounts).
GhostBandWidth15000 cmEntities this close to another server's area are shown there as ghosts.
bRealClassGhostsonGhosts are non-authoritative copies of the entity's own class, so casts, interfaces and components work on them.
GhostClass, StandInKindsnoneA stand-in ghost class of your own (an AWPGhostActor subclass) instead, for all kinds or the kinds listed.
OwnerFunctionsemptyFunctions whose calls on a ghost run on the entity's owner, by path, such as /Script/MyGame.MyInteractable:Interact.
bForwardEngineCallsonBlueprint calls of ApplyDamage, ApplyPointDamage, LaunchCharacter, AddImpulse and AddForce on a ghost run on its owner.
GameStateServerId1Only used without a coordinator: the one server whose GameState replicates. With a coordinator, the coordinator names it. 0 lets every server replicate its own.

Checking a project#

worldplane check reads a project's config and C++ (no editor, no build) and lists what it needs before it runs on WorldPlane, each finding with a file and line. It exits 1 when there are errors.

worldplane check path/to/MyGame          # or path/to/MyGame.uproject
worldplane check path/to/MyGame --fix    # make the mechanical changes first
worldplane check path/to/MyGame --json

--fix makes the changes that need no judgment about the game, then reports what is left: it lists or enables the plugin in the .uproject, sets the four DefaultEngine.ini settings, adds SetupIrisSupport(Target) to modules with fast arrays, and adds the damage-forwarding line to pawns' TakeDamage (with the include and module dependency it needs). Files keep their byte-order mark and line endings; a file that isn't UTF-8 is left alone, with the reason.

CheckSeverityWhat it looks for
configerror (one warning)The WorldPlane plugin, and the Iris, handle generator, no-pawn connection and subobject list settings.
iris-moduleerrorA module with fast arrays whose Build.cs doesn't call SetupIrisSupport.
multicastwarningNetMulticast RPCs, which the gateway drops from actors no player owns.
relevancywarning or noteIsNetRelevantFor overrides, which Iris never calls.
entitywarningPawns, player controllers and PlayerStates that your settings leave without a UWPEntityComponent.
damagewarning or noteA pawn's TakeDamage that doesn't forward damage from a ghost to the owner.
owner-functionerror, warning or noteOwnerFunctions entries that return a value, name no function in the project, or are reached through reflection only.

It parses C++ by its shape, not with a compiler, so it can miss things and, rarely, flag what is fine. Blueprints and levels are binary and are not read.

What game code needs#

Plain game code works across servers. Most of what a single-server game does needs nothing new; a few patterns that only hold on one server need a change.

What needs nothing#

  • Entity identity. Zone servers give every pawn, player controller and PlayerState a UWPEntityComponent as it spawns or loads. A pawn is a player's while a player controls it, otherwise an NPC.
  • Network handles are deterministic through the handle generator in the config.
  • Moving players. Pawn, controller and PlayerState move with their SaveGame properties, movement mode and character movement timestamps.
  • Registering with the coordinator. A zone server started with -WPServerId and -WPCoordinator registers before its first world has actors.
  • NPCs. An arriving NPC gets its default AI controller.
  • Plain references across a border. An AActor* or UObject* in a client RPC or a replicated property that names another server's entity resolves to that server's ghost of it. Around each handoff of that entity (about 3 s) it still arrives as null.
  • Blueprint damage and movement on a ghost (ApplyDamage, ApplyPointDamage, LaunchCharacter, AddImpulse, AddForce) runs on the owner.
  • Owner-only state. COND_OwnerOnly and COND_SkipOwner properties reach the right clients through the gateway, as on one server.
  • Agones. On hosted zones each server and the gateway runs as an Agones GameServer; the plugin sends its health and ready signals.

What needs a change#

PatternOn WorldPlane
NetMulticast RPCsThe gateway drops multicasts from actors without an owning player. Use replicated state, or UWPEventsComponent: the owning server calls Emit(Name, Values) and clients bind OnEvent. It keeps the last 8 events, so it suits hit reactions, damage numbers and sounds.
IsNetRelevantForIris never calls it. Use WorldPlane::Relevancy::Update(Actor, IsRelevant) (WPGatewayClients.h), which works on zone servers and the gateway, and call it again when the answer can change. Use entity ids, not PlayerId, in rules.
C++ damage to a pawnThe engine refuses damage to a pawn without authority, so a pawn's TakeDamage starts with one line: if (WorldPlane::ForwardDamageToOwner(this, Damage, Event, By, Causer)) { return Damage; }. worldplane check --fix adds it.
Calls that change another server's entityList the function in OwnerFunctions (calls through reflection: Blueprint, BlueprintNativeEvent, interface Execute_, delegates), or put WP_RUN_ON_OWNER(Params...) at the top of the UFUNCTION (WPOwnerCalls.h). Functions that return nothing only. A change made on a ghost and overwritten by its owner is logged as WP_LOST_WRITE, naming the property.
Gameplay effects on a ghostWith WorldPlaneGAS, one line at the top of your ability system component's ApplyGameplayEffectSpecToSelf: if (WorldPlane::GAS::ForwardToOwner(*this, Spec)) { return FActiveGameplayEffectHandle(); }. The owner rebuilds the effect from the instigator's ghost and applies it to the real entity.
Replicated actors placed in levelsEach is kept by the server whose area holds it at load, once per zone. Replicated actors are best spawned at runtime by the server that owns their location.
Placed spawnersName the class in PlacedSpawnerClasses (or tag it WPSpawner) and spawn its entities with it as their Owner. Keep each entity's id with WorldPlane::GetEntityId; WorldPlane::IsSpawnedEntityAlive says whether it lives anywhere in the zone, and WorldPlane::GetSpawnedEntities lists what an earlier copy made.
A GameState pointer kept from BeginPlayOnly one server's GameState replicates, and it can move to another server. Look it up each time with GetWorld()->GetGameState(). Zone-wide state kept there must be the same on every server, or live elsewhere.
Areas and idsUse WorldPlane::GetAreas(), WorldPlane::FindOwnerServer(Location) and WorldPlane::AllocateEntityId() rather than reading settings or making ids.

Carrying more state in a handoff#

FWPPlayerHandoffHooks lets a game take part in a player's handoff:

  • CanHandOff(Controller, Pawn): on the old server, each time the player is past the border; return false to wait (for example while a save is in flight).
  • OnGather(Controller, Pawn, OutState): on the old server, before its actors stop.
  • OnApply(Controller, Pawn, State): on the new server, after spawn and possession.
  • RegisterRestoreProvider(Provider): once, at module startup. A restore provider carries one named, versioned piece of player state (skills, cooldowns, a quest log) from the old server to the new one, so the new server reads nothing from your backend.
FWPRestoreProvider Provider = FWPRestoreProvider::ForStruct<FMySkills>(TEXT("mygame.skills"), /*Version*/ 2,
    [](const FWPRestoreContext& Context, FMySkills& Out) { /* old server: fill Out */ return true; },
    [](const FWPRestoreContext& Context, const FMySkills& In, int32 SentVersion) { /* new server: apply In */ return true; });
Provider.MinVersion = 1;                              // still reads v1 from servers on the previous build
Provider.DependsOn = { TEXT("mygame.character") };    // applied after that provider
Provider.OnFailed = [](const FWPRestoreContext& Context, EWPRestoreFailure Reason) { /* read your backend instead */ };
FWPPlayerHandoffHooks::RegisterRestoreProvider(MoveTemp(Provider));
  • Gather also runs every wp.CheckpointSeconds (2 s) with Context.bCheckpoint set, for crash recovery: keep it cheap and free of side effects. If a server stops, the server that now owns the player's position rebuilds them from the last checkpoint, and the gateway keeps the player connected meanwhile.
  • Apply runs before the arriving actors first replicate, and must not wait for anything.
  • Failures are never silent: each is logged on the new server, and OnFailed gets the reason (not_sent, version_mismatch, apply_failed, dependency_failed, scope_mismatch).
  • A game with persistence makes sure the old server writes nothing after the handoff.

WorldPlane player accounts (optional)#

A game without accounts of its own can use WorldPlane's, per environment:

  1. Your launcher or login screen signs the player in with POST /v1/play/{tenant}/{project}/{env}/sessions (or /accounts to sign up) and gets a token, good for an hour.
  2. The client connects to the gateway with ?token=<token>. token is in ForwardedLoginOptions by default.
  3. Turn on bRequirePlayerToken. The zone's coordinator checks the token on the player's first join, and refuses that player alone if it is missing, expired or for another environment.
  4. Read who the player is from the login options WPPlayer= (a stable id) and WPPlayerName=, in PreLogin, Login or PostLogin.

Games with their own accounts leave it off and forward their own token through ForwardedLoginOptions. A server that should accept the gateway's own connection without the game's normal login check calls WorldPlane::IsTrustedGatewayLogin(World, Options) in its PreLogin.

Packaging a Linux server build#

WorldPlane runs the dedicated server for Linux x86-64. Servers and the gateway run the same build.

  1. A server target. Add Source/MyGameServer.Target.cs if your project has none:

    using UnrealBuildTool;
    
    public class MyGameServerTarget : TargetRules
    {
        public MyGameServerTarget(TargetInfo Target) : base(Target)
        {
            Type = TargetType.Server;
            DefaultBuildSettings = BuildSettingsVersion.Latest;
            IncludeOrderVersion = EngineIncludeOrderVersion.Latest;
            ExtraModuleNames.Add("MyGame");
        }
    }
  2. Build, cook and stage it for Linux with Unreal's automation tool, from your engine folder (or package Linux Server from the editor's Platforms menu):

    Engine\Build\BatchFiles\RunUAT.bat BuildCookRun -project="C:\Path\MyGame\MyGame.uproject" -noP4 ^
      -noclient -server -serverplatform=Linux -serverconfig=Development ^
      -build -cook -stage -pak -map=/Game/Maps/World ^
      -stagingdirectory="C:\Builds\MyGame" -unattended -utf8output

    The staged server is in C:\Builds\MyGame\LinuxServer, with MyGameServer.sh at its top.

  3. Zip the LinuxServer folder and check the archive as the control plane will:

    worldplane build check MyGameLinuxServer.zip
  4. Push it, or upload it in the panel's Builds:

    worldplane build push --project mystudio/mygame --label 0.4.1 MyGameLinuxServer.zip

An archive is accepted when it has:

  • exactly one <Name>Server.sh;
  • a <Name>Server binary under Binaries/Linux that is an x86-64 Linux executable;
  • the WorldPlane plugin, loose (Plugins/WorldPlane/WorldPlane.uplugin) or listed in the staging manifest Manifest_UFSFiles_*.txt that packaging writes beside the pak, at plugin Version 1 or later;
  • no files that look like secrets: .env, *.pem, *.key, id_rsa*, id_ed25519*. Secrets come from the environment, never from builds;
  • at most 20 GiB.

The first start runs your server alone. On app.worldplane.net a new build is imaged, then started once on a game node with none of your environment's arguments, and must keep running for a minute. A server that stops by itself there makes the build rejected, with its last log lines. If your server needs a token from its arguments, log that it is missing and keep running.

Server arguments#

You don't start servers yourself: the zone's coordinator does. Every zone server gets the map, then:

-log -unattended -nosteam -port={game_port} -WPServerId={index} -WPScope={tenant}/{project}/{env}
-WPCoordinator={coordinator} -WPTransportPort={transport_port} -WPSessionKey=...

The gateway is the same build, started with the map and:

-log -unattended -nosteam -port=7777 -ini:Engine:[ConsoleVariables]:wp.proxy.Enabled=1
-WPScope={tenant}/{project}/{env} -WPCoordinator={coordinator} -WPSessionKey=...

Your own arguments come after these, on every server and the gateway: the environment's extra_args, set with --server-arg in the CLI or Extra server arguments in the panel. They can use placeholders:

PlaceholderBecomes
{index}The server's number.
{role}server or gateway. -ServerId=mygame-{role}-{index} names each process uniquely to your own backend.
{env:NAME}The environment's secret NAME. A deploy whose arguments use a secret the environment lacks is refused, naming it.
worldplane env secret set mystudio/mygame/test API_TOKEN
worldplane env update mystudio/mygame/test "--server-arg=-ApiToken={env:API_TOKEN}"
worldplane deploy mystudio/mygame/test <build id>

Read it in your game as any command-line value:

FString Token;
FParse::Value(FCommandLine::Get(), TEXT("ApiToken="), Token);

--server-arg replaces the arguments, so give every one the servers need. Arguments and secrets take effect at the next deploy.

The plugin's own arguments#

ArgumentMeaning
-WPServerId=NThis zone server's id.
-WPCoordinator=URLThe zone's coordinator: the server registers there and takes its areas from it.
-WPScope=tenant/project/environmentOverrides the Scope setting. A coordinator refuses a server of another scope, and the server exits rather than join the wrong zone.
-WPTransportPort=NThe port servers use to talk to each other.
-WPSessionKey=KEYThe zone's shared key. Servers accept the gateway's connection only with it.
wp.proxy.Enabled=1Makes the process a gateway. With -WPCoordinator and -WPScope it takes its servers from the coordinator and follows them coming and going.