Home Nakama Authoritative Multiplayer
Post
Cancel

Nakama Authoritative Multiplayer

This page is being deprecated, more concise and up to date information can be found here

This is not a tutorial nor am I an expert in anything here. I am learning and documenting as I learn. Things here may be wrong, feel free to point them out and reach out to me :)

Introduction

As mentioned in the previous post Nakama has the capacity to run custom server logic within the Nakama instance. I want to dive deeper into this functionality, from my initial research this appears to be easy enough although the documentation seems vague and not intuitive for bird brains like myself.

RPCs

Judging from the documentation and several hours’ worth of online content you must define server logic into functions called Remote Procedure Calls or RPCs these are bootstrapped onto your Nakama instance as described in my previous article. These server-side functions are used to execute functions as we don’t trust the client to execute such as hit detection, points earned, profanity filters, etc. RPCs can be called by other servers or the clients themselves.

Example Logic

server side logic example:

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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
var InitModule = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
    initializer.registerRpc('healthcheck', rpcHealthCheck);
    initializer.registerRpc('createtestmatch', rpcCreatetestMatch);
    initializer.registerMatch(MatchModule, {
        matchInit: matchInit,
        matchJoinAttempt: matchJoinAttempt,
        matchJoin: matchJoin,
        matchLeave: matchLeave,
        matchLoop: matchLoop,
        matchSignal: matchSignal,
        matchTerminate: matchTerminate
    });
};
function rpcCreatetestMatch(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
    let matchName = payload;
    let matchId = nk.matchCreate(testMatchModule, { match_name: matchName});
    logger.info("test match created manually");
    return JSON.stringify({ success: true, matchid: matchId });
}
const matchInit = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, params: {[key: string]: string}): {state: nkruntime.MatchState, tickRate: number, label: string} {
    logger.debug('Lobby match created');
  
    const presences: nkruntime.Presence[] = [];
    return {
      /*
        playerData will be a data container that holds player position and rotation among other data...
        emptyTicks is the number of ticks that have passed that we deem to be useless or 'empty' perhaps if there is no players or less than some number of players...
      */
      state: { presences: presences, playerData: {}, emptyTicks: 0, match_name: params.match_name},
      tickRate: 30,
      label: JSON.stringify({ mode: 'match', match_name: params.match_name})
    };
  };
  
  const matchJoinAttempt = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, presence: nkruntime.Presence, metadata: {[key: string]: any }) : {state: nkruntime.MatchState, accept: boolean, rejectMessage?: string | undefined } | null {
    logger.debug('%q attempted to join Lobby match', ctx.userId);
    return {
      state,
      accept: true
    };
  }
  
  const matchJoin = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, presences: nkruntime.Presence[]) : { state: nkruntime.MatchState } | null {
    presences.forEach(function (presence) {
      state.presences[presence.userId] = presence;
      logger.debug('%q joined Lobby match', presence.userId);
    });
    return {
      state
    };
  }
  
  const matchLeave = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, presences: nkruntime.Presence[]) : { state: nkruntime.MatchState } | null {
    presences.forEach(function (presence) {
      delete (state.presences[presence.userId]);
      logger.debug('%q left Lobby match', presence.userId);
    });
    return {
      state
    };
  }
  
  const matchLoop = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, messages: nkruntime.MatchMessage[]) : { state: nkruntime.MatchState} | null {
    // if there are no players present increment empty ticks...
    if (state.presences.length < 1) state.emptyTicks++;
    if (state.emptyTicks > 10000) return null;

    // for each message recieved from clients...
    messages.forEach(function (message) {
      logger.info('Received %v from %v', message.data, message.sender.userId);
      // decode data and parse with json...
      let data = JSON.parse(nk.binaryToString(message.data));
      // log data...
      logger.info('data: %v', JSON.stringify({userid:message.sender.userId, data:data}));
      // store data to player data map...
      state.player_data[message.sender.userId] = data;
      // process player data for transmission...
      let send_data = JSON.stringify(state.player_data);

      // send data to all present clients...
      dispatcher.broadcastMessage(10, send_data, null, null, true);
    });
    return {
      state
    };
  }
  
  const matchTerminate = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, graceSeconds: number) : { state: nkruntime.MatchState} | null {
    logger.debug('Lobby match terminated');
  
    const message = `Server shutting down in ${graceSeconds} seconds.`;
    dispatcher.broadcastMessage(2, message, null, null);
    return {
      state
    };
  }
  
  const matchSignal = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, dispatcher: nkruntime.MatchDispatcher, tick: number, state: nkruntime.MatchState, data: string) : { state: nkruntime.MatchState, data?: string } | null {
    logger.debug('Lobby match signal received: ' + data);
    return {
      state,
      data: "Lobby match signal received: " + data
    };
  }

If we want authoritative multiplayer then either the match must be initalized by the server manually or throught matchmaking, if you want the users to be able to start a new authoritative match you must impelment a custom RPC function that will create the match on behalf of the user and have them call that function or impleament a match making system that will iniialize matches after a certain number of people or other ceritera is met.

Initalize Match

After bootstrapping your server logic onto your Nakama instance and you’ve verified everything is working, you are set to start. To start you need to decide how players will find or create matches. Some games only offer matchmaking where users are placed into pools of other players who fit some criteria and are also looking to find a match, once sufficient amounts of players have been found a match is created and players can play. In other games, players create match instances where other players can join. The ladder is seemingly the easier of the two right now so that is what this example will cover. As you may have noticed there exist an RPC function called matchInit this is how our match instance is created. In this function, you define how the match will be structured things like states, player array, tickRate, Match labels, etc. Once the match Init has completed it’s functions Nakama will immediately enter the matchLoop. The matchLoop is where you will handle things like starting a match, ending a match, and player calculations.

On the client we can call the create match to call the matchInit rpc.

1
2
3
4
    let client = new nakamajs.Client("defaultkey", "nakama.xxxx.xxx", "7350", true);
    let socket = client.createSocket(true);
    await socket.connect(session, true);
    let match = await client.rpc(session, 'rpcCreateMatch', "Match_1");

So upon deep diving Nakama for client-side prediction, it has come to my attention that Nakama on its own is very poor for things like shooters or any other physics-dependent competitive game -_-

So I’ve spent quite a bit of time sitting on this and doing some additional research I have since learned it’s not really necessary to have a completely authoritative multiplayer, most smaller games actually use a slight mix of both as a completely authoritative server is very expensive.

Conclusion

So I started this September 17th and it is now October 30th, I have run into so many roadblocks and issues setting up a simple multiplayer scene, I’ve updated some of the code here to better reflect that and will create a supplementary article breaking down all the steps to create a simple multiplayer scene with a lobby menu, joinable matches, handling player animations, positions, rotations, etc. I feel this is easier than appending it to this article since it would require refactoring the basic code here and complicate things further.

This post is licensed under CC BY 4.0 by the author.