Fusion
Karts (Racing)
This tutorial is based on Karts
game sample available on https://doc.photonengine.com/fusion/current/game-samples/fusion-karts#download
To run Karts project you need Editor Version 2020.3.47f1 or any 2020 LTS version.
Set up the Fusion
Open downloaded Karts project in unity.
Follow instruction in given link to set up Fussion AppId
: https://doc.photonengine.com/fusion/current/tutorials/host-mode-basics/1-getting-started#step_6___create_an_app_id
Set up Tournament-SDK
Start by importing Tournament-SDK to your Unity project.
Setting up the scene
Add necessary objects
-
In
Project
section, openAssets/Scenes
-> double-clickLaunch
. -
In
Hierarchy
add 2 new GameObjects abovePrompts
, rename them toTournamentListUI
andTournamentHubUI
.
Adjust resolution
-
Select
TournamentListUI
. -
In
Rect Transform
component in left top corner change adjustement to stretched. -
Set
Left
,Top
,Right
,Bottom
parameters to 0. -
Do the same for
TournamentHubUI
.
Setting up the objects
-
Add
UIScreen
as component to both new objects. -
Add
TounamentHubScreen
andTounamentListScreen
toTounamentHubUI
andTounamentHubUI
respectively.Tournament(Hub/List)Screen
can be found inAssets/TournamentSDK_Demo/Prefabs/DefaultUIScreens
.Make sure
SubScreen
inside bothTounamentHubScreen
andTounamentListScreen
is enabled! -
Disable
TounamentListUI
andTounamentHubUI
.
Setting up necessary layout
-
In
Hierarchy
go to ->Main Canvas/Main Menu Screen/LayoutGroup/Footer/LayoutGroup
, selectExit Button
and press Ctrl+D to duplicate the object. -
Inspector
will open right after object duplication, change button's name toTournaments
, also findRect Transform
changeWidth
to 470. -
Open newly created object in
Hierarchy
, selectText
and changeText
component toTournaments
.
Setting up the buttons
Moving from Main Menu Screen to Tournament List UI
-
Select
Tournaments
object created previosly inHierarchy
. -
Find
Button
component inInspector
. -
In
On Click()
field, drag ang dropMain Menu Screen
fromHierarchy
into field underRuntime Only
. -
Change
No Function
toUIScreen/FocuScreen
. -
In
Hierarchy
findTournamentListUI
, drag and drop it intoOn Click()
field underUIScreen.FocusScreen
whereNone (UI Screen)
is written.
Moving from Tournament List UI to Main Menu Screen
-
In
Hierarchy
go toTournamentListUI/TournamentListScreen/SubScreen/Canvas/Title
. -
Select
ButtonUnderline(Back)
, in it'sButton
component drag and dropTournamentListUI
into field underRuntime Only
. -
then change
No Function
toUIScreen/Back
.
Moving from Tournament List UI to Tournament Hub UI
-
Go to
TournamentListUI/TournamentListScreen/SubScreen/Canvas/Scroll View/ViewPort/Content/TournamentListItem/ButtonUnderline(Open)/(Play)
. -
Add new action to
On Click()
. -
Drag
TournamentListUI
into field underRuntime Only
. -
Change
No Function
toUIScreen/FocusScreen
. -
Drag
TournamentHubUI
into field underUIScreen.FocusScreen
.Due to we used prefabs of
TournamentListScreen
andTournamentHubScreen
,GUITournamentListScreen
script has unset variableTournamentHubScreen
. -
Go to
TournamentListUI/TournamentListScreen/SubScreen
there will be unsetTournament Hub Screen
. -
From
TournamentHubUI/TournamentHubScreen
dragSubScreen
into unsetTournament Hub Screen
.
Moving from Tournament Hub UI to Tournament List UI
Go to ButtonUnderline(Back)
in TournamentHubUI
same way as for TournamentListUI
.
-
Add another action on button by pressing
+
. -
Drag and drop
TournamentHubUI
into field underRuntime Only
. -
Change
No Function
toUIScreen/Back
. -
Go to
TournamentHubUI/TournamentHubScreen/SubScreen
inGUISubScreen
component will be unsetReturn Screen
. -
From
TournamentListUI/TournamentListScreen
dragSubScreen
into unsetReturn Screen
field.
Implementing Tournament-SDK
Create/Set up BackboneManager
-
Create new
GameObject
on the very bottom ofMain Canvas
, rename it toBackboneManager
. -
Press
Add Component
-> start typingBackbone Manager
, it will appear in search tab, click on it. -
Backbone Manager
component will appear inInspector
. Make sureInitialize On Start
box is UN-ticked. -
Add
Resource Cache
component toBackbone Manager
object.
Implementing client initialization flow into BackboneIntegration
-
In
Inspector
ofBackboneManager
object selectAdd Component
-> typeBackboneIntegration
-> selectNew Script/Create and Add
. -
Add following code into
BackboneIntegration
script.using Gimmebreak.Backbone.User; using System.Collections; using UnityEngine; using UnityEngine.UI; public class BackboneIntegration : MonoBehaviour { private WaitForSeconds waitOneSecond = new WaitForSeconds(1); [SerializeField] private Button tournamentButton = default; private IEnumerator Start() { // Disable tournament button until user is logged in tournamentButton.interactable = false; // wait until player nick was set (this happens on initial screen) while (string.IsNullOrEmpty(ClientInfo.Username)) { yield return this.waitOneSecond; } // keep trying to initialize client while (!BackboneManager.IsInitialized) { yield return BackboneManager.Initialize(); yield return this.waitOneSecond; } // create arbitrary user id (minimum 64 chars) based on nickname // ClientInfo.Username is the nickname you set on the first launch of the game string arbitraryId = "1000000000000000000000000000000000000000000000000000000000000001" + ClientInfo.Username; // log out user if ids do not match if (BackboneManager.IsUserLoggedIn && BackboneManager.Client.User.GetLoginId(LoginProvider.Platform.Anonym) != arbitraryId) { Debug.LogFormat("Backbone user({0}) logged out.", BackboneManager.Client.User.UserId); yield return BackboneManager.Client.Logout(); } // log in user if (!BackboneManager.IsUserLoggedIn) { yield return BackboneManager.Client.Login(LoginProvider.Anonym(true, ClientInfo.Username, arbitraryId)); if (BackboneManager.IsUserLoggedIn) { Debug.LogFormat("Backbone user({0}) logged in.", BackboneManager.Client.User.UserId); } else { Debug.LogFormat("Backbone user failed to log in."); } } if (BackboneManager.IsUserLoggedIn) { // Enable tournament button, because if user is logged in, // then all the information needed is already available tournamentButton.interactable = true; } } }
BackboneIntegration
component now misses theTournamentButton
-
Drag
Tournaments
button fromMainMenuScreen/LayoutGroup/Footer/LayoutGroup
, intoTournamentButton
field inBackboneIntegration
component.
Preparation to implement TournamentMatchHandler
Further changes are done in order to prevent any errors while implementing TournamentMatchHandler
.
Changes are done in game logic itself so that tournament match would have access to necessary data.
GameLauncher
Go to GameLauncher
script add/change variables/methods as follows:
-
Add
SessionName
variable to store name of the session, so we could gain access to it from other Game classes:public string SessionName { get { if (_runner != null) { return _runner.SessionInfo.Name; } return null; } }
-
Add
SetTournamentLobby()
to set gameMode suitable for tournaments.// First player tries to connect to session as a Host, // if session is already created, then user tries to reconnect as a Client public void SetTournamentLobby() => _gameMode = GameMode.AutoHostOrClient;
-
Go to
JoinOrCreateLobby()
method, in_runner.StartGame({...})
change:DisableClientSessionCreation = true
TO
// Allows game to attempt to create a session as a Client
// Condition is false only when tournament starts the session
// False because we don't want to disable that option
DisableClientSessionCreation = _gameMode != GameMode.AutoHostOrClient
-
Change
SetConnectionStatus()
to:private void SetConnectionStatus(ConnectionStatus status) { Debug.Log($"Setting connection status to {status}"); ConnectionStatus = status; if (!Application.isPlaying) return; if (status == ConnectionStatus.Disconnected || status == ConnectionStatus.Failed) { SceneManager.LoadScene(LevelManager.LOBBY_SCENE); UIScreen.BackToInitial(); // We know that GameMode.AutoHostOrClient is set only when tournament is starting the session // If it is tournament session, then by the end of it we want player to be directed to TournamentHubUI if (_gameMode == GameMode.AutoHostOrClient) { LevelManager.LoadTournamentHub(); } } }
-
Change
OnPlayerJoined()
to:public void OnPlayerJoined(NetworkRunner runner, PlayerRef player) { if (runner.IsServer) { // GameMode.AutoHostOrClient allows tournament session Host to spawn a GameManager if (_gameMode == GameMode.Host || _gameMode == GameMode.AutoHostOrClient) runner.Spawn(_gameManagerPrefab, Vector3.zero, Quaternion.identity); var roomPlayer = runner.Spawn(_roomPlayerPrefab, Vector3.zero, Quaternion.identity, player); roomPlayer.GameState = RoomPlayer.EGameState.Lobby; } SetConnectionStatus(ConnectionStatus.Connected); }
LevelManager
Go to LevelManager
script add/change variables/methods as follows:
-
Add variables:
// LevelManager will use these variables to redirect player back to TournamentHubScreen after tournament match [SerializeField] private UIScreen tournamentHubScreenUI; [SerializeField] private GUITournamentHubScreen tournamentHubScreenGUI; [SerializeField] private UIScreen tournamentListScreenUI;
-
Add method
LoadTournamentHub()
:public static void LoadTournamentHub() { // Redirect player to TournamentListUI and then to TournamentHubUI to keep correct chronology UIScreen.Focus(Instance.tournamentListScreenUI); Instance.tournamentHubScreenGUI.Initialize(GameManager.Instance.TournamentId); UIScreen.Focus(Instance.tournamentHubScreenUI); }
GameManager
Go to GameManager
script add/change variables/methods as follows:
-
Add variables:
// Values to personalize tournament match and later proceed it's statistics [Networked(OnChanged = nameof(OnLobbyDetailsChangedCallback))] public long GameSessionId { get; set; } [Networked(OnChanged = nameof(OnLobbyDetailsChangedCallback))] public long TournamentId { get; set; } = 0; [Networked(OnChanged = nameof(OnLobbyDetailsChangedCallback))] public long TournamentMatchId { get; set; }
-
Add method:
// Allows us to chech if game is a tournament game public bool IsTournamentGame() => this.isActiveAndEnabled && this.TournamentId != 0;
ClientInfo
Go to ClientInfo
script add/change variables/methods as follows:
-
Add static variables to get information about
Tournament(User/Team)Id
when match result is being proceed:public static long TournamentUserId { get => long.Parse(PlayerPrefs.GetString("C_TournamentUserId", "0")); set => PlayerPrefs.SetString("C_TournamentUserId", value.ToString()); } public static byte TournamentTeamId { get => byte.Parse(PlayerPrefs.GetString("C_TournamentTeamId", "0")); set => PlayerPrefs.SetString("C_TournamentTeamId", value.ToString()); }
RoomPlayer
Go to RoomPlayer
script add/change variables/methods as follows:
-
Add variables:
// These variables allow us to identify player and to proceed players statistics after match [Networked] public long TournamentUserId { get; set; } [Networked] public byte TournamentTeamId { get; set; }
-
In
Spawned()
method findRPC_SetPlayerStats()
and change it to:RPC_SetPlayerStats(ClientInfo.Username, ClientInfo.KartId, ClientInfo.TournamentUserId, ClientInfo.TournamentTeamId);
-
Change
RPC_SetPlayerStats()
method to:private void RPC_SetPlayerStats(NetworkString<_32> username, int kartId, long tournamentUserId, byte tournamentTeamId) { // Allows game to store and update information about Tournament(Team/User)Id on in game object Username = username; KartId = kartId; TournamentUserId = tournamentUserId; TournamentTeamId = tournamentTeamId; }
Add new GUI/UIs to LevelManager
Level Manager
is responsible for returning player back to TournamentHubUI
after match is finished.
-
Go to
Main Canvas
. -
Add
TournamentHubScreenUI
,TournamentHubScreenGUI
andTournamentListUI
as shown:
Implementing TournamentMatchHandler
-
Go to
TournamentHubUI/TournamentHubScreen/SubScreen/Canvas
. -
Add empty object to
Canvas
and rename it toTournamentMatchHandler
. -
Add new script to it called
TournamentMatchHandler
. -
Add following code into it.
using Fusion;
using Gimmebreak.Backbone.Core;
using Gimmebreak.Backbone.Tournaments;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TournamentMatchHandler : TournamentMatchCallbackHandler
{
Tournament tournament;
TournamentMatch tournamentMatch;
ITournamentMatchController tournamentMatchController;
bool sessionStarted;
bool creatingSession;
private GameLauncher _launcher;
private WaitForSeconds waitOneSec = new WaitForSeconds(1);
private string tournamentSessionName;
void Awake()
{
_launcher = FindObjectOfType<GameLauncher>();
}
public void OnDisable()
{
// Unattach Actions from RoomPlayer Actions
RoomPlayer.PlayerJoined -= OnPlayerEnteredRoom;
RoomPlayer.PlayerLeft -= OnPlayerLeftRoom;
RoomPlayer.PlayerChanged -= OnPlayerPropertiesUpdate;
}
public override void OnJoinTournamentMatch(Tournament tournament, TournamentMatch match, ITournamentMatchController controller)
{
// User is requesting to join a tournament match, create or join appropriate session
this.tournament = tournament;
this.tournamentMatch = match;
this.tournamentMatchController = controller;
this.sessionStarted = false;
this.creatingSession = false;
this.tournamentSessionName = $"{this.tournamentMatch.Secret}_{this.tournamentMatch.CurrentGameCount}";
// Attach actions to RoomPlayer Actions
RoomPlayer.PlayerJoined += OnPlayerEnteredRoom;
RoomPlayer.PlayerLeft += OnPlayerLeftRoom;
RoomPlayer.PlayerChanged += OnPlayerPropertiesUpdate;
// Join Photon session
StartCoroutine(JoinRoomRoutine());
}
private IEnumerator JoinRoomRoutine()
{
while (this.tournamentMatch != null)
{
// If you require specific region for tournament, you can use
// tournament custom properties providing the info about required region.
// string cloudRegion = this.tournament.CustomProperties.Properties["cloud-region"];
// If tournament match is finished then leave
if (this.tournamentMatch.Status == TournamentMatchStatus.MatchFinished ||
this.tournamentMatch.Status == TournamentMatchStatus.Closed)
{
// Check if connected session is for finished match
if (GameLauncher.ConnectionStatus == ConnectionStatus.Connected &&
_launcher.SessionName == this.tournamentSessionName)
{
_launcher.LeaveSession();
}
}
// Try to connect to tournament match session
else if (GameLauncher.ConnectionStatus == ConnectionStatus.Disconnected)
{
// Set player propery with UserId so we can identify users in session
// Set max players for session based on tournament phase setting
ServerInfo.MaxUsers = (byte)(this.tournament.GetTournamentPhaseById(this.tournamentMatch.PhaseId).MaxTeamsPerMatch * this.tournament.PartySize);
// Join or create Photon session with tournamemnt match secret as session id
ServerInfo.LobbyName = this.tournamentSessionName;
ClientInfo.LobbyName = this.tournamentSessionName;
ClientInfo.TournamentUserId = BackboneManager.Client.User.UserId;
ClientInfo.TournamentTeamId = this.tournamentMatch.GetMatchUserById(BackboneManager.Client.User.UserId).TeamId;
_launcher.SetTournamentLobby();
_launcher.JoinOrCreateLobby();
}
// If we are in wrong session then leave
else if (GameLauncher.ConnectionStatus == ConnectionStatus.Connected &&
this.tournamentSessionName != _launcher.SessionName)
{
_launcher.LeaveSession();
}
yield return this.waitOneSec;
}
}
public override bool IsConnectedToGameServerNetwork()
{
// Check if user is connected to photon and ready to join a session
if (GameLauncher.ConnectionStatus == ConnectionStatus.Connected)
{
return true;
}
return false;
}
public override bool IsGameSessionInProgress()
{
// Check if game session has started
return sessionStarted;
}
public override bool IsUserConnectedToMatch(long userId)
{
// Check if tournament match user is connected to session
foreach (RoomPlayer rp in RoomPlayer.Players)
{
if (rp.TournamentUserId == userId) { return true; }
}
return false;
}
public override bool IsUserReadyForMatch(long userId)
{
// In particular case if player is connected to the match it's considered to be ready
return IsUserConnectedToMatch(userId);
}
public override void OnLeaveTournamentMatch()
{
this.tournament = null;
this.tournamentMatch = null;
this.tournamentMatchController = null;
_launcher.LeaveSession();
}
public override void StartGameSession(IEnumerable<TournamentMatch.User> checkedInUsers)
{
// Start tournament game session with users that checked in.
// Be aware that this callback can be called multiple times until
// sessionStarted returns true.
// Check if session has started
if (sessionStarted)
{
return;
}
// Check if session is not being requested
if (!this.creatingSession)
{
this.creatingSession = true;
// Create tournament game session
BackboneManager.Client.CreateGameSession(
checkedInUsers,
this.tournamentMatch.Id,
0)
.ResultCallback((gameSession) =>
{
this.creatingSession = false;
// Check if game session was created
if (gameSession != null)
{
// Indicate that session has started
this.sessionStarted = true;
RoomPlayer.Local.RPC_ChangeReadyState(true);
RoomPlayer.Local.KartId = 1;
// Set session properties
GameManager.Instance.GameSessionId = gameSession.Id;
GameManager.Instance.TournamentId = this.tournament.Id;
GameManager.Instance.TournamentMatchId = this.tournamentMatch.Id;
}
})
.Run(this);
}
}
// Photon callback when player entered the session
public void OnPlayerEnteredRoom(RoomPlayer player)
{
long userId = player.TournamentUserId;
if (this.tournamentMatchController != null)
{
// Report user who joined room
this.tournamentMatchController.ReportJoinedUser(userId);
}
}
// Photon callback when player disconnected from session
public void OnPlayerLeftRoom(RoomPlayer player)
{
if (this.tournamentMatchController != null)
{
long userId = player.TournamentUserId;
// Report user who disconnected from room
this.tournamentMatchController.ReportDisconnectedUser(userId);
}
}
// Photon callback when player properties are updated
public void OnPlayerPropertiesUpdate(RoomPlayer player)
{
if (this.tournamentMatchController != null)
{
// Reporting status change will refresh match metadata
this.tournamentMatchController.ReportStatusChange();
}
}
}
Implementing TournamentMatchHandler into ActiveMatchContainer
After all necessary changes are done, add TournamentMatchHandler
to ActiveMatchContainer
as MatchHandler
.
ActiveMatchContainer
can be found in TournamentHubUI/TournamentHubScreen/SubScreen/Canvas/ActiveMatchContainer
.
Implementing Result submission
- Go to
EndRaceUI
script. - Add variable that will keep track if result processing started.
private bool processingStarted = false;
- Find
RedrawResultsList()
method and edit it as follows:
public void RedrawResultsList(KartComponent updated)
{
var parent = resultsContainer.transform;
ClearParent(parent);
var karts = GetFinishedKarts();
for (var i = 0; i < karts.Count; i++)
{
var kart = karts[i];
// As we disconnect player from session before dispawned is called,
// this method will be called when kart.Controller.RoomUser is already destroyed.
// Doesn't happen in ordinary match.
if (kart.Controller.RoomUser != null)
{
Instantiate(resultItemPrefab, parent)
.SetResult(kart.Controller.RoomUser.Username.Value, kart.LapController.GetTotalRaceTime(), i + 1);
}
}
EnsureContinueButton(karts);
}
- Now edit
EnsureContinueButton()
method and addProcessResult()
method:
private void EnsureContinueButton(List<KartEntity> karts)
{
var allFinished = karts.Count == KartEntity.Karts.Count;
// Remove submission button and proceed result without user interaction to prevent any errors connected to that
if (!this.processingStarted && this.isActiveAndEnabled && allFinished && GameManager.Instance.IsTournamentGame())
{
StartCoroutine(ProcessResult(karts));
}
}
private IEnumerator ProcessResult(List<KartEntity> karts)
{
// Notify that result processing has started
this.processingStarted = true;
List<GameSession.User> users = new List<GameSession.User>();
Dictionary<long, float> results = new Dictionary<long, float>();
GameSession gameSession;
for (int i = 0; i < karts.Count; i++)
{
// Loop through each kart to obtain necessary information for result submition
var tempUserId = tempSorted[i].Controller.RoomUser.TournamentUserId;
var tempTeamId = tempSorted[i].Controller.RoomUser.TournamentTeamId;
var tempUserValue = tempSorted[i].LapController.GetTotalRaceTime();
// All karts are already sorted by time
users.Add(new GameSession.User(tempUserId, tempTeamId) { Place = i + 1 });
results.Add(tempUserId, tempUserValue);
}
// Create new GameSession
gameSession = new GameSession(
GameManager.Instance.GameSessionId,
0,
users,
GameManager.Instance.TournamentMatchId);
// Attach users and their results to current GameSession
gameSession.Users.ForEach(user =>
{
gameSession.AddStat(1, user.UserId, (decimal)results[user.UserId]);
});
//report game session
yield return BackboneManager.Client.SubmitGameSession(gameSession);
//refresh tournament data
yield return BackboneManager.Client.LoadTournament(GameManager.Instance.TournamentId);
// Leave session after result was submited
FindObjectOfType<GameLauncher>().LeaveSession();
}
Tournament creation & Final test
Build project
We need at least 2 players to be sure that project is working fine, so build project to create 2nd player.
Don't start it immediately, wait till we create a tournament, otherwise tournament won't be visible on TournamentListScreen
.
Creating tournament template
Creating the template
-
There you will see
No tournament templates .Create your first template to get started
. -
Press
Create your first template
.
Edit template
-
Tournament template will appear. Select
Edit template
. -
Go to
Description
and setTournament Name
. -
Go to
Registration
set:
-
Maximum players
- 2 -
Party(team) size
- 1 -
Registration rules
->Open to everyone
- Go to
Format/Add Phase
In Format
set:
Teams
- 2Min teams per match
- 2Max Teams per match
- 2
Leave field Max loses
in Scores
empty
In Rounds
set:
Type
- BO3Minimum game time (minutes)
- 2Maximum round time (minutes)
- 8
-
On the bottom of the screen you should see
Careful - you have unsaved changes!
, pressSave Changes
.
Start tournament
-
Get back to
Tournament templates
page. -
Press
Schedule
, setTime
toYour current time + 5 minutes
and pressStart tournament
.
Final test
-
Now get back to Unity and start the project.
-
Press
Tournaments
button that was added earlier. You should seeTournamentListScreen
and the tournament that you just added.Tournament may be unavailable to register for some time, wait until
Sign up
button is available to register to tournament. -
Repeat the process in build version
-
When tournament will start, button
Ready to play
will become available. -
Press on both Unity and Build
Ready to play
. -
Finish the match on both players.
-
Get back to: https://www.tournament-sdk.com/schedule
-
You should see tournament.
-
Click on it, to see more information about the tournament and played matches.
-
To check more detailed information about every match played during the tournament, go to
Phase 1
. -
Press
Show matches
to the right from any participant. -
Click
Show details
for more information. -
Click
Game #ID
andStats
for more information.