Tournaments

Tournaments are competitions which span a short period where opponents compete over a prize.

This document outlines the design of tournaments for Nakama server. It covers the rules for how players find tournaments, which ones they can join, when they're allowed to submit scores, and how rewards are distributed when the tournament ends.

Rules

A tournament is created with an optional reset schedule and a duration. These values allow for flexible control over how long a tournament can be played before it is reset for the next duration. For example a tournament could be created that starts at noon each day and be played for one hour. This would be expressed with a CRON expression ("0 12 * * *") and a duration of 3600 seconds.

The tournament can restrict the number of opponents allowed (i.e. First come first serve) and enforce an optional join requirement. For example each opponent must join before they can submit scores with only the first 10,000 opponents are allowed to join.

Tournaments are created programmatically to start in the future or immediately upon creation and are all expressed as leaderboards with special configuration.

A tournament can also be played by opponents who are not users. For example a guild tournament can be implemented where score submissions are made by guild ID.

List Tournaments

Find tournaments which have been created on the server. Tournaments can be filtered with categories and via start and end times. This function can also be used to see the tournaments that an owner (usually a user) has joined.

curl -X GET \
  'http://127.0.0.1:7350/v2/tournament?category_start=<category_start>
  &category_end=<category_end>
  &start_time=<start_time>
  &end_time=<end_time>
  &limit=<limit>
  &cursor=<cursor>' \
  -H 'Authorization: Bearer <session token>'
var categoryStart = 1;
var categoryEnd = 2;
var startTime = 1538147711;
var endTime = -1; // all tournaments from the start time
var limit = 100; // number to list per page
var cursor = null;
var result = await client.listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor);
var categoryStart = 1;
var categoryEnd = 2;
var startTime = 1538147711;
var endTime = -1L; // all tournaments from the start time
var limit = 100; // number to list per page
var cursor = null;
var result = await client.ListTournamentsAsync(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor);
var categoryStart = 1;
var categoryEnd = 2;
var startTime = 1538147711;
var endTime = -1L; // all tournaments from the start time
var limit = 100; // number to list per page
var cursor = null;
var result = await client.ListTournamentsAsync(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor);
int categoryStart = 1;
int categoryEnd = 2;
int startTime = 1538147711;
int endTime = -1; // all tournaments from the start time
int limit = 100; // number to list per page
String cursor = null;
TournamentList tournaments = client.listTournaments(session, categoryStart, categoryEnd, startTime, endTime, limit, cursor).get();
// Will be made available soon.
GET /v2/tournament
  ?category_start=<category_start>
  &category_end=<category_end>
  &start_time=<start_time>
  &end_time=<end_time>
  &limit=<limit>
  &cursor=<cursor>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

Join Tournament

A tournament may need to be joined before the owner can submit scores. This operation is idempotent and will always succeed for the owner even if they have already joined the tournament.

curl -X POST \
  'http://127.0.0.1:7350/v2/tournament/<tournament_id>/join'
  -H 'Authorization: Bearer <session token>'
var id = "someid";
var success = await client.joinTournament(session, id);
var id = "someid";
var success = await client.JoinTournamentAsync(session, id);
var id = "someid";
var success = await client.JoinTournamentAsync(session, id);
String id = "someid";
boolean success = client.joinTournament(session, id).get();
// Will be made available soon.
POST /v2/tournament/<tournament_id>/join
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

List Tournament Records

Fetch a mixed list of tournament records as well as a batch of records which belong to specific owners. This can be useful to build up a leaderboard view which shows the top 100 players as well as the scores between the current user and their friends.

curl -X GET \
  'http://127.0.0.1:7350/v2/tournament/<tournament_id>?owner_ids=<owner_ids>
  &limit=<limit>
  &cursor=<cursor>'
  -H 'Authorization: Bearer <session token>'
var id = "someid";
var ownerIds = ["some", "friends", "user ids"];
var result = await client.listTournamentRecords(session, id, owenrIds);
result.records.forEach(function(record) {
  console.log("Record username %o and score %o", record.username, record.score);
});
var id = "someid";
var limit = 100;
var cursor = null;
var result = await client.ListTournamentRecordsAsync(session, id, new []{ session.UserId }, limit, cursor);
var id = "someid";
var limit = 100;
var cursor = null;
var result = await client.ListTournamentRecordsAsync(session, id, new []{ session.UserId }, limit, cursor);
String id = "someid";
LeaderboardRecordList records = client.listLeaderboardRecords(session, id, session.getUserId()).get();
// Will be made available soon.
GET /v2/tournament/<tournament_id>?owner_ids=<owner_ids>&limit=<limit>&cursor=<cursor>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

List Tournament Records Around Owner

Fetch the list of tournament records around the owner.

curl -X GET \
  'http://127.0.0.1:7350/v2/tournament/<tournament_id>/owner/<owner_id>?limit=<limit>'
  -H 'Authorization: Bearer <session token>'
var id = "someid";
var ownerId = "some user ID";
var limit = 100;
var result = await client.listTournamentRecordsAroundOwner(session, id, ownerId, limit);
var id = "someid";
var ownerId = session.UserId;
var limit = 100;
var result = await client.ListTournamentRecordsAroundOwnerAsync(session, id, ownerId, limit);
var id = "someid";
var ownerId = session.UserId;
var limit = 100;
var result = await client.ListTournamentRecordsAroundOwnerAsync(session, id, ownerId, limit);
String id = "someid";
String ownerId = session.getUserId();
int limit = 100;
TournamentRecordList records = client.listTournamentRecordsAroundOwner(session, id, ownerId, limit).get();
// Will be made available soon.
GET /v2/tournament/v2/tournament/<tournament_id>/owner/<owner_id>?limit=<limit>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

Write Tournament Record

Submit a score and optional subscore to a tournament leaderboard. If the tournament has been configured with join required this will fail unless the owner has already joined the tournament.

curl -X GET \
  'http://127.0.0.1:7350/v2/tournament/<tournament_id>'
  -H 'Authorization: Bearer <session token>'
var id = "someid";
var score = 100;
var subscore = 10;
var metadata = {
  "weather_conditions": "sunny",
  "track_name": "Silverstone"
}
var newrecord = client.writeTournamentRecord(session, id, score, subscore, metadata);
var id = "someid";
var score = 100L;
var subscore = 10L;
// using Nakama.TinyJson
var metadata = new Dictionary<string, string>()
{
  { "weather_conditions", "sunny" },
  { "track_name", "Silverstone" }
}.ToJson();
var newrecord = client.WriteTournamentRecordAsync(session, id, score, subscore, metadata);
var id = "someid";
var score = 100L;
var subscore = 10L;
// using Nakama.TinyJson
var metadata = new Dictionary<string, string>()
{
  { "weather_conditions", "sunny" },
  { "track_name", "Silverstone" }
}.ToJson();
var newrecord = client.WriteTournamentRecordAsync(session, id, score, subscore, metadata);
string id = "someid";
int score = 10;
int subscore = 20;
final String metadata = "{\"tarmac\": \"wet\"}";
LeaderboardRecord record = client.writeTournamentRecord(session, id, score, subscore, metadata).get();
// Will be made available soon.
GET /v2/tournament/v2/tournament/<tournament_id>
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>

Authoritative Functions

The runtime functions can be accessed from Lua or Go code and enable custom logic to be used to apply additional rules to various aspects of a tournament. For example it may be required that an opponent is higher than a specific level before they're allowed to join the tournament.

All API design examples are written in Go. For brevity imports and some variables are assumed to exist.

Create Tournament

Create a tournament with all it's configuration options.

local id = "4ec4f126-3f9d-11e7-84ef-b7c182b36521"
local authoritative = false
local sort = "desc" -- one of: "desc", "asc"
local operator = "best" -- one of: "best", "set", "incr"
local reset = "0 12 * * *" -- noon UTC each day
local metadata = {
  weather_conditions = "rain"
}
title = "Daily Dash"
description = "Dash past your opponents for high scores and big rewards!"
category = 1
start_time = 0 -- start now
end_time = 0 -- never end, repeat the tournament each day forever
duration = 3600 -- in seconds
max_size = 10000 -- first 10,000 players who join
max_num_score = 3 -- each player can have 3 attempts to score
join_required = true -- must join to compete
nk.tournament_create(sortOrder, operator, reset, metadata, title, description, category,
  start_time, endTime, duration, max_size, max_num_score, join_required)
// import "github.com/gofrs/uuid"
id := uuid.Must(uuid.NewV4())
sortOrder := "desc" // one of: "desc", "asc"
operator := "best"  // one of: "best", "set", "incr"
resetSchedule := "0 12 * * *" // noon UTC each day
metadata := map[string]interface{}{}
title := "Daily Dash"
description := "Dash past your opponents for high scores and big rewards!"
category := 1
startTime := nil // start now
endTime := nil // never end, repeat the tournament each day forever
duration := 3600 // in seconds
maxSize := 10000 // first 10,000 players who join
maxNumScore := 3 // each player can have 3 attempts to score
joinRequired := true // must join to compete
err := nk.TournamentCreate(id.String(), sortOrder, operator, resetSchedule, metadata, title, description, category, startTime, endTime, duration, maxSize, maxNumScore, joinRequired)
if err != nil {
  logger.Printf("unable to create tournament: %q", err.Error())
  return "", errors.New("failed to create tournament"), 3
}

Delete Tournament

Delete a tournament by it's ID.

local id = "4ec4f126-3f9d-11e7-84ef-b7c182b36521"
nk.tournament_delete(id)
err := nk.TournamentDelete(id)
if err != nil {
  logger.Printf("unable to delete tournament: %q", err.Error())
  return "", errors.New("failed to delete tournament"), 3
}

Add Score Attempts

Add additional score attempts to the owner's tournament record. This overrides the max number of score attempts allowed in the tournament for this specific owner.

local id = "someid"
local owner = "someuserid"
local attempts = 10
nk.tournament_add_attempt(id, owner, attempts)
id := "someid"
userID := "someuserid"
attempts := 10
err := nk.TournamentAddAttempt(id, userID, attempts)
if err != nil {
  logger.Printf("unable to update user %v record attempts: %q", userID, err.Error())
  return "", errors.New("failed to add tournament attempts"), 3
}

Reward Distribution

When a tournament's active period ends a function registered on the server will be called to pass the expired records for use to calculate and distribute rewards to owners.

To register a reward distribution function in Go use the initializer.

local function distribute_rewards(_context, tournament, session_end, expiry)
  // ...
end
nk.register_tournament_end(distribute_rewards)
import (
  "context"
  "database/sql"
  "log"

  "github.com/heroiclabs/nakama/api"
  "github.com/heroiclabs/nakama/runtime"
)

func InitModule(ctx context.Context, logger *log.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) {
  initializer.RegisterTournamentEnd(distributeRewards)
}

func distributeRewards(ctx context.Context, logger *log.Logger, db *sql.DB, nk runtime.NakamaModule, tournament *api.Tournament, end int64, reset int64) {
  // ...
  return nil
}

A simple reward distribution function which sends a persistent notification to the top ten players to let them know they've won and adds coins to their virtual wallets would look like:

local function distribute_rewards(_context, tournament, session_end, expiry)
  local notifications = {}
  local wallet_updates = {}
  local records, owner_records, nc, pc = nk.leaderboard_records_list(tournament.id, nil, 10)
  for i = 0, #records do
    notifications[i] = {
      code = 1,
      content = { coins = 100 },
      persistent = true,
      subject = "Winner",
      user_id = records[i].owner_id
    }
    wallet_updates[i] = {
      user_id = records[i].owner_id,
      changeset = { coins = 100 },
      metadata = {}
    }
  end

  nk.wallets_update(wallet_updates, true)
  nk.notifications_send(notifications)
end
func distributeRewards(ctx context.Context, logger *log.Logger, _, nk runtime.NakamaModule, tournament *api.Tournament, end int64, reset int64) {
  notifications := make([]*runtime.NotificationSend, 0, 10)
  var content = map[string]interface{}{}
  changeset := map[string]interface{}{"coins": 100}
  records, _, _, _, err := nk.LeaderboardRecordsList(tournament.Id, []string{}, 10, nil, reset)
  for _, record := range records {
    wallets = append(wallets, &runtime.WalletUpdate{record.OwnerId, changeset, content})
    notifications = append(notifications, &runtime.NotificationSend{record.OwnerId, "Winner", content, 1, "", true})
  }
  err = nk.WalletsUpdate(wallets, true)
  if err := nil {
    logger.Printf("failed to update winner wallets: %v", err)
    return err
  }
  err = nk.NotificationsSend(notifications)
  if err := nil {
    logger.Printf("failed to send winner notifications: %v", err)
    return err
  }
  return nil
}

Advanced

Tournaments can be used to implement a league system. The main difference between a league and a tournament is that leagues are usually seasonal and incorporate a ladder or tiered hierarchy that opponents can progress on.

A league can be structured as a collection of tournaments which share the same reset schedule and duration. The reward distribution function can be used to progress opponents between one tournament and the next in between each reset schedule.