Reading data on the client

Although the bulk of the game logic in an Aether Engine simulation is written to run on a cluster, this alone is not a game. Without having clients that can connect, authenticate and read data from the simulation, all we are only half done! In order to assist with this, the Aether Engine comes with a client side library - the repclient.

The repclient exposes methods for authenticating with a muxer, then reading messages sent from the simulation and sending messages back to the simulation.


Authenticating the player

Within Aether Hunt, the connection to the server side simulation is handled in the AReplicationActor class. As an Unreal Engine actor, we will hook into the Tick method to handle our connection, authentication and message reading requirements. Note that because we are making network requests that can take multiple frames to complete, most of this work will be done on a background thread. In order to create a connection to the simulation, we will need an instance of the repclient class. This is handled by the FAetherConnection class, within ReplicationActor.h., and the constructor for this class starts the background thread.

FAetherConnection::FAetherConnection(FString InHostName) {
HostName = InHostName;
PendingConnection = false;
// Start thread
Thread = FRunnableThread::Create(this, TEXT("AetherConnection"), 0, TPri_BelowNormal);
}

Note how we have to provide a hostname here - this should be the name of the muxer to connect to. For local connections this will be aether-sdk.mshome.net (when running the SDK VM on Windows through Hyper-V).

We can start the connection on the background thread in the Run method

uint32 FAetherConnection::FAetherConnection::Run() {
if (!PendingConnection) {
const char* Hostname = TCHAR_TO_ANSI(*(HostName));
// The repclient will attempt to connect to the muxer in the constructor. This
// method is blocking, hence the background thread. Note that the muxer port is
// always 8881.
Repclient = MakeShareable<repclient>(new repclient(Hostname, "8881"));
}
return 0;
}

We can check from outside the background thread for the success of the connection to the muxer through the repclient with Repclient::IsValid.

bool FAetherConnection::IsFinished() const
{
return Repclient.IsValid();
}

Now that we have a background thread that we can use to crete a connection to the server, we can integrate this with our AReplicationActor. We will use Unreal to create one instance of the AReplicationActor in our world, and use the Unreal BeginPlay callback to initialise the connection.

void AReplicationActor::BeginPlay() {
Super::BeginPlay();
FConnectionDetails& ConnectionDetails = Cast<UAetherHuntGameInstance>(GetGameInstance())->ConnectionDetails;
const char* Hostname = TCHAR_TO_ANSI(*(ConnectionDetails.AetherHostname));
CurrentConnectionThread = new FAetherConnection(ConnectionDetails.AetherHostname);
ConnectionTimeoutTimer = ConnectionTimeout;
}

Now on each AReplicationActor::Tick we can check if our background thread has finished connecting. If we have taken too long to connect it is polite to let the user know, and in this case we will send them back to the main menu.

void AReplicationActor::Tick(float DeltaTime) {
// Always start with Super::Tick
Super::Tick(DeltaTime);
UWorld* World = GetWorld();
if (!World) {
return;
}
// Check if we have connected to Aether. This will be set to true when we
// have fully authenticated
if (!ConnectedToAether) {
// Check if the connection has timed out
if (CurrentConnectionThread == nullptr || !CurrentConnectionThread->IsFinished()) {
ConnectionTimeoutTimer -= DeltaTime;
if (ConnectionTimeoutTimer <= 0.0f) {
// Some error checking and resource releasing ommitted for brevity
ConnectionTimeoutTimer = 0.0f;
APlayerController* PC = GetWorld()->GetFirstPlayerController();
PC->ClientReturnToMainMenu("Endpoint not reachable");
}
return;
}
...

This will ensure that we wait until the connection is established. Once we have a connection to the muxer we will need to authenticate the user. For Aether Hunt this is very simplified - in reality we will want to check that the user is logged into an account provider and has a valid copy of the game. This process should generate a token that we can send to our muxer. We can do this with the Repclient::authenticate_player_id_with_token method. This takes two parameters - a player ID (which should be unique across all players) and an authentication token. For Aether Hunt our authentication token will be just the player ID.

...
FConnectionDetails& ConnectionDetails =
Cast<UAetherHuntGameInstance>(GetGameInstance())->ConnectionDetails;
// Take ownership of the Repclient instance that has been created in the background thread
Repclient = CurrentConnectionThread->GetRepclientBack();
Repclient->authenticate_player_id_with_token(
ConnectionDetails.PlayerAsUID(),
GenerateAetherToken(ConnectionDetails.PlayerAsUID())
);
...

Here GenerateAetherToken is very simple, but this would be the appropriate place to do any integration with third party authentication providers.

std::array<unsigned char, 32> GenerateAetherToken(uint64 PlayerID) {
return StringToCharArray<32>(std::to_string(PlayerID));
}

Now that we have authenticated the client with the simulation, our simulation logic expects that we will send an EVENT_CONNECT message to notify the simulation that we are connected.


Sending events

Sending messages with the repclient requires that we create a message using the same protocol that we used on the muxer for decoding events, and calling Repclient::send.

...
{
using namespace AetherHuntProtocol;
aether_event player_registration_event;
player_registration_event.type = EVENT_CONNECT;
player_registration_event.connect.player_id = FCString::Atoi64(*ConnectionDetails.PlayerID);
Repclient->send(&player_registration_event, sizeof(player_registration_event));
}
ConnectedToAether = true;
// Thread releasing logic ommitted for brevity
...

Note that send will block the current thread, and that we need to use the same AetherHuntProtocol namespace that we were using previously.

We can of course call send from anywhere we have a repclient. For example, to send the EVENT_BITE message to indicate the user has tried to bite some prey, we can send that message from within the AReplicationActor that owns the Repclient instance.

void AReplicationActor::TryBite() {
{
using namespace AetherHuntProtocol;
aether_event player_bite_event;
player_bite_event.type = EVENT_BITE;
player_bite_event.bite.predator_id = PlayerEntityId;
Repclient->send(&player_bite_event, sizeof(player_bite_event));
}
}

And the pattern repeats for moving the predator

void AReplicationActor::MovePredator(FVector2D NewPos) {
{
using namespace AetherHuntProtocol;
aether_event player_move_event;
player_move_event.type = EVENT_MOVE;
player_move_event.move.p = vec2f{
((float)(NewPos.X) / SIMULATION_SCALE_MULTIPLIER),
((float)(NewPos.Y) / SIMULATION_SCALE_MULTIPLIER)
};
player_move_event.move.entity_id = PlayerEntityId;
Repclient->send(&player_move_event, sizeof(player_move_event));
}
}

Now that we can send events to the simulation, we will need to be able to update the client to reflect the current state of the simulation, and that means we need to be able to receive events from the simulation.


Receiving events

The repclient provides a blocking method to receive messages from the simulation, Repclient::tick. This will wait for any new message before returning a pointer to the latest message. For Aether Hunt, we will remain inside the AReplicationActor::Tick method indefinitely, only updating the simulation when we receive new events from the server.

...
size_t message_size;
while (true) {
void* Payload = Repclient->tick(&message_size);
if (Payload == nullptr || message_size < sizeof(AetherHuntProtocol::muxer_header)) {
return;
}
...

Here Repclient->tick will return a pointer to the message and populate message_size when a message is available. Once that message is available we need to deserialise it.

...
const auto Header = static_cast<AetherHuntProtocol::muxer_header*>(Payload);
uint8_t* PredatorsPtr = static_cast<uint8_t*>(Payload)
+ sizeof(AetherHuntProtocol::muxer_header);
uint8_t* PreyPtr = PredatorsPtr
+ (Header->number_of_predators * sizeof(AetherHuntProtocol::predator));
PlayerEntityId = Header->player_entity_id;
AetherHuntProtocol::predator* PredatorList = static_cast<AetherHuntProtocol::predator*>(
static_cast<void*>(PredatorsPtr)
);
AetherHuntProtocol::prey* PreyList = static_cast<AetherHuntProtocol::prey*>(
static_cast<void*>(PreyPtr)
);
...

As we are sending a raw uncompressed bytestream from the muxer, the deserialisation here is straightforward. Recall that we only send a list of predators and prey that are within a range of the player from the muxer - that was our network relevancy. As a result, these lists will only include those predators and prey - we do not need to know about any outside that range as we are not going to render them.

Finally we need to actually update the Unreal agents to reflect the new data we have from the server.

...
// PredatorToSpawn is the hook into the Unreal Editor to allow us to choose what
// Predators look like
if (PredatorToSpawn) {
UpdateAgentsFromPODs(
*World, PlayerEntityId, PredatorList, Header->number_of_predators,
PredatorMap, PredatorToSpawn, SIMULATION_SCALE_MULTIPLIER, PLAYER_VIEW_RADIUS
);
}
// PreyToSpawn is the hook into the Unreal Editor to allow us to choose what Prey
// look like
if (PreyToSpawn) {
UpdateAgentsFromPODs(
*World, PlayerEntityId, PreyList, Header->number_of_prey,
PreyMap, PreyToSpawn, SIMULATION_SCALE_MULTIPLIER, PLAYER_VIEW_RADIUS
);
}
} // end while(true) loop

UpdateAgentsFromPODs handles the actual updating of the position and orientation of agents within Unreal. Now we have a fully running simulation, complete with muxer that does some basic network relevancy, along with an Unreal Client that we can connect to the simulation.