Quantum

Blueless (Shooter)

Integrate tournaments to a fast and action-packed online shooter. Follow this example to learn any action packed game can benefit for the tournament integration.

Unity: 2021.3.30f1. - PC (Windows / Mac), WebGL and Mobile (Android)
The sample game developed by BitCake Studio for Photon Engine.

Want to skip to the end?

We would really recommend to follow the sample and integrate the tournament system yourself. This way you will understand what the system can do and how to integrate it to your game.

However, you can also download a fully funtional demo that was created by following exactly these steps.

Download Demo

1. Set up the project

Set up Quantum

To run the sample in online multiplayer mode, first create a Quantum AppId in the PhotonEngine Dashboard and paste it into the AppId field in PhotonServerSettings asset.

May be that instead of App Settings > App Id Realtime you will have to go App Settings > App Id.

QSetUp


2. Tournament-SDK UI

Start by importing Tournament-SDK to your Unity project.

To set up UI to direct player among MainMenu, TournamentListScreen and TournamentHubScreen, first we are going to add necessary changes/additions to the game code.

Prepare Scripts to implement Tournament-SDK UI

  1. Create new script called UITournamentList.

  2. Open new script and copy paste following code that is necessary to direct player from TournamentListScreen to MainMenu or TournamentHubScreen:

UITournamentList

using Quantum.Demo;

public class UITournamentList : UIScreen<UITournamentList>
{
    public void OnGetBackToMainMenu()
    {
        HideScreen(); // Hide Tournament List Screen
        UIConnect.ShowScreen(); // Show Main Menu screen
    }

    public void OnPlayOpenTournamentHub()
    { 
        HideScreen(); // Hide Tournament List Screen
        UITournamentHub.ShowScreen(); // Show Tournament Hub Screen
    }
}
  1. Create new script called UITournamentHub.

  2. Open new script and copy paste following code that is necessary to direct player from TournamentHubScreen to TournamentListScreen:

UITournamentHub

    using Quantum.Demo;

    public class UITournamentHub : UIScreen<UITournamentHub>
    {
        public void OnGetBackToTournamentList()
        {
            HideScreen(); // Hide Tournament Hub Screen
            UITournamentList.ShowScreen(); // Show Tournament List Screen
        }
    }

Prepare code to implement Tournament-SDK UI

  1. Open script called UIConnect.

  2. Add variable to store GUITournamentHubScreen crucial for tournament initialization:

    [SerializeField] private GUITournamentHubScreen TournamentHubScreen;
    
  3. Add two following methods:

    // Directs player from MainMenu to TournamentListScreen
    public void OnTournamentClicked()
    {
        HideScreen(); // Hide MainMenu
        UITournamentList.ShowScreen(); // Show Tournament List Screen
    }
    
    // Method that will redirect player back to the TournamentHubScreen after the tournament match has been finished
    public void ReturnToTournament(long tournamentId)
    {
        HideScreen(); // Hide MainMenu
        TournamentHubScreen.Initialize(tournamentId); // Initialize tournament using tournamentId saved from last played tournament
        UITournamentHub.ShowScreen(); // Show Tournament Hub Screen
    }
    

Add UI

Tournament Button

  1. In Hierarchy go to UICanvas > Menu > LayoutHorzontal > VerticalLayout > Content > ConnectPanel.

  2. Duplicate last element called ReconnectButton.

  3. Rename it to TournamentButton.

    TSDKUI

  4. Go TournamentButton > ButtonText and change Text component to TOURNAMENTS.

Tournament List & Tournament Hub

  1. Add two new objects to UICanvas scene.

  2. Rename them to TournamentListUI and TournamentHubUI.

  3. Drag&Drop TournamentListScreen and TournamentHubScreen respectively.

  4. Make sure TournamentListUI > TournamentListScreen > SubScreen is Enabled.

    TSDKUI

  5. Find Rect transform component and reset Left/Right/Bottom/Top to 0.

  6. Set scale for X/Y/Z to 1.

    TSDKUI

Set Up UI

  1. Add newly created scripts UITournamentList and UITournamentHub to Menu object in Hierarchy to make them accessible.

  2. Drag&Drop TournamentListUI into UITournamentList > Panel component.

  3. Drag&Drop TournamentHubUI into UITournamentHub > Panel component.

    TSDKUI

TournamentButton

To make TournamentButton direct player from MainMenu to TournamentListScreen:

  1. Go to UICanvas > Menu > LayoutHorzontal > VerticalLayout > Content > ConnectPanel > TournamentButton.

  2. In Button component change first OnClick action from UIConnect.OnReconnectClicked to UIConnect.OnTournamentClicked.

    TSDKUI

TournamentList to TournamentHub

To allow TournamentList direct player to TournamentHub:

  1. Drag&Drop TournamentHubUI > TournamentHubScreen > SubScreen to TournamentListUI > TournamentListScreen > SubScreen in GUITournamentListScreen > TournamentHubScreen component.

    TSDKUI

  2. Go to UICanvas > Menu > TournamentListUI > TournamentListScreen > SubScreen > Canvas > Scroll View > Viewport > Content > TournamentListItem > ButtonUnderline(Open) find Button component.

  3. Add new OnClick() action, drag&drop Menu into empty field under Runtime Only, change No Function to UITournamentList.OnPlayOpenTournamentHub

    TSDKUI

  4. Repeat this process for TournamentHubUI as well.

TournamentList to MainMenu

To allow TournamentList direct player back to MainMenu:

  1. Go to UICanvas > Menu > TournamentListUI > TournamentListScreen > SubScreen > Canvas > Title > ButtonUnderline(Back), find Button component.

  2. Drag&drop Menu into field under Runtime Only and set it to UITournamentList.OnGetBackToMainMenu.

    TSDKUI

TournamentHub to TournamentList

  1. Drag&Drop TournamentListUI > TournamentListScreen > SubScreen to TournamentHubUI > TournamentHubScreen > SubScreen in GUISubScreen > ReturnScreen component.

    TSDKUI

  2. Go to UICanvas > Menu > TournamentHubUI > TournamentHubScreen > SubScreen > Canvas > Title > ButtonUnderline(Back), find Button component.

  3. Add new action, drag&drop Menu into empty field under Runtime Only and set it to UITournamentHub.OnGetBackToTournamentList.

    TSDKUI

Backbone Integration

To initialize TournamentList we need to create an object that will do it on every launch of the game.

  1. Add new GameObject to UICanvas and rename it to BackboneIntegration.

  2. Add BackboneManager and Resource Cache components to it.

  3. Create new script called BacboneIntegration and copy paste following code into it.

    using Gimmebreak.Backbone.User;
    using Quantum.Demo;
    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()
        {
            // First way as in previous game would be to use UIConnect.Instance.Username.text
    
            // Second option is to check if PlayerPref contains saved name and use it, because what if player didn't input any text as name
            var localName = string.IsNullOrEmpty(PlayerPrefs.GetString("Quantum.Demo.UIConnect.LastUsername")) ? UIConnect.Instance.Username.text : PlayerPrefs.GetString("Quantum.Demo.UIConnect.LastUsername");
    
            // 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(localName))
            {
                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" + localName;
            // 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, localName, 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;
            }
        }
    }
    
  4. Get back to the scene and find BackboneIntegration component. Add TournamentButton to the missing field in component.

    TSDKUI


3. Implementing Tournament logic into the Quantum game

Quantum code changes/additions

To succesfully merge Tournament logic into Quantum game, let's first prepare game to handle/carry/mantain tournament information inside the game.

  1. Open Quantum part of the project. Go to project location, there must be folder called quantum_code open it as a project in an editor.

  2. Open RuntimePlayer.User and copy paste following code into it:

    using Photon.Deterministic;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Text;
    
    namespace Quantum
    {
        partial class RuntimePlayer
        {
    
            public AssetRefEntityPrototype PrototypeRef;
            public string PlayerName;
            public string BodyId;
            public long TournamentUserId;
            public byte TournamentTeamId;
    
            partial void SerializeUserData(BitStream stream)
            {
                stream.Serialize(ref PrototypeRef.Id.Value);
                stream.Serialize(ref PlayerName);
                stream.Serialize(ref BodyId);
                stream.Serialize(ref TournamentUserId);
                stream.Serialize(ref TournamentTeamId);
            }
        }
    }
    
  3. Now to apply added changes save them and re-build the quantum project.

Game code changes/additions

UIConnecting is script that responsible to set up room properties and start lobby, that's why it's important to set custom info about tournament room here.

Find method OnConnectedToMaster() and change as shown below:

public void OnConnectedToMaster()
{
    if
    else

    if
    else

    if

    // Above is unchanged code

    var joinRandomParams = new OpJoinRandomRoomParams();
    _enterRoomParams = new EnterRoomParams();
    _enterRoomParams.RoomOptions = new RoomOptions();
    _enterRoomParams.RoomOptions.IsVisible = true;
    _enterRoomParams.RoomOptions.MaxPlayers = TournamentMatchHandler.MaxUserTournament != 0 ? TournamentMatchHandler.MaxUserTournament : Input.MAX_COUNT;
    _enterRoomParams.RoomOptions.Plugins = new string[] { "QuantumPlugin" };
    _enterRoomParams.RoomOptions.CustomRoomProperties = new Hashtable {
{ "HIDE-ROOM", false },
{ "MAP-GUID", defaultMapGuid },
};
    _enterRoomParams.RoomOptions.PlayerTtl = PhotonServerSettings.Instance.PlayerTtlInSeconds * 1000;
    _enterRoomParams.RoomOptions.EmptyRoomTtl = PhotonServerSettings.Instance.EmptyRoomTtlInSeconds * 1000;
    _enterRoomParams.RoomName = string.IsNullOrEmpty(TournamentMatchHandler.RoomName) ? null : TournamentMatchHandler.RoomName;

    // Below is unchanged code

    if
}

Result processing

To start result processing we have to find place where game end is being tracked.

  1. Go to ScoreboardHud script.

  2. Add variable to track if result processing has started.

    private bool processingStarted = false;
    
  3. Find method OnGameEnded() and change as follows:

    private unsafe void OnGameEnded(EventOnGameEnded onGameEnded)
    {
        _alwaysShow = true;
    
        if (TournamentMatchHandler.TournamentGame && !this.processingStarted) StartCoroutine(ProcessResult(QuantumRunner.Default.Game.Frames.Verified));
    }
    
  4. Now copy paste method ProcessResult():

    private IEnumerator ProcessResult(Frame f)
    {
        this.processingStarted = true;
    
        List<GameSession.User> users = new List<GameSession.User>();
        Dictionary<long, int> kills = new Dictionary<long, int>();
        Dictionary<long, int> deaths = new Dictionary<long, int>();
        // Iterate through game players and gather placements and stats
    
        for (int i = 0; i < _sortedRobots.Count; i++)
        {
            // Using EntityRef get PlayerID which contains PlayerRef to obtain RuntimePlayer
            RuntimePlayer rp = f.GetPlayerData(f.Get<PlayerID>(_sortedRobots[i]).PlayerRef);
            // Using EntityRef of particular player get Score construct that contains Kills and Deaths
            Score score = f.Get<Score>(_sortedRobots[i]);
            long userId = rp.TournamentUserId;
            byte teamId = rp.TournamentTeamId;
    
            Debug.LogWarning($"User with ID -> {userId} # Place -> {i+1} # Kills -> {score.Kills} # Deaths -> {score.Deaths}");
            users.Add(new Gimmebreak.Backbone.GameSessions.GameSession.User(userId, teamId) { Place = (i + 1) });
            // Get players kills stat
            kills.Add(userId, score.Kills);
            // Get players death stat
            deaths.Add(userId, score.Deaths);
        }
        // Create a game session object to be submitted
        GameSession gameSession = new Gimmebreak.Backbone.GameSessions.GameSession(
            (long)UIMain.Client.CurrentRoom.CustomProperties["GameSessionId"],
            0,
            users,
            (long)UIMain.Client.CurrentRoom.CustomProperties["TournamentMatchId"]);
    
        // Add game session stats
        gameSession.Users.ForEach(user =>
        {
            gameSession.AddStat(1, user.UserId, kills[user.UserId]);
            gameSession.AddStat(2, user.UserId, deaths[user.UserId]);
        });
    
        //report game session
        yield return BackboneManager.Client.SubmitGameSession(gameSession);
        //refresh tournament data
        yield return BackboneManager.Client.LoadTournament((long)UIMain.Client.CurrentRoom.CustomProperties["TournamentId"]);
    
        //check if tournament match was not finished (if not another game should be played)
        TournamentMatchHandler.TournamentGame = false;
        UIConnect.Instance.ReturnToTournament((long)UIMain.Client.CurrentRoom.CustomProperties["TournamentId"]);
        UIMain.Client.Disconnect();
    }
    

Implementing Tournament Match Handler

  1. In Hierarchy go to UICanvas > TournamentHubUI > TournamentHubScreen > Canvas at the very bottom of it, create a new game object and rename it to TournamentMatchHandler.

  2. Create new script called TournamentMatchHandler and add this script as component to TournamentMatchHandler object.

  3. Copy paste following code into it:

    using ExitGames.Client.Photon;
    using Gimmebreak.Backbone.Core;
    using Gimmebreak.Backbone.Tournaments;
    using Photon.Realtime;
    using Quantum;
    using Quantum.Demo;
    using System.Collections;
    using System.Collections.Generic;
    using System.Data;
    using System.Linq;
    using UnityEngine;
    
    public class TournamentMatchHandler : TournamentMatchCallbackHandler, IInRoomCallbacks
    {
        Tournament tournament;
        TournamentMatch tournamentMatch;
        ITournamentMatchController tournamentMatchController;
        bool sessionStarted;
        bool creatingSession;
        private WaitForSeconds waitOneSec = new WaitForSeconds(1);
        private string tournamentSessionName;
        public static long TournamentUserId;
        public static byte TournamentTeamId;
        public static byte MaxUserTournament;
        public static string RoomName;
        public static bool TournamentGame = false;
    
        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}";
            // Join Photon session
            StartCoroutine(JoinRoomRoutine());
        }
    
        public void OnDisable()
        {
            if (UIMain.Client != null)
            {
                UIMain.Client.RemoveCallbackTarget(this);
            }
        }
    
        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 (UIMain.Client != null && UIMain.Client.State == ClientState.Joined && UIMain.Client.CurrentRoom.Name == this.tournamentSessionName)
                    {
                        UIMain.Client.Disconnect();
                    }
                }
                // Try to connect to tournament match session
                else if (!(UIMain.Client != null && UIMain.Client.State == ClientState.Joined))
                {
                    // Set max players for session based on tournament phase setting
                    MaxUserTournament = (byte)(this.tournament.GetTournamentPhaseById(this.tournamentMatch.PhaseId).MaxTeamsPerMatch * this.tournament.PartySize);
                    // Join or create Photon session with tournamemnt match secret as session id
                    RoomName = this.tournamentSessionName;
                
                    TournamentUserId = BackboneManager.Client.User.UserId;
                    TournamentTeamId = this.tournamentMatch.GetMatchUserById(BackboneManager.Client.User.UserId).TeamId;
    
                    UIConnect.Instance.OnConnectClicked();
    
                    // Set player propery with UserId so we can identify users in session
                    ExitGames.Client.Photon.Hashtable customProperties = new ExitGames.Client.Photon.Hashtable()
                    {
                        { "TournamentUserId", BackboneManager.Client.User.UserId},
                        { "TournamentTeamId", this.tournamentMatch.GetMatchUserById(BackboneManager.Client.User.UserId).TeamId}
                    };
    
                    UIMain.Client.AddCallbackTarget(this);
                    TournamentGame = true;
                    UIMain.Client.LocalPlayer.SetCustomProperties(customProperties);
                }
                // If we are in wrong session then leave
                else if (UIMain.Client != null && UIMain.Client.State == ClientState.Joined && this.tournamentSessionName != UIMain.Client.CurrentRoom.Name)
                {
                    UIMain.Client.Disconnect();
                }
                yield return this.waitOneSec;
            }
        }
    
        public override bool IsConnectedToGameServerNetwork()
        {
            // Check if user is connected to photon and ready to join a session
            if (UIMain.Client != null && UIMain.Client.State == ClientState.Joined)
            {
                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
            if (UIMain.Client.CurrentRoom == null) return false;
            foreach (var player in UIMain.Client.CurrentRoom.Players.Values)
            {
                if (player.CustomProperties.ContainsValue(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;
            TournamentGame = false;
            UIMain.Client?.RemoveCallbackTarget(this);
            UIMain.Client?.Disconnect();
        }
    
        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;
    
                            if (UIMain.Client.LocalPlayer.IsMasterClient)
                            {
                                ExitGames.Client.Photon.Hashtable hashtable = new ExitGames.Client.Photon.Hashtable();
                                hashtable.Add("GameSessionId", gameSession.Id);
                                hashtable.Add("TournamentMatchId", this.tournamentMatch.Id);
                                hashtable.Add("TournamentId", this.tournament.Id);
                                UIMain.Client.CurrentRoom.SetCustomProperties(hashtable);
                                UIRoom.Instance.OnStartClicked();
                            }
                            UITournamentHub.HideScreen();
                        }
                    })
                    .Run(this);
            }
        }
    
        public void OnPlayerEnteredRoom(Player newPlayer)
        {
            if (this.tournamentMatchController != null)
            {
                // Report user who joined room
                this.tournamentMatchController.ReportJoinedUser((long)newPlayer.CustomProperties["TournamentUserId"]);
            }
        }
    
        public void OnPlayerLeftRoom(Player otherPlayer)
        {
            {
                // Report user who disconnected from room
                this.tournamentMatchController.ReportDisconnectedUser((long)otherPlayer.CustomProperties["TournamentUserId"]);
            }
        }
    
        public void OnPlayerPropertiesUpdate(Player targetPlayer, ExitGames.Client.Photon.Hashtable changedProps)
        {
            if (this.tournamentMatchController != null)
            {
                // Reporting status change will refresh match metadata
                this.tournamentMatchController.ReportStatusChange();
            }
        }
    
        public void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
        {
            if (this.tournamentMatchController != null)
            {
                // Reporting status change will refresh match metadata
                this.tournamentMatchController.ReportStatusChange();
            }
        }
    
        public void OnMasterClientSwitched(Player newMasterClient)
        {
        }
    }
    
  4. Get back to Unity project, find UICanvas > TournamentHubUI > TournamentHubScreen > SubScreen > Canvas > ActiveMatchContainer, find component GUITournamentActiveMatch there will be empty field MatchHandler drag&drop TournamentMatchHandler into it.

    TSDKUI

4. Schedule a tournament

Build project

We need at least 2 players to be sure that project is working fine, so build project to create 2nd player.

Creating tournament template

Creating the template

  1. Go to https://www.tournament-sdk.com/tournaments

  2. There you will see No tournament templates .Create your first template to get started.

  3. Press Create your first template.

    TTemplate

Edit template

  1. Tournament template will appear. Select Edit template.

    TTemplate

  2. Go to Description and set Tournament Name.

    TTemplate

  3. Go to Registration set:

  • Maximum players - 2

  • Party(team) size - 1

  • Registration rules -> Open to everyone

    TTemplate

  1. Go to Format/Add Phase

In Format set:

  • Teams - 2
  • Min teams per match - 2
  • Max Teams per match - 2

Leave field Max loses in Scores empty

In Rounds set:

  • Type - BO3
  • Minimum game time (minutes) - 2
  • Maximum round time (minutes) - 8
  1. On the bottom of the screen you should see Careful - you have unsaved changes!, press Save Changes.

    TTemplate

Start tournament

  1. Get back to Tournament templates page.

  2. Press Schedule, set Time to Your current time + 5 minutes and press Start tournament.

    TTemplate

5. Final test

  1. Now get back to Unity and start the project.

  2. Press Tournaments button that was added earlier. You should see TournamentListScreen and the tournament that you just added.

    FinalTest

    Tournament may be unavailable to register for some time, wait until Sign up button is available to register to tournament.

    FinalTest

  3. Repeat the process in build version

  4. When tournament will start, button Ready to play will become available.

    FinalTest

  5. Press on both Unity and Build Ready to play.

    FinalTest

    FinalTest

  6. Finish the match on both players.

  7. Get back to: https://www.tournament-sdk.com/schedule

  8. You should see tournament.

    FinalTest

  9. Click on it, to see more information about the tournament and played matches.

  10. To check more detailed information about every match played during the tournament, go to Phase 1.

  11. Press Show matches to the right from any participant.

  12. Click Show details for more information.

    FinalTest

  13. Click Game #ID and Stats for more information.

    FinalTest