Heroic Labs Documentation

Code Samples #

This page provides some common examples for the functionality available that can be used as templates when developing your project using the Go runtime.

Match handler #

A match handler represents all server-side functions for handling game inputs and operations for authoritative multiplayer matches. See the Match Handler API and Match Runtime API reference pages to learn about the match handler functions.

This is an example of a Ping-Pong match handler. Messages received by the server are broadcast back to the peer who sent them.

 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
package example

import (
    "context"
    "database/sql"
    "strconv"

    "github.com/heroiclabs/nakama-common/runtime"
)

type MatchState struct {
    presences map[string]runtime.Presence
}

type Match struct{}

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
    logger.Info("Hello Multiplayer!")
    err := initializer.RegisterMatch("standard_match", newMatch)

    if err != nil {
        logger.Error("[RegisterMatch] error: ", err.Error())
        return err
    }

    return nil
}

func newMatch(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (m runtime.Match, err error) {
    return &Match{}, nil
}

func (m *Match) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) {
    state := &MatchState{
        presences: make(map[string]runtime.Presence),
    }

    tickRate := 1
    label := ""

    return state, tickRate, label
}

func (m *Match) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) {
    acceptUser := true

    return state, acceptUser, ""
}

func (m *Match) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
    mState, _ := state.(*MatchState)

    for _, p := range presences {
        mState.presences[p.GetUserId()] = p
    }

    return mState
}

func (m *Match) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} {
    mState, _ := state.(*MatchState)

    for _, p := range presences {
        delete(mState.presences, p.GetUserId())
    }

    return mState
}

func (m *Match) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} {
    mState, _ := state.(*MatchState)

    for _, presence := range mState.presences {
        logger.Info("Presence %v named %v", presence.GetUserId(), presence.GetUsername())
    }

    for _, message := range messages {
        logger.Info("Received %v from %v", string(message.GetData()), message.GetUserId())
        reliable := true
        dispatcher.BroadcastMessage(1, message.GetData(), []runtime.Presence{message}, reliable)
    }

    return mState
}

func (m *Match) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} {
    message := "Server shutting down in " + strconv.Itoa(graceSeconds) + " seconds."
    reliable := true
    dispatcher.BroadcastMessage(2, []byte(message), []runtime.Presence{}, reliable)

    return state
}

func (m *Match) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) {
    return state, "signal received: " + data
}

Context #

This example demonstrates extracting the ID of the calling user and a key stored as an environment variable:

1
2
3
4
5
6
7
8
9
// Getting the calling user ID from the context
userId  := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)

// Getting environment variables from the context
env := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
secretKey, ok := env["SECRET_KEY"]
if !ok {
  // Did not find the environment variable
}

Database handler #

This example creates a system ID - an ID that cannot be used from a client - and the custom SQL query inserting it in the users table:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
  var systemId string
  if env, ok := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string); ok {
    systemId = env["SYSTEM_ID"]
  }

  _, err := db.ExecContext(ctx, `
INSERT INTO users (id, username)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING
  `, systemId, "sysmtem_id")
  if err != nil {
    logger.Error("Error: %s", err.Error())
  }

  return nil
}

RPC #

The example below registers a function with the identifier custom_rpc_func_id. This ID can then be used within client code to send an RPC message to execute the function on the server and return the result.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func CustomRpcFunc(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
		logger.Info("Payload: %s", payload)

		// "payload" is bytes sent by the client we'll JSON decode it.
		var value interface{}
		if err := json.Unmarshal([]byte(payload), &value); err != nil {
				return "", runtime.NewError("unable to unmarshal payload", 13)
		}

		response, err := json.Marshal(value)
		if err != nil {
				return "", runtime.NewError("unable to marshal payload", 13)
		}

    return string(response), nil
}

// Register as an RPC function, this call should be in InitModule.
if err := initializer.RegisterRpc("custom_rpc_func_id", CustomRpcFunc); err != nil {
  logger.Error("Unable to register: %v", err)
  return err
}

Before hook #

The code example below fetches the current user’s profile and checks the metadata, which is assumed to be JSON encoded with "{level: 12}" in it. If a user’s level is too low, an error is thrown to prevent the Friend Add message from being passed onwards in the 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
func BeforeAddFriends(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AddFriendsRequest) (*api.AddFriendsRequest, error) {
    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return nil, runtime.NewError("missing user id", 13)
    }

    account, err := nk.UsersGetId(ctx, []string{userID})
    if err != nil {
        return nil, runtime.NewError("user not found", 5)
    }

    var metadata map[string]interface{}
    if err := json.Unmarshal([]byte(account.GetUser().GetMetadata()), &metadata); err != nil {
        return nil, runtime.NewError("corrupted user metadata", 13)
    }

    // Let's assume we've stored a user's level in their metadata.
    if level, ok := metadata["level"].(int); !ok || level < 10 {
        return nil, runtime.NewError("must reach level 10 before you can add friends", 9)
    }

    return in, nil
}

// Register as a before hook for the appropriate feature, this call should be in InitModule.
if err := initializer.RegisterBeforeAddFriends(BeforeAddFriends); err != nil {
    logger.Error("Unable to register: %v", err)
    return err
}

After hook #

The example code below writes a record to a user’s storage when they add a friend. Any data returned by the function will be discarded.

 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
func AfterAddFriends(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, in *api.AddFriendsRequest) error {
    userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
    if !ok {
        return runtime.NewError("missing user id", 13)
    }

    value, err := json.Marshal(map[string]interface{}{"user_ids": in.GetIds()})
    if err != nil {
        return runtime.NewError("unable to marshal data", 13)
    }

    if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{
        &runtime.StorageWrite{
            Collection: "rewards",
            Key:        "reward",
            UserID:     userID,
            Value:      string(value),
        },
    }); err != nil {
        return runtime.NewError("unable to write to storage engine", 13)
    }

    return nil
}

// Register as an after hook for the appropriate feature, this call should be in InitModule.
if err := initializer.RegisterAfterAddFriends(AfterAddFriends); err != nil {
  logger.Error("Unable to register: %v", err)
  return err
}

Example module #

As a fun example let’s use the Pokéapi and build a helpful module.

 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
import (
		"context"
		"database/sql"
		"encoding/json"
		"errors"
		"io/ioutil"
		"net/http"

		"github.com/heroiclabs/nakama-common/runtime"
)

const apiBaseUrl = "https://pokeapi.co/api/v2"

// All Go modules must have a InitModule function with this exact signature.
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
		// Register the RPC function.
		if err := initializer.RegisterRpc("get_pokemon", GetPokemon); err != nil {
				logger.Error("Unable to register: %v", err)
				return err
		}

		return nil
	}

func LookupPokemon(logger runtime.Logger, name string) (map[string]interface{}, error) {
		resp, err := http.Get(apiBaseUrl + "/pokemon/" + name)

		if err != nil {
				logger.Error("Failed request %v", err.Error())
				return nil, runtime.NewError("unable to retrieve api data", 13)
		}

		defer resp.Body.Close()
		body, err := ioutil.ReadAll(resp.Body)

		if err != nil {
				logger.Error("Failed to read body %v", err.Error())
				return nil, runtime.NewError("failed to read body", 13)
		}

		if resp.StatusCode >= 400 {
				logger.Error("Failed request %v %v", resp.StatusCode, body)
				return nil, runtime.NewError("failed api request", 13)
		}

		var result map[string]interface{}
		if err := json.Unmarshal(body, &result); err != nil {
      return "", runtime.NewError("unable to unmarshal data", 13)
    }

		return result, nil
}

func GetPokemon(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
		// We'll assume payload was sent as JSON and decode it.
		var input map[string]string
		if err := json.Unmarshal([]byte(payload), &input); err != nil {
      return "", runtime.NewError("unable to unmarshal payload", 13)
    }

		result, err := LookupPokemon(logger, input["PokemonName"])

		if err != nil {
				return "", runtime.NewError("unable to find pokemon", 5)
		}

		response, err := json.Marshal(result)

		if err != nil {
				return "", runtime.NewError("unable to marshal response", 13)
		}

		return string(response), nil
}

You can now make an RPC call for a Pokémon from a client:

Client
1
2
3
curl "http://127.0.0.1:7350/v2/rpc/get_pokemon" \
  -H 'authorization: Bearer <session token>'
  -d '"{\"PokemonName\": \"dragonite\"}"'
Client
1
2
3
4
const payload = { "PokemonName": "dragonite"};
const rpcid = "get_pokemon";
const pokemonInfo = await client.rpc(session, rpcid, payload);
console.log("Retrieved pokemon info: %o", pokemonInfo);
Client
1
2
3
4
var payload = JsonWriter.ToJson(new { PokemonName = "dragonite" });
var rpcid = "get_pokemon";
var pokemonInfo = await client.RpcAsync(session, rpcid, payload);
System.Console.WriteLine("Retrieved pokemon info: {0}", pokemonInfo);
Client
1
2
3
4
5
6
7
8
auto successCallback = [](const NRpc& rpc)
{
  	std::cout << "Retrieved pokemon info: " << rpc.payload << std::endl;
};

string payload = "{ \"PokemonName\": \"dragonite\" }";
string rpcid = "get_pokemon";
client->rpc(session, rpcid, payload, successCallback);
Client
1
2
3
4
5
6
Map<String, String> payloadData = new HashMap<>();
payloadData.put("PokemonName", "dragonite");
String payload = new Gson().toJson(payloadData, payloadData.getClass());
String rpcid = "get_pokemon";
Rpc pokemonInfo = client.rpc(session, rpcid, payload);
System.out.format("Retrieved pokemon info: %s", pokemonInfo.getPayload());
Client
1
2
3
4
5
6
7
var payload = {"PokemonName": "dragonite"}
var rpc_id = "get_pokemon"
var pokemon_info : NakamaAPI.ApiRpc = yield(client.rpc_async(session, rpc_id, JSON.print(payload)), "completed")
if pokemon_info.is_exception():
	print("An error occurred: %s" % pokemon_info)
	return
print("Retrieved pokemon info: %s" % [parse_json(pokemon_info.payload)])
Client
1
2
3
4
5
6
7
8
POST /v2/rpc/get_pokemon
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
{
  "PokemonName": "dragonite"
}
Client
1
2
3
4
local payload = { PokemonName = "dragonite"}
local rpcid = "get_pokemon"
local pokemon_info = client.rpc_func(rpcid, json.encode(payload)) 
pprint("Retrieved pokemon info:", pokemon_info)

Related Pages