Fusion Razor Madness

Level Advanced

Overview

The Fusion Razor Madness sample is a tick based platformer racing game for 8+ players. The smooth and precise player movement combined with the ability to wall-jump towards the level brings a good sense of control and satisfaction when you jump and avoid hazards of all kind.

Back To Top

Download

Version Release Date Download
1.1.3 Oct 21, 2022 Fusion Razor Madness 1.1.3 Build 9

Back To Top

Networked Platformer 2D Controller

Precise Player Prediction

When dealing with a platformer movement, it is important to make the player see and feel the immediate consequences of their decisions. With that in mind, the player movement uses predicted client physics that perfectly match the snapshot position.

To enable Client-side prediction, go to the Network Project Config and set Server physics Mode to Client Prediction.

Setting Client Predicted Physics in the Network Project Config
Setting Client Predicted Physics in the Network Project Config.

Then, on the PlayerScript, set the NetworkRigidbody2D interpolation data source to Predicted. Only the input authority is predicted locally, whereas the proxies still update through snapshot interpolation.

public override void Spawned(){
    if(Object.HasInputAuthority)
    {
        // Set Interpolation data source to predicted if is input authority
        _rb.InterpolationDataSource = InterpolationDataSources.Predicted;
    }
}

Back To Top

Better Jump Logic

With the input and current jump state it is possible better use forces to create a heavy but controllable feel for the player.

It is important to call this function in FixedUpdateNetwork() to allow re-simulations to be done. In addition,Runner.DetaTime (specific to Fusion) has to be used instead of the regular Time.deltaTime from Unity to synchronize all clients the same way for a given tick.

private void BetterJumpLogic(InputData input)
{
    if (_isGrounded) { return; }
    if (_rb.Rigidbody.velocity.y < 0)
    {
        if (_wallSliding && input.AxisPressed())
        {
            _rb.Rigidbody.velocity += Vector2.up * Physics2D.gravity.y * (wallSlidingMultiplier - 1) * Runner.DeltaTime;
        }
        else
        {
            _rb.Rigidbody.velocity += Vector2.up * Physics2D.gravity.y * (fallMultiplier - 1) * Runner.DeltaTime;
        }
    }
    else if (_rb.Rigidbody.velocity.y > 0 && !input.GetState(InputState.JUMPHOLD))
    {
        _rb.Rigidbody.velocity += Vector2.up * Physics2D.gravity.y * (lowJumpMultiplier - 1) * Runner.DeltaTime;
    }
}

This way, the player can jump higher if they so desire and fall slowly when sliding on walls.

Back To Top

Sync Death State

Using the OnChanged callback proxies graphics are disabled by a server confirmed death rather than a client-side predicted / simulated death that is unconfirmed by the server; this also allows to re-enable the proxies' graphics on the server says-so.

[Networked(OnChanged = nameof(OnSpawningChange))]
private NetworkBool Respawning { get; set; }
public static void OnSpawningChange(Changed<PlayerBehaviour> changed)
{
    if (changed.Behaviour.Respawning)
    {
        changed.Behaviour.SetGFXActive(false);
    }
    else
    {
        changed.Behaviour.SetGFXActive(true);
    }
}

Back To Top

Network Object To Hold Player Data

It is possible to make a class to hold any [Networked] data related to a player by deriving it from NetworkBehaviour and keep it on a NetworkObject.

public class PlayerData: NetworkBehaviour
{
    [Networked]
    public string Nick { get; set; }
    [Networked]
    public NetworkObject Instance { get; set; }

    [Rpc(sources: RpcSources.InputAuthority, targets: RpcTargets.StateAuthority)]
    public void RPC_SetNick(string nick)
    {
        Nick = nick;
    }

    public override void Spawned()
    {
        if (Object.HasInputAuthority)
            RPC_SetNick(PlayerPrefs.GetString("Nick"));

        DontDestroyOnLoad(this);
        Runner.SetPlayerObject(Object.InputAuthority, Object);
        OnPlayerDataSpawnedEvent?.Raise(Object.InputAuthority, Runner);
    }
}

In this case only the player Nick is required and a reference to the current NetworkObject over which the player has input authority. OnPlayerDataSpawnedEvent is a custom event to handle Lobby synchronization in this sample. When the player joined the Nick can be is set from a text input field or another source, then the NetworkObject prefab is spawned (it has an instance of the PlayerData script and). This NetworkObject then sets itself as the main object for this PlayerRef via the Runner.SetPlayerObject function on Spawned().

public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    if (runner.IsServer)
    {
        runner.Spawn(PlayerDataNO, inputAuthority: player);
    }

    if (runner.LocalPlayer == player)
    {
        LocalRunner = runner;
    }

    OnPlayerJoinedEvent?.Raise(player, runner);
}

When data from a specific player is need, it can be retrieved by calling the NetworkRunner.TryGetPlayerObject() method and look for the PlayerData component on the NetworkObject in question.

public PlayerData GetPlayerData(PlayerRef player, NetworkRunner runner)
{
    NetworkObject NO;
    if (runner.TryGetPlayerObject(player, out NO))
    {
        PlayerData data = NO.GetComponent<PlayerData>();
        return data;
    }
    else
    {
        Debug.LogError("Player not found");
        return null;
    }
}

This data can be used and / or manipulated as required.

   //e.g
    PlayerData data = GetPlayerData(player, Runner);
    Runner.despawn(data.Instance);
    string playerNick = data.Nick;

Back To Top

Spectator Mode

When a player finish the race before the needed number of winners has been reached, they enter the spectator mode. Whilst spectating they are unable to control their character and their camera is allowed to follow a player of their choice. The spectating player can navigate between the remaining players' view using the arrow keys.

/// <summary>
/// Set player state as spectator.
/// </summary>
public void SetSpectating()
{
    _spectatingList = new List<PlayerBehaviour>(FindObjectsOfType<PlayerBehaviour>());
    _spectating = true;
    CameraTarget = GetRandomSpectatingTarget();
}

private void Update()
{
    if (_spectating)
    {
        if (Input.GetKeyDown(KeyCode.RightArrow))
        {
            CameraTarget = GetNextOrPrevSpectatingTarget(1);
        }
        else if (Input.GetKeyDown(KeyCode.LeftArrow))
        {
            CameraTarget = GetNextOrPrevSpectatingTarget(-1);
        }
    }
}

private void LateUpdate()
{
    if (CameraTarget == null)
    {
        return;
    }

    _step = Speed * Vector2.Distance(CameraTarget.position, transform.position) * Time.deltaTime;

    Vector2 pos = Vector2.MoveTowards(transform.position, CameraTarget.position + _offset, _step);
    transform.position = pos;
}

Back To Top

Obstacles

Fixed Saw

The simplest saw is only a unity GameObject for which the collision is detected in a tick safe way, like FixedNetworkUpdate().

Keep in mind OnCollisionEnter and OnCollisionExit are NOT reliable on re-simulations.

Back To Top

Rotating Saw

A rotating saw that uses NetworkTransform component to be in sync between all clients. It calculates a position on a circle on FixedUpdateNetwork with a [Networked] property to make it safe for re-simulations and applies it.

[Networked] private int Index { get; set; }

public override void FixedUpdateNetwork()
{
    transform.position = PointOnCircle(_radius, Index, _origin);
    _line.SetPosition(1, transform.position);
    Index = Index >= 360 ? 0 : Index + (1 * _speed);
}

public static Vector2 PointOnCircle(float radius, float angleInDegrees, Vector2 origin)
{
    // Convert from degrees to radians via multiplication by PI/180        
    float x = (float)(radius * Mathf.Cos(angleInDegrees * Mathf.PI / 180f)) + origin.x;
    float y = (float)(radius * Mathf.Sin(angleInDegrees * Mathf.PI / 180f)) + origin.y;

    return new Vector2(x, y);
}

Make sure every property which can changed and is used to calculate the position is [Networked]. Since the _speed is only defined in the Editor once for each RotatingSaw script and never changes, it can be a normal Unity property.

Back To Top

Moving Saw

A moving saw uses the same principle as rotating saws; however, instead of a position on a circle, it uses a list of positions defined in the Editor and interpolates its position between those.

[Networked] private float _delta { get; set; }
[Networked] private int _posIndex { get; set; }
[Networked] private Vector2 _currentPos { get; set; }
[Networked] private Vector2 _desiredPos { get; set; }

public override void FixedUpdateNetwork()
{
    transform.position = Vector2.Lerp(_currentPos, _desiredPos, _delta);
    _delta += Runner.DeltaTime * _speed;

    if (_delta >= 1)
    {
        _delta = 0;
        _currentPos = _positions[_posIndex];
        _posIndex = _posIndex < _positions.Count - 1 ? _posIndex + 1 : 0;
        _desiredPos = _positions[_posIndex];
    }
}

As before, remember to mark all properties which can be changed at runtime and impact the position calculation as [Networked].

Back To Top

Project

Folder Structure

The project is subdivided in categories folder.

  • Arts: Contains all the arts asset used in the project, as well as the tilemap assets and animations files.
  • Audio: Contains the sfx and music files.
  • Photon: The Fusion package.
  • Physics Materials: The player physic material.
  • Prefabs: All the prefabs used in the project, the most important being the Player prefab.
  • Scenes: The lobby and levels scenes.
  • Scriptable Objects: Contains the scriptables used like the audio channel and audio assets.
  • Scripts: The core of the Demo, Scripts folder is subdivided in logic categories as well.
  • URP: The Universal Render Pipeline Assets used on the project.

Back To Top

Lobby

The lobby uses a modified version of the Network Debug Start GUI. After entering their desired nickname, players can chose between play a single player game, host a game or join an existing room as a client.

Lobby Menu
Lobby Menu.

Inside the Room View
Inside the Room View.

At this point, the host will create a NetworkObject for each player in the room with their data. As shown in Network Object to hold Player Data. After joining a room, a list of players will shown. Only the host can start the game by pressing the Start Game button.

Back To Top

Game Start

When the host starts the game, the next level will be picked by the LoadingManager script. It uses runner.SetActiveScene(scenePath) to load the desired level.

N.B.: Only the host can set an active scene on the NetworkRunner.

Using the LevelBehaviour.Spawned() method, the PlayerSpawner is requested to spawn all the players who had been registered in the lobby and give them their current Input Authority.

To be fair, after five seconds the players are released to start the race regardless of whether they have finished loading the level. This is to avoid infinite loading times due to individual client discrepencies in the loading process should something go awry.

Those seconds are counted by a TickTimer.

[Networked]
private TickTimer StartTimer { get; set; }

private void SetLevelStartValues()
{
    //...
    StartTimer = TickTimer.CreateFromSeconds(Runner, 5);
}

Back To Top

Handling Input

Fusion captures player input using Unity's standard input handling mechanism, stores it in a data structure which can be sent across the Network, and then works off this data structure in the FixedUpdateNetwork() method. In this example, all of this is implemented by the InputController class using the InputData Structure, though it hands off the actual state changes to the PlayerMovement and PlayerBehaviour classes.

Back To Top

Finish Race

The LevelBehaviour maintains an array of winners to get the top 3 players' IDs.

[Networked, Capacity(3)] private NetworkArray<int> _winners => default;
public NetworkArray<int> Winners { get => _winners; }

When a player crosses the finish line, it informs the LevelBehaviour. The LevelBehaviour then checks if the correct number of winners has been reached; if so, the level is over and the results are displayed.


To Document Top