Heroic Labs Documentation

Passive Multiplayer #

Implementing passive multiplayer gameplay is accomplished differently depending on the game, if it is entirely asynchronous or has some elements of real-time gameplay. This guide provides a high-level example for a game that is entirely asynchronous.

In this model a group is used to represent the multiplayer match, with any match participants being members of this group.

For this example the owner of the group is the system user, but it can also be an individual user that creates the group (match).

Server
1
2
3
4
5
6
7
8
9
let userId = '<creatorUserId>';
let name = 'My First Passive MP Match';
let description = 'This is my first passive multiplayer match';
let lang = 'en';
let open = true;
let avatarURL = '';

let group = {} as nkruntime.Group;
group = nk.groupCreate(userId, name, userId, lang, description, avatarURL, open);
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
userId := "<creatorUserId>"
name := "My First Passive MP Match"
description := "This is my first passive multiplayer match"
lang := "en"
open := true
avatarURL := ""
metaData := make(map[string]interface{})
maxCount := 100

group, err := nk.GroupCreate(ctx, userId, name, userId, lang, description, avatarURL, open, metaData, maxCount)
if err != nil {
  logger.Error("unable to create group: %v", err)
  return err
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
local user_id = "<creatorUserId>"
local name = "My First Passive MP Match"
local description = "This is my first passive multiplayer match"
local lang = "en"
local open = true
local avatar_url = ""
local metadata = {}
local max_count = 100

local group, err = nk.group_create(user_id, name, user_id, lang, description, avatar_url, open, metadata, max_count)

With the groupId returned after the group is created, players can join directly or be added via server runtime function.

Server
1
2
3
4
let groupId = '<groupId>';
let userIds = ['<userId>', '<userId1>', '<userId2>'];

nk.groupUsersAdd(groupId, userIds);
Server
1
2
3
4
5
6
7
8
callerId := "<creatorUserId>"
groupId := "<groupId>"
userIds := []string{"<userId>", "<userId1>", "<userId2>"}
err := nk.GroupUsersAdd(ctx, callerId, groupId, userIds)
if err != nil {
  logger.Error("unable to add group users: %v", err)
  return err
}
Server
1
2
3
local group_id = "<groupId>"
local user_ids = { "<userId>", "<userId1>", "<userId2>" }
nk.group_users_add(group_id, user_ids)

The match state for it is stored in a collection, with each match’s group ID used as the key:

Server
1
2
3
4
5
let newObjects = nkruntime.StorageWriteRequest[] = [
    { collection: 'matchState', key: '<groupId>', userId: '<creatorUserId>', value: {} },
];

nk.storageWrite(newObjects);
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
acks, err := nk.StorageWrite(ctx, []*runtime.StorageWrite {
  {
    Collection: "matchState",
    Key: "<groupId>",
    UserID: "<creatorUserId>",
    Value: "<JsonValue>",
  },
})

if err != nil {
  logger.Error("unable to write storage objects: %v", err)
  return err
}
Server
1
2
3
4
5
local new_objects = {
    { collection = "matchState", key = "<groupId>", user_id = "<creatorUserId>", value = {} },
}

nk.storage_write(new_objects)

During the course of the match, RPC functions can be used to update the match state and send notifications to the participants.

Server
  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
105
106
107
108
109
110
111
112
// Define a struct for the payload data
type moveData struct {
  GroupId string `json:"groupId"`
  Move    string `json:"move"`
}

// Define a struct for the match state
type matchState struct {
  CreatorId string              `json:"creatorId"`
  GroupId   string              `json:"groupId"`
  Players   []string            `json:"players"`
  Moves     map[string][]string `json:"moves"`
}

// This RPC takes a player's move (such as a Chess move) and applies it to the match state before broadcasting the move to all other players
if err := initializer.RegisterRpc("make_move", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  // Get the user id
  userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
  if !ok {
    logger.Error("unable to get user id")
    return "", runtime.NewError("unable to find user id", 13)
  }

  // Unmarshal the payload
  var data moveData
  if err := json.Unmarshal([]byte(payload), &data); err != nil {
    logger.Error("unable to unmarshal payload: %v", err)
    return "", runtime.NewError("invalid payload", 3)
  }

  // Get the group
  groups, err := nk.GroupsGetId(ctx, []string{data.GroupId})
  if err != nil {
    logger.Error("unable to find group: %v", err)
    return "", runtime.NewError("invalid group id", 3)
  }

  if len(groups) == 0 {
    logger.Error("group does not exist")
    return "", runtime.NewError("group does not exist", 5)
  }

  group := groups[0]

  // Get the current match state
  records, err := nk.StorageRead(ctx, []*runtime.StorageRead{
    {
      Collection: "matchState",
      Key:        data.GroupId,
      UserID:     group.CreatorId,
    },
  })

  if err != nil || len(records) == 0 {
    logger.Error("unable to find match state")
    return "", runtime.NewError("unable to find match state", 13)
  }

  /*
    Given a match state with a structure such as:
    {
      "creatorId": "<creatorId>",
      "groupId": "<groupId>",
      "players": [ "<userId>" ],
      "moves": {
      "<userId>": [ "e4", "Nf3" ],
      "<userId2>": [ "e5" ]
      },
    }
  */
  var matchState matchState
  if err := json.Unmarshal([]byte(records[0].Value), &matchState); err != nil {
    logger.Error("unable to unmarshal match state: %v", err)
    return "", runtime.NewError("unable to get match state", 13)
  }

  // Add the player's move to their move list
  // This example assumes the game is something similar to Chess
  // Note: You would normally validate this move before applying it
  matchState.Moves[userId] = append(matchState.Moves[userId], data.Move)

  // Notify all players of the player's move
  for _, playerUserId := range matchState.Players {
    nk.NotificationSend(ctx, playerUserId, "", map[string]interface{}{
      "matchId": matchState.GroupId,
      "userId":  matchState.CreatorId,
      "move":    data.Move,
    }, 1, matchState.CreatorId, true)
  }

  // Marshal the response
  matchStateJson, err := json.Marshal(matchState)
  if err != nil {
    logger.Error("error marshaling response: %v", err)
    return "", runtime.NewError("internal error", 13)
  }

  // Update the match state
  nk.StorageWrite(ctx, []*runtime.StorageWrite{
    {
      Collection: "matchState",
      Key:        matchState.GroupId,
      UserID:     matchState.CreatorId,
      Value:      string(matchStateJson),
    },
  })

  return "{ \"success\": true }", nil
}); err != nil {
  logger.Error("unable to register make_move rpc: %v", err)
  return err
}
Server
 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
// This RPC takes a player's move (such as a Chess move) and applies it to the match state before broadcasting the move to all other players
let RpcMakeMove : nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string) {
  const data = JSON.parse(payload);

  // Get the group
  const groups = nk.groupsGetId([data.groupId]);

  if (groups.length === 0) {
    logger.error("unable to find group");
    return JSON.stringify({ error: "unable to find group" });
  }

  const group = groups[0];

  // Get the current match state
  const results = nk.storageRead([{
    collection: "matchState",
    key: group.id,
    userId: group.creatorId
  }]);

  if (results.length === 0) {
    logger.error("unable to find match state");
    return JSON.stringify({ error: "unable to find match state" });
  }

  /* Given a match state with a structure such as:
  {
    "creatorId": "<creatorId>",
    "groupId": "<groupId>",
    "players": [ "<userId>" ],
    "moves": {
      "<userId>": [ "e4", "Nf3" ],
      "<userId2>": [ "e5" ]
    },
  }
  */
  const matchState = results[0].value;

  // Add the player's move to their move list
  // This example assumes the game is something similar to Chess
  // Note: You would normally validate this move before applying it
  matchState.moves[ctx.userId].push(data.move); // e.g. Nf3

  // Notify all players of the player's move
  matchState.players.forEach(function (userId) {
    nk.notificationSend(userId, "match_move", { matchId: matchState.groupId, userId: ctx.userId, move: data.move }, 1, matchState.creatorId);
  });

  // Update the match state
  nk.storageWrite([{
    collection: "matchState",
    key: matchState.groupId,
    userId: matchState.creatorId,
    value: matchState
  }]);

  return JSON.stringify({ success: true });
};
Server
 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
-- This RPC takes a player's move (such as a Chess move) and applies it to the match state before broadcasting the move to all other players
nk.register_rpc(function(context, payload)
    local data = nk.json_decode(payload)
    local groups = nk.groups_get_id({ data.groupId })

    -- Get the group
    if #groups == 0 then
        nk.logger_error("unable to find group")
        return nk.json_encode({ error = "unable to find group" })
    end

    local group = groups[1]

    -- Get the current match state
    local objects = nk.storage_read({
        {
            collection = "matchState",
            key = group.id,
            user_id = group.creator_id
        }
    })

    if #objects == 0 then
        nk.logger_error("unable to find match state")
        return nk.json_encode({ error = "unable to find match state" })
    end

    --[[
    Given a match state with a structure such as:
    {
      "creatorId": "<creatorId>",
      "groupId": "<groupId>",
      "players": [ "<userId>" ],
      "moves": {
        "<userId>": [ "e4", "Nf3" ],
        "<userId2>": [ "e5" ]
      },
    }
    --]]
    local match_state = objects[1].value

    -- Add the player's move to their move list
    -- This example assumes the game is something similar to Chess
    -- Note: You would normally validate this move before applying it
    table.insert(match_state.moves[context.user_id], data.move) -- e.g. Nf3

    -- Notify all players of the player's move
    for _, user_id in ipairs(match_state.players) do
        nk.notification_send(user_id, "match_move", { match_id = match_state["groupId"], user_id = context.user_id, move = data.move }, 1, match_state["creatorId"], true)
    end

    -- Update the match state
    nk.storage_write({
        {
            collection = "matchState",
            key = match_state["groupId"],
            user_id = match_state["creatorId"],
            value = match_state
        }
    })

    return nk.json_encode({ success = true })
end, "make_move")