Chain Runner’s has been one of my favorite communities I’ve had the pleasure of becoming a part of. The community is full of builders and genuine people that truly care about the space (not just people that solely care about “number go up” tech).
Teams like the Chain Runners team and Anonymice team are trail blazers in this space. They go above and beyond what’s “normal and necessary” to ensure they create the best products that push the space forward.
I wanted to write this article to showcase the on-chain tech that composes each Chain Runner because I don’t think a lot of people fully appreciate/understand it, but it’s honestly super fucking cool. I hope this can help people understand the sheer big brain thinking that went into this project and encourage those same people to support real builders.
This article is going to be fairly technical, so I am just going to assume you understand the basics of on-chain vs off-chain NFTs and a high level understanding of solidity/programming. Here is a cool article shows the differences of on-chain vs off-chain.
Disclaimer, some of the traits I mention here are just for example purposes to make everything easier to understand, so they might not correlate exactly to what you see on OS or Etherscan.
First and foremost, shout out Anonymice (and every other on-chain project) that are just continuing to build. The trait weighting logic was inspired by you guys and it’s awesome to see your community continue to build as well.
This is the core mint function that constructs your runner. Every runner has a dna
that determines what traits that runner has (more on this later). The dna
is created by generating a pseudo-random number on-chain by hashing various block values and txn values, and then converting that into a uint256
. This essentially takes a whole bunch of pseudo-random bytes and turns it into a large number.
Although this isn’t perfectly random, it’s completely fine in this case since it was just for mint and realistically, it would’ve been impossible to game this since the runners minted out so fast.
This is where all the magic happens, so buckle in and let me learn you a thing or two.
Small disclaimer, recently the Chain Runner switched tokenURIs point to off-chain URLs to let people verify their pfp on Twitter (Twitter blue doesn’t support fully on chain SVGs… $26B company can’t figure out how to decode SVGs… smh). However, before this change, the tokenURI pointed to an on-chain image renderer - this is what we will be going over.
Look… I know what you’re probably thinking… what the fuck do those numbers mean? Well at least, that was my first impression, but don’t worry, it looks complex but it’s actually beautifully simple.
WEIGHTS
is a 3D array, the first index represents the race of the runner (human, skull, bot, etc), the second index represents the individual traits for each race, and the third index represents the probability for each trait. We’ll focus on the first 2 indexes of WEIGHTS
for now. While this isn’t proper Solidity syntax, let’s represent the array as WEIGHTS[Race][Trait]
.
Let’s make this easier to understand by changing up some of the syntax in the above code snippet.
In WEIGHTS
, index 0 represents the race (in this case human), second index 0 represents the trait (in this case Background):
WEIGHTS[0][0] == WEIGHTS[Human][Background]
Let’s do the rest:
WEIGHTS[0][1] == WEIGHTS[Human][Face]
WEIGHTS[0][2] == WEIGHTS[Human][Mouth]
WEIGHTS[0][3] == WEIGHTS[Human][Nose]
. . .
WEIGHTS[0][12] == WEIGHTS[Human][Mouth Accessory]
Every single race has the possibility to have all 13 traits, but that is determined by the actual array of numbers for each trait (the 3rd index in WEIGHTS
).
If we look at: WEIGHTS[0][3] = [645, 0, 1290, 322, 645, 645, 645, 967, 322, 967, 645, 967, 967, 973]; // in our syntax this is WEIGHTS[Human][Nose]
The long array of numbers represents the probability of each trait, in this case, the probability of each nose trait. Essentially, the bigger the number, the higher probability a runner will have that trait. The array sums up to 10,000 and the probability of a trait is essentially the array_value / 10,000.
Lets add comments to the array so it’s easier to see what’s going on:
WEIGHTS[0][3] = [
645, //probability of noseTrait1 is 645/10000
0, //probability of noseTrait2 is 0/10000
1290, //probability of noseTrait3 is 1290/10000
… etc
]
You may have noticed the 0
. That just means, for the human race, that specific nose trait is not selectable (for instance, it may be the bot nose which only the bot race can have).
Now that we understand what WEIGHTS
represents, and that the long array of numbers is actually just the traits are mapped to probabilities, let’s see how a runners dna
actually determines its traits.
A runner’s dna
is an uint256
which will look something like this: 29961126176421124262784123274363413448521573139494556849866782425143528472800
. We then take our dna
and feed it to this function:
This function takes our dna
and returns 13 different numbers in an array (NUM_LAYERS == 13
which is how many traits we have). Each number in the array is created by modding the dna
with 10000 (which will return the last 4 digits of the dna
), and then we bit shift dna
to the left by 14. We shift by 14 because it’s the lowest power of 2 that is > 10000. If we shifted by any less bits, the previous number would have a 1 bit correlation with the next number. We keep doing that in a loop until we get 13 numbers, 1 for each trait. These numbers determine which trait we get.
Imagine that the function returns the array [1234, 5432, 4443, 1111, etc]
for our dna
. So how do we take these numbers and actually see which trait we got? That happens in this function:
Caveat: our dna
also determines our runners race but let’s just say our runner is a human for all examples.
I’ll show you what this function is doing with an example.
This is the array returned for our dna
was [1234, 5432, 4443, 1111, etc]
. The index of each array elements maps directly to the index of the trait in WEIGHTS
.
So if 1111
in our array is at index 3, this maps to WEIGHTS[Human][index 3]
which is WEIGHTS[Human][Nose]
.
Recalling the trait probability array for the nose trait for the human race (from way above): WEIGHTS[Human][Nose] = [645, 0, 1290, 322, 645, 645, 645, 967, 322, 967, 645, 967, 967, 973]; // nose trait probability array
How does 1111
determine which nose trait we get? We loop through the nose trait probability array and sum up all the numbers until we reach a sum that bounds 6785
. So essentially, we loop through the array:
1: sum = 645 // WEIGHTS[Human][Nose][0]
is 1111
within [0,645]? If not, keep going.
2: sum = 645 + 0 = 645 // previous sum + WEIGHTS[Human][Nose][1]
is 1111
within [0,645]? If not, keep going.
3: sum = 645 + 1290 = 1935 // previous sum + WEIGHTS[Human][Nose][2]
is 1111
within [645, 1290]? If yes, this is the nose trait your runner gets.
Because the nose trait probability at index 2 bounds 1111
, our runner’s nose trait is whatever nose trait is at index 2. This is defined by the Chain Runner’s team (maybe it’s the septum piercing nose trait or maybe it’s something else).
We do this for every single trait by looping the array [1234, 5432, 4443, 1111, etc]
, this will determine all 13 traits for the runner.
Caveat, sometimes the probability you get from your dna
is greater than the sum of the traits array, that simply means, your runner will not have that trait at all. This is some runners have different number of traits.
This is essentially the crux of how a runner’s trait is determined.
Boy o boy that was a lot of information. Take a quick breather :) We’re almost done!
This function takes in a runners dna
and creates all the probabilities for the traits and sees which traits our runner has (line 252 calls the exact function we described above getLayerIndex()
). Let’s break down this function in steps:
Line 243: Use the dna
to get the array that determines which traits we have ([1234, 5432, 4443, 1111, etc]
from our above example).
Line 244: Get the race of our runner (assume human).
Line 246-250: Some traits are optional, so we check to see if the runner has these traits such as face accessory or mask.
Line 251-268: Essentially loop through all 13 traits and see what traits our runner has using the array from splitting our dna
([1234, 5432, 4443, 1111, etc]
). Line 260 is just doing some layering formatting depending on the traits of the running (ie don’t add conflicting traits to the runner).
If the runner has a specific trait, add it to TraitTypes
which is just another array that concretely defines what traits our runner has (ie TraitTypes = [Background, Face, Nose, etc]
.
On line 264, you can see they’re not adding the string Background
or Face
to TraitTypes
but rather some random characters. Those characters are actually base64 encoded versions of the traits. This allows for 1 less computation since we have to return a base64 encoded version of traits at the very end.
For example, QmFja2dyb3VuZCAg
is Background
in base64 encoding.
Line 269: Return all the necessary info for this runner relating to the traits it has.
We’re almost there! This is the last section.
This is the tokenURI function that formats all the JSON metadata and constructs the onchain SVGs.
Line 211 (which is cut off):
(Layer [NUM_LAYERS] memory tokenLayers, Color [NUM_COLORS][NUM_LAYERS] memory tokenPalettes, uint8 numTokenLayers, string[NUM_LAYERS] memory traitTypes) = getTokenData(runnerData.dna);
This gets all the trait data for our runner, everything mentioned in this article thus far. This line calls the exact function I explained above. numTokenLayers
is the number of layers for the runner (number of traits), tokenLayers
are the actual trait names for the runner (background 1, nose 2, face 1, etc) , traitTypes
as mentioned before are the actual base64 encoded trait categories (Background, Face, etc), and the rest is for the SVG creation.
Line 213 - 218 Loops through all the layers our runner has and constructs the JSON string. These are already based 64 encoded to save computation again. Here are the decoded versions:
eyAg
== {
LCB7
== {,
InRyYWl0X3R5cGUiOiAi
== "trait_type": "
IiwidmFsdWUiOiAi
== ","value": "
IiB9
= “}
So essentially the loop is creating the runners JSON metadata like {“trait_type”: “Background”, “value”: “background 1”} , {“trait_type”: “Face”, “value”: “face 1”}, etc
. Pretty nifty huh?
Once we have this JSON, we call tokenSVGBuffer
on line 219 which takes our runners layers and actually constructs the SVGs! I won’t go over the creation of SVGs since that isn’t unique to Chain Runners. It’s essentially creating SVGs using HTML SVG classes and there are a ton of resources online.
Now finally, we have our JSON metadata and SVG image constructed, so now all we have to do is format these in a proper data URI that the browser can render which is line 220-228. Of course, it’s all already base64 encoded. Won’t go over data URIs since there’s a bunch of resources online about them and they’re not Chain Runner specific
Voila! That is how your runners are created!
If you made it to the end, you are a trooper! I hope you can see why I am so enthusiastic about this project and hope you can see how fucking cool this all is!
If you enjoyed this, maybe drop me a follow :) I post a lot about solidity and shit post from time to time :)
Hope to see ya in the Chain Runner’s discord!