...

Unity Networked FPS

Multiplayer FPS Prototype; two clients are able to connect and play via Steam. features include P2P Connection, host to client simulation, and a lobby finder.

...
FEATURES:
  • Establish a connection between two users simultaneously. (AKA Lobby)
  •    Each user can create a lobby and another can join that said lobby.

  • Steam connectivty to allow players to join each other with ease.
  •    Players can get invites, or join off of their friends to easily connect, and enjoy. (Side note: this also allows for Steams Nat Punchthrough which allows connection without portforwarding.

  • Server Authoritative for competitive gameplay.
  •    Keep all players in sync of movement, and allow the server to dictate what goes and what doesn't.


    Lobby and client connections were accomplished using Netcode for GameObjects with Facepunch Steamworks. The issues I had were to get the data from the Steam API to interface properly with the Unity objects.

    The code snippet below displays how I displayed Lobbies that were created.

    I created a filter of isTesting as I was using the test AppID Steam provides. This way I could filter my lobbies out from others. I created a for loop to iterate through each lobby and display each that has that filter attached, thus using Unity's UI ToolKit to display in a list-view each item that it has grabbed, giving the list the data it needs to present.

    Asterisks surrounding words such as Button or Label interfered with the HTML and code presentation


    if (SteamManager.Instance.lobbiesList != null)
    {
    
        //Create a list of data. In this case, numbers from 1 to 1000.
        foreach (Lobby lobby in SteamManager.Instance.lobbiesList.ToList())
        {
    
            SteamManager.Instance.activeLobbies.Add(lobby);
            Debug.Log(SteamManager.Instance.activeLobbies.Count);
    
        }
    
        int i = 0;
        
        int itemCount = SteamManager.Instance.activeLobbies.Count;
        var items = new List>(itemCount);
        for (i = 0; i < itemCount; i++)
        {
            items.Add(Tuple.Create(SteamManager.Instance.activeLobbies[i].GetData("lobbyName"), SteamManager.Instance.activeLobbies[i].Id, SteamManager.Instance.activeLobbies[i].MemberCount, SteamManager.Instance.activeLobbies[i].MaxMembers));
            Debug.Log(items[i].Item1);
            //Debug.Log(activeLobbies[i]);
        }
    
        // The "makeItem" function is called when the
        // ListView needs more items to render.
        Func makeItem = () =>
        {
            var root = new VisualElement();
            var label = new Label();
            root.Add(label);
            return root;
        };
    
        // As the user scrolls through the list, the ListView object
        // recycles elements created by the "makeItem" function,
        // and invoke the "bindItem" callback to associate
        // the element with the matching data item (specified as an index in the list).
        Action bindItem = (e, i) =>
        {
            var label = e.Q<*Label*>();
            label.text = items[i].Item1 + " | Members: " + items[i].Item3 + "/" + items[i].Item4;
        };
    
        // Provide the list view with an explict height for every row
        // so it can calculate how many items to actually display
        const int itemHeight = 64;
    
        var listView = new ListView(items, itemHeight, makeItem, bindItem);
    
        listView.onItemsChosen += objects => SteamManager.Instance.JoinLobby(items[listView.selectedIndex].Item2);
        //SteamManager.Instance.currLobby = items[listView.selectedIndex].Item2;
        //listView.onSelectionChange += objects => Debug.Log(items[listView.selectedIndex].ToString());
    
        // List Style
        listView.showAlternatingRowBackgrounds = AlternatingRowBackground.All;
        listView.selectionType = SelectionType.Single;
        listView.style.flexGrow = 1.0f;
        listView.style.fontSize = 48f;
        listView.style.justifyContent = Justify.Center;
        listView.showBoundCollectionSize = true;
    
        body.Add(listView);
    }                                   
                                        



    Server Prediction and Client Reconciliation were my two primary problems that I had to solve.

    The code below checks if the player is not equal to the server position then the server will correct the player by setting the client's transform to that of the server's transform.


    void OnServerStateChanged(TransformState previousState, TransformState serverState) {
    if (!IsLocalPlayer) return;
    
    if (_previousTransformState == null) {
        _previousTransformState = serverState;
    }
    
    TransformState calculatedState = _transformStates.First(localState => localState.Tick == serverState.Tick);
    
    if (calculatedState.Position != serverState.Position) {
        Debug.Log("CORRECTING CLIENT POS!");
            
        //Teleport the player to server pos.
        TeleportPlayer(serverState);
    
        //Replay the inputs that happened after. | Change to for loop if needed. (Not optimised as is.
        IEnumerable inputs = _inputStates.Where(input => input.Tick > serverState.Tick);
    
        // Lazy way of sorting, algorithm would be better.
        inputs = from input in inputs orderby input.Tick select input;
    
            foreach (InputState inputState in inputs) {
                MovePlayer(inputState.movementInput);
                RotatePlayer(inputState.lookInput);
    
                TransformState newTranformState = new TransformState()
                {
                    Tick = inputState.Tick,
                    Position = transform.position,
                    Rotation = transform.rotation,
                    HasStartedMoving = true
    
                };
    
                // Could be written better once I understand how to write it better.
                for (int i = 0; i < _transformStates.Length; i++)
                {
                    if (_transformStates[i].Tick == inputState.Tick)
                    {
                        _transformStates[i] = newTranformState;
                        break;
                    }
                }
            }
        }
    }
    
    private void TeleportPlayer(TransformState state)
    {
        _controller.enabled = false;
        transform.position = state.Position;
        transform.rotation = state.Rotation;
        _controller.enabled = true;
    
        for (int i = 0; i < _transformStates.Length; i++)
        {
            if (_transformStates[i].Tick == state.Tick)
            {
                _transformStates[i] = state;
                break;
            }
        }
    }