Hunter Gatherer
Project Type Group project | 4 students
Project Timeline 8 weeks | 2020
Software Used Unity
Languages Used C#

Hunter Gatherer is a multiplayer real-time strategy game located on an island. You manage your tribe by gathering resources, crafting items, and setting up your camp to rest. Meanwhile all players are pushed towards each other by the flood. You prepare for combat against other tribes to come out victorious.
Setting up the base structure as modular as possible was the main focus since future student teams will continue development on this game.

For networking we used photon. It uses a peer to peer solution where one of the players is the host.
Benjamin van der Wolf (one of the team members) wrote a wrapper for photon's SDK.

During the project I worked on the win/los condition using this photon wrapper. The system is made to make a fair judgement on who has won the game when two players die at approximately the same time.

When a player dies it sends a 'loss request' with a time stamp to the host. The host will wait for 200 ms to collect all requests. If other players send a 'loss request' to the host within this wait time the host will determine who died first. If the last two players die at the same time, the game will result in a draw.

The flow chart below shows how the host handles a loss request.

Win/Loss condition flow diagram


These code blocks show the two methods used to decide whether a player wins, loses, or draws.

//Decides on what decision to make based on the time difference from the requests
private void DecideOnEndGameState(NetworkingRequest finishedRequest, NetworkingRequestType type, int targetViewId)
{
    //Create array of requests ordered by their timestamp
    KeyValuePair<int, string>[] requests = finishedRequest.timeStamps.OrderBy(timeStamp => DateTime.Parse(timeStamp.Value)).ToArray();
    Dictionary<int, int> content = new Dictionary<int, int>();

    //Get the amount of players still alive
    IEnumerable<PhotonPlayer> players = PhotonNetwork.PlayerList.Where(player => (int)player.CustomProperties[PlayerPropertyHandler.PlayerStateKey] == (int)PlayerState.Playing);
    int amountOfPlayersAlive = players.Count();

    if (requests.Length < amountOfPlayersAlive)
    {
        //If the amount of request is smaller than the amount of players alive all players requesting will lose
        for (int i = 0; i < requests.Length; i++)
        {
            content.Add(requests[i].Key, (int)NetworkingLoseDesicionType.Lost);
        }

        if (requests.Length == amountOfPlayersAlive - 1)
        {
            //If there is only one player left that didn't make a loss request that player will win
            Dictionary<int, string> dict = requests.ToDictionary(x => x.Key, x => x.Value);

            foreach (var player in players)
            {
                if (!dict.ContainsKey(player.ActorNumber))
                {
                    //Raise event to notify the winner cliet it won
                    RaiseEvent(NetworkingEventType.PlayerWinGame, null, new int[] { player.ActorNumber }, true);
                    break;
                }
            }
        }
    }
    else { content = GetCloseEndGameStateResults(requests); }

    PhotonNetwork.RaiseEvent((byte)NetworkingRequestDecision.LoseDecision, new object[] { content, targetViewId }, GetEventOptions(finishedRequest), SendOptions.SendReliable);
}
//Gets the EndGame state result when it needs to decide if between winning, losing or a draw
private Dictionary<int, int> GetCloseEndGameStateResults(KeyValuePair<int, string>[] requests)
{
    Dictionary<int, int> content = new Dictionary<int, int>();

    NetworkingLoseDesicionType endState = NetworkingLoseDesicionType.Lost;

    for (int i = 0; i < requests.Length; i++)
    {
        //Once it is draw all other request will be draw so no logic needs to be applied
        if (endState != NetworkingLoseDesicionType.Draw)
        {
            if (i == requests.Length - 1)
            {
                //Win if request is the latest and no other requests are draw
                endState = NetworkingLoseDesicionType.Win;
            }
            else if ((DateTime.Parse(requests[i].Value) - DateTime.Parse(requests[requests.Length - 1].Value)).TotalMilliseconds < GameStateManager.Instance.DrawMargin)
            {
                //Draw if the time between the the last request and this request is inside of the drawMargin
                endState = NetworkingLoseDesicionType.Draw;
            }
            else { endState = NetworkingLoseDesicionType.Lost; }
        }
        //Add the state with player to the content dictionary
        content.Add(requests[i].Key, (int)endState);
    }

    return content;
}

To make sure we don't run into problems with the User Interface when switching scenes, like having to reset references or having to rework a lot of elements for small changes, I used a scalable solution. Instead of having a prefab of the UI, there is a scene that loads additively with the game scene and displays the UI. All UI elements only need references to objects within the UI scene. Using the MVP pattern the UI script only needs to subscribe to events from a presenter to update correctly.

The flow chart below shows how the scene loading solution works inside Hunter Gatherer.

Scene loading flow diagram


The following code snippet shows the LoadGameScene method which loads the new scene and if needed the UI. After the new scene is loaded it unloads the previous scene.

//Loads a game scene
public void LoadGameScene(string sceneName)
{
    //Don't continue if scene doesn't exits
    if (!SceneIsLoadable(sceneName))
    {
        return;
    }

    //Don't load the scene if it's already loaded
    if (!UnitySceneManager.GetSceneByName(sceneName).isLoaded && !isLoading)
    {
        isLoading = true;

        //Load new game scene
        UnitySceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive).completed += loadOperation =>
        {
            //Unload current game scene
            UnitySceneManager.UnloadSceneAsync(UnitySceneManager.GetActiveScene()).completed += unloadOperation =>
            {
                //Set new game scene as active scene
                UnitySceneManager.SetActiveScene(UnitySceneManager.GetSceneByName(sceneName));
            };

            isLoading = false;

            StartCoroutine(CheckPlayersLoaded());

            //Load the GameUI scene
            LoadGameUIScene(gameUISceneName);
        };
    }
    else
    {
        LoadLoadedOrLoadingSceneError(sceneName);
    }
}

The flooding system is made to be a designer friendly tool. It allows the designer to easily change the amount of flood sections depending on the map. The height of flood sections can easily be adjusted with a flood indicator that shows the flood on top of the section.

The following code shows a method used to add new flood sections.

//Adds flood sections
private void AddSections(int amount)
{
    if (amount <= 0) { return; }

    for (int i = 0; i < amount; i++)
    {
        //Spawn section and set parent
        Transform sectionEdge = Instantiate(floodSectionPrefab, base.transform);
        sectionEdge.transform.parent = gameObject.transform;

        //Set position of section
        sectionEdge.position = new Vector3(
            sectionEdge.position.x,
            SectionEdges.Count == 0 ? 0 : SectionEdges[SectionEdges.Count - 1].transform.position.y + 10,
            sectionEdge.position.z);

        SectionEdges.Add(sectionEdge.GetComponent<SectionSettings>());
    }
}

The following code shows a method used to remove flood sections.

    //Removes and deletes flood sections
    private void RemoveSections(int amount)
    {
        if (amount <= 0) { return; }

#if UNITY_EDITOR
        for (int i = 0; i < amount; i++)
        {
            //Delay the deletion of object because destroyImmediate can't be called in OnValidate and Destroy can't be called in the editor
            UnityEditor.EditorApplication.delayCall += () =>
            {
                DestroyImmediate(SectionEdges[SectionEdges.Count - 1].gameObject);
                SectionEdges.RemoveAt(SectionEdges.Count - 1);
            };
        }
#endif
    }