Tutorial: Card Game with Tokens
Build a collectible card game on Dash Platform where cards are documents that can be traded, and an in-game currency token is used for purchases. This tutorial combines data contracts, documents, and tokens into a cohesive application.
Environment: Steps 1-2 (contract design and deployment) are run from a Node.js script using a developer/operator identity. Steps 3 onward (minting, trading, querying) can run in either Node.js or a browser app using the published contract ID.
What you will learn
- Designing a contract with both document types and tokens
- Using documents as game items (cards) owned by identities
- Token-based in-game economy (minting rewards, spending on packs)
- Document transfers for card trading between players
- Querying collections and leaderboards
Prerequisites
npm install @dashevo/evo-sdk
You need a funded testnet identity. This tutorial uses two identities to demonstrate trading.
Step 1: Design the game contract
The contract defines three document types and one token:
- card — A collectible card with rarity, power, and element
- deck — A player's active deck configuration
- match — Match result history
- GemToken — In-game currency for buying card packs
import {
TokenConfigurationConvention, TokenConfigurationLocalization, TokenConfiguration,
ChangeControlRules, AuthorizedActionTakers, TokenDistributionRules,
TokenKeepsHistoryRules, TokenMarketplaceRules, TokenTradeMode,
} from '@dashevo/evo-sdk';
const gameSchema = {
card: {
type: 'object',
properties: {
name: { type: 'string', maxLength: 63, position: 0 },
element: { type: 'string', enum: ['fire', 'water', 'earth', 'air', 'shadow'], position: 1 },
rarity: { type: 'string', enum: ['common', 'uncommon', 'rare', 'legendary'], position: 2 },
power: { type: 'integer', minimum: 1, maximum: 100, position: 3 },
defense: { type: 'integer', minimum: 1, maximum: 100, position: 4 },
ability: { type: 'string', maxLength: 128, position: 5 },
edition: { type: 'integer', minimum: 1, position: 6 },
},
required: ['name', 'element', 'rarity', 'power', 'defense', 'edition'],
additionalProperties: false,
},
deck: {
type: 'object',
properties: {
name: { type: 'string', maxLength: 63, position: 0 },
cardIds: {
type: 'array',
items: { type: 'string', maxLength: 44 },
minItems: 5,
maxItems: 10,
position: 1,
},
},
required: ['name', 'cardIds'],
additionalProperties: false,
},
match: {
type: 'object',
properties: {
player1Id: { type: 'string', maxLength: 44, position: 0 },
player2Id: { type: 'string', maxLength: 44, position: 1 },
winnerId: { type: 'string', maxLength: 44, position: 2 },
player1Score: { type: 'integer', minimum: 0, position: 3 },
player2Score: { type: 'integer', minimum: 0, position: 4 },
timestamp: { type: 'integer', position: 5 },
},
required: ['player1Id', 'player2Id', 'winnerId', 'timestamp'],
additionalProperties: false,
},
};
// Build the token configuration using SDK classes
const localization = new TokenConfigurationLocalization(true, 'Gem', 'Gems');
const conventions = new TokenConfigurationConvention({ en: localization }, 0);
const ownerOnly = new ChangeControlRules({
authorizedToMakeChange: AuthorizedActionTakers.ContractOwner(),
adminActionTakers: AuthorizedActionTakers.ContractOwner(),
});
const noOne = new ChangeControlRules({
authorizedToMakeChange: AuthorizedActionTakers.NoOne(),
adminActionTakers: AuthorizedActionTakers.NoOne(),
});
const gemTokenConfig = new TokenConfiguration({
conventions,
conventionsChangeRules: noOne,
baseSupply: 0n,
maxSupply: 10_000_000n, // 10 million Gems total
maxSupplyChangeRules: noOne,
keepsHistory: new TokenKeepsHistoryRules({
isKeepingMintingHistory: true,
isKeepingBurningHistory: true,
isKeepingTransferHistory: true,
}),
distributionRules: new TokenDistributionRules({
perpetualDistributionRules: noOne,
newTokensDestinationIdentityRules: noOne,
mintingAllowChoosingDestination: true,
mintingAllowChoosingDestinationRules: noOne,
changeDirectPurchasePricingRules: noOne,
}),
marketplaceRules: new TokenMarketplaceRules(TokenTradeMode.NotTradeable(), noOne),
manualMintingRules: ownerOnly,
manualBurningRules: ownerOnly,
freezeRules: noOne,
unfreezeRules: noOne,
destroyFrozenFundsRules: noOne,
emergencyActionRules: noOne,
mainControlGroupCanBeModified: AuthorizedActionTakers.NoOne(),
});
Step 2: Deploy the contract
import { EvoSDK, DataContract, Document, Identifier, IdentitySigner } from '@dashevo/evo-sdk';
const sdk = EvoSDK.testnetTrusted();
await sdk.connect();
// Game operator identity
const operatorId = 'OPERATOR_IDENTITY_ID';
const operatorKey = 'OPERATOR_PRIVATE_KEY_WIF';
// Set up signing
const operatorIdentity = await sdk.identities.fetch(operatorId);
const operatorIdentityKey = operatorIdentity.publicKeys[0];
const operatorSigner = new IdentitySigner();
operatorSigner.addKeyFromWif(operatorKey);
const nonce = await sdk.identities.nonce(operatorId);
const dataContract = new DataContract({
ownerId: new Identifier(operatorId),
identityNonce: nonce + 1n,
schemas: gameSchema,
tokens: { 0: gemTokenConfig },
});
const contract = await sdk.contracts.publish({
dataContract,
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
const contractId = contract.id.toString();
console.log('Game contract:', contractId);
Step 3: Mint starter Gems for a new player
When a player joins, give them starter Gems:
async function onboardPlayer(playerId: string) {
// Token operations require a CRITICAL security level key
// Gift 100 Gems to the new player
await sdk.tokens.mint({
dataContractId: new Identifier(contractId),
tokenPosition: 0,
amount: 100n,
recipientId: new Identifier(playerId),
identityId: new Identifier(operatorId),
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
console.log(`Welcomed ${playerId} with 100 Gems`);
}
Step 4: Create a card pack (operator mints cards)
The operator creates cards as documents. Each card is owned by the operator initially, then transferred to players when purchased.
// Define a set of cards for a pack
const starterPack = [
{ name: 'Flame Sprite', element: 'fire', rarity: 'common', power: 15, defense: 10, edition: 1 },
{ name: 'Tidal Guardian', element: 'water', rarity: 'common', power: 10, defense: 20, edition: 1 },
{ name: 'Stone Golem', element: 'earth', rarity: 'uncommon', power: 25, defense: 30, edition: 1 },
{ name: 'Wind Dancer', element: 'air', rarity: 'common', power: 20, defense: 12, edition: 1 },
{ name: 'Shadow Wraith', element: 'shadow', rarity: 'rare', power: 40, defense: 15, edition: 1 },
];
async function createCards(cards: typeof starterPack) {
for (const card of cards) {
const cardDoc = new Document({
documentTypeName: 'card',
dataContractId: new Identifier(contractId),
ownerId: new Identifier(operatorId),
properties: card,
});
await sdk.documents.create({
document: cardDoc,
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
console.log(`Created: ${card.name} (${card.rarity})`);
}
}
await createCards(starterPack);
Step 5: Player buys a card pack
The purchase flow:
- Player spends Gems (transfer to operator)
- Operator transfers card documents to the player
const PACK_PRICE = 50n; // 50 Gems per pack
async function buyPack(playerId: string, playerKey: string) {
// Set up player signing (token ops require CRITICAL security level key)
const playerIdentity = await sdk.identities.fetch(playerId);
const playerIdentityKey = playerIdentity.publicKeys[0];
const playerSigner = new IdentitySigner();
playerSigner.addKeyFromWif(playerKey);
// Player pays Gems to the operator
await sdk.tokens.transfer({
dataContractId: new Identifier(contractId),
tokenPosition: 0,
amount: PACK_PRICE,
recipientId: new Identifier(operatorId),
senderId: new Identifier(playerId),
identityKey: playerIdentityKey,
signer: playerSigner,
});
console.log(`Player paid ${PACK_PRICE} Gems`);
// Operator transfers cards to the player
// (In production, select random cards from available pool)
const availableCards = await sdk.documents.query({
dataContractId: contractId,
documentTypeName: 'card',
where: [['$ownerId', '==', operatorId]],
limit: 5,
});
for (const [cardId, card] of availableCards) {
if (!card) continue;
await sdk.documents.transfer({
document: card,
recipientId: new Identifier(playerId),
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
const props = card.properties as Record<string, unknown>;
console.log(`Transferred ${props.name} to player`);
}
}
Step 6: Query a player's collection
async function getCollection(playerId: string) {
const cards = await sdk.documents.query({
dataContractId: contractId,
documentTypeName: 'card',
where: [['$ownerId', '==', playerId]],
orderBy: [['power', 'desc']],
limit: 100,
});
console.log(`\n${playerId}'s collection:`);
for (const [id, card] of cards) {
if (!card) continue;
const d = card.properties as Record<string, unknown>;
console.log(` [${d.rarity}] ${d.name} — ${d.element} — ATK:${d.power} DEF:${d.defense}`);
}
return cards;
}
Filter by rarity
const legendaries = await sdk.documents.query({
dataContractId: contractId,
documentTypeName: 'card',
where: [
['$ownerId', '==', playerId],
['rarity', '==', 'legendary'],
],
limit: 50,
});
Step 7: Trade cards between players
Player-to-player trading using document transfers:
async function tradeCards(
fromId: string, fromKey: string, fromCardId: string,
toId: string, toKey: string, toCardId: string,
) {
// Set up signers for both players
const fromIdentity = await sdk.identities.fetch(fromId);
const fromIdentityKey = fromIdentity.publicKeys[0];
const fromSigner = new IdentitySigner();
fromSigner.addKeyFromWif(fromKey);
const toIdentity = await sdk.identities.fetch(toId);
const toIdentityKey = toIdentity.publicKeys[0];
const toSigner = new IdentitySigner();
toSigner.addKeyFromWif(toKey);
// Fetch both card documents
const fromCard = await sdk.documents.get(contractId, 'card', fromCardId);
const toCard = await sdk.documents.get(contractId, 'card', toCardId);
// Player A sends their card to Player B
await sdk.documents.transfer({
document: fromCard,
recipientId: new Identifier(toId),
identityKey: fromIdentityKey,
signer: fromSigner,
});
// Player B sends their card to Player A
await sdk.documents.transfer({
document: toCard,
recipientId: new Identifier(fromId),
identityKey: toIdentityKey,
signer: toSigner,
});
console.log('Trade complete!');
}
Step 8: Record a match result
async function recordMatch(
player1Id: string, player2Id: string,
winnerId: string,
p1Score: number, p2Score: number,
) {
const matchDoc = new Document({
documentTypeName: 'match',
dataContractId: new Identifier(contractId),
ownerId: new Identifier(operatorId),
properties: {
player1Id,
player2Id,
winnerId,
player1Score: p1Score,
player2Score: p2Score,
timestamp: Date.now(),
},
});
await sdk.documents.create({
document: matchDoc,
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
// Reward the winner with Gems (token ops require CRITICAL security level key)
await sdk.tokens.mint({
dataContractId: new Identifier(contractId),
tokenPosition: 0,
amount: 10n,
recipientId: new Identifier(winnerId),
identityId: new Identifier(operatorId),
identityKey: operatorIdentityKey,
signer: operatorSigner,
});
console.log(`Match recorded. ${winnerId} wins and earns 10 Gems!`);
}
Step 9: Leaderboard
Query match history to build a win count:
async function getWinCounts() {
const matches = await sdk.documents.query({
dataContractId: contractId,
documentTypeName: 'match',
orderBy: [['timestamp', 'desc']],
limit: 100,
});
const wins = new Map<string, number>();
for (const [, doc] of matches) {
if (!doc) continue;
const props = doc.properties as Record<string, unknown>;
const winner = props.winnerId as string;
wins.set(winner, (wins.get(winner) ?? 0) + 1);
}
// Sort by wins descending
const sorted = [...wins.entries()].sort((a, b) => b[1] - a[1]);
console.log('\nLeaderboard:');
sorted.forEach(([id, count], i) => {
console.log(` ${i + 1}. ${id.slice(0, 8)}... — ${count} wins`);
});
}
Architecture recap
┌──────────────────────────────────────────────────┐
│ Game Contract │
├──────────────────┬───────────────┬───────────────┤
│ card (document) │ deck (doc) │ match (doc) │
│ - name, element │ - cardIds[] │ - players │
│ - rarity, power │ │ - winner │
│ - transferable │ │ - scores │
├──────────────────┴───────────────┴───────────────┤
│ GemToken (token position 0) │
│ - in-game currency │
│ - minted as rewards, spent on packs │
└──────────────────────────────────────────────────┘
Next steps
- Add deck validation — check that a deck only contains cards the player owns
- Implement card pricing with
sdk.documents.setPrice()for a marketplace - Add seasonal editions with different
editionnumbers - Build a real-time game client that listens for match results
- Use groups for guild/clan systems with shared card pools