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
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 :
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
:
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 );