🧮

Making NFTs playable

How to derive a playable entity from an NFT token
One of the main motivations for this project starts with this question: how to abstract away an entity that may act as a game actor from a specific NFT token?
First, comes the design of an abstract unit that can be fit for generic purpose battle systems. This abstraction we call the Card, which contains a set of stats to be used in calculating game outcomes: Health Points, Attack, an Attack Modifier, Defence, a Defence Modifier, Speed, Typing, and Rarity. Given a set of stats, we then must pick arbitrary boundaries for them. Considering we want to use this entity entirely on-chain, we should use uint8 for parameters we do not expect to ever be over 255 and uint16 for the rest. This leaves us with a series of parameters to be used with a wide range of numerical combinations.
Then, now that we have set the parameters we want, how to derive those values from the token? The first option for this would be to rely on a collection's metadata entries. Through metadata, we can assert rarity of an NFT token within a collection, from which we can assign a rarity value within the abstracted system. Given a collection has many traits, we can either divide them by grouped sets from which to derive a single stat, or pick a set arbitrarily according to flavour, e.g. a combination of traits for "Feet", "Shoes", and "Pants" could be used to compute a speed value within the expected bounds by assigning arbitrary numerical values to the distinct trait values.
The likelier best option for a stat sheet distribution amongst a discrete group that yields mostly just outcomes with outliers leading to both under-performing and over-performing cards would be to try to follow a Gaussian distribution (also known as normal distribution or bell curve). For this purpose, using similarly scored attributes distributed uniformly will let us use them as dice rolls of sorts, given a speed statistic bound within the values [1-131], we can then use two 1-44 dice and a 1-45 dice, which would give us a value in [3-133], we then can subtract 2 to the final result to obtain the desired speed value in a distribution that closely resembles what we want. This method may ring a bell for tabletop RPG players, as this would be 2d44+1d45-2 in dice roll notation. We could simplify this expression to 3d44-2, leading to a upper bound of 130 instead.
Distribution for 2d44+1d45-2
The more dice you use, the more packed the average result is towards the mean. While this is a good solution for more standard collections, it falls flat in order to objectively score tokens which pertain to a collection where traits can be often absent and have extremely variying amount of possible values, like a rare trait with five possible values and another rare trait with the same chance of appearing in a NFT but that possesses up to twenty possible values. Not only this, but some collections are simply lacking in traits, perhaps having none at all, which is known in NFT communities as 1/1, where each entry is unique by itself, like in Sprotos. In those cases, not even the rarity value can be objectively computed.
Then, our best option is to arbitrarily pick the mean we want for every statistic as well as the standard deviations and compute a set of random numbers within a range of values where the randomly generated numbers are grouped following the aforementioned Gaussian distribution. So, we just write a Card generator that generates a stat point for every single card independent of others and are then randomised and mixed in the resulting cards using a stochastic process.
Let's illustrate this method with a JavaScript code snippet, part of our codebase to generate the game metadata. First, we use the Marsaglia Polar method, notably faster than the Box-Muller transform, to obtain two random points that fit the criteria 0 < s = x² + y² < 1:
function genPointMarsagliaPolar() {
let x, y, s = 0.0
do {
x = 2.0 * Math.random() - 1.0
y = 2.0 * Math.random() - 1.0
s = x * x + y * y
} while (s >= 1.0 || s == 0)
s = Math.sqrt((-2.0 * Math.log(s)) / s)
return x * s
}
With this we sample a point from N(0, 1) (do notice too that we are not using the spare value), but we want to sample from N(µ, σ), so we add the option to set the mean and standard deviation:
function genGaussianFromMeanAndSd(mean, sd) {
return mean + genPointMarsagliaPolar() * sd
}
But we do want the generated value to be within specific arbitrary bounds, and this method often will yield a point that's too far from the mean, therefor we wrap the function with this one to only produce values within the desired bounds:
function genBoundGaussianPoint(mean, sd, min, max) {
let x = 0
do {
x = genGaussianFromMeanAndSd(mean, sd);
} while (x < min || x > max)
return x
}
Which leads us to this helpful function to generate random values for stats in the desired normal distribution:
function gaussianDistribution(amount, mean, sd, min, max) {
const points = []
const pointCount = []
for (let i = 0; i < max; i++) {
pointCount.push(0)
}
for (let i = 0; i < amount; i++) {
const point = Math.floor(genBoundGaussianPoint(mean, sd, min, max));
points.push(point)
pointCount[point]++
}
return points;
}
Now we can produce an array with as many values as cards needed, randomise them all, and mix them on a single card in order to produce the desired randomised cards. And, with this, we have two methods that can be combined if necessary to produce the battle metadata necessary so the NFT tokens can play all game modes.