This document is about: FUSION 2
SWITCH TO

Impostor

Level 4

Overview

This sample uses the HostMode topology.

The Fusion Impostor demonstrates an approach on how to develop the coreloop of a social deduction game for up to 8 players, as well as how to integrate and handle communication with the Photon Voice SDK with a Fusion project. Fore more information on Photon Voice please see the Voice page in the manual.
Fusion Impostor was originally created using Fusion 1.0; however, it has been ported to Fusion 2.0 but preserves a majority of the functionality of the Fusion 1.0 version.

Some of the highlights of this sample are:

  • Voice communication in the pre-grame lobby and in-game
  • Fully networked game state machine and system encompassing pre-game, play, meetings, and post-game outcomes
  • Shared interaction points such as task stations and crewmate bodies
  • Customizable game settings (number of impostors, movement speed, player collision, etc)
  • Synchronized state of objects in the world like doors
  • A variety of crewmate tasks built upon a modular interaction system
  • Using Photon Voice to provide handle a variety of voice communication types
  • Setting up rooms as a host using codes for clients to join
  • Region settings, nickname, and microphone selection

Technical Info

  • Unity 2021.3.33f1

Before You Start

To run the sample

  • Create a Fusion AppId in the PhotonEngine Dashboard and paste it into the App Id Fusion field in Real Time Settings (reachable from the Fusion menu).
  • Create a Voice AppId in the PhotonEngine Dashboard and paste it into the App Id Voice field in Real Time Settings
  • Then load the Launch scene and press Play.

Download

Version Release Date Download
2.0.1 May 30, 2024 Fusion Impostor 2.0.1 Build 560

Folder Structure

The main Scripts folder /Scripts has a subfolder called Networkingwhich encompasses the main networking implementations in the sample, as well as the Networked State Machine. The other subfolders such as Player and Managers contain the logic for gameplay behaviours and management respectively.

PlayerRegistry

The PlayerRegistry stores a reference to each player in the room, and provides utility methods for selecting and performing actions on one or many players.

Getting Into a Game

Users can either join or host a room using a room code. Entering a room code is optional if the user chooses to host. Once in a room, the code to join will be displayed at the bottom of the screen.

The room code is accessed via: runner.SessionInfo.Name.

The NetworkStartBridge serves as an intermediary to NetworkDebugStart. StartHost() will get a random 4 character string from RoomCode if a specific code is not specified.

Pre-Game

During the pre-game phase, players can choose to set their color from the table in the center of the lobby area, select their preferred microphone device from the settings. The host can customize the game settings and is responsible for starting the game.

Handling Input

Networked inputs are polled in the PlayerInputBehaviour.cs script. It is also here that input blocking is done. Additionally, server-side checks are done in PlayerMovement.cs before carrying out inputs.

Keyboard

  • WASD to walk
  • E to interact
  • Keypad Enter to start game (as host, during pre-game only)

Mouse

  • Left click to walk
  • Click the buttons in the UI to interact

The Player

The Players's behaviour is defined by three different components:

  • PlayerObject: holds a reference to the PlayerRef this object is associated with, and contains the player's index in the room, their nickname, and selected color.
  • PlayerMovement: responsible for player locomotion and input. It also holds gameplay-essential data and methods - notably the IsDead, IsSuspect, and EmergencyMeetingUses properties.
  • PlayerData: the visual component of the player. It primarily handles the materials, sets animator properties and instantiates the nickname UI.

Interactables

  • Color Kiosk : Located on the center table in the Pre-Game room. Players can select from any of 12 preset colors which have not been taken by another player.
  • Settings Kiosk : Located at the top of the Pre-Game room, the host can select the game settings and start the game from here
  • Emergency Button : the emergency button can be pressed a limited amount of times per round to call a meeting
  • Tasks : 14 task stations featuring 5 unique task minigames are placed throughout the map for crewmates to complete
  • Bodies : the body of a murdered player can be reported to call a meeting for free by crewmates, or impostors who are trying to cover their tracks

Tasks

Task stations can be found throughout the map. Crewmates can interact with these when they are within range.

  • Thermostat (TemperatureTask.cs) : Make the two numbers equal by pushing the up and down arrows
  • Sliders (SlidersTask.cs) : Drag each slider to align with the red outline. They will become locked when positioned correctly.
  • Pattern Match (PatternMatchTask.cs) : Push buttons on the right panel matching the sequence of lights that flash on the left panel.
  • Number Sequence (NumberSequenceTask.cs) : Push each number in ascending numerical order (1-8)
  • Download Files (DownloadTask.cs) : Push the Download button and wait for the bar to fill in order to complete it.

Voice

For Voice 2 integration in Fusion Impostor the two scripts provided by Photon Voice 2 are used:

  • FusionVoiceNetwork is added to PrototypeRunner prefab.
  • VoiceNetworkObject is used on the Player prefab, with a Speaker as a child of the given prefab too.

Migration Notes

As previously mentioned, Fusion Impostor was ported to Fusion 2.0 from Fusion 1.0. You can read more about migrating from Fusion 1.0 to Fusion 2.0 here. The following are some of the changes made during that porting process.

FSM

The Fusion 1.0 version of this sample used a custom Finite State Machine to manage game state. This sample uses Fusion 2.0's FSM Addon with the goal of more cleanly organizing the different game states and their accompanying scripts. In the Main GameObject's hierarchy, there is now the following:

The GameState hierarchy in the project
The GameState hierarchy in the project

Each state inherits from StateBehaviour, a NetworkBehaviour used by the StateMachine system. These have OnEnterState and OnExitState and other functions that are handled on the networking side; as well as other methods such as OnEnterStateRender and OnExitStateRender that are used more for rendering changes in the game that don't affect networked gameplay. The following is an example using the VotingResultsStateBehaviour of this sample:

C#

/// <summary>
/// State for handles the game once voting has finished
/// </summary>
public class VotingResultsStateBehaviour : StateBehaviour
{
    /// <summary>
    /// Which state will we go to next
    /// </summary>
    private StateBehaviour nextState;

    /// <summary>
    /// How long we will wait before going to the next state in seconds.
    /// </summary>
    private float nextStateDelay;

    protected override void OnEnterState()
    {
        // If a player has been ejected...
        if (GameManager.Instance.VoteResult is PlayerObject pObj)
        {
            pObj.Controller.IsDead = true;
            pObj.Controller.Server_UpdateDeadState();

            int numCrew = PlayerRegistry.CountWhere(p => !p.Controller.IsDead && p.Controller.IsSuspect == false);
            int numSus = PlayerRegistry.CountWhere(p => !p.Controller.IsDead && p.Controller.IsSuspect == true);

            if (numCrew <= numSus)
            {   // impostors win if they can't be outvoted in a meeting
                WinStateBehaviour winState = Machine.GetState<WinStateBehaviour>();
                winState.crewWin = false;
                nextState = winState;
            }
            else if (numSus == 0)
            {   // crew wins if all impostors have been ejected
                WinStateBehaviour winState = Machine.GetState<WinStateBehaviour>();
                winState.crewWin = true;
                nextState = winState;
            }
            else
            {   // return to play if the game isn't over
                nextState = Machine.GetState<PlayStateBehaviour>();
            }

            nextStateDelay = 3f;
        }
        else
        {   // return to play if there was nobody ejected
            nextState = Machine.GetState<PlayStateBehaviour>();
            nextStateDelay = 2f;
        }
    }
    protected override void OnEnterStateRender()
    {
        GameManager.im.gameUI.EjectOverlay(GameManager.Instance.VoteResult);
    }

    protected override void OnFixedUpdate()
    {
        if (Machine.StateTime > nextStateDelay)
        {
            Machine.ForceActivateState(nextState);
        }
    }
}

With this, when the state is entered, OnEnterState is called by only the player with state authority, in this case, the host, and executes methods that affect gameplay; however, every client will execute OnEnterStateRender so the game's UI will show the results of the vote properly.

KCC & Lag Compensation

The original version of this sample used Fusion 1.0's KCC. This version is similar to Fusion 2.0's Advanced KCC; however, the Simple KCC for Fusion 2.0 is more than sufficient for this game. The biggest change was that by using the Simple KCC, the OnCollisionEnter and OnCollisionExit present in the Advanced KCC are no longer present. To fix this, the collision checks for interactables and other players was moved to the FixedUpdateNetwork method of PlayerMovement using Lag Compensation. Because players can fall out of the impostor's kill range when moving, especially during games with poor connections, lag compensation was integrated into this sample to this collision detection more accurate. You can read more about lag compensation here. The following code shows how both the updated KCC and lag compensation work within PlayerMovement's Fixed Update Network method:

C#

public override void FixedUpdateNetwork()
{
    bool hasInput = GetInput(out PlayerInput input);

    if (hasInput && input.IsDown(PlayerInputBehaviour.BUTTON_START_GAME))
    {
        GameManager.Instance.Server_StartGame();
    }

    Vector3 direction = default;
    bool canMoveOrUseInteractables = activeInteractable == null && GameManager.Instance.MeetingScreenActive == false && GameManager.Instance.VotingScreenActive == false && hasInput;

    if (canMoveOrUseInteractables)
    {
        // BUTTON_WALK is representing left mouse button
        if (input.IsDown(PlayerInputBehaviour.BUTTON_WALK))
        {
            direction = new Vector3(
                Mathf.Cos((float)input.Yaw * Mathf.Deg2Rad),
                0,
                Mathf.Sin((float)input.Yaw * Mathf.Deg2Rad)
            );
        }
        else
        {
            if (input.IsDown(PlayerInputBehaviour.BUTTON_FORWARD))
            {
                direction += TransformLocal ? transform.forward : Vector3.forward;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_BACKWARD))
            {
                direction -= TransformLocal ? transform.forward : Vector3.forward;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_LEFT))
            {
                direction -= TransformLocal ? transform.right : Vector3.right;
            }

            if (input.IsDown(PlayerInputBehaviour.BUTTON_RIGHT))
            {
                direction += TransformLocal ? transform.right : Vector3.right;
            }

            direction = direction.normalized;
        }
    }

    simpleCC.Move(direction * Speed);

    if (direction != Vector3.zero)
    {
        Quaternion targetQ = Quaternion.AngleAxis(Mathf.Atan2(direction.z, direction.x) * Mathf.Rad2Deg - 90, Vector3.down);
        cc.SetLookRotation(Quaternion.RotateTowards(transform.rotation, targetQ, lookTurnRate * 360 * Runner.DeltaTime));
    }

    // Performs an overlap sphere test to see if the player is close enough to interactables
    int lagHit = Runner.LagCompensation.OverlapSphere(transform.position, cc.Settings.Radius, Object.InputAuthority, lagCompensatedHits, _interactableLayerMask,
        options: HitOptions.IncludePhysX);

    // Can the player report, kill, or use the interactable.
    bool canReport = false, canKill = false, canUse = false;

    // The lists of nearby players and interactables are cleared with every check.
    nearbyInteractables.Clear();
    nearbyPlayers.Clear();

    // Iterates through the results
    for (int i = 0; i < lagHit; i++)
    {
        if (lagCompensatedHits[i].Hitbox is Hitbox hb)
        {
            // We don't bother tryingt to find nearby players if we are the suspect.
            if (IsSuspect && !hb.transform.IsChildOf(transform) && hb.gameObject.layer == _playerRadiusLayerMask && hb.GetComponentInParent<PlayerObject>() is PlayerObject player)
            {
                nearbyPlayers.Add(player);
                canKill = true;
            }
            continue;
        }

        GameObject hitGameObject = lagCompensatedHits[i].Collider.gameObject;

        if (hitGameObject.TryGetComponent<Interactable>(out var hitInteractable))
        {
            if (!nearbyInteractables.Contains(hitInteractable))
                nearbyInteractables.Add(hitInteractable);

            if (hitInteractable is DeadPlayer)
                canReport = true;
            else
                canUse = hitInteractable.CanInteract(this);
        }
    }

    if (HasInputAuthority)
    {
        GameManager.im.gameUI.reportButton.interactable = canReport;
        GameManager.im.gameUI.killButton.interactable = canKill;
        GameManager.im.gameUI.useButton.interactable = canUse;
    }

    if (!canMoveOrUseInteractables)
        return;

    actionPerformed = false;

    // When pressing the interact button, there's no clear way to know what action is being done, so this order is used.
    if (input.IsDown(PlayerInputBehaviour.BUTTON_REPORT) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryToReportDeadPlayer();

    if (input.IsDown(PlayerInputBehaviour.BUTTON_USE) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryToUseStation();

    if (input.IsDown(PlayerInputBehaviour.BUTTON_KILL) || input.IsDown(PlayerInputBehaviour.BUTTON_INTERACT))
        TryKill();
}

Additionally, the following changes were made to work with lag compensation:

  • Hitbox Manager component was added to the main NetworkRunner prefab.
  • Hitbox Root component was to the root of the Player prefab.
  • Hitbox component was added to the Kill Radius GameObject in the Player prefab and its Collider component was removed.
The Hitbox component on the Player character
The Hitbox component on the Kill Radius GameObject of the Player prefab.

Scripts & Prototyping

The following folder, Assets/Fusion/Scripts, which is present in Fusion 1.0 and contains various tools for prototyping such as PlayerSpawnerPrototype, no longer exists in Fusion 2.0. While the numerous scripts in this directory can be upgraded, many of them were redundant, overly complicated, and/or unnecessary, so these items were removed from this sample. The only script preserved was InputBehaviourPrototype, which has been moved to Assets/Scripts/Networking. Only one other new class was created, PlayerSpawner, a SimulationBehaviour attached to the main NetworkRunner prefab that handles spawning a NetworkObject when a player joins:

C#

public class PlayerSpawner : SimulationBehaviour, IPlayerJoined
{
    public NetworkObject playerObject;

    public void PlayerJoined(PlayerRef player)
    {
        if (Runner.IsServer)
        {
            NetworkObject spawnedPlayer = Runner.Spawn(playerObject, position: GameManager.Instance.preGameMapData.GetSpawnPosition(player.AsIndex), inputAuthority: player);
        }
    }
    
    public void PlayerLeft(PlayerRef player)
    {
        if (Runner.IsServer)
        {
            PlayerObject leftPlayer = PlayerRegistry.GetPlayer(player);
            if (leftPlayer != null)
            {
                Runner.Despawn(leftPlayer.Object);
            }
        }
    }
}

When a player joins, this script will make sure the server spawns a new instance of playerObject, making sure it has the proper position and inputAuthority; when a player leaves, the PlayerRegistry returns which PlayerObject was associated with the PlayerRef of the player who left and despawns that player.

Back to top