Heroic Labs Documentation

Client Relayed Multiplayer #

The real-time multiplayer engine makes it easy for users to set up and join matches where they can rapidly exchange data with opponents. This relayed multiplayer (also known as client-authoritative) model is suitable for many game types, such as simple 1vs1 or co-op games, where authoritative control on the server is not important (e.g. cheating is a non-factor).

In relayed multiplayer, Nakama facilitates the exchange of data regardless of message size or content. Any data sent through a match is immediately routed to the client requested match opponents. The only match data maintained by Nakama are the match ID and list of presences in that match.

Client messages destined for the other connected clients are forwarded by the server without inspection. Since Nakama does not track the amount or content of the data forwarded in a relayed multiplayer match, there is no cheat detection, error correction, or other such functionality available. This approach relies on one client in each match, decided upon by the clients themselves, to act as the host. This host will reconcile state changes between peers and perform arbitration on ambiguous or malicious messages sent from bad clients.

Any user can participate in matches with other users, there is no ability to password protect or otherwise restrict access to a match. There is no explicit limit on the number of players in a match, only the practical limits imposed by your game design (the message size and frequency) and available resources (server hardware and network capability).

Users can create, join, and leave matches with messages sent from clients.

Matches exists on the server only until the last participant has left. They are kept in-memory and cannot be persisted.

Create a match #

A new match can be created by any user by explicitly calling the “match create” operation. No match state can be provided by the creator when creating a match.

The server will assign a unique ID to the new match. This ID can be shared with other users for them to join the match.

Client
1
2
var response = await socket.createMatch();
console.log("Created match with ID:", response.match.match_id);
Client
1
2
var match = await socket.CreateMatchAsync();
Console.WriteLine("New match with id '{0}'.", match.Id);
Client
1
2
3
4
rtClient->createMatch([](const NMatch& match)
{
    std::cout << "Created Match with ID: " << match.matchId << std::endl;
});
Client
1
2
Match match = socket.createMatch().get();
System.out.format("Created match with ID %s.", match.getId());
Client
1
2
3
4
5
6
7
var created_match : NakamaRTAPI.Match = yield(socket.create_match_async(), "completed")

if created_match.is_exception():
    print("An error occurred: %s" % created_match)
    return

print("New match with id %s.", created_match.match_id)
Client
1
2
3
4
5
6
7
8
local result = socket.match_create()

if result.error then
  print(result.error.message)
  return
end

print("Created match with ID", result.match.match_id)

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Users can optionally provide a name when creating a new match. This name is used to generate the match ID, meaning two players creating matches with the same name will end up with identical match IDs and, as a result, in the same match.

Client
1
2
var matchName = "Heroes";
var match = await client.CreateMatchAsync(matchName);
Client
1
2
var match_name = "Heroes";
var match : NakamaRTAPI.Match = yield(socket.create_match_async(matchName), "completed");

Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.
Code snippet for this language C++/Unreal/Cocos2d-x has not been found. Please choose another language to show equivalent examples.
Code snippet for this language JavaScript/Cocos2d-js has not been found. Please choose another language to show equivalent examples.
Code snippet for this language Java/Android has not been found. Please choose another language to show equivalent examples.
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

A user can leave a match at any point, which will notify all other users. When all users have left the match it ceases to exist.

Join a match #

A user can join a specific match using its ID. Matches cannot be password protected or otherwise closed, if a user has the match ID then they are able to join. Matches can be joined at any point until the last participant leaves.

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var id = "<matchid>";
var match = await socket.joinMatch(id);

var connectedOpponents = match.presences.filter((presence) => {
  // Remove your own user from list.
  return presence.user_id != match.self.user_id;
});

connectedOpponents.forEach((opponent) => {
  console.log("User id %o, username %o.", opponent.user_id, opponent.username);
});
Client
1
2
3
4
5
6
7
var matchId = "<matchid>";
var match = await socket.JoinMatchAsync(matchId);

foreach (var presence in match.Presences)
{
    Console.WriteLine("User id '{0}' name '{1}'.", presence.UserId, presence.Username);
}
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
string matchId = "<matchid>";
rtClient->joinMatch(matchId, {}, [](const NMatch& match)
{
    std::cout << "Joined Match!" << std::endl;

    for (auto& presence : match.presences)
    {
        if (presence.userId != match.self.userId)
        {
            std::cout << "User id " << presence.userId << " username " << presence.username << std::endl;
        }
    }
});
Client
1
2
3
4
5
6
String matchId = "<matchid>";
Match match = socket.joinMatch(matchId).get();

for (UserPresence presence : match.getPresences()) {
    System.out.format("User id %s name %s.", presence.getUserId(), presence.getUsername());
}
Client
1
2
3
4
5
6
7
8
9
var match_id = "<matchid>"
var joined_match = yield(socket.join_match_async(match_id), "completed")

if joined_match.is_exception():
    print("An error occurred: %s" % joined_match)
    return

for presence in joined_match.presences:
    print("User id %s name %s'." % [presence.user_id, presence.username])
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
local match_id = "<matchid>"
local result = socket.match_join(match_id)

if result.error then
  print(result.error.message)
  return
end

for _,user in  ipairs(result.match.presences) do
  print("User id", user.user_id, "name", user.name)
end

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Upon joining a match, a list of match opponents is returned in the success callback. Keep in mind this list might not include all users, it contains users who are connected to the match at that point in time.

List opponents #

When a user creates or joins a new match they receive an initial list of connected opponents. After this initial list, the server pushes events to connected clients with the match joins and match leaves that occur - if no changes to the presence list occur then no server updates are sent. Events are batched for efficiency, meaning any event can contain multiple joins and/or leaves.

These events can be used to update the list of connected opponents so your players can see an accurate view of all match participants.

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var connectedOpponents = [];
socket.onmatchpresence = (presences) => {
  // Remove all users who left.
  connectedOpponents = connectedOpponents.filter(function(co) {
    var stillConnectedOpponent = true;

    presences.leaves.forEach((leftOpponent) => {
      if (leftOpponent.user_id == co.user_id) {
        stillConnectedOpponent = false;
      }
    });

    return stillConnectedOpponent;
  });

  // Add all users who joined.
  connectedOpponents = connectedOpponents.concat(presences.joins);
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var connectedOpponents = new List<IUserPresence>(2);

socket.ReceivedMatchPresence += presenceEvent =>
{
    foreach (var presence in presenceEvent.Leaves)
    {
        connectedOpponents.Remove(presence);
    }

    connectedOpponents.AddRange(presenceEvent.Joins);

    // Remove yourself from connected opponents.
    connectedOpponents.Remove(self);
    Console.WriteLine("Connected opponents: [{0}]", string.Join(",\n  ", connectedOpponents));
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
rtListener->setMatchPresenceCallback([](const NMatchPresenceEvent& event)
{
    for (auto& presence : event.joins)
    {
        std::cout << "Joined user: " << presence.username << std::endl;
    }

    for (auto& presence : event.leaves)
    {
        std::cout << "Left user: " << presence.username << std::endl;
    }
});
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
List<UserPresence> connectedOpponents = new ArrayList<UserPresence>();

public void onMatchPresence(final MatchPresenceEvent matchPresence) {
    connectedOpponents.addAll(matchPresence.getJoins());

    for (UserPresence leave : matchPresence.getLeaves()) {
        for (int i = 0; i < connectedOpponents.size(); i++) {
            if (connectedOpponents.get(i).getUserId().equals(leave.getUserId())) {
                connectedOpponents.remove(i);
            }
        }
    };
});
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var connected_opponents = {}

func _ready():
    # First, setup the socket as explained in the authentication section.
    socket.connect("received_match_presence", self, "_on_match_presence")

func _on_match_presence(p_presence : NakamaRTAPI.MatchPresenceEvent):
    for p in p_presence.joins:
        connected_opponents[p.user_id] = p

    for p in p_presence.leaves:
        connected_opponents.erase(p.user_id)

    print("Connected opponents: %s" % [connected_opponents])
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
local connected_opponents = {}
socket.on_matchpresence(function(message)
  for _,p in ipairs(message.match_presence_event.leaves) do
    connected_opponents[p.user_id] = nil
  end

  for _,p in ipairs(message.match_presence_event.joins) do
    connected_opponents[p.user_id] = p
  end
end)

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Some best practices to keep in mind for listing opponents:

  • Register a client-side presence event listener before joining or creating a match
  • For batched events with both a join and leave for the same presence, process the leave then join for presences already in the list and process the join then leave for presences not in the list

Send data messages #

A user in a match can send data messages which will be received by all other opponents. These messages are streamed in real-time to the destined clients and can contain any binary content. Nakama broadcasts messages in the order received, not necessarily in the order they are sent.

The binary content in each data message should be as small as possible within the maximum transmission unit (MTU) of 1500 bytes. It is common to use JSON and preferable to use a compact binary format like Protocol Buffers or FlatBuffers.

When further reducing the message size and/or frequency is not possible, it is best to prioritize sending fewer messages. For example, 1 message of 1000 bytes per second is better than 5 messages of 200 bytes per second.

To identify each message as a specific “command” it contains an Op code as well as the payload.

Client
1
2
3
4
var id = "<matchid>";
var opCode = 1;
var data = { "move": {"dir": "left", "steps": 4} };
socket.sendMatchState(id, opCode, data);
Client
1
2
3
4
5
// using Nakama.TinyJson;
var matchId = "<matchid>";
var opCode = 1;
var newState = new Dictionary<string, string> {{"hello", "world"}}.ToJson();
socket.SendMatchStateAsync(matchId, opCode, newState);
Client
1
2
3
4
string id = "<matchid>";
int64_t opCode = 1;
NBytes data = "{ \"move\": {\"dir\": \"left\", \"steps\" : 4} }";
rtClient->sendMatchData(id, opCode, data);
Client
1
2
3
4
String id = "<matchid>";
int opCode = 1;
String data = "{\"message\":\"Hello world\"}";
socket.sendMatchData(id, opCode, data);
Client
1
2
3
4
var match_id = "<matchid>"
var op_code = 1
var new_state = {"hello": "world"}
socket.send_match_state_async(match_id, op_code, JSON.print(new_state))
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
local match_id = "<matchid>"
local op_code = 1

local data = json.encode({
  move = {
    dir = "left",
    steps = 4
  }
})

local result = socket.match_data_send(match_id, op_code, data)

if result.error then
  print(result.error.message)
  return
end

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

While messages are broadcast to all other match presences by default, users can optionally specify a desired subset of the match participants (i.e. their friends, teammates, etc.) to receive the message exclusively.

Client
1
2
// Only send data to the first presence in the match presences array
socket.sendMatchState(id, opCode, data, [match.presences[0]]);
Client
1
2
// Only send data to the first presence in the match presences array
await socket.SendMatchStateAsync(matchId, opCode, newState, new [] { match.presences.First() });
Client
1
2
// Only send data to the first presence in the match presences array
rtClient->sendMatchData(id, opCode, data, { match.presences[0] });
Client
1
2
// Only send data to the first presence in the match presences array
socket.sendMatchData(id, opCode, data, match.getPresences().get(0));
Client
1
2
// Only send data to the first presence in the match presences array
socket.send_match_state_async(match_id, op_code, JSON.print(new_state), [current_match.presences[0]])
Client
1
2
-- Only send data to the first presence in the match presences array
local result = socket.match_data_send(match_id, op_code, data, { match.presences[0] })

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Op codes #

An op code is a numeric identifier for the type of message sent. Op codes can provide insight into the purpose and content of a message before you decode it.

They can be used to define commands within the gameplay which belong to certain user actions, such as:

  • Initial state synchronization
  • Ready status
  • Ping / Pong
  • Game state update
  • Emote

See the Fish Game tutorial for an example implementation.

Receive data messages #

The server delivers data in the order it processes data messages from clients. A client can add a callback for incoming match data messages. This should be done before they create (or join) and leave a match.

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
socket.onmatchdata = (result) => {
  var content = result.data;

  switch (result.op_code) {
    case 101:
      console.log("A custom opcode.");
      break;
    default:
      console.log("User %o sent %o", result.presence.user_id, content);
  }
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Use whatever decoder for your message contents.
var enc = System.Text.Encoding.UTF8;
socket.ReceivedMatchState += newState =>
{
    var content = enc.GetString(newState.State);

    switch (newState.OpCode)
    {
        case 101:
            Console.WriteLine("A custom opcode.");
            break;
        default:
            Console.WriteLine("User '{0}'' sent '{1}'", newState.UserPresence.Username, content);
    }
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
rtListener->setMatchDataCallback([](const NMatchData& data)
{
    switch (data.opCode)
    {
        case 101:
            std::cout << "A custom opcode." << std::endl;
            break;
        default:
            std::cout << "User " << data.presence.userId << " sent " << data.data << std::endl;
            break;
    }
});
Client
1
2
3
4
5
6
SocketListener listener = new AbstractSocketListener() {
    @Override
    public void onMatchData(final MatchData matchData) {
        System.out.format("Received match data %s with opcode %d", matchData.getData(), matchData.getOpCode());
    }
};
Client
1
2
3
4
5
6
func _ready():
    # First, setup the socket as explained in the authentication section.
    socket.connect("received_match_state", self, "_on_match_state")

func _on_match_state(p_state : NakamaRTAPI.MatchData):
    print("Received match state with opcode %s, data %s" % [p_state.op_code, parse_json(p_state.data)])
Client
1
2
3
4
socket.on_matchdata(function(message)
  local data = json.decode(message.match_data.data)
  local op_code = tonumber(message.match_data.op_code)
end)

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Leave a match #

Users can leave a match at any point. This may occur voluntarily via client action (quitting the match) or involuntarily (e.g. network connectivity), but in either case it must be accounted for and handled appropriately in your game logic.

Client
1
2
var id = "<matchid>";
socket.leaveMatch(id);
Client
1
2
var matchId = "<matchid>";
await socket.LeaveMatchAsync(matchId);
Client
1
2
string matchId = "<matchid>";
rtClient->leaveMatch(matchId);
Client
1
2
String matchId = "<matchid>";
socket.leaveMatch(matchId).get();
Client
1
2
3
4
5
6
var match_id = "<matchid>"
var leave : NakamaAsyncResult = yield(socket.leave_match_async(match_id), "completed")
if leave.is_exception():
    print("An error occurred: %s" % leave)
    return
print("Match left")
Client
1
2
3
4
5
6
local match_id = "<matchid>"
local result = socket.match_leave(match_id)
if result.error then
  print(result.error.message)
  return
end

Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

A match ends when all users have left. At that time it’s ID becomes invalid and cannot be re-used to join again.

Examples #

Match host rotation #

You must determine how the match host will be selected from among the connected clients. This is best implemented without requiring any “negotiation” between the match participants while still ensuring that all clients acknowledge the same host.

You can accomplish this by deterministically sorting the match presences and selecting a host based on any desired factor. In the example below, we sort the presence list and select the lowest indexed session ID as the host:

Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Declare a variable to store which presence is the host
var hostPresence;

// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
socket.onmatchmakermatched = (matchmakerMatched) => {
  var hostSessionId = matchmakerMatched.users.map(user => user.presence.session_id).sort();
  hostPresence = matchmakerMatched.users.filter(user => user.presence.session_id == hostSessionId)[0];
};

// When receiving a match presence event, check if the host left and if so recalculate the host presence
socket.onmatchpresence = (matchPresence) => {
  if (matchPresence.leaves.find(presence => presence.user_id === hostPresence.user_id))
  {
    var hostSessionId = match.presences.map(presence => presence.session_id).sort();
    hostPresence = match.presences.filter(presence => presence.session_id === hostSessionId)[0];
  }
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Declare a variable to store which presence is the host
IUserPresence hostPresence;

// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
socket.ReceivedMatchmakerMatched += matched =>
{
  hostPresence = matched.Users.OrderBy(x => x.Presence.SessionId).First().Presence;
};

// When receiving a match presence event, check if the host left and if so recalculate the host presence
socket.ReceivedMatchPresence += matchPresenceEvent =>
{
  if (matchPresenceEvent.Leaves.Any(x => x.UserId == hostPresence.UserId))
  {
    hostPresence = match.Presences.OrderBy(x => x.SessionId).First();
  }
};
Client
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Declare a variable to store which presence is the host
NUserPresence hostPresence;

// Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
listener.setMatchmakerMatchedCallback([&done, &hostPresence](NMatchmakerMatchedPtr matchmakerMatched) {
    std::sort(matchmakerMatched->users.begin(), matchmakerMatched->users.end(), [](const NMatchmakerUser lhs, const NMatchmakerUser rhs) {
        return lhs.presence.sessionId < rhs.presence.sessionId;
    });
    hostPresence = matchmakerMatched->users[0].presence;
});

// When receiving a match presence event, check if the host left and if so recalculate the host presence
listener.setMatchPresenceCallback([&match, &hostPresence](NMatchPresenceEvent matchPresenceEvent) {
    for (int i = 0; i < matchPresenceEvent.leaves.size(); i++) {
        if (matchPresenceEvent.leaves[i].sessionId == hostPresence.sessionId) {
            std::sort(match.presences.begin(), match.presences.end(), [](const NUserPresence lhs, const NUserPresence rhs) {
                return lhs.sessionId < rhs.sessionId;
            });
            hostPresence = match.presences[0];
        }
    }
});
Client
 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
// Declare a variable to store which presence is the host (as a final 1 length array so we can access it correctly)
final UserPresence[] hostPresence = new UserPresence[1];

SocketListener socketListener = new SocketListener() {
  // Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
  @Override
  public void onMatchmakerMatched(MatchmakerMatched matchmakerMatched) {
    List<MatchmakerUser> users = matchmakerMatched.getUsers();
    users.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.getPresence().getSessionId(), b.getPresence().getSessionId()));
    hostPresence[0] = users.get(0).getPresence();
  }

  // When receiving a match presence event, check if the host left and if so recalculate the host presence
  @Override
  public void onMatchPresence(MatchPresenceEvent e) {
    if (e.getLeaves() != null) {
      e.getLeaves().forEach(presence -> {
        if (presence.getSessionId() == hostPresence[0].getSessionId()) {
          List<UserPresence> matchPresences = match.getPresences();
          matchPresences.sort((a, b) -> String.CASE_INSENSITIVE_ORDER.compare(a.getSessionId(), b.getSessionId()));
          hostPresence[0] = matchPresences.get(0);
        }
      });
    }
  }
}
Client
 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
# Declare a variable to store which presence is the host
var host_presence : NakamaRTAPI.UserPresence

# Define comparer functions
func _presence_comparer(a : NakamaRTAPI.UserPresence, b : NakamaRTAPI.UserPresence):
	return a.session_id < b.session_id

func _user_comparer(a : NakamaRTAPI.MatchmakerUser, b : NakamaRTAPI.MatchmakerUser):
	return a.presence.session_id < b.presence.session_id

# Upon receiving a matchmaker matched event, deterministically calculate the host by sorting the session Ids
func _on_matchmaker_matched(matchmaker_matched : NakamaRTAPI.MatchmakerMatched):
	matchmaker_matched.users.sort_custom(self, "_user_comparer")
	host_presence = matchmaker_matched.users[0].presence
	current_match = yield(socket.join_match_async(matchmaker_matched.match_id), "completed")

# When receiving a match presence event, check if the host left and if so recalculate the host presence
func _on_match_presence(match_presence_event : NakamaRTAPI.MatchPresenceEvent):
	for presence in match_presence_event.leaves:
		if presence.session_id == host_presence.session_id:
			current_match.presences.sort_custom(self, "_presence_comparer")
			if len(current_match.presences) < 1:
				host_presence = current_match.self_user
			else:
				host_presence = current_match.presences[0]

Code snippet for this language Defold has not been found. Please choose another language to show equivalent examples.
Code snippet for this language cURL has not been found. Please choose another language to show equivalent examples.
Code snippet for this language REST has not been found. Please choose another language to show equivalent examples.

Related Pages