Remote Configuration #

Remote configuration is a way to customize the behavior of an app or game via in-app parameters stored on a remote server. This can be used to implement feature flags or adjust settings which change the appearance or behavior of the app or game.

Developers can use remote configuration to remove the hassle of a lengthy review process or modifying the game or app and then waiting for users to update. This makes it especially useful with mobile projects.

This page shows two options for managing and fetching in-app parameters to deliver a personalized experience to your players. The first approach uses Satori, our dedicated LiveOps solution for games studios.

The second provides a more generalized approach, using server-side code or the storage engine to store the parameters, and fetching them with a HTTP request.

Satori #

Satori provides a robust feature flagging system which can be used to manage in-app parameters, and has been designed to work seamlessly with Nakama.

It’s a great option for teams who want to get up and running quickly with remote configuration, and enables non-technical team members to manage the parameters via a dashboard.

See the Personalization Guide in the official Satori documentation for a detailed example and different use-cases.

Generalized approach #

Manage In-app parameters #

The configuration settings sent to the app or game need to be stored on the server. The best way to store the information depends on how often the data will be changed.

For mostly static data it’s most efficient to embed it as data structures in server-side code and for more dynamic data it’s better to use a read-only storage record.

With both of these approaches you can access remote configuration before you’ve done register/login or connected with a user session. The in-app parameters you configure can be initialized at the earliest point of application startup.

Static parameters #

The simplest approach uses server-side code to represent the in-app parameters as a static variable. A change to the parameters after the server has started would require an update to the Lua code and a server restart.

Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- The code could be stored in a module named `"rc.lua"` and placed in the runtime path for the server.

local nk = require("nakama")

-- In-app parameters stored in a static variable.
local parameters = {
  reachable_levels = 10,
  max_player_level = 90,
  min_version = 12
}

local function remote_configuration(_context, _payload)
  return nk.json_encode({ rc = parameters })
end

nk.register_rpc(remote_configuration, "rc")
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
var parameters = map[string]interface{}{
  "reachable_levels": 10,
  "max_player_level": 90,
  "min_version":      12,
}

func RemoteConfig(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  responseBytes, err := json.Marshal(map[string]interface{}{"rc": parameters})
  if err != nil {
    return "", err
  }

  return string(responseBytes), nil
}

// Register as RPC function, this call should be in InitModule.
if err := initializer.RegisterRpc("rc", RemoteConfig); err != nil {
  logger.Error("Unable to register: %v", err)
  return err
}
Server
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const parameters = {
  reachableLevels: 10,
  maxPlayerLevel: 90,
  minVersion: 12
};

const RemoteConfigRpc : nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string | void {
  return JSON.stringify(parameters);
}

// Register as RPC function, this should be in InitModule.
initializer.registerRpc('rc', RemoteConfigRpc);

Dynamic parameters #

For in-app parameters which may be changed via Analytics or with a LiveOps dashboard it’s more flexible to store the configuration settings in the storage engine as a read-only record.

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
--Same as above we'll use server-side code with a module named `"rc.lua"` and placed in the runtime path for the server.

local nk = require("nakama")

local parameters = {
  reachable_levels = 10,
  max_player_level = 90,
  min_version = 12
}

local object = {
  collection = "configuration",
  key = "rc",
  value = parameters,
  permission_read = 1,
  permission_write = 0,
  version = "*" -- Only write object if it does not already exist.
}
pcall(nk.storage_write, { object }) -- Write object, ignore errors.

local function remote_configuration(_context, _payload)
  local rc = {
    collection = object.collection,
    key = object.key
  }
  local objects = nk.storage_read({ rc })
  return objects[1].value
end

nk.register_rpc(remote_configuration, "rc")
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
const (
  configCollection = "configuration"
  configKey = "rc"
)

func SaveConfig(ctx context.Context, nk runtime.NakamaModule, logger runtime.NakamaModule) error {
  parameters := map[string]interface{}{
    "reachable_levels": 10,
    "max_player_level": 90,
    "min_version":      12,
  }

  b, err := json.Marshal(parameters)
  if err != nil {
    return err
  }

  objects := []*runtime.StorageWrite{
    &runtime.StorageWrite{
      Collection:      configCollection,
      Key:             configKey,
      Value:           string(b),
      Version:         "*", // Only write if object does not exist already.
      PermissionRead:  1,
      PermissionWrite: 0,
    },
  }

  if _, err := nk.StorageWrite(ctx, objects); err != nil {
    return err
  }
  return nil
}

func RemoteConfig(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
  objectIds := []*runtime.StorageRead{
    &runtime.StorageRead{
      Collection: configCollection,
      Key:        configKey,
    },
  }

  records, err := nk.StorageRead(ctx, objectIds)
  if err != nil {
    return "", err
  }
  if len(records) == 0 {
    return "", errors.New("No config found.")
  }
  return records[0].Value, nil
}

// Ensure the configuration object is stored, this call should be in InitModule.
SaveConfig(ctx, nk, logger)
// Register as RPC function, this call should be in InitModule.
if err := initializer.RegisterRpc("rc", RemoteConfig); err != nil {
  logger.Error("Unable to register: %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
const configCollection = 'configuration';
const configKey = 'rc';

const SaveConfig = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama) {
  let parameters = {
    reachableLevels: 10,
    maxPlayerLevel: 90,
    minVersion: 12
  };

  const storageWrite : nkruntime.StorageWriteRequest = {
    collection: configCollection,
    key: configKey,
    value: parameters,
    userId: '00000000-0000-0000-0000-000000000000',
    version: '*', // Only write if object doesn't exist already,
    permissionRead: 1,
    permissionWrite: 0
  };

  nk.storageWrite([ storageWrite ]);
}

const RemoteConfigRpc : nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string | void {
  const readRequest : nkruntime.StorageReadRequest = {
    collection: configCollection,
    key: configKey,
    userId: '00000000-0000-0000-0000-000000000000'
  };
  
  const records = nk.storageRead([readRequest]);
  if (records.length == 0) {
    throw new Error('No config found')
  }
  
  return JSON.stringify(records[0].value);
}

// Ensure the configuration object is stored, this call should be in InitModule.
SaveConfig(ctx, logger, nk)

// Register as RPC function, this call should be in InitModule.
initializer.registerRpc('rc', RemoteConfigRpc);

Fetch In-app parameters #

With either approach used to store in-app parameters you can fetch the configuration with a HTTP request.

.Net/Unity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Remember to change the host, port, and auth values for how you've setup your server
var host = "127.0.0.1";
var port = 7350;
var path = "rc";
var auth = "defaultkey";

var format = "http://{0}:{1}/v2/rpc/{2}?http_key={3}";
var url = string.Format(format, Host, Port, Path, Auth);
var headers = new Dictionary<string, string>();
headers.Add("Content-Type", "application/json");
headers.Add("Accept", "application/json");

WWW www = new WWW(url, null, headers);
yield return www;
if (!string.IsNullOrEmpty(www.error)) {
    Debug.LogErrorFormat("Error occurred: {0}", www.error);
} else {
    var response = Encoding.UTF8.GetString(www.bytes);
    Debug.Log(response);
    // output
    // {"rc":{"max_player_level":90,"min_version":12,"reachable_levels":10}}
}

Curl:

1
2
3
4
5
curl -X POST "http://127.0.0.1:7350/v2/rpc/rc?http_key=defaultkey" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json'
# output
# {"rc":{"max_player_level":90,"min_version":12,"reachable_levels":10}}