Razor Madness
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
.
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.
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:
- Generic Dungeon Pack by Bakudas
- Rocky Roads by Essssam
- Platform/Metroidvania Pixel Art Asset Pack by o_lobster
IMPORTANT: To use them in a commercial project, it is required to purchase a license from the respective creators.
Back to top