CryptoVikings
Search…
Technical Deep Dive
A full breakdown of the CryptoViking minting and generation process
The process you begin by clicking mint has our contracts and our API working together to produce a CryptoViking's on-chain representation, its metadata and its final image.
We integrated Chainlink VRF to provision one provably-fair random number per CryptoViking, which is used to break down the Viking's representation. We use Ethereum events to break up the process into a few steps to keep distinct functional concerns separate from one another and to facilitate recovery and data integrity.
We have two contracts, named after the Norse equivalent of the Greek Fates:
  • Nornir - responsible for NFT minting, speaking to VRF, generating statistics, name changes and representation storage
  • NornirResolver - responsible for using a CryptoViking's statistics to resolve its attributes/asset names, implementing the probability of each asset within its set
Here's a full breakdown of the entire generation process, from mint to image generation.

Step 1: Mint

Nornir: mintViking()
1
function mintViking(uint256 count) public {
2
doMint(count, false);
3
}
Copied!
When you hit the mintViking function with the number you'd like to mint, we first validate that everything seems right with a series of requires, and then action the mint itself.
We take payment, then tokens are minted and random numbers requested from VRF in sequence with requestRandomness(). We store the token ID against the VRF request ID so that we can match the number that comes back later on to the NFT it's for.
Here's a look at the important bits of the minting loop:
Nornir: doMint()
1
// container for minted IDs for sending to the front end
2
uint256[] memory mintedIds = new uint256[](count);
3
4
// count => requested quantity
5
for (uint256 i = 0; i < count; i++) {
6
uint256 id = totalSupply();
7
8
_safeMint(msg.sender, id);
9
_setTokenURI(id, id.toString());
10
11
// make the call to VRF, storing the token ID against the VRF request ID
12
requestIdToVikingId[requestRandomness(keyHash, fee)] = id;
13
14
mintedIds[i] = id;
15
}
16
17
// emit VikingsMinted to prompt the front end to move along the walkthrough
18
emit VikingsMinted(mintedIds);
Copied!

Step 2: VRF Callback

Nornir: fulfilRandomness()
1
function fulfillRandomness(bytes32 requestId, uint256 randomNumber) internal override {
2
// retrieve the token ID this random number is for
3
uint256 vikingId = requestIdToVikingId[requestId];
4
5
// store the random number against the token ID for later
6
vikingIdToRandomNumber[vikingId] = randomNumber;
7
8
// prompt our API to begin the process
9
emit VikingReady(vikingId);
10
}
Copied!
When VRF is done producing a token's number, it calls our fulfilRandomness() override. All we do here is store the random number against the token ID (retrieved by the VRF request ID) and prompt our API to begin the generation process for the Viking in question.

Step 3: Generate

Nornir: generateViking()
1
function generateViking(uint256 vikingId) public onlyOwner {
2
// ensure that generation is allowed
3
require(vikingIdToRandomNumber[vikingId] != 0, 'Viking not minted');
4
require(vikingStats[vikingId].appearance == 0, 'Viking already generated');
5
6
// retrieve the Viking's random number
7
uint256 randomNumber = vikingIdToRandomNumber[vikingId];
8
9
// use some math to take sections of the number for VikingStats
10
// all numbers are 2-digit, except the last, which is 4-digit
11
// the VikingStats is stored permanently against the token ID
12
vikingStats[vikingId] = NornirStructs.VikingStats(
13
string(abi.encodePacked('Viking #', vikingId.toString())),
14
(randomNumber % 100),
15
(randomNumber % 10000) / 100,
16
(randomNumber % 10**6) / 10**4,
17
(randomNumber % 10**8) / 10**6,
18
(randomNumber % 10**10) / 10**8,
19
(randomNumber % 10**12) / 10**10,
20
(randomNumber % 10**14) / 10**12,
21
(randomNumber % 10**16) / 10**14,
22
(randomNumber % 10**18) / 10**16,
23
(randomNumber % 10**20) / 10**18,
24
(randomNumber % 10**28) / 10**20
25
);
26
27
// increment a counter we use to detect data integrity issues for recovery
28
generatedVikingCount++;
29
30
// set the Viking's name in a mapping we use for validating uniqueness
31
vikingNames[
32
keccak256(abi.encodePacked('Viking #', vikingId.toString()))
33
] = true;
34
35
// prompt the API to continue the process for this Viking
36
emit VikingGenerated(vikingId);
37
}
Copied!
When the API picks up a VikingReady event, it queues up a call to generateViking() for the ID it received.
In generateViking(), we recover the random number associated with the token ID and do some math to take 2 and 4 digit segments of it before storing the resultant VikingStats struct in a publicly-accessible mapping against the token ID.
This VikingStats will serve as the basis for the next step in the process, which the API is prompted to queue up with a VikingGenerated event.
VikingStats looks like this:
NornirStructs.VikingStats
1
struct VikingStats {
2
string name;
3
// 2-digit selector for boots asset
4
uint256 boots;
5
// 2-digit selector for bottoms asset
6
uint256 bottoms;
7
// 2-digit selector for helmet asset
8
uint256 helmet;
9
// 2-digit selector for shield asset
10
uint256 shield;
11
// 2-digit selector for weapon asset
12
uint256 weapon;
13
// 2-digit statistic, influencing the condition of the weapon
14
uint256 attack;
15
// 2-digit statistic, influencing the condition of the shield
16
uint256 defence;
17
// 2-digit statistic, influencing the condition of the helmet
18
uint256 intelligence;
19
// 2-digit statistic, influencing the condition of the boots
20
uint256 speed;
21
// 2-digit statistic, influencing the condition of the bottoms
22
uint256 stamina;
23
// 4-digit combined selectors for beard, body, face and top assets
24
uint256 appearance;
25
}
Copied!

Step 4: Resolve

Nornir: resolveViking()
1
function resolveViking(uint256 vikingId) public onlyOwner {
2
// ensure that resolution is allowed
3
require(vikingStats[vikingId].appearance != 0, 'Viking not generated');
4
require(bytes(vikingComponents[vikingId].weapon).length == 0, 'components already resolved');
5
require(bytes(vikingConditions[vikingId].weapon).length == 0, 'Conditions already resolved');
6
7
// call out to NornirResolver to resolve the VikingConditions for the Viking
8
// the VikingConditions is stored permanently against the token ID
9
vikingConditions[vikingId] = nornirResolverContract.resolveConditions(vikingStats[vikingId]);
10
11
// call out to NornirResolver to resolve the VikingComponents for the Viking
12
// the VikingComponents is stored permanently against the token ID
13
vikingComponents[vikingId] = nornirResolverContract.resolveComponents(vikingStats[vikingId], vikingConditions[vikingId]);
14
15
// increment a counter we use to detect data integrity issues for recovery
16
resolvedVikingCount++;
17
18
// prompt the API to generate the Viking's image
19
emit VikingResolved(vikingId, vikingStats[vikingId], vikingComponents[vikingId], vikingConditions[vikingId]);
20
}
Copied!
When the API picks up a VikingGenerated event, it queues up a call to resolveViking() for the ID it received.
In resolveViking(), we recover the VikingStats associated with the token ID and ask NornirResolver to resolve the Viking's item conditions and asset names, storing that information in two structs - VikingConditions and VikingComponents - publicly against the token ID.
The on-chain representation breakdown is now complete - we prompt the API to generate an image with a VikingResolved event.
VikingConditions and VikingComponents look like this:
NornirStructs.VikingConditions
NornirStructs.VikingComponents
1
// VikingConditions contains resolved condition names for each of a Viking's items
2
// conditions modify an associated asset visually
3
struct VikingConditions {
4
string boots;
5
string bottoms;
6
string helmet;
7
string shield;
8
string weapon;
9
}
Copied!
1
// VikingComponents contains resolved asset names for each of a Viking's parts
2
struct VikingComponents {
3
string beard;
4
string body;
5
string face;
6
string top;
7
string boots;
8
string bottoms;
9
string helmet;
10
string shield;
11
string weapon;
12
}
Copied!
And here's how NornirResolver resolves them. The actual resolution functions and full list of asset names are hidden until we open source the system.
NornirResolver: resolveConditions()
NornirResolver: resolveComponents()
1
function resolveConditions(NornirStructs.VikingStats memory stats) external pure returns (NornirStructs.VikingConditions memory) {
2
return NornirStructs.VikingConditions(
3
// speed statistic conditions the boots
4
resolveClothesCondition(stats.speed),
5
6
// stamina statistic conditions the bottoms
7
resolveClothesCondition(stats.stamina),
8
9
// intelligence statistic conditions the helmet
10
resolveItemCondition(stats.intelligence),
11
12
// defence statistic conditions the shield
13
resolveItemCondition(stats.defence),
14
15
// attack statistic conditions the weapon
16
resolveItemCondition(stats.attack)
17
);
18
}
19
20
// conditions are split by Clothes and Items
21
// they're selected based on the value of the statistic
22
23
/*
24
Item Conditions (helmet, shield, weapon):
25
None
26
Destroyed
27
Battered
28
War Torn
29
Battle Ready
30
Flawless
31
32
Clothes Conditions (boots, bottoms):
33
Standard
34
Ragged
35
Rough
36
Used
37
Good
38
Perfect
39
*/
Copied!
1
function resolveComponents(NornirStructs.VikingStats memory stats, NornirStructs.VikingConditions memory conditions) external pure returns (NornirStructs.VikingComponents memory) {
2
return NornirStructs.VikingComponents(
3
// first two digits of 'appearance' select the beard
4
resolveBeard(stats.appearance / 1000000),
5
6
// second 2 digits of 'appearance' select the body
7
resolveBody((stats.appearance / 10000) % 100),
8
9
// third 2 digits of 'appearance' select the face
10
resolveFace((stats.appearance / 100) % 100),
11
12
// final 2 digits of 'appearance' select the top
13
resolveTop(stats.appearance % 100),
14
15
// boots selected by boots stat, modified by boots condition
16
resolveBoots(stats.boots, conditions.boots),
17
18
// bottoms selected by bottoms stat, modified by bottoms condition
19
resolveBottoms(stats.bottoms, conditions.bottoms),
20
21
// helmet selected by helmet stat, modified by helmet condition
22
resolveHelmet(stats.helmet, conditions.helmet),
23
24
// shield selected by shield stat, modified by weapon condition
25
resolveShield(stats.shield, conditions.shield),
26
27
// weapon selected by weapon stat, modified by weapon condition
28
resolveWeapon(stats.weapon, conditions.weapon)
29
);
30
}
Copied!

Step 5: Image Generation

Once the API receives the complete VikingStats, VikingConditions and VikingComponents, it can generate the final Viking image.
First it massages the information into a structure called VikingSpecification, which designates among other things the file paths for each of the selected assets, and then it supplies the VikingSpecification to the image generator.
Here's a snippet of the file path production:
API: VikingSpecificationHelper.resolveFilePaths()
1
// VikingComponents and VikingConditions are 1-1 reflections of their on-chain counterparts
2
private static resolveFilePaths(components: VikingComponents, conditions: VikingConditions): VikingSpecification['filePaths'] {
3
// component + condition name sanitizer for use in filenames
4
const cleanName = (name: string): string => name.replace(/[\s-]/g, '_').toLocaleLowerCase();
5
6
// resolve the file path for the beard - just run the chain-provided beard component through cleanName() for the file name
7
const beardFile = path.join(VikingSpecificationHelper.directories.beards, `beard_${cleanName(components.beard)}.png`);
8
9
// ...
10
11
// resolve the file path for the helmet
12
let helmetFile = undefined;
13
// if the condition was 'None', do not select a helmet
14
if (conditions.helmet !== 'None') {
15
// the style and the conditions are cleanName()'d chain-provided data
16
const style = cleanName(components.helmet);
17
const condition = cleanName(conditions.helmet);
18
19
// the file name is constructed with both the style and the condition to select the right variation
20
helmetFile = path.join(VikingSpecificationHelper.directories.helmets, style, `helmet_${style}_${condition}.png`);
21
}
22
23
// ...
24
25
return {
26
beard: beardFile,
27
// ...
28
helmet: helmetFile
29
// ...
30
};
31
}
Copied!
And here's the actual image generation routine in full:
API: ImageHelper.generateVikingImage()
1
public static async generateVikingImage(vikingSpecification: VikingSpecification): Promise<void> {
2
// wrap the GM process into a Promise so that it can be awaited
3
return new Promise((resolve, reject) => {
4
// images are named numerically so as to decouple storage + retrieval from the Viking's actual name
5
const filePath = path.join(ImageHelper.VIKING_OUT, `viking_${vikingSpecification.number}.png`);
6
7
// initialize an empty gm()
8
const image = gm('');
9
10
// pass in the asset parts in a specific layering order
11
image
12
.in(vikingSpecification.filePaths.body)
13
.in(vikingSpecification.filePaths.face)
14
.in(vikingSpecification.filePaths.top)
15
.in(vikingSpecification.filePaths.beard)
16
.in(vikingSpecification.filePaths.bottoms)
17
.in(vikingSpecification.filePaths.boots);
18
19
// only pass in helmet, shield and weapon if their associated conditions weren't 'None'
20
if (vikingSpecification.filePaths.helmet) {
21
image.in(vikingSpecification.filePaths.helmet);
22
}
23
if (vikingSpecification.filePaths.shield) {
24
image.in(vikingSpecification.filePaths.shield);
25
}
26
if (vikingSpecification.filePaths.weapon) {
27
image.in(vikingSpecification.filePaths.weapon);
28
}
29
30
// configure and output the composed image
31
image
32
.background('transparent')
33
.mosaic()
34
.resize(1024, 1024)
35
.write(filePath, ((err) => {
36
err ? reject(`${err.message} : ${JSON.stringify(vikingSpecification.filePaths)}`) : resolve()
37
}));
38
});
39
}
Copied!
The full API source will become available when we open source the system as part of our roadmap.