Appearance
Appearance
Metaplay SDK can bind NFT wallets to players and represent players' NFTs in-game. It does this by interfacing with services such as Immutable X (a layer 2 blockchain scaling platform) to acquire NFT ownership information. You can define C# classes representing your NFT collections, and Metaplay SDK can then translate such C# objects to and from publicly visible NFT metadata files.
Metaplay service contract required
Developing Web3 games using Metaplay generally requires project-specific work in two main areas: authenticating wallet ownership and adding support for your project's ledge. Most web3 games also want to develop cross-platform clients that need a platform-agnostic login system.
We have customers running all of the above in production, but the all of features are not yet officially part of the Metaplay SDK. If you're interested in developing Web3 games, please reach out to us to discuss details.
This quick guide shows the steps to add NFTs to your game. By the end of it, you will have configured the game to interface with Immutable X, created an NFT representation in the game, and bound a wallet to a player account.
Here's an overview of the steps ahead:
After completing the quick guide, you should look at Other Features for an explanation of features important for real usage of NFTs.
Web3 features are behind the feature flag EnableWeb3
in MetaplayCoreOptions.FeatureFlags
. Set this to true
in your IMetaplayCoreOptionsProvider
implementation (e.g., GlobalOptions
class in the sample projects).
You should also set EnableImmutableXLinkClientLibrary
to true
, which will be needed to bind a wallet to a player account.
Enabling the Web3 features requires some boilerplate setup, described below.
The Web3 features include additional AdminApi permissions.
You don't need to do anything here if you're using the SDK default roles and permissions.
However, if your Options.*.yaml
s override the AdminApi:Permissions
definitions, you'll need to add the Web3-specific permissions. When you start the server, it will print any missing permissions; add those to your options yaml, with the desired roles.
The Web3 features introduce new database tables, so an EF Core migration is needed. Go to your game's Server project directory (e.g., Backend/Server
) and run dotnet ef migrations add InitialWeb3
.
NFT collections are configured in the server's options. For example:
sWeb3:
NftCollections:
Heroes:
Ledger: LocalTesting
# Note: contract address not used in LocalTesting mode.
#ContractAddress: 0x0123456789abcDEF0123456789abCDef01234567
MetadataFolder: "metadata/heroes"
NftCollections
dictionary is the collection's id. It is internal to the game, so it does not need to match any name or identifier set up in Immutable X. It is, however, used as part of the NFT persistence schema, so it should remain stable.Ledger
must be either LocalTesting
or ImmutableX
. LocalTesting
is intended for initial set-up development or testing, when the collection hasn't yet been set up in Immutable X. It should be changed to ImmutableX
to get real NFT ownership info.ContractAddress
is the Ethereum address of the contract. Written in hex form, it must be cased as described in EIP-55. If the casing is incorrect in the options, the server will print an error message at start - you can get the correctly-cased version from that message. When Ledger
is LocalTesting
, ContractAddress
is not needed.MetadataFolder
defines the final part of the metadata URL for the collection. See section Metadata Storage in S3 for a description of how the full metadata paths are formed.You'll need to enable WebSocket support on the server in order to support WebGL clients. Wallet authentication is only supported in WebGL clients.
See page Metaplay & WebGL, in particular section Enabling WebGL.
NFTs in the game are represented as subclasses of MetaNft
. Each such class belongs to one collection, identified by the same collectionId
as a key in the Web3:NftCollections
dictionary in the server options.
The server automatically generates the NFT's metadata JSON based on [NftMetadataCoreProperty]
and [NftMetadataCustomProperty]
annotations on the class members.
Game config references are allowed in NFT classes but must use MetaRef<>
(not the plain config data type).
The following example defines a class belonging to the Heroes
collection, which we configured in the server options. This example assumes there exists a HeroTypeInfo
config data type that defines per-hero-type config attributes. The HeroNft.HeroType
config reference thus defines the type of the hero, and the Level
member is per-instance data, possibly mutable by game logic.
[MetaNft(collectionId: "Heroes")]
[MetaSerializableDerived(1)]
[SupportedSchemaVersions(1, 1)]
public class HeroNft : MetaNft
{
[NftMetadataCustomProperty]
[MetaMember(1)] public MetaRef<HeroTypeInfo> HeroType;
[NftMetadataCustomProperty]
[MetaMember(2)] public int Level = 1;
[NftMetadataCustomProperty]
public int BaseAttackPower => HeroType.Ref.BaseAttackPower;
[NftMetadataCoreProperty]
public string Name => HeroType.Ref.Name;
[NftMetadataCoreProperty]
public string Description => $"Level {Level.ToString(CultureInfo.InvariantCulture)} hero {HeroType.Ref.Name}";
[NftMetadataCoreProperty]
public string ImageUrl => HeroType.Ref.ImageUrl;
}
On the Web3 page of the LiveOps Dashboard, you should now be able to see the collection.
Clicking on "View NFT Collection" takes you to the collection-specific page. There, you can use the "Init single NFT" button to initialize an NFT entry in the game.
⚠️ Caveat
The form is auto-generated based on the MetaNft
-deriving classes. Form auto-generation does not yet support all kinds of member types you might want to use. If you encounter such issues, it may be possible to work around them by using [Transient]
helper properties with more basic types.
The server initializes a new NFT state, which is then shown in the NFTs list. Clicking in the list on "View NFT" takes you to the NFT-specific page.
Note that the server does not automatically mint the NFT (even if configured to use Immutable X instead of the Local Testing mode); that will need to be done externally. In other words, the NFT only exists within the game, until you mint it in Immutable X.
When an NFT is initialized, the server writes its metadata JSON. By default, a local server writes its metadata under PublicBlobStorage/Web3PseudoBucket/
in the server's working directory. On a properly-configured cloud-deployed server, the metadata is instead written into the configured S3 bucket.
If you haven't already, you should now create an actual NFT contract in Ethereum (either mainnet or the Goerli testnet) and register a collection in Immutable X. See Immutable X's documentation on how to do that.
Now that you have an NFT collection in Immutable X, let's make the game aware of it. Starting with the server options configured earlier, augment them with the information about your deployed collection. Also, enable player authentication via Immutable X, which enables binding a wallet to a player account, thus allowing real NFT ownership to be reflected in the game.
Web3:
# Should be either EthereumMainnet or GoerliTestnet.
ImmutableXNetwork: GoerliTestnet
# Enable player authentication to enable wallet binding.
EnableImmutableXPlayerAuthentication: true
# This product name will be displayed to the player during the
# wallet binding flow.
ImmutableXPlayerAuthenticationProductName: "My Game Name"
# This secret should be set to an unguessable, per-game string.
# This example was generated with python:
# import uuid; print(uuid.uuid4())
# Generate your own random string instead of using this!
ImmutableXPlayerAuthenticationChallengeHmacSecret: "35995352-df6c-4a18-ada1-992eb8a9eac7"
# Optional Immutable X API key. See https://docs.x.immutable.com/docs/api-rate-limiting
ImmutableXApiKey: ...
NftCollections:
Heroes:
# Change Ledger from LocalTesting to ImmutableX.
Ledger: ImmutableX
# Assign your collection's actual contract address.
ContractAddress: 0x0c61Baa20CfC1d5fAa3e732d7a14F3971C0c1529
MetadataFolder: "metadata/heroes"
In an earlier step, you initialized an NFT on the server. The server does not mint NFTs, but if you now mint the NFT manually, it should be reflected in the dashboard as the server is now interfacing with Immutable X.
You can mint the NFT, for example, as described in Immutable X's documentation. You should mint the NFT for a wallet that is under your control so that you can later test it in the game.
If the game server is running when you mint an NFT, the server observes the NFT ownership and will update with a brief delay. Otherwise, it may need an explicit trigger to update, such as its owning player logging in; or, for manual testing, you may use the “Refresh Ownership” button.
Note that if you're testing on a local server, it likely won't have written the NFT's metadata into any actual S3 bucket, but instead on your local machine. In this case, when you mint the NFT, Immutable X won't see the metadata and the NFT will appear in an incomplete manner in marketplaces. See Metadata Storage in S3 on configuring your cloud servers.
To associate players with Immutable X wallets, and thus with owned NFTs, the game client should have a button that invokes ImmutableXLinkSdkHelper.LoginWithImmutableXAsync
:
public void OnClickImxLogin()
{
MetaTask.Run(async () =>
{
await ImmutableXLinkSdkHelper.LoginWithImmutableXAsync();
});
}
This will initiate a login flow that uses a browser wallet plugin (such as MetaMask).
ℹ️ Note
The login flow consists of multiple steps and may open the wallet plugin popup twice, and some browsers block the second popup. If this happens, it should help to click the login button again after the first popup has closed, or alternatively adjust the browser's popup blocker settings.
After the login is successful and the wallet has been associated with the player account, the server associates the wallet's NFTs with the player. This will be reflected in the dashboard, in the NFT detail page (as the Owning Player), as well as the player detail page which has a list of the player's owned NFTs.
After a wallet has been bound to a player, the owned NFTs are available in playerModel.Nft.OwnedNfts
. Like other shared state in playerModel
, this OwnedNfts
state is readable from shared game logic, client code, and server code alike. However, OwnedNfts
should be normally treated as a read-only mirror of the NFT states; it should not be directly modified in PlayerAction
s. Instead, if your game has mutable state in NFTs, you should use player-NFT transactions to mutate them. See section Mutating NFT State for more information.
This section explains features not covered in the Quick Guide.
In order to make game-produced NFT metadata visible to Immutable X and other marketplaces, the server writes the metadata into an S3 bucket. The contents of the S3 bucket will need to be publicly readable.
On the infra side, you'll need to set up an S3 bucket that can be written to by the game server, and read publicly via some URL. Metaplay's infra-modules
repository has a module in components/nft/
for this.
The game server configuration for the S3 bucket access is done in the Web3
section of server options. The following example shows what you might put in Options.base.yaml
. You may wish to assign some of these options from the infra side instead, if appropriate.
Web3:
# Region for NFT metadata S3 storage
S3Region: eu-west-1
# Name of the S3 bucket
NftBucketName: mycompany-nft
# Folder prefix added to the path of all metadata.
# Intended for separating the metadata stored by different projects or environments.
NftBasePath: develop
# Publicly-readable URL to the bucket.
NftBucketUrl: https://my.company.dev/nft
Using this example configuration, along with the Heroes
collection configured in the Quick Guide, the server would behave as follows: when hero NFT with id 123 is initialized in the game, the server will write its metadata to bucket mycompany-nft
in region eu-west-1
, with path develop/metadata/heroes/123
.
NftBasePath
, the per-collection MetadataFolder
, and the token's id.NftBucketUrl
: https://my.company.dev/nft/develop/metadata/heroes/123
.https://my.company.dev/nft/develop/metadata/heroes
. Immutable X will append the final slash and the token id when it queries metadata.Each of these options can be overridden per collection in the NftCollections
entries, with fields MetadataS3Region
, MetadataBucketName
, MetadataBucketUrl
, and MetadataBasePath
.
ℹ️ Note
On a local server, NFT metadata is by default stored not in S3, but on the local filesystem under PublicBlobStorage/Web3PseudoBucket/
, rooted at the server's working directory. This is controlled by the boolean Web3:UsePseudoBucketInPublicBlobStorage
(by default true in local servers, false elsewhere).
In some cases, you may have an NFT collection that is not "owned" by the game, but rather the NFT metadata is operated by external means, and the game server only needs to be able to import existing metadata into the game. In such cases the server does not need write access to the bucket, and S3Region
and NftBucketName
can be omitted. The per-collection field MetadataManagementMode
needs to be set to Foreign
(by default it is Authoritative
):
Web3:
# ...
NftCollections:
Heroes:
Ledger: ImmutableX
ContractAddress: 0x0c61Baa20CfC1d5fAa3e732d7a14F3971C0c1529
MetadataFolder: "metadata/heroes"
MetadataManagementMode: Foreign
In this case, you would upload the metadata to the S3 bucket and mint the NFTs before initializing them in the game. Then, the in-game NFT representation can be initialized by importing the metadata into the server as described in section Batch Initialization from Existing Metadata.
In addition to metadata, NFTs typically involve additional media which needs to be hosted publicly. Typical media are images for the NFT collection and for the NFTs themselves. Metaplay SDK does not mandate any specific way of hosting this media, but a simple way is to use the same bucket as for NFT metadata.
An example structure of the NFT bucket could be:
(bucket root)
develop/
metadata/
heroes/
(individual NFT metadata files are here)
assets/
heroes/
heroes-icon.jpg
heroes-image.jpg
nfts/
(NFT images are here)
The collection icon and image URLs are configured in Immutable X.
The NFT image URLs are in the NFT metadata, as the value of the top-level image
property. In your C# MetaNft
-deriving class, they would typically be given by a property named ImageUrl
with attribute [NftMetadataCoreProperty]
.
There are various operations that can be conducted via the per-collection and per-NFT pages in the dashboard.
The dashboard has a few different ways to initialize NFTs, described in the following sections. There are a few concepts that apply for most of them.
MetadataManagementMode: Foreign
, then whenever a new NFT is initialized or its state is modified, the server will write the NFT's metadata. ⚠️ Long-running operations
When initializing a large batch of NFTs in one operation, it can take some time to complete. If you experience timeouts, this may be the cause - the system does not currently handle long-running operations with perfect grace. Note that an operation may still complete server-side even if it times out from the dashboard's point of view - keep an eye on the NFT list in that case. If this commonly occurs, you may want to try doing the initializations in smaller batches (e.g. 500 instead of 1000 in one operation). The server currently has a safety limit of 1000 NFTs per batch to avoid blocking the central NftManager
entity for arbitrarily long times.
As seen in the Quick Guide, the "Init single NFT" button lets you create the in-game representation of an NFT. The form is auto-generated based on your existing MetaNft
-deriving classes. If the "Token Id" field is left empty, the server automatically allocates a token id.
To initialize multiple NFTs conveniently, you can produce a CSV and use the "Init NFTs from CSV" button. The format of the CSV is as follows.
MetaNft
-deriving class.NftClass
which specifies the class name for each row.Id
which specifies the id for each row. To use automatic id allocation, omit the Id
column or leave it empty.To import NFTs from existing metadata (such as when you have a collection like ones described in "Foreign" NFT collections), you can use the "Init NFTs from metadata" button. Here you enter the range of NFT ids (starting id and count) to initialize. The server will download the metadata from the collection's configured public metadata API URL, and will then map it to the C# class. The metadata mapping here is roughly a reverse operation of when metadata is generated by the server based on an NFT instance: the top-level JSON metadata properties are mapped to C# members.
Note that for large batches, the metadata downloading can take a significant amount of time. The dashboard displays a banner for the ongoing download operation. Note that there is currently no mechanism for cancelling the operation after it has been started - the server will continue it (or fail on error) even if the dashboard UI popup is closed.
If you need to run custom code after an NFT has been created from metadata, you can override MetaNft.OnMetadataImported
.
In the per-NFT dashboard view, the "Edit" button can be used to change the state of an existing NFT. This is largely the same as the single NFT initialization with override enabled, but with the added convenience that the dashboard form is auto-filled with the NFT's current state.
Your NFTs may involve state which can be mutated by in-game operations. As mentioned in the Quick Guide, the playerModel.Nft.OwnedNfts
should not be mutated directly in PlayerAction
s (changes made in this way will not be persistent, as they will not be reflected in the game's ground truth NFT storage). Instead, a "player-NFT transaction" mechanism must be used.
Here is an example player-NFT transaction which upgrades a given hero, taking gold from the player as payment. The transaction mechanism is more elaborate than PlayerAction
s, because its execution needs to be coordinated between the player actor and the NFT ground truth entity (NftManager
). See comments on PlayerNftTransaction
in the SDK code for more detailed explanations.
[MetaSerializableDerived(1)]
public class PlayerUpgradeHeroNft : PlayerNftTransaction
{
/// <summary>
/// Which hero to upgrade.
/// </summary>
[MetaMember(1)] public NftId HeroId;
public override IEnumerable<NftKey> TargetNfts => new NftKey[]
{
new NftKey(NftCollectionId.FromString("Heroes"), HeroId)
};
PlayerUpgradeHeroNft() { }
public PlayerUpgradeHeroNft(NftId heroId)
{
HeroId = heroId;
}
[MetaSerializableDerived(1)]
public class Context : ContextBase
{
[MetaMember(1)] public int UpgradeCost;
Context() {}
public Context(int upgradeCost)
{
UpgradeCost = upgradeCost;
}
}
public override MetaActionResult Execute(IPlayerModelBase playerBase, OrderedDictionary<NftKey, MetaNft> nfts, bool commit, ref ContextBase context)
{
PlayerModel player = (PlayerModel)playerBase;
// Note: Transactions should operate on the `nfts` parameter of Execute,
// instead of on player.Nft.OwnedNfts.
// `nfts` includes the NFTs specified by the `TargetNfts` property.
HeroNft heroNft = (HeroNft)nfts.Values.Single();
// Calculate cost and validate
int upgradeCost = heroNft.HeroType.Ref.UpgradeCost;
if (player.Wallet.NumGold < upgradeCost)
{
// \note When Execute returns non-Success, CancelPlayer and FinalizePlayer
// will not be called, and thus we don't need to set `context` here.
return ActionResult.NotEnoughResources;
}
if (commit)
{
// Take payment from player, and increment hero level
player.Wallet.NumGold -= upgradeCost;
heroNft.Level++;
}
// Store in the context how much resources the player paid, so that CancelPlayer can refund it.
context = new Context(upgradeCost);
return MetaActionResult.Success;
}
public override void CancelPlayer(IPlayerModelBase playerBase, ContextBase context)
{
PlayerModel player = (PlayerModel)playerBase;
// Give back the amount of resources paid by the player.
int upgradeCost = ((Context)context).UpgradeCost;
player.Wallet.NumGold += upgradeCost;
}
public override void FinalizePlayer(IPlayerModelBase player, ContextBase context)
{
}
}
Now, this transaction can be invoked by the client via MetaplayClient.NftClient.ExecuteNftTransaction
:
// Note: An NFT's token id is MetaNft.TokenId
public void UpgradeHero(NftId heroTokenId)
{
PlayerNftTransaction transaction = new PlayerUpgradeHeroNft(heroTokenId);
MetaplayClient.NftClient.ExecuteNftTransaction(transaction);
}
If your project does not use MetaplayClient
, you'll need to hold an NftClient
somewhere else.
Note that the entire execution of a transaction is not immediate: while the Execute
method executes immediately, it may still get cancelled if the transaction fails to get committed (e.g. if the player loses ownership of the NFT just as the transaction being executed).
Upon successful completion of a transaction, player.Nft.OwnedNfts
will get updated accordingly. The NFT metadata JSON will also be updated, but please note that Immutable X keeps a clone of the metadata, which it acquires at the time of minting and does not automatically refresh.
These are some of the current limitations in the SDK's current implementations. If these or any other issues become a problem for you, please let us know.
NftManager
entity or the server.NftId
) are only 64-bit unsigned integers, whereas the NFT standards specify 256-bit unsigned integers.MetaNft
-deriving classes must use MetaRef<>
(not the plain config data type), or else config reference resolving errors will occur at runtime.