Unreal Tournament Integration

Here we will describe how we modified the latest Unreal Tournament to use Genvid.

Preparation

You will need to download the Unreal Tournament source code.

This code is available from GitHub but only to registered developers. To register, follow the steps highlighted here.

Our current code is based off of the following changelist:

commit b82f21dc6af1907201aeec58ae7d38a1bb0c9bb2
Author: Peter Knepley <[email protected]>
Date:   Tue Nov 15 16:46:53 2016 -0500

    Copying

    //UT/Release-Next/...

    to //UT/Release-Live/...

    [CL 3199151 by Peter Knepley in UT-Release-Live branch]

Once you have downloaded the source code, switch to that changelist:

git checkout b82f21dc6af1907201aeec58ae7d38a1bb0c9bb2

Normally, some git-hook should call setup.bat, but we recommend you call it by hand for good measure. This script will download and install all of the necessary requirements.

You should then follow the steps listed in Epic’s own documentation.

The gist of it is to run GenerateProjectFiles.bat, open up UE4.sln, and compile a few targets (ShaderCompileWorker and UnrealLightmass). Then, with UnrealTournament selected, simply Build and Debug (F5). It does take some time, so be patient.

Make sure you have a working Unreal Tournament build before proceeding.

Installation

The integration requires various files which we added and some modifications to existing UE4 files to make Unreal Tournament run with Genvid.

  • Copy GenvidPlugin under UnrealTournament/Plugins/.
  • Copy GenvidConnector.h under UnrealTournament/Source/UnrealTournament/Public/.
  • Copy GenvidConnector.cpp under UnrealTournament/Source/UnrealTournament/Private/.

Details

This section presents the modifications to be made to several UE4 files.

UnrealTournament.Build.cs

At the very end of public UnrealTournament(TargetInfo Target) add the following line:

        PrivateDependencyModuleNames.Add("GenvidPlugin");

This line makes all of the GenvidPlugin public interfaces available to all of the UnrealTournament sub-project.

UTGameState

Some modifications are required in the AUTGameState class, we suggest that you surround them with preprocessor macros:

#if GENVID
// Code...
#endif //GENVID

The GENVID value will be defined in the UTGameState.h file. This will allow developers to quickly isolate the modifications.

Let us first describe the required changes to UTGameState.h.

Right before:

#include "UTGameState.generated.h"

Insert the following lines:

#define GENVID 1

#if GENVID
#include "GenvidConnector.h"
#endif

Further down, at the end of the AUTGameState class definition, insert the following lines:

#if GENVID
private:
    GenvidConnector Genvid;
#endif

Right after:

virtual void BeginPlay() override;

Insert the following lines:

#if GENVID
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
#endif

We will now describe the required changes to UTGameState.cpp.

In the AUTGameState::Tick() function, add a call to Genvid.Tick().

#if GENVID
    Genvid.Tick(DeltaTime);
#endif

This method simply calls GenvidUpdate() at regular intervals. Due to the fact that UWorld::GetTimeSeconds() judders when setting a low TimeDilation value, we have opted to use UWorld::GetRealTimeSeconds() instead.

void GenvidConnector::Tick(float DeltaSeconds)
{
    // Note: GetTimeSeconds() is the world time, and judders when dilation is really slow, so we use GetRealTimeSeconds().
    float CurTime = GWorld->GetRealTimeSeconds();
    if (CurTime >= GenvidNextTriggerTime)
    {
        GenvidUpdate(GWorld);
        GenvidNextTriggerTime = CurTime + GenvidUpdateDelay;
    }
}

The GenvidUpdate() method is where the bulk of the game data is both gathered and sent:

void GenvidConnector::GenvidUpdate(UWorld *World)
{
    GenvidTimecode tc = Genvid_GetCurrentTimecode();

    // Submit game data.
    if(bSendGameData)
    {
        GenvidStatus gs;

        FGenvidData_Locations Locations;
        CollectGameData(World, Locations, GenvidPlayersInfo);

        std::string LocationsJSON = FGenvidUtils::ConvertFStructToJSONStdString(Locations);
        gs = Genvid_SubmitGameData(tc, sStreamID_Locations.c_str(), LocationsJSON.data(), (int)LocationsJSON.size());
        if(GENVID_FAILED(gs))
        {
            UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Locations.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
        }

        std::string PlayersInfoJSON = FGenvidUtils::ConvertFStructToJSONStdString(GenvidPlayersInfo);
        gs = Genvid_SubmitGameData(tc, sStreamID_PlayerInfo.c_str(), PlayersInfoJSON.data(), (int)PlayersInfoJSON.size());
        if(GENVID_FAILED(gs))
        {
            UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_PlayerInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
        }

        // Submit notifications for popularity
        FGenvidData_PlayersCheerInfo GenvidCheersInfo;
        GenvidPlayersInfo.CollectCheerInfo(&GenvidCheersInfo);
        std::string CheersInfoJSON = FGenvidUtils::ConvertFStructToJSONStdString(GenvidCheersInfo);
        gs = Genvid_SubmitNotification(sStreamID_CheersInfo.c_str(), CheersInfoJSON.data(), (int)CheersInfoJSON.size());

        //Modifying the game speed with the KV
        float resultSpeed = 0.0f;
        GenvidStatus valueSpeed = Genvid_GetParameterFloat("genvid.kv", "ut4/gamespeed", &resultSpeed);
        
        if (resultSpeed > 0.0f)
        {
            resultSpeed = FMath::Clamp(resultSpeed, 0.0001f, 20.0f); // Same as in CheatManager::Slomo().
            GWorld->GetWorldSettings()->TimeDilation = resultSpeed;
        }
        else
        {
            UE_LOG(LogGenvid, Warning, TEXT("Invalid game speed."));
        }
    }

    // Handle MapReduce results.
    GenvidStatus gs = Genvid_CheckForEvents();
    if(GENVID_FAILED(gs) && gs != GenvidStatus_ConnectionTimeout)
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CheckForEvents() failed: %s (%d)."), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Handle of player popularity.
    FString MostPopular;
    float HighestPopularity = 0.0f;
    for (auto Iter = GenvidPlayersInfo.Players.CreateIterator(); Iter; ++Iter)
    {
        FGenvidData_PlayerInfo& PlayerInfo = *Iter;
        PlayerInfo.Popularity -= GenvidUpdateDelay; // Lose 1 unit every second.
        if (PlayerInfo.Popularity < 0.f)
        {
            PlayerInfo.Popularity = 0.f;
        }
        if (PlayerInfo.Popularity > HighestPopularity)
        {
            MostPopular = PlayerInfo.Name;
            HighestPopularity = PlayerInfo.Popularity;
        }
    }

    // Update new lead.
    AUTPlayerController* CurrentCameraController = GetCurrentCameraController(World);
    if (CurrentCameraController)
    {
        if(HighestPopularity > 0.0f)
        {
            if(GenvidCurrentLead != MostPopular)
            {
                AUTPlayerState* LeadPlayerState = GetPlayerState(World, *MostPopular);
                if(LeadPlayerState)
                {
                    CurrentCameraController->ViewPlayerNum(LeadPlayerState->SpectatingID);
                    GenvidCurrentLead = MostPopular;
                    UE_LOG(LogGenvid, Log, TEXT("New Lead is '%s'."), *GenvidCurrentLead);
                }
                else
                {
                    CurrentCameraController->EnableAutoCam();
                    GenvidCurrentLead.Empty();
                    UE_LOG(LogGenvid, Log, TEXT("Failed to set lead to '%s'."), *MostPopular);
                }
            }
        }
        else
        {
            if(!GenvidCurrentLead.IsEmpty())
            {
                CurrentCameraController->EnableAutoCam();
                GenvidCurrentLead.Empty();
                UE_LOG(LogGenvid, Log, TEXT("Cleared lead; switching to auto-cam."));
            }
        }
    }

    // Report camera changes.
    if (CurrentCameraController)
    {
        if (GenvidLastSpectatedPlayerState != CurrentCameraController->LastSpectatedPlayerState)
        {
            // Camera changed target.
            GenvidLastSpectatedPlayerState = CurrentCameraController->LastSpectatedPlayerState;

            FGenvidData_CurrentCamera CurrentCamera;
            if(GenvidLastSpectatedPlayerState)
            {
                CurrentCamera.Name = GenvidLastSpectatedPlayerState->PlayerName;
            }

            std::string CurrentCameraJSON = FGenvidUtils::ConvertFStructToJSONStdString(CurrentCamera);
            /*GenvidStatus*/ gs = Genvid_SubmitGameData(tc, sStreamID_CurrentCamera.c_str(), CurrentCameraJSON.data(), (int)CurrentCameraJSON.size());
            if(GENVID_FAILED(gs))
            {
                UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_CurrentCamera.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
            }

            UE_LOG(LogGenvid, Log, TEXT("Camera changed to look at '%s'."), *CurrentCamera.Name);
        }
    }

    if (GenvidConfirmedKills.Kills.Num() > 0)
    {
        // Confirm player kills.
        std::string ConfirmedKillsJSON = FGenvidUtils::ConvertFStructToJSONStdString(GenvidConfirmedKills);
        gs = Genvid_SubmitAnnotation(tc, sStreamID_ConfirmedKills.c_str(), ConfirmedKillsJSON.data(), (int)ConfirmedKillsJSON.size());
        if (GENVID_FAILED(gs))
        {
            UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_ConfirmedKills.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
        }
        GenvidConfirmedKills.Reset();
    }
}

This method gathers player and camera information with CollectGameData(), converts them to JSON format using the FGenvidUtils::ConvertFStructToJSONStdString() utility routine, and then submits it on appropriate game data streams so it can reach the website.

Note

Using UE4 JSON serialization routines is a suboptimal decision which we took mainly due to simplicity arguments of this tutorial. We recommend developers use a much more compact binary representation in order to save as much bandwidth as possible. The extra cost incurred on the web browser side should be worth the extra effort.

The function Genvid_CheckForEvents() is then called, which itself will possibly invoke calls to AUTGameState::GenvidSubscriptionCallback(). This will handle results coming from the event channels.

Following that, we have code which fades player popularity over time, and detects the lead player. The camera will then focus on that lead player, or revert back to auto-cam mode if popularity is depleted.

When the camera starts following a new player (detected by using the GenvidLastSpectatedPlayerState member), a separate message is sent to the website.

After that, we report back confirming the players killed remotely.

The GenvidSubscriptionCallback() method gets called automatically whenever event data is available:

void GenvidConnector::GenvidSubscriptionCallback(const struct GenvidEventSummary *summary)
{
    UE_LOG(LogGenvid, VeryVerbose, TEXT("GenvidSubscriptionCallback(%s[%d])"), UTF8_TO_TCHAR(summary->id), summary->numResults);

    if (summary->id == sReduceID_KillPlayer)
    {
        for (int i = 0; i < summary->numResults; ++i)
        {
            const auto & result = summary->results[i];
            FString PlayerName =  UTF8_TO_TCHAR(result.key.fields[1]); // ["kill_player", "<name>"]

            APawn* PlayerPawn = nullptr;
            AUTPlayerState* PlayerState = GetPlayerState(GWorld, PlayerName, &PlayerPawn);
            if (PlayerPawn)
            {
                UE_LOG(LogGenvid, Log, TEXT("Killing '%s'."), *PlayerName);

                AActor* Actor = PlayerPawn;
                float DamageAmount = MAX_FLT; //INFINITY
                FVector HitLoc = Actor->GetActorLocation();
                FVector HitDir = FVector(0.0f, 0.0f, 1.0f);
                FHitResult Hit = FHitResult(Actor, nullptr, HitLoc, HitDir);
                FPointDamageEvent DamageEvent = FPointDamageEvent(DamageAmount, Hit, -HitDir, UDamageType::StaticClass());
                Actor->TakeDamage(DamageAmount, DamageEvent, nullptr, nullptr);

                GenvidConfirmedKills.Kills.Add(PlayerName);
            }
            else
            {
                UE_LOG(LogGenvid, Warning, TEXT("'Failed to kill '%s'."), *PlayerName);
            }
        }
    }
    else if (summary->id == sReduceID_Cheer)
    {
        for (size_t r = 0; r < summary->numResults; ++r)
        {
            const auto & result = summary->results[r];
            FString playerName =  UTF8_TO_TCHAR(result.key.fields[1]); // The key is ["cheer", <playername>]
            for (size_t v = 0; v < result.numValues; ++v)
            {
                const auto & value = result.values[v];
                if (value.reduce == GenvidReduceOp_Count) 
                {
                    FGenvidData_PlayerInfo* playerInfo = GenvidPlayersInfo.Players.FindByKey(playerName);
                    if (playerInfo != nullptr)
                    {
                        UE_LOG(LogGenvid, Log, TEXT("Cheering '%s'."), *playerName);
                        playerInfo->Popularity += (5 * value.value); // 5s per cheer.
                        if(playerInfo->Popularity > 60.0f)
                        {
                            playerInfo->Popularity = 60.0f;
                        }
                    }
                    else
                    {
                        UE_LOG(LogGenvid, Warning, TEXT("Invalid player cheered: '%s'"), *playerName);
                        continue;
                    }
                }
            }
        }
    }
    else
    {
        UE_LOG(LogGenvid, Warning, TEXT("Received unknown subject '%s'."), UTF8_TO_TCHAR(summary->id));
        return;
    }
}

// Callback routine invoked by Genvid_CheckForEvents() to handle commands.
void GenvidConnector::GenvidSubscriptionCommandCallback(const GenvidCommandResult* InCmd)
{
    //std::string id(cmd->id);
    size_t Pos;
    std::string CmdFull = InCmd->value;
    Pos = CmdFull.find(' ');
    std::string Cmd = CmdFull.substr(0, Pos);
    Pos = CmdFull.find_first_not_of(' ', Pos);
    std::string Args;
    if(Pos != std::string::npos)
    {
        Args = CmdFull.substr(Pos);
    }

    if(Cmd == "changeCamera")
    {
        FString CameraName = UTF8_TO_TCHAR(Args.c_str());
        if(CameraName.IsEmpty())
        {
            UE_LOG(LogGenvid, Log, TEXT("Changing camera back to auto-cam."));
            AUTPlayerController* CurrentCameraController = GetCurrentCameraController(GWorld);
            if(CurrentCameraController)
            {
                CurrentCameraController->EnableAutoCam();
            }
            return;
        }
        else
        {
            // Search for a player of the specified name.
            APawn* PlayerPawn = nullptr;
            AUTPlayerState* PlayerState = GetPlayerState(GWorld, CameraName, &PlayerPawn);
            if (PlayerState)
            {
                AUTPlayerController* CurrentCameraController = GetCurrentCameraController(GWorld);
                UE_LOG(LogGenvid, Log, TEXT("Changing camera to player '%s'."), *CameraName);
                if(CurrentCameraController)
                {
                    CurrentCameraController->ViewPlayerNum(PlayerState->SpectatingID);
                }
                return;
            }

            // Search for spectating camera of the specified name..
            int32 CamCount = 0;
            for (FActorIterator It(GWorld); It; ++It)
            {
                AUTSpectatorCamera* Cam = Cast<AUTSpectatorCamera>(*It);
                if (Cam)
                {
                    if (Cam->CamLocationName == CameraName)
                    {
                        UE_LOG(LogGenvid, Log, TEXT("Changing camera to '%s'."), *CameraName);
                        AUTPlayerController* Controller = GetCurrentCameraController(GWorld);
                        if (Controller)
                        {
                            Controller->ViewCamera(CamCount);
                            return;
                        }
                        else
                        {
                            UE_LOG(LogGenvid, Warning, TEXT("Game cheat 'ChangeCamera' couldn't find a proper controller."));
                            return;
                        }
                    }
                    ++CamCount;
                }
            }

            UE_LOG(LogGenvid, Warning, TEXT("Couldn't find camera named '%s'."), *CameraName);
            return;
        }
    }

    UE_LOG(LogGenvid, Warning, TEXT("Invalid command: '%s'."), UTF8_TO_TCHAR(InCmd->value));
}

It simply interprets the returning events, and triggers proper game behavior, such as changing camera to follow a newly-defined lead, setting a new game speed (to allow for slow-motion), or even killing a specific player (and reporting back that it was performed).

In the AUTGameState::BeginPlay() function, add a call to Genvid.BeginPlay() in order to proceed with the initialization process for the GenvidPlugin.

#if GENVID
    Genvid.BeginPlay(PrimaryActorTick);
#endif

In this call the following will be done:

  1. We create all the streams required for the game (video stream and data stream).
  2. We subscribe to Event channels that we want to receive.
  3. We start the video auto capture using a utility routine from FGenvidUtils.
  4. We report that BeginPlay() was called.
  5. We set various variables used by the Tick() function, which we also enable.
void GenvidConnector::BeginPlay(FActorTickFunction &PrimaryActorTick)
{
    GAreScreenMessagesEnabled = false;
    GScreenMessagesRestoreState = false;

    GenvidStatus gs;

    // Genvid_Initialize() is handled by the GenvidPlugin.

    // Create data streams.
    gs = Genvid_CreateStream(sStreamID_Audio.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Audio.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_Video.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Video.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_GameState.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_CurrentCamera.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_CurrentCamera.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_ConfirmedKills.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_ConfirmedKills.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_TeamInfo.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_TeamInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_MatchInfo.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_MatchInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_PlayerInfo.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_PlayerInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_CreateStream(sStreamID_Locations.c_str());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_CreateStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Locations.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    auto gameEventCallback = [](const GenvidEventSummary *summary, void *userData)
    {
        auto This = reinterpret_cast<GenvidConnector*>(userData);
        This->GenvidSubscriptionCallback(summary);
    };
    sGameEventCallback = (GenvidEventSummaryCallback)gameEventCallback;

    // Define event subscriptions
    gs = Genvid_Subscribe(sReduceID_Cheer.c_str(), sGameEventCallback, this);
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_Subscribe(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sReduceID_Cheer.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_Subscribe(sReduceID_KillPlayer.c_str(), sGameEventCallback, this);
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_Subscribe(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sReduceID_KillPlayer.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Define command subscriptions.
    auto commandCallback = [](const GenvidCommandResult* result, void* userData)
    {
        auto This = reinterpret_cast<GenvidConnector*>(userData);
        This->GenvidSubscriptionCommandCallback(result);
    };
    sGameCommandCallback = (GenvidCommandCallback)commandCallback;

    gs = Genvid_SubscribeCommand(sCommandID_Game.c_str(), sGameCommandCallback, this);
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubscribeCommand(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sCommandID_Game.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Activate auto-capture for the current window.
    if (!FGenvidUtils::StartAutoCapture(sStreamID_Audio.c_str(), sStreamID_Video.c_str(), GEngine->GameViewport->GetWindow().Get()))
    {
        UE_LOG(LogGenvid, Error, TEXT("FGenvidUtils::StartAutoCapture(%s, %s) failed."), UTF8_TO_TCHAR(sStreamID_Audio.c_str()), UTF8_TO_TCHAR(sStreamID_Video.c_str()));
    }

    // Inform website of BeginPlay().
    FGenvidData_GameState GameState("Playing");
    std::string GameStateJSON = FGenvidUtils::ConvertFStructToJSONStdString(GameState);
    gs = Genvid_SubmitGameData(-1, sStreamID_GameState.c_str(), GameStateJSON.c_str(), (int)GameStateJSON.size());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Start sending game data.
    bSendGameData = true;

    // Internal state bookkeeping.
    GenvidLastSpectatedPlayerState = nullptr;
    sGameEventCallback = nullptr;
    GenvidPlayersInfo.Reset();
    GenvidTeamsInfo.Reset();
    GenvidConfirmedKills.Reset();
    GenvidCurrentLead.Empty();

    // Internal state bookkeeping.
    GenvidLastSpectatedPlayerState = nullptr;
    GenvidUpdateDelay = 1.0f / 30.0f;
    GenvidNextTriggerTime = 0.0f; // Trigger right away.
    PrimaryActorTick.SetTickFunctionEnable(true);
}

After the AUTGameState::BeginPlay() function, add the following function:

#if GENVID
void AUTGameState::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);

    Genvid.EndPlay(EndPlayReason);
}
#endif

In the EndPlay() function of the GenvidConnector the opposite of the initialization process is performed:

void GenvidConnector::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    GenvidStatus gs;

    // Inform website of EndPlay().
    FGenvidData_GameState GameState("");
    std::string GameStateJSON = FGenvidUtils::ConvertFStructToJSONStdString(GameState);
    gs = Genvid_SubmitGameData(-1, sStreamID_GameState.c_str(), GameStateJSON.c_str(), (int)GameStateJSON.size());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Disable auto-capture.
    FGenvidUtils::StopAutoCapture(sStreamID_Audio.c_str(), sStreamID_Video.c_str());

    gs = Genvid_UnsubscribeCommand(sCommandID_Game.c_str(), sGameCommandCallback, this);
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_UnsubscribeCommand(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sCommandID_Game.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_Unsubscribe(sReduceID_KillPlayer.c_str(), sGameEventCallback, this);
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_Unsubscribe(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sReduceID_KillPlayer.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_Unsubscribe(sReduceID_Cheer.c_str(), sGameEventCallback, this);
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_Unsubscribe(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sReduceID_Cheer.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Destroy streams.
    gs = Genvid_DestroyStream(sStreamID_Locations.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Locations.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_PlayerInfo.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_PlayerInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_MatchInfo.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_MatchInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_TeamInfo.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_TeamInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_ConfirmedKills.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_ConfirmedKills.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_CurrentCamera.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_CurrentCamera.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_GameState.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_Video.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Video.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    gs = Genvid_DestroyStream(sStreamID_Audio.c_str());
    if(GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_DestroyStream(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_Audio.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Genvid_Terminate() is handled by the GenvidPlugin.

    // Internal state bookkeeping.
    GenvidLastSpectatedPlayerState = nullptr;
    sGameEventCallback = nullptr;
    GenvidPlayersInfo.Reset();
    GenvidTeamsInfo.Reset();
    GenvidConfirmedKills.Reset();
    GenvidCurrentLead.Empty();
}

In the AUTGameState::HandleMatchHasStarted() function, add a call to Genvid.HandleMatchHasStarted().

#if GENVID
    Genvid.HandleMatchHasStarted(GetWorld(), PlayerArray, Teams);
#endif

In the HandleMatchHasStarted() method, we retrieve the player list for later use. We then send an event to indicate that the match has started. We also collect and send team information to the website. Finally, we set a boolean to actually start sending game events.

void GenvidConnector::HandleMatchHasStarted(UWorld* InWorld, TArray<class APlayerState*>& PlayerArray, TArray<AUTTeamInfo*>& Teams)
{
    if (!bSendGameData) {
        UE_LOG(LogGenvid, Warning, TEXT("HandleMatchHasStarted: Streams not created.  Error will happen."));
    }
    GenvidStatus gs;

    GenvidTimecode tc = Genvid_GetCurrentTimecode();

    // Collect match information.
    if(CollectMatchInfo(InWorld, GenvidMatchInfo))
    {
        std::string MatchInfoJSON = FGenvidUtils::ConvertFStructToJSONStdString(GenvidMatchInfo);
        gs = Genvid_SubmitGameData(tc, sStreamID_MatchInfo.c_str(), MatchInfoJSON.c_str(), (int)MatchInfoJSON.size());
        if (GENVID_FAILED(gs))
        {
            UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_MatchInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
        }
    }

    // Add all players (could not find any UT4 event for these, and they are all available here).
    GenvidPlayersInfo.Reset();
    for (int i = 0; i < PlayerArray.Num(); ++i)
    {
        APlayerState* Player = PlayerArray[i];
        if (!Player->bIsSpectator)
        {
            GenvidPlayersInfo.Players.Emplace(Player->PlayerName);
        }
    }

    // Publish match start.
    FGenvidData_GameState GameState("MatchStarted");
    std::string GameStateJSON = FGenvidUtils::ConvertFStructToJSONStdString(GameState);
    gs = Genvid_SubmitGameData(-1, sStreamID_GameState.c_str(), GameStateJSON.c_str(), (int)GameStateJSON.size());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }

    // Collect and send new team info.
    GenvidTeamsInfo.Reset();
    for (int i = 0; i < Teams.Num(); ++i)
    {
        AUTTeamInfo* Team = Teams[i];
        GenvidTeamsInfo.Teams.Emplace(Team->TeamIndex, Team->TeamName, Team->TeamColor);
    }
    std::string TeamInfoJSON = FGenvidUtils::ConvertFStructToJSONStdString(GenvidTeamsInfo);
    gs = Genvid_SubmitGameData(tc, sStreamID_TeamInfo.c_str(), TeamInfoJSON.data(), (int)TeamInfoJSON.size());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_TeamInfo.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }
}

In the AUTGameState::HandleMatchHasEnded() function, add a call to Genvid.HandleMatchHasEnded().

#if GENVID
    Genvid.HandleMatchHasEnded();
#endif

Inversely, when HandleMatchHasEnded() is called, we stop sending game data on every frame, and report the end of the match to the website.

void GenvidConnector::HandleMatchHasEnded()
{
    // Stop sending game data.
    bSendGameData = false;

    // Publish match end.
    FGenvidData_GameState GameState("MatchEnded");
    std::string GameStateJSON = FGenvidUtils::ConvertFStructToJSONStdString(GameState);
    GenvidStatus gs = Genvid_SubmitGameData(-1, sStreamID_GameState.c_str(), GameStateJSON.c_str(), (int)GameStateJSON.size());
    if (GENVID_FAILED(gs))
    {
        UE_LOG(LogGenvid, Error, TEXT("Genvid_SubmitGameData(%s) failed: %s (%d)."), UTF8_TO_TCHAR(sStreamID_GameState.c_str()), UTF8_TO_TCHAR(Genvid_StatusToString(gs)), (int)gs);
    }
}

// Collects various match information when it starts.
bool GenvidConnector::CollectMatchInfo(UWorld* InWorld, FGenvidData_MatchInfo& OutMatchInfo)
{
    OutMatchInfo.MapName = UUTGameplayStatics::GetLevelName(InWorld);

    for (FActorIterator It(InWorld); It; ++It)
    {
        AUTSpectatorCamera* Cam = Cast<AUTSpectatorCamera>(*It);
        if (Cam)
        {
            OutMatchInfo.SpectatorCameras.Add(Cam->CamLocationName);
        }
    }

    return true;
}

The remaining routines CollectGameData(), GetCurrentCameraController, and GetPlayerState() are simple utility routines used to gather specific information in the UWorld instance.