Collectible Card Mechanics #

Reconstructing Fun video series
This guide is adapted from our Reconstructing Fun video series, where we explore how to build popular game mechanics using Nakama and Hiro. You can watch the full video below and follow our channel on YouTube for the latest updates.

In the world of mobile gaming, collectible card mechanics are fast becoming a key meta gameplay feature, with players across the globe engaged by the progression and sense of achievement this gameplay can provide when implemented well. But developing these intricate systems can often be challenging and time-consuming, requiring a significant investment of time and resources.

In this guide we’ll explore how you can quickly integrate collectible card mechanics using Nakama and Hiro to produce gameplay experiences reminiscent of massive successes like Clash Royale from Supercell.

Prerequisites #

To follow this guide you’ll need to:

Once that’s out of the way, you can familiarize yourself with the full project code we’ll be using in this guide by cloning the Collectible Cards repository from GitHub.

Server-side #

Let’s start by taking a look at the server-side code we’ll be using to implement the collectible card mechanics, beginning with the main.go file.

main.go #

You can reference the full code for this file in the linked repository above. Here we’ll break down the key components of the code.

Define error messages #

First we define the error messages we’ll use throughout the codebase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var (
	errBadInput        = runtime.NewError("input contained invalid data", 3) // INVALID_ARGUMENT
	errInternal        = runtime.NewError("internal server error", 13)       // INTERNAL
	errMarshal         = runtime.NewError("cannot marshal type", 13)         // INTERNAL
	errNoInputAllowed  = runtime.NewError("no input allowed", 3)             // INVALID_ARGUMENT
	errNoInputGiven    = runtime.NewError("no input was given", 3)           // INVALID_ARGUMENT
	errNoUserIdFound   = runtime.NewError("no user ID in context", 3)        // INVALID_ARGUMENT
	errNoUsernameFound = runtime.NewError("no username in context", 3)       // INVALID_ARGUMENT
	errUnmarshal       = runtime.NewError("cannot unmarshal type", 13)       // INTERNAL
)

InitModule function #

Next we define our InitModule function, which is called when the server starts up. Here we’ll initialize the Hiro systems - Economy and Inventory - we’ll be using, and register the RPC functions we’ll be implementing.

 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
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	props, ok := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
	if !ok {
		return errors.New("invalid context runtime env")
	}

	env, ok := props["ENV"]
	if !ok || env == "" {
		return errors.New("'ENV' key missing or invalid in env")
	}

	hiroLicense, ok := props["HIRO_LICENSE"]
	if !ok || hiroLicense == "" {
		return errors.New("'HIRO_LICENSE' key missing or invalid in env")
	}

	binPath := "hiro.bin"
	systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
		hiro.WithEconomySystem(fmt.Sprintf("base-economy-%s.json", env), true),
		hiro.WithInventorySystem(fmt.Sprintf("base-inventory-%s.json", env), true))
	if err != nil {
		return err
	}

	cardDefinitions, err := NewCollectibleCardDefinition(nk, fmt.Sprintf("collectable-cards-%s.json", env))
	if err != nil {
		return err
	}

	if err = initializer.RegisterRpc("InventoryListCards", InventoryListCardsRpcFn(systems, cardDefinitions)); err != nil {
		return err
	}

	if err = initializer.RegisterRpc("InventoryCardUpgrade", InventoryCardUpgradeRpcFn(systems, cardDefinitions)); err != nil {
		return err
	}

	return nil
}

There are two RPC functions we’re implementing as part of this game feature:

  • InventoryListCards to return a list of cards the player currently owns, and
  • InventoryCardUpgrade to be used when a player has accumulated enough cards to be able to upgrade a card to the next level.

Hiro system definitions #

Next we define the Hiro system definitions we’ll be using to implement the collectible card mechanics. These are defined in the base-inventory-dev1 file and base-economy-dev1 file, respectively.

Inventory #

The Hiro Inventory system enables you to define and manage the items that can be collected and used by players in your game. In this example, we’ll use the Inventory system to define the collectible cards that players can acquire and use in the game, setting attributes like their name, description, maximum count, stackability, and rarity.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// ...
"card_knightmare": {
            "name": "Knightmare",
            "description": "",
            "category": "cards",
            "item_sets": [
                "cards",
                "cards_common"
            ],
            "max_count": 999,
            "stackable": true,
            "consumable": false,
            "consume_reward": null,
            "string_properties": {
                "card_rarity": "common"
            },
            "numeric_properties": {}
        }
// ...

Notice that we are defining two collections for each card - cards and cards_common in this case - which we use to group the cards into their respective rarity group (i.e. whether it’s rare, epic, or legendary).

The card_rarity property inside our string_properties field is used to define the rarity of the card, which we’ll use to determine the card’s stats and upgrade costs.

Economy #

The Hiro Economy system enables you to define and manage the currencies that players can earn and spend in your game, and also define the currencies and amounts that each player begins the game with.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
    "initialize_user": {
        "currencies": {
            "coins": 9999999,
            "gems": 999
        },
        "items": {
            "card_knightmare": 99999,
            "card_kanon": 50,
            "card_potion": 99,
            "card_shuri": 21,
            "card_woofy": 9,
            "card_tulkit": 5,
            "card_charja": 17,
            "card_nox": 5
        }
    },
    "store_items": {}
}

Here we define the currencies and amounts that each player begins the game with, as well as the items that each player begins the game with. In this example, we’ve given each player an amount of every type of card, as well as a large amount of coins and a smaller amount of gems, so that we can illustrate the upgrade process later on.

collectible_cards.go #

This file, available here, is where we utilize the Hiro systems we’ve defined to implement the collectible card mechanics.

Here we define card attributes and upgrade costs based on their rarity and rank, outline rarity stat structures with gameplay elements, and determine the cost structure, defining how many cards and coins are needed for upgrades.

 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
// ...
type CollectibleCardsDefinition struct {
	RarityStatsLadder     map[string][]*RarityStats    `json:"rarityStatsLadder"`
	RarityCardCostsLadder map[string][]*RarityCardCost `json:"rarityCardCostsLadder"`
}

type RarityStats struct {
	Points      int `json:"points"`
	Probability int `json:"probability"`
}

type RarityCardCost struct {
	CardCount int `json:"cardCount"`
	Coins     int `json:"coins"`
}

func NewCollectibleCardDefinition(nk runtime.NakamaModule, path string) (*CollectibleCardsDefinition, error) {
	file, err := nk.ReadFile(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	bytes, err := io.ReadAll(file)
	if err != nil {
		return nil, err
	}

	definition := &CollectibleCardsDefinition{
		RarityStatsLadder:     make(map[string][]*RarityStats),
		RarityCardCostsLadder: make(map[string][]*RarityCardCost),
	}
	if err = json.Unmarshal(bytes, definition); err != nil {
		return nil, err
	}

	return definition, nil
}
// ...

The CollectibleCardsDefinition contains the information needed to generate the stats and upgrade costs for the collectible cards.

The NewCollectibleCardDefinition function is used to read the JSON file containing this information - collectable-cards-dev1 here - and unmarshal it into the CollectibleCardsDefinition struct.

Client-side #

CollectibleCardGameCoordinator #

This file bootstraps our game with a list of systems to be used, and provides a list of systems for deterministic start-up. In our case, we’re initializing the Inventory and Economy core systems from Hiro, and finally our custom Collectible Card Game system.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ...
systems.Add(nakamaSystem);
        
        // Add the Inventory system
        var inventorySystem = new InventorySystem(logger, nakamaSystem);
        systems.Add(inventorySystem);
        
        // Add the Economy system
        var economySystem = new EconomySystem(logger, nakamaSystem, EconomyStoreType.Unspecified);
        systems.Add(economySystem);
        
        // Add the Collectible Card Game system
        var collectibleCardSystem = new CollectibleCardSystem(logger, nakamaSystem);
        systems.Add(collectibleCardSystem);

        return Task.FromResult(systems);
// ...

CollectibleCardSystem #

This file contains the client logic for the Collectible Card Game system, including functions to fetch the current player cards and handle card upgrades.

To start with, we have some public dictionaries to store the card stats and costs, and the player’s current cards:

1
2
3
4
5
// ...
public Dictionary<string, List<CardCost>> CardCosts => _cardCosts;
    public Dictionary<string, List<CardStat>> CardStats => _cardStats;
    public Dictionary<string, Card> Cards => _cards;
// ...

Then we have two functions to fetch the current player cards and handle card upgrades, each of which calls the corresponding RPC function on the server-side.

 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
// ...
public async Task GetCardsAsync()
    {
        var result = await _nakamaSystem.Client.RpcAsync(_nakamaSystem.Session, "InventoryListCards");
        var cardList = result.Payload.FromJson<CardList>();
        
        _cardCosts = cardList.CardCosts;
        _cardStats = cardList.CardStats;
        _cards = cardList.Cards;
        
        NotifyObservers();
    }

    public async Task UpgradeCardAsync(string itemId)
    {
        var payload = new Dictionary<string, string>
        {
            {"itemId", itemId}
        };
        var result = await _nakamaSystem.Client.RpcAsync(_nakamaSystem.Session, "InventoryCardUpgrade", payload.ToJson());
        var cardList = result.Payload.FromJson<CardList>();
        
        _cardCosts = cardList.CardCosts;
        _cardStats = cardList.CardStats;
        _cards = cardList.Cards;
        
        NotifyObservers();
    }
// ...

CardManager #

The CardManager manages all calls to our Hiro systems, and creates system observers for each system to handle UI updates based on system changes.

 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
// ...
public async Task InitAsync(EconomySystem economySystem, CollectibleCardSystem collectibleCardSystem)
    {
        _economySystem = economySystem;
        _collectibleCardSystem = collectibleCardSystem;

        SystemObserver<EconomySystem>.Create(economySystem, OnEconomySystemChanged);
        SystemObserver<CollectibleCardSystem>.Create(collectibleCardSystem, OnCollectibleCardSystemChanged);

        // Fetch first data from collectible card system
        await _collectibleCardSystem.GetCardsAsync();
    }

    private void OnEconomySystemChanged(EconomySystem system)
    {
        // Update the coins display
        _coins = system.Wallet["coins"];
        
        UpdateUI();
    }

    private void OnCollectibleCardSystemChanged(CollectibleCardSystem system)
    {
        UpdateUI();
    }

    private void UpdateUI()
    {
        // Update coins display
        coinsLabel.text = _coins.ToString();
        
        // Clear the existing card grid
        foreach (Transform t in cardGrid.transform)
        {
            Destroy(t.gameObject);
        }
        
        // Sort cards by name
        var sortedCards = _collectibleCardSystem.Cards.Select(x => x.Value).OrderBy(x => x.Name);

        // Hide the loading spinner
        loadingSpinner.SetActive(false);
        
        // Add cards to the grid
        foreach (var card in sortedCards)
        {
            var newCard = Instantiate(cardPrefab, cardGrid.transform);
            var collectibleCardUI = newCard.GetComponent<CollectibleCardUI>();
            
            collectibleCardUI.Clicked += () => OnCardClicked(card);
            collectibleCardUI.SetCard(card, _cardSprites[card.Name], _coins);
        }
    }
// ...