Unity Example

Multiplayer authoritative game
in 75 lines of code.

Start with one queue call, one score submit, and one move payload. Then see the full Unity TicTacToe controller that handles matching, turn flow, vote validation, and game end state without a custom backend.

These Unity multiplayer examples focus on production concerns: authoritative move validation, player ownership rules, hidden hand mechanics, and server-synced leaderboard writes. Use these snippets as a baseline for turn-based PvP, async matches, and social board games where cheating resistance and predictable backend behavior matter.

Quick Snippets

Simple API, iterate fast.

// Find Match
// Queue one player into your relay config and start as soon as another client joins.
await Relay.MatchWithAnyone("player1", ExampleConfig.Slug);

// Submit leaderboard score
await Leaderboard.SubmitScore("player1", 5);

// Send a move
Relay.SendJson(index.ToString());
Relay.EndMyTurn();

// React to relay messages
Relay.OnMoveMade += (message, _) => OnMoveMade(message);
Relay.OnVoteFailed += OnVoteFailed;
Relay.OnMatchStarted += (_, _) => { statusText.text = "Game started"; };
Full Relay Example: Turns, Voting, Json

Unity Tic Tac Toe game.

One script handles match start, sending json, per-turn validation, cheat detection, this specific game rules and win resolution. No separate authoritative game server code is required here.

using System.Collections.Generic;
using System.Linq;
using TurnKit.Internal.ParrelSync;
using UnityEngine;
using UnityEngine.UI;

namespace TurnKit.Example
{
    public class TicTacToeControllerExample : MonoBehaviour
    {
        [SerializeField] private List<Text> texts;
        [SerializeField] private InputField playerIdText;
        [SerializeField] private Text gameEndText;
        [SerializeField] private Text statusText;
        [SerializeField] private Toggle allowInvalidMovesToggle;
        private readonly int[][] _winConditions = { new[] {0, 1, 2}, new[] {3, 4, 5}, new[] {6, 7, 8},
            new[] {0, 3, 6}, new[] {1, 4, 7}, new[] {2, 5, 8},
            new[] {0, 4, 8}, new[] {2, 4, 6}
        };

        private void Awake()
        {
#if UNITY_EDITOR
            playerIdText.text = ClonesManager.IsClone() ? "player2" : "player1";
#endif
            Relay.OnMoveMade += (message, _) => OnMoveMade(message);
            Relay.OnVoteFailed += OnVoteFailed;
            Relay.OnMatchStarted += (_, _) => { statusText.text = "Game started"; };
        }

        public async void FindMatch()
        {
            foreach (var text in texts) text.text = "";
            gameEndText.text = "";
            statusText.text = "Waiting for opponent, connect with another client";
            await Relay.MatchWithAnyone(playerIdText.text, ExampleConfig.Slug);
        }

        public void OnCellClick(int index)
        {
            if (!string.IsNullOrEmpty(texts[index].text) && !allowInvalidMovesToggle.isOn) return;
            Relay.SendJson(index.ToString());
            Relay.EndMyTurn();
        }

        private void OnVoteFailed(VoteFailedMessage voteFailedMessage)
        {
            gameEndText.text = "Cheating detected game ended";
            statusText.text = "Game ended";
        }

        private void OnMoveMade(MoveMadeMessage message)
        {
            int cellIndex = int.Parse(message.json);
            bool isLegalMove = string.IsNullOrEmpty(texts[cellIndex].text) || allowInvalidMovesToggle.isOn;
            Relay.Vote(message.moveNumber, isLegalMove);

            if (!isLegalMove) return;

            string symbol = message.moveNumber % 2 == 0 ? "O" : "X";
            texts[cellIndex].text = symbol;
            if (CheckWin()) EndGame($"{symbol} won ! Press Find Match to replay it");
            else if (message.moveNumber == 9) EndGame("A draw, close one! Press Find Match to replay it");
        }

        private void EndGame(string gameEndReason)
        {
            Relay.EndGame();
            gameEndText.text = gameEndReason;
            statusText.text = "Game ended";
        }

        private bool CheckWin() => _winConditions.Any(l => !string.IsNullOrEmpty(texts[l[0]].text) && texts[l[0]].text == texts[l[1]].text && texts[l[0]].text == texts[l[2]].text);
    }
}
Full Relay Example: Hand Hiding, Ownership

Unity Rock Paper Scissor game.

In addition to features covered in previous example here is manipulation of server lists, showcasing hand hiding, ownership of lists

using System.Collections.Generic;
using System.Linq;
using TurnKit.Internal.ParrelSync;
using UnityEngine;
using UnityEngine.UI;

namespace TurnKit.Example
{
    public class RockPaperScissorsControllerExample : MonoBehaviour
    {
        [SerializeField] private InputField playerIdText;
        [SerializeField] private Text gameEndText;
        [SerializeField] private Text statusText;
        [SerializeField] private Text opponentText;

        private RelayList myHand;
        private RelayList opponentHand;
        private RelayList revealedList;
        private bool isSignPicked;
        private readonly string[] validSigns = {"ROCK", "PAPER", "SCISSORS"};
        private void Awake()
        {
#if UNITY_EDITOR
            playerIdText.text = ClonesManager.IsClone() ? "player2" : "player1";
#endif
            Relay.OnMoveMade += OnMoveMade;
            Relay.OnTurnChanged += OnTurnChanged;
            Relay.OnVoteFailed += OnVoteFailed;
            Relay.OnMatchStarted += (msg,initialLists) =>
            {
                myHand = Relay.GetMyLists(ExampleConfig.Tag.hand).First();
                opponentHand = Relay.GetOpponentsLists(ExampleConfig.Tag.hand).First();
                revealedList = Relay.GetMyLists(ExampleConfig.Tag.table).First();
                statusText.text = "Game started";
            };
        }

        public async void FindMatch()
        {
            isSignPicked = false;
            opponentText.text = "";
            gameEndText.text = "";
            statusText.text = "Waiting for opponent, connect with another client";
            await Relay.MatchWithAnyone(playerIdText.text, ExampleConfig.Slug);
        }

        public void SignChosen(string sign)
        {
            if (isSignPicked) return;
            myHand.Spawn(sign);
            Relay.EndMyTurn();
        }

        private void OnVoteFailed(VoteFailedMessage voteFailedMessage)
        {
            gameEndText.text = "Cheating detected game ended";
            statusText.text = "Game ended";
        }

        private void OnMoveMade(MoveMadeMessage msg, IReadOnlyList<RelayList> arg2)
        {
            Relay.Vote(msg.moveNumber, IsMoveValid(msg));
            if (msg.playerId != playerIdText.text) opponentText.text = "Opponent chosen sign";
            else isSignPicked = true;
            
            if (revealedList.Count == 2) //signs are revealed
            {
                var mySign = revealedList.Items.First(x => x.CreatorSlot == Relay.MySlot).Slug;
                var opponentSign = revealedList.Items.First(x => x.CreatorSlot != Relay.MySlot).Slug;
                opponentText.text = $"Opponent chose {opponentSign}";
                if (mySign == opponentSign)
                {
                    EndGame("A draw, close one! Press Find Match to replay it");
                    return;
                }
                
                bool iWin = (mySign == "ROCK" && opponentSign == "SCISSORS") ||
                            (mySign == "PAPER" && opponentSign == "ROCK") ||
                            (mySign == "SCISSORS" && opponentSign == "PAPER");
                EndGame(iWin ? "You won ! Press Find Match to replay it" : "You lost! Press Press Find Match to replay it");
            }
        }

        private bool IsMoveValid(MoveMadeMessage msg)
        {
            foreach (var change in msg.changes)
            {
                if (change.type == ChangeType.SPAWN) // player adding to not owned list is covered by server, it checks ownership
                {
                    if (change.toList.Items.Count > 1) return false; // tried to pick a second sign.
                    if (!validSigns.Contains(change.toList.Items.First().Slug)) return false; // {act.slug} is not a valid sign
                }

                if (change.type == ChangeType.MOVE)
                {
                    if (change.items.Length != 1) return false; // 1 sign moved from each list
                    if (myHand.Items.Count != 0 || opponentHand.Items.Count != 0) return false; // must be no items remaining
                }
            }
            return true;
        }
        
        private void OnTurnChanged(TurnChangedMessage message)
        {
            if (myHand.Items.Count == 1 && opponentHand.Items.Count == 1 && Relay.IsMyTurn) // both players picked their sign
            {
                Debug.Log("Both players ready. Executing Reveal...");
                myHand.Move(SelectorType.ALL).To(revealedList);
                opponentHand.Move(SelectorType.ALL).IgnoreOwnership().To(revealedList);
                Relay.EndMyTurn();
            }
        }
        
        private void EndGame(string gameEndReason)
        {
            Relay.EndGame();
            gameEndText.text = gameEndReason;
            statusText.text = "Game ended";
        }
    }
}
Full Leaderboard Example: Submit and get results

Client submit and get results.

This code works in single player as well, features getting and submitting score (can be optionally disabled in config).

using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

namespace TurnKit.Example
{
    public class LeaderboardControllerExample : MonoBehaviour
    {
        [Header("Input")] [SerializeField] private InputField playerIdText;
        [SerializeField] private InputField scoreInput;

        [Header("Leaderboard")] [SerializeField]
        private Transform content;

        [SerializeField] private LeaderboardEntryViewExample entryPrefab;

        private readonly List<LeaderboardEntryViewExample> _entries = new();
        
        public async void OnSubmitScoreButton()
        {
            var name = playerIdText.text;
            var score = double.TryParse(scoreInput.text, out var s) ? s : 0;
            await Leaderboard.SubmitScore(name, score);
            await RefreshTopScores();
        }

        public async void OnGetTopScoresButton()
        {
            await RefreshTopScores();
        }

        public async void OnGetPlayerRankAndSurroundingButton()
        {
            PlayerScore result = await Leaderboard.GetPlayerRank(playerIdText.text);
            PopulateList(result.scores, result.startRank);
        }
        
        public async void OnGetTopScoresAndPlayerRankButton()
        {
            CombinedScores result = await Leaderboard.GetCombined(playerIdText.text); // Can add more params like Leaderboard.GetCombined(playerNameInput.text, 10, 3, true, "new-admin-added-leaderboard");
            PopulateList(result.topScores.scores);
            PopulateList(result.playerScore.scores, result.playerScore.startRank, true);
        }

        private async Task RefreshTopScores()
        {
            var result = await Leaderboard.GetTopScores(playerIdText.text);
            PopulateList(result.scores);
        }

        private void PopulateList(List<LeaderboardEntry> scores, long startRank = 1, bool skipDestroy = false)
        {
            if (!skipDestroy)
            {
                foreach (var e in _entries) Destroy(e.gameObject);
                _entries.Clear();
            }

            for (int i = 0; i < scores.Count; i++)
            {
                var view = Instantiate(entryPrefab, content);
                view.rankText.text = $"#{startRank + i}";
                view.nameText.text = scores[i].n;
                view.scoreText.text = scores[i].s.ToString("N0");
                _entries.Add(view);
            }
        }
    }
}
Full Leaderboard Example: Relay synced authorative submission

Results from relay match are submitted server side.

Leaderboards are synced to relay results for authorative (voted by other players in that relay match) result submission. See how to set it up.

using System.Collections.Generic;
using TurnKit.Internal.ParrelSync;
using UnityEngine;
using UnityEngine.UI;

namespace TurnKit.Example
{
    public class RelaySyncedLeaderboardControllerExample : MonoBehaviour
    {
        [Header("Input")] [SerializeField] private InputField playerIdText;
        [SerializeField] private InputField scoreInput;
        [SerializeField] private Text statusText;

        [Header("Leaderboard")] [SerializeField]
        private Transform content;

        [SerializeField] private LeaderboardEntryViewExample entryPrefab;

        private readonly List<LeaderboardEntryViewExample> _entries = new();
        
        private void Awake()
        {
#if UNITY_EDITOR
            playerIdText.text = ClonesManager.IsClone() ? "player2" : "player1";
#endif
            Relay.OnMatchStarted += (_, _) => { statusText.text = "Game started"; };
            Relay.OnMoveMade += (msg, _) => { ValidateStatChange(msg); };
            Relay.OnGameEnded += (_) => { statusText.text = "Game ended"; };
        }

        private void ValidateStatChange(MoveMadeMessage msg)
        {
            bool isExampleValid = !(msg.statChanges.TryGet(ExampleConfig.Stats.Score, out var scoreChange) && scoreChange.Value < 0);
            // add your game logic here to check if score is valid
            // alternatively use msg.statChanges.allChanges or msg.statChanges.doubleChanges
            Relay.Vote(msg.moveNumber, isExampleValid);
        }

        public async void OnFindMatch()
        {
            statusText.text = "Waiting for opponent, connect with another client";
            await Relay.MatchWithAnyone(playerIdText.text, ExampleConfig.Slug);
        }
        
        public void OnAddRelayScoreButton()
        {
            Relay.Stat(ExampleConfig.Stats.Score).ForPlayer(Relay.MySlot).Add(double.Parse(scoreInput.text));
            // this stat is connected to Leaderboards via config and executes Leaderboard.SubmitScore but on backend and is verified by votes of other players in this match
            // go to asset menu TurnKit > Configuration > ExampleConfig to see connection, can use same way for your own webhooks to your backend or similar
            Relay.EndMyTurn();
        }

        public void OnEndRelayMatch()
        {
            Relay.EndGame();
            statusText.text = "Click end game on other client too";
        }

        public async void OnShowTopScores()
        {
            statusText.text = "Game ended";
            var result = await Leaderboard.GetTopScores(playerIdText.text);
            var scores = result.scores;
            
            foreach (var e in _entries) Destroy(e.gameObject);
            _entries.Clear();

            for (int i = 0; i < scores.Count; i++)
            {
                var view = Instantiate(entryPrefab, content);
                view.rankText.text = $"#{1 + i}";
                view.nameText.text = scores[i].n;
                view.scoreText.text = scores[i].s.ToString("N0");
                _entries.Add(view);
            }
        }
    }
}
Unity Multiplayer Code Examples (Relay + Leaderboards) - TurnKit