This document is about: FUSION 2
SWITCH TO

Razor Madness

Level 4

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.

Download

Version Release Date Download
2.0.1 Jun 17, 2024 Fusion Razor Madness 2.0.1 Build 575

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.

C#

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

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.

C#

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.

Sync Death State

With a ChangeDetector 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.

C#

[Networked]
private NetworkBool Respawning { get; set; }

public override void Render()
{
    foreach (var change in _changeDetector.DetectChanges(this))
    {
        switch (change)
        {
            case nameof(Respawning):
                SetGFXActive(!Respawning);
                break;
        }
    }
}

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.

C#

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().

C#

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.

C#

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.

C#

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

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.

C#

/// <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;
}

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.

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.

C#

[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.

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.

C#

[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].

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.

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.

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.

C#

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

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

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.

Finish Race

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

C#

[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.

3rd Party Assets

The Razor Madness Sample includes several assets provided courtesy of their respective creators. The full packages can be acquired for your own projects at their respective site:

IMPORTANT: To use them in a commercial project, it is required to purchase a license from the respective creators.

Back to top