Unity Ninja Battle Tutorial #

Ninja Battle is an open-source game project sponsored by the Heroic Games Grant. Developed using Unity, Ninja Battle is a real-time top-down 2d battler where players control sprinting ninjas who drop deadly caltrops as they run across the grid.

Ninja Battle
Ninja Battle

The game, and this tutorial, highlight the following Nakama features:

Prerequisites #

To easily follow with this tutorial, perform the following before proceeding:

Project structure #

The Ninja Battle project consists of seven scenes:

SceneDescription
InitializerInitializes all singletons. Only run once per session.
SplashHandles authentication / login.
HomeMain menu of Ninja Battle, where players can start a match, change their name, and see their trophies.
LobbyPlayer has joined a match and is waiting for opponents.
BattleWhere the gameplay occurs.
RoundResultsDisplays how many rounds each player in the match has won.
FinalResultsDisplays the winner of the match.

Helper functions #

This project also contains a small set of helper functions serving as example implementations of some Nakama features. NakamaManager is the base script for these functions and is used to perform user login and logout, and sending RPCs to the server.

Authentication #

The NakamaManager scripts enables you to use three types of login:

1
2
3
4
5
6
// Generate a random UDID and save it to PlayerPrefs
NakamaManager.Instance.LoginWithUdid();
// Use the device's unique identifier
NakamaManager.Instance.LoginWithDevice();
// Use a custom ID to handle authentication
NakamaManager.Instance.LoginWithCustomId();

You can see the Ninja Battle implementation in NakamaAutoLogin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
using UnityEngine;

namespace Nakama.Helpers
{
    public class NakamaAutoLogin : MonoBehaviour
    {
        #region FIELDS

        [SerializeField] private float retryTime = 5f;

        #endregion

        #region BEHAVIORS

        private void Start()
        {
            NakamaManager.Instance.onLoginFail += LoginFailed;
            TryLogin();
        }

        private void OnDestroy()
        {
            NakamaManager.Instance.onLoginFail -= LoginFailed;
        }

        private void TryLogin()
        {
            NakamaManager.Instance.LoginWithUdid();
        }

        private void LoginFailed()
        {
            Invoke(nameof(TryLogin), retryTime);
        }

        #endregion
    }
}

The NakamaEvents component exposes all NakamaManager events to the Unity inspector, with these events helping to handle Ninja Battle’s game logic. For example, the Splash scene executes a scene change when receiving the onLoginSuccess event.

Setting up usernames #

Ninja Battle is designed for players to be able to join a match and start playing right away, they do not need to specifically create a username for themselves. For new accounts a random two word name is generated for players that do not want to set a custom name.

SetDisplayName.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using TMPro;
using UnityEngine;
using Nakama.Helpers;

namespace NinjaBattle.General
{
    public class SetDisplayName : MonoBehaviour
    {
        #region FIELDS

        private const float delay = 1f;

        [SerializeField] private TMP_InputField inputField = null;
        [SerializeField] private string[] firstPart = null;
        [SerializeField] private string[] secondPart = null;

        private NakamaUserManager nakamaUserManager = null;

        #endregion

        #region BEHAVIORS

        private void Start()
        {
            nakamaUserManager = NakamaUserManager.Instance;
            nakamaUserManager.onLoaded += ObtainName;
            inputField.onValueChanged.AddListener(ValueChanged);
            if (nakamaUserManager.LoadingFinished)
                ObtainName();
        }

        private void OnDestroy()
        {
            inputField.onValueChanged.RemoveListener(ValueChanged);
            nakamaUserManager.onLoaded -= ObtainName;
        }

        private void ObtainName()
        {
            if (string.IsNullOrEmpty(nakamaUserManager.DisplayName))
                inputField.text = firstPart[Random.Range(0, firstPart.Length)] + secondPart[Random.Range(0, secondPart.Length)];
            else
                inputField.text = nakamaUserManager.DisplayName;
        }

        private void ValueChanged(string newValue)
        {
            CancelInvoke(nameof(UpdateName));
            Invoke(nameof(UpdateName), delay);
        }

        private void UpdateName()
        {
            if (inputField.text != nakamaUserManager.DisplayName)
                nakamaUserManager.UpdateDisplayName(inputField.text);
        }

        #endregion
    }
}

Players can change their usernames as desired. This is handled via the UpdateDisplayName function in the NakamaUserManager:

1
2
3
4
5
6
// ...
public async void UpdateDisplayName(string displayName)
{
    await NakamaManager.Instance.Client.UpdateAccountAsync(NakamaManager.Instance.Session, null, displayName);
}
// ...

Gameplay #

The gameplay logic in Ninja Battle consists of two parts, one handled via the Unity client and the other via a custom RPC using the server runtime.

From the Home scene the player presses the Battle button and triggers the FindMatch function:

1
2
3
4
5
private void FindMatch()
{
    button.interactable = false;
    MultiplayerManager.Instance.JoinMatchAsync();
}

The MultiplayerManager registers for the upcoming match states:

1
NakamaManager.Instance.Socket.ReceivedMatchState += Receive;

Using NakamaManager the JoinOrCreateMatchRpc RPC is called:

1
2
3
4
private const string JoinOrCreateMatchRpc = "JoinOrCreateMatchRpc";
// ...
IApiRpc rpcResult = await NakamaManager.Instance.SendRPC(JoinOrCreateMatchRpc);
// ...

The joinOrCreateMatch RPC is then executed on the server. This function looks for any open match for the player to join and, if one cannot be found, creates a new match:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
let joinOrCreateMatch: nkruntime.RpcFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, payload: string): string
{
    let matches: nkruntime.Match[];
    const MatchesLimit = 1;
    const MinimumPlayers = 0;
    var label: MatchLabel = { open: true }
    matches = nakama.matchList(MatchesLimit, true, JSON.stringify(label), MinimumPlayers, MaxPlayers - 1);
    if (matches.length > 0) {
        return matches[0].matchId;
    }

    return nakama.matchCreate(MatchModuleName);
}

The client receives the matchId returned by the server and sends a join request:

1
2
string matchId = rpcResult.Payload;
match = await NakamaManager.Instance.Socket.JoinMatchAsync(matchId);

The onMatchJoin event is called and the GameManager, subscribed to that event, switches to the Lobby scene:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
onMatchJoin?.Invoke();

// ...

MultiplayerManager.Instance.onMatchJoin += JoinedMatch;
...
private void JoinedMatch()
{
    ResetPlayerWins();
    GoToLobby();
}
...
private void GoToLobby()
{
    SceneManager.LoadScene((int)Scenes.Lobby);
}

Client side logic #

This section details the client side logic for Ninja Battle.

MultiplayerManager #

The MultiplayerManager handles the logic for joining a match, and sending and receiving messages for that match on the server. These messages are described by the following OperationCodes:

CodeNameDescription
0PlayersA list of players is sent to a new player on match join.
1PlayerJoinedThe display name of a new player is sent to all other players in a match.
2PlayerInputThe direction input of a player, sent by the client to all other clients.
3PlayerWonClient message of the match winner. Same winner must be reported by all clients for the server to trust the message.
4DrawClient message of a match draw. Same result must be reported by all clients for the server to trust the message.
5ChangeSceneServer message to all clients indicating which scene to change to.

The MultiplayerManager receives all messages and distributes them according to subscriptions, for example:

1
MultiplayerManager.Instance.Subscribe(MultiplayerManager.Code.PlayerInput, ReceivedPlayerInput);

To send a message, send an object that can be serialized:

1
2
3
4
private void SendData(int tick, Direction direction)
{
   MultiplayerManager.Instance.Send(MultiplayerManager.Code.PlayerInput, new InputData(tick, (int)direction));
}

To receive a message, use the `MultiplayerManager to deserialize to the class you want:

1
2
3
4
5
private void ReceivedPlayerInput(MultiplayerMessage message)
{
   InputData inputData = message.GetData<InputData>();
   SetPlayerInput(GetPlayerNumber(message.SessionId), inputData.Tick, (Direction)inputData.Direction);
}

Multiplayer identity #

This script holds the unique ID of each player, the SessionId here:

MultiplayerIdentity.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using UnityEngine;

namespace Nakama.Helpers
{
    public partial class MultiplayerIdentity : MonoBehaviour
    {
        #region FIELDS

        private static int currentId = 0;

        #endregion

        #region PROPERTIES

        public string Id { get; private set; }
        public bool IsLocalPlayer { get => MultiplayerManager.Instance.Self != null && MultiplayerManager.Instance.Self.SessionId == Id; }

        #endregion

        #region BEHAVIORS

        private void Awake()
        {
            AssignIdentity();
        }

        private void AssignIdentity()
        {
            Id = currentId++.ToString();
        }

        public void SetId(string id)
        {
            Id = id;
        }

        public static void ResetIds()
        {
            currentId = default(int);
        }

        #endregion
    }
}

Spawning players #

The spawning of players is handled by the Map class, taking each player and putting them on different starting positions:

Map.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ...
public void InstantiateNinja(int playerNumber, SpawnPoint spawnPoint, string sessionId)
{
    Ninja ninja = Instantiate(ninjaPrefab, transform);
    ninja.Initialize(spawnPoint, playerNumber, this, sessionId);
    Ninjas.Add(ninja);
    if (MultiplayerManager.Instance.Self.SessionId == sessionId)
        gameCamera.SetStartingPosition(spawnPoint.Coordinates);
}
// ...

Rollback #

Ninja Battle utilizes a basic implementation of rollback netcode. You can learn more about this concept here.

The RollbackVar handles saving of information on a timeline using a dictionary with int representing each tick of the gameloop and T being any desired type.

RollbackVar.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
using System.Collections.Generic;

namespace NinjaBattle.Game
{
    public class RollbackVar<T>
    {
        #region FIELDS

        private Dictionary<int, T> history = new Dictionary<int, T>();

        #endregion

        #region PROPERTIES

        public T this[int tick]
        {
            get
            {
                return history.ContainsKey(tick) ? history[tick] : default(T);
            }

            set
            {
                if (history.ContainsKey(tick))
                    history[tick] = value;
                else
                    history.Add(tick, value);
            }
        }

        #endregion

        #region BEHAVIORS

        public bool HasValue(int tick)
        {
            return history.ContainsKey(tick);
        }

        public T GetLastValue(int tick)
        {
            for (; tick >= 0; tick--)
                if (HasValue(tick))
                    return history[tick];

            return default(T);
        }

        public void EraseFuture(int tick)
        {
            List<int> keysToErase = new List<int>();
            foreach (KeyValuePair<int, T> keyValuePair in history)
                if (keyValuePair.Key > tick)
                    keysToErase.Add(keyValuePair.Key);

            foreach (int i in keysToErase)
                history.Remove(i);
        }

        #endregion
    }
}

Server side logic #

This section details the server-side game logic in Ninja Battle.

RPC #

For Ninja Battle, we register an RPC so we can send information from a client to the server from outside a match. This is done inside the InitModule of our main.ts file:

1
2
3
4
5
6
7
8
9
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer)
{
    initializer.registerRpc("JoinOrCreateMatchRpc", joinOrCreateMatch);
}

let joinOrCreateMatch: nkruntime.RpcFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, payload: string): string
{
    return "response";
}

This RPC can then be called from any client with an open socket connection:

1
return await client.RpcAsync(session, rpc, payload);

Match handler #

The match_handler is used to manage matches from beginning to end. Like the RPC above, it is also registered inside the InitModule of our main.ts file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const JoinOrCreateMatchRpc = "JoinOrCreateMatchRpc";
const LogicLoadedLoggerInfo = "Custom logic loaded.";
const MatchModuleName = "match";

function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer)
{
    initializer.registerRpc(JoinOrCreateMatchRpc, joinOrCreateMatch);
    initializer.registerMatch(MatchModuleName, {
        matchInit,
        matchJoinAttempt,
        matchJoin,
        matchLeave,
        matchLoop,
        matchTerminate,
        matchSignal
    });

    logger.info(LogicLoadedLoggerInfo);
}

All match logic is held by the match_handler in a dictionary that can be cast to an interface. Here we use an interface called GameState:

match_handler.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface GameState
{
    players: Player[]
    playersWins: number[]
    roundDeclaredWins: number[][]
    roundDeclaredDraw: number[]
    scene: Scene
    countdown: number
    endMatch: boolean
}

Match loop #

The matchLoop function is called each tick, on each tick all client messages sent during that period of time are received as a list. With this list you can then decide what to do with the messages: to send them back to the clients or execute custom logic.

match_handler.ts:

1
2
3
4
5
6
7
8
9
// ...
let matchLoop: nkruntime.MatchLoopFunction = function (context: nkruntime.Context, logger: nkruntime.Logger, nakama: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, messages: nkruntime.MatchMessage[])
{
    let gameState = state as GameState;
    processMessages(messages, gameState, dispatcher, nakama);
    processMatchLoop(gameState, nakama, dispatcher, logger);
    return gameState.endMatch ? null : { state: gameState };
}
// ...

When processed by the processMessages function if the message code contains custom logic then a function is called, alternatively the default logic is for the message to be send to all clients:

match_handler.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ...
function processMessages(messages: nkruntime.MatchMessage[], gameState: GameState, dispatcher: nkruntime.MatchDispatcher, nakama: nkruntime.Nakama): void
{
    for (let message of messages)
    {
        let opCode: number = message.opCode;
        if (MessagesLogic.hasOwnProperty(opCode)) {
            MessagesLogic[opCode](message, gameState, dispatcher, nakama);
        } else {
            messagesDefaultLogic(message, gameState, dispatcher);
        }
    }
}

function messagesDefaultLogic(message: nkruntime.MatchMessage, gameState: GameState, dispatcher: nkruntime.MatchDispatcher): void
{
    dispatcher.broadcastMessage(message.opCode, message.data, null, message.sender);
}
// ...

For Ninja Battle, all custom logic is registered in the consts.ts file.

Time counters #

Ninja Battle Lobby and RoundResults scenes employ a server run countdown to wait for the next scene:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function matchLoopLobby(gameState: GameState, nakama: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher): void
{
    if (gameState.countdown > 0 && getPlayersCount(gameState.players) > 1)
    {
        gameState.countdown--;
        if (gameState.countdown == 0)
        {
            gameState.scene = Scene.Battle;
            dispatcher.broadcastMessage(OperationCode.ChangeScene, JSON.stringify(gameState.scene));
            dispatcher.matchLabelUpdate(JSON.stringify({ open: false }));
        }
    }
}

The countdown is set on matchJoin with a duration in seconds multiplied to the tick rate of the server:

1
gameState.countdown = DurationLobby * TickRate;

Determining match winners #

Due to the use of rollback netcode in Ninja Battle, it is possible for erroneous winner messages to be sent by a client. For the server to trust the client message and determine a winner, all client messages must state the same outcome as occurring on the same tick:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function playerWon(message: nkruntime.MatchMessage, gameState: GameState, dispatcher: nkruntime.MatchDispatcher, nakama: nkruntime.Nakama): void
{
    if (gameState.scene != Scene.Battle || gameState.countdown > 0)
        return;

    let data: PlayerWonData = JSON.parse(nakama.binaryToString(message.data));
    let tick: number = data.tick;
    let playerNumber: number = data.playerNumber;
    if (gameState.roundDeclaredWins[tick] == undefined)
        gameState.roundDeclaredWins[tick] = [];

    if (gameState.roundDeclaredWins[tick][playerNumber] == undefined)
        gameState.roundDeclaredWins[tick][playerNumber] = 0;

    gameState.roundDeclaredWins[tick][playerNumber]++;
    if (gameState.roundDeclaredWins[tick][playerNumber] < getPlayersCount(gameState.players))
        return;

    gameState.playersWins[playerNumber]++;
    gameState.countdown = DurationBattleEnding * TickRate;
    dispatcher.broadcastMessage(message.opCode, message.data, null, message.sender);
}

Ending matches #

A match ends when any player has reached three victories or all players have disconnected. To end a match, return null on any of the match functions:

1
return gameState.endMatch ? null : { state: gameState };

Incrementing user trophies #

In Ninja Battle the winning player receives a trophy when the match ends. To store a player’s trophies we must first perform a storage read for that user, increment the existing value by the desired amount, and then perform a storage write with the new value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let storageReadRequests: nkruntime.StorageReadRequest[] = [{
    collection: CollectionUser,
    key: KeyTrophies,
    userId: winner.presence.userId
}];

let result: nkruntime.StorageObject[] = nakama.storageRead(storageReadRequests);
var trophiesData: TrophiesData = { amount: 0 };
for (let storageObject of result)
{
    trophiesData = <TrophiesData>storageObject.value;
    break;
}

trophiesData.amount++;
let storageWriteRequests: nkruntime.StorageWriteRequest[] = [{
    collection: CollectionUser,
    key: KeyTrophies,
    userId: winner.presence.userId,
    value: trophiesData
}];

nakama.storageWrite(storageWriteRequests);