View as Markdown

Implement friend codes

Let players generate a code they can share with others outside the game to add each other as friends.

A friend code can work in two ways:

  • As a short code a player types into the game
  • As a deep link that opens the app store if the game isn’t installed yet
    • If it’s already installed, opens the game directly and adds the friend automatically

Using custom RPCs , build a way to generate and claim unique friend codes to make adding friends easier.

Players can generate a friend code to send outside the game, or claim one to add each other as friends
Players can generate a friend code to send outside the game, or claim one to add each other as friends

Before you begin #

The example project for this guide is built in Unity. To follow along, clone the Friend Codes project and open it in your Unity Editor.

Running the server locally #

The sample project includes a Docker Compose file that starts Nakama and the database. Run it to see the friend codes backend in action as you work through the guide:

  1. Go to the server/ directory.
  2. From the command line, run docker compose up --build.
  3. Access the Nakama console at http://localhost:7351.

Initialize the custom RPCs #

Register two custom RPCs: one to generate a unique code, and one to claim it.

Step 1

Register the systems in InitModule #

Inside main.go, register the custom RPCs in your InitModule function.

main.go
1
2
3
4
5
6
if err := initializer.RegisterRpc("generate_friend_code", RpcGenerateFriendCode); err != nil {
  return err
}
if err := initializer.RegisterRpc("claim_friend_code", RpcClaimFriendCode); err != nil {
  return err
}
Step 2

Create friendcodes.go #

Create a new file friendcodes.go to keep main.go tidy. This is where you’ll define the custom RPCs and their logic. Start by defining some constants at the top of the file.

friendcodes.go
1
2
3
4
5
6
7
const (
	friendCodesCollection = "invite_codes"
	userInviteCollection  = "invite_codes_user"
	codeLength            = 6
	codeAlphabet          = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no O/0, I/1 to avoid ambiguity
	codeTTL               = 72 * time.Hour                     // time to live (how long the code is valid for after creation)
)
Step 3

Define structs #

Next, define structs to use when interacting with the storage engine and sending or receiving RPCs.

friendcodes.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type inviteRecord struct {
	OwnerID   string `json:"owner_id"`
	ExpiresAt int64  `json:"expires_at"`
}

type userInviteRecord struct {
	Code      string `json:"code"`
	ExpiresAt int64  `json:"expires_at"`
}

type claimRequest struct {
	Code string `json:"code"`
}

GenerateFriendCode RPC #

Start with the GenerateFriendCode RPC.

Step 1

RPC body #

Here’s what the RPC is doing:

  1. Finding which user requested to generate a friend code.
  2. Checking custom storage to see whether this user already has a valid friend code that was previously generated.
  3. If they do have a valid friend code already, simply return that to the caller.
  4. If not, generate/mint a new code.
  5. After successfully generating a new code, write it to a custom storage collection for future lookup.
    • friendCodesCollection: Only accessible by the server. Provides a direct O(1) lookup to find who a code belongs to when redeeming.
    • userInviteCollection: Readable by the owner. Used to check if they already have a valid code.
  6. Return the new code to the caller.
friendcodes.go
 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
// RpcGenerateFriendCode Try to generate a new friend code for the user, first checking if a valid code already exists.
func RpcGenerateFriendCode(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	userID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok || userID == "" {
		return "", runtime.NewError("no user id in context", 3)
	}

	now := time.Now()

	// Reuse an existing, valid code for this user rather than generating a new one.
	if existing, err := readUserInvite(ctx, nk, userID); err == nil && existing != nil {
		if existing.ExpiresAt > now.Unix() {
			return marshalCodeResponse(existing.Code, existing.ExpiresAt)
		}
	}

	code, err := mintUniqueCode(ctx, nk)
	if err != nil {
		logger.Error("failed to mint invite code: %v", err)
		return "", runtime.NewError("could not generate code", 13)
	}

	expiresAt := now.Add(codeTTL).Unix()

	globalRec := inviteRecord{
		OwnerID:   userID,
		ExpiresAt: expiresAt,
	}
	globalVal, _ := json.Marshal(globalRec)

	userRec := userInviteRecord{Code: code, ExpiresAt: expiresAt}
	userVal, _ := json.Marshal(userRec)

	writes := []*runtime.StorageWrite{
		{
			Collection:      friendCodesCollection,
			Key:             code,
			Value:           string(globalVal),
			PermissionRead:  0,
			PermissionWrite: 0,
		},
		{
			Collection:      userInviteCollection,
			Key:             "active",
			UserID:          userID,
			Value:           string(userVal),
			PermissionRead:  1,
			PermissionWrite: 0,
		},
	}

	if _, err := nk.StorageWrite(ctx, writes); err != nil {
		logger.Error("failed to write invite code: %v", err)
		return "", runtime.NewError("could not save code", 13)
	}

	return marshalCodeResponse(code, expiresAt)
}
Step 2

Read user invite #

Check the custom storage collection to see if this user already has an existing friend code.

friendcodes.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Try to find an existing code for the user.
func readUserInvite(ctx context.Context, nk runtime.NakamaModule, userID string) (*userInviteRecord, error) {
	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: userInviteCollection, Key: "active", UserID: userID},
	})
	if err != nil || len(objs) == 0 {
		return nil, err
	}
	var rec userInviteRecord
	if err := json.Unmarshal([]byte(objs[0].Value), &rec); err != nil {
		return nil, err
	}
	return &rec, nil
}
Step 3

Mint unique code #

Generates a new code for the user, checking storage after generation to make sure it’s unique. If it’s not unique, it generates a new code and tries again. This uses a fixed number of retries to avoid the server getting stuck in unforeseen circumstances.

friendcodes.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Call GenerateRandomCode until the value is unique, stops after 5 attempts.
func mintUniqueCode(ctx context.Context, nk runtime.NakamaModule) (string, error) {
	for attempt := 0; attempt < 5; attempt++ {
		code, err := generateRandomCode()
		if err != nil {
			return "", err
		}
		objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
			{Collection: friendCodesCollection, Key: code},
		})
		if err == nil && len(objs) == 0 {
			return code, nil
		}
	}
	return "", runtime.NewError("could not generate a unique code, try again", 13)
}
Step 4

Generate random code #

Generate a new code with codeLength length, using only the defined codeAlphabet characters.

friendcodes.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Generate a random codeLength code only using characters in the codeAlphabet.
func generateRandomCode() (string, error) {
	bytes := make([]byte, codeLength)
	if _, err := rand.Read(bytes); err != nil {
		return "", err
	}
	out := make([]byte, codeLength)
	for i, b := range bytes {
		out[i] = codeAlphabet[int(b)%len(codeAlphabet)]
	}
	return string(out), nil
}
Step 5

Marshal code response #

Helper function to create the JSON response that the client is expecting.

friendcodes.go
1
2
3
4
5
6
7
func marshalCodeResponse(code string, expiresAt int64) (string, error) {
	resp, err := json.Marshal(map[string]any{
		"code":       code,
		"expires_at": expiresAt,
	})
	return string(resp), err
}

ClaimFriendCode RPC #

Next, build the ClaimFriendCode RPC.

Step 1

RPC body #

Here’s what the RPC is doing:

  1. Finding which user requested to claim a friend code.
  2. Unmarshal the payload and try to read the code.
  3. Make sure that the code is valid and has not expired.
  4. Make sure that the user is not trying to claim their own code.
  5. Finally, attempt to add the two users as friends. This is done by calling FriendsAdd both ways to establish the relationship.
friendcodes.go
 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
// RpcClaimFriendCode Try to claim a friend code for the calling user.
func RpcClaimFriendCode(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	claimerID, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok || claimerID == "" {
		return "", runtime.NewError("no user id in context", 3)
	}

	var req claimRequest
	if err := json.Unmarshal([]byte(payload), &req); err != nil || req.Code == "" {
		return "", runtime.NewError("code is required", 3)
	}

	objs, err := nk.StorageRead(ctx, []*runtime.StorageRead{
		{Collection: friendCodesCollection, Key: req.Code},
	})
	if err != nil || len(objs) == 0 {
		return "", runtime.NewError("invalid or expired code", 5)
	}

	var rec inviteRecord
	if err := json.Unmarshal([]byte(objs[0].Value), &rec); err != nil {
		return "", runtime.NewError("invalid or expired code", 5)
	}
	if time.Now().Unix() > rec.ExpiresAt {
		return "", runtime.NewError("invalid or expired code", 5)
	}
	if rec.OwnerID == claimerID {
		return "", runtime.NewError("you can't claim your own code", 3)
	}

	// Add in both directions so the request is auto-confirmed
	if err := nk.FriendsAdd(ctx, claimerID, "", []string{rec.OwnerID}, nil, nil); err != nil {
		logger.Error("friendsAdd (claimer->owner) failed: %v", err)
		return "", runtime.NewError("could not add friend", 13)
	}
	if err := nk.FriendsAdd(ctx, rec.OwnerID, "", []string{claimerID}, nil, nil); err != nil {
		logger.Error("friendsAdd (owner->claimer) failed: %v", err)
		return "", runtime.NewError("could not add friend", 13)
	}

	resp, _ := json.Marshal(map[string]any{
		"success": true,
	})
	return string(resp), nil
}

Test your friend codes #

With the server running and the Unity project open, confirm each piece works end to end.

In the Unity client:

  1. Select Play in the Unity Editor and sign in to create a player.
  2. Select Generate. The code copies automatically to your clipboard.
    A player generates a friend code that copies to the clipboard
    A player generates a friend code that copies to the clipboard
  3. Use the Account Switcher to switch to a second account.
  4. Paste the friend code in the Enter code… field and select Claim.
    A second player claims the friend code and adds the first player as a friend
    A second player claims the friend code and adds the first player as a friend

Switch back to the initial player to confirm the second player now appears in their friends list.

Deep linking #

Set up Unity to enable deep linking. This lets you:

  1. Generate a deep link with the friend code embedded
  2. Send the deep link to a friend outside the game
  3. Let them open the link. If configured correctly, this takes them to the app store if they don’t have the app yet, or opens the app directly
  4. Read the code from the deep link when the app opens this way, and automatically call the RPC

For iOS, see Unity’s guide to enabling deep linking . For Android, see Unity’s guide to enabling deep linking .

After you enable deep linking, go to your server code and adjust the const block to define deepLinkScheme (set this to the scheme you configured when enabling deep linking for your platform). Then modify the marshalCodeResponse function to also return a deep link to the client.

Android Studio Emulator
If testing using the Android Studio Emulator, change the host address to 10.0.2.2, otherwise the app won’t be able to reach your localhost server. See here for more details.
friendcodes.go
1
2
3
4
5
6
7
8
const (
	friendCodesCollection = "invite_codes"
	userInviteCollection  = "invite_codes_user"
	codeLength            = 6
	codeAlphabet          = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no O/0, I/1 to avoid ambiguity
	codeTTL               = 72 * time.Hour                     // time to live (how long the code is valid for after creation)
	deepLinkScheme        = "myunityapp"
)
friendcodes.go
1
2
3
4
5
6
7
8
func marshalCodeResponse(code string, expiresAt int64) (string, error) {
	resp, err := json.Marshal(map[string]any{
		"code":       code,
		"expires_at": expiresAt,
		"deep_link":  deepLinkScheme + "://friendcode?code=" + code,
	})
	return string(resp), err
}

Now, update the Unity client’s UI to display the deep link instead of just the code.

FriendCodesController.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
private async Task GenerateFriendCode()
{
    try
    {
        var session = NakamaSingleton.Instance.Session;
        var result = await NakamaSingleton.Instance.Client.RpcAsync(session, "generate_friend_code");
        var response = JsonUtility.FromJson<FriendCodeData>(result.Payload);

        Debug.Log($"Code generated successfully.");

        // Display code on UI and copy to clipboard.
        friendCodeField.SetValueWithoutNotify(response.deep_link);
        GUIUtility.systemCopyBuffer = response.deep_link;
    }
    catch (Exception e)
    {
        Debug.LogWarning($"Generate friend code failed: {e.Message}");
    }
}

Finally, hook into DeepLinkManager to call the claim_friend_code RPC when the app opens through a deep link, or when someone selects a deep link while the app is running.

  1. Listen for the OnInviteCodeReceived event. This handles the case where the app is already open.
FriendCodesController.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private void OnEnable()
{
    if (DeepLinkManager.Instance != null)
    {
        DeepLinkManager.Instance.OnInviteCodeReceived += HandleInviteCodeReceived;
    }
}

private void OnDisable()
{
    if (DeepLinkManager.Instance != null)
    {
        DeepLinkManager.Instance.OnInviteCodeReceived -= HandleInviteCodeReceived;
    }
}
  1. Check DeepLinkManager.Instance.PendingInviteCode directly after initializing. This handles the case where the deep link opened the app.
FriendCodesController.cs
 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
private void Start()
{
    InitializeUI();
    NakamaSingleton.Instance.ReceivedStartError += e =>
    {
        Debug.LogException(e);
        errorPopup.style.display = DisplayStyle.Flex;
        errorMessage.text = e.Message;
    };
    NakamaSingleton.Instance.Socket.ReceivedNotification += OnReceivedNotification;
    NakamaSingleton.Instance.ReceivedStartSuccess += session =>
    {
        OnInitialized?.Invoke(session, this);

        // Load friends by default.
        _ = UpdateFriendsList(FriendState.Friend);

        // If a friend code was already waiting before we were ready, try to add them.
        var pending = DeepLinkManager.Instance != null ? DeepLinkManager.Instance.PendingInviteCode : null;
        if (!string.IsNullOrEmpty(pending))
        {
            _ = ClaimFriendCode(pending);
        }
    };
}

Conclusion #

You’ve implemented friend codes, letting players easily connect without accepting requests or typing long usernames. With deep-linking, players can simply click a link sent externally by a friend to download/open the app and then automatically add each other as friends.

See also #