Exploring Randomness In JavaScript
In my post yesterday, on building a color palette utility in Alpine.js, randomness played a big part: each swatch was generated as a composite of randomly selected Hue (0..360)
, Saturation (0..100
), and Lightness (0..100
) values. As I was putting that demo together, I came across the Web Crypto API. Normally, when generating random values, I use the the Math.random()
method; but, the MDN docs mention that Crypto.getRandomValues()
is more secure. As such, I ended up trying Crypto
out (with a fallback to the Math
module as needed). But, this left me wondering if "more secure" actually meant "more random" for my particular use-case.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Randomness, from a security standpoint, has an actual meaning. I'm not a security expert; but, my understanding is that when a pseudo-random number generator (PRNG) is considered "secure", it means that the sequence of numbers that it will produce—or has already produced—cannot be deduces by an attacker.
When it comes to "random color generators", like my color palette utility, the notion of "randomness" is much more fuzzy. In my case, the random color generation is only as random as is "feels" to the user. In other words, the effectiveness of the randomness is part of the overall user experience (UX).
To this end, I want to try generating some random visual elements using both Math.random()
and crypto.getRandomValues()
to see if one of the methods feels substantively different. Each trial will contain a randomly generated <canvas>
element and a randomly generated set of integers. Then, I will use my (deeply flawed) human intuition to see if one of the trials looks "better" than the other.
The Math.random()
method works by returning a decimal value between 0
(inclusive) and 1
(exclusive). This can be used to generate random integers by taking the result of the randomness and multiplying it against a range of possible values.
In other words, if Math.random()
returned 0.25
, you'd pick the value that lands closest to 25% along the given min-max range. And, if Math.random()
returned 0.97
, you'd pick the value that lands closest to 97% along the given min-max range.
The crypto.getRandomValues()
method works very differently. Instead of returning a single value, you pass-in a Typed Array with a pre-allocated size (length). The .getRandomValues()
method then fills that array with random values dictated by the min/max values that can be stored by the given Type.
To make this exploration easier, I want both approaches to work roughly the same. So, instead of having to deal with decimals in one algorithm and integers in another algorithm, I'm going to force both algorithms to rely on decimal generation. Which means, I have to coerce the value
returned by .getRandomValues()
into a decimal (0..1
):
value / ( maxValue + 1 )
I'll encapsulate this difference in two method, randFloatWithMath()
and randFloatWithCrypto()
:
/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Math module.
*/
function randFloatWithMath() {
return Math.random();
}
/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Crypto module.
*/
function randFloatWithCrypto() {
var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
var maxInt = 4294967295;
return ( randomInt / ( maxInt + 1 ) );
}
With these two methods in place, I can then assign one of them to a randFloat()
reference which can be used to seamless generate random values along a given range using either algorithm interchangeably:
/**
* I generate a random integer in between the given min and max, inclusive.
*/
function randRange( min, max ) {
return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );
}
Now to create the experiment. The user interface is small and is powered by Alpine.js. Each trial uses the same Alpine.js component; but, its constructor receives an argument that determines which randFloat()
implementation will be used:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" href="./main.css" />
</head>
<body>
<h1>
Exploring Randomness In JavaScript
</h1>
<div class="side-by-side">
<section x-data="Explore( 'math' )">
<h2>
Math Module
</h2>
<!-- A very large number of random {X,Y} coordinates. -->
<canvas
x-ref="canvas"
width="320"
height="320">
</canvas>
<!-- A very small number of random values coordinates. -->
<p x-ref="list"></p>
<p>
Duration: <span x-text="duration"></span>
</p>
</section>
<section x-data="Explore( 'crypto' )">
<h2>
Crypto Module
</h2>
<!-- A very large number of random {X,Y} coordinates. -->
<canvas
x-ref="canvas"
width="320"
height="320">
</canvas>
<!-- A very small number of random values coordinates. -->
<p x-ref="list"></p>
<p>
Duration: <span x-text="duration"></span>ms
</p>
</section>
</div>
<script type="text/javascript" src="./main.js" defer></script>
<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
</body>
</html>
As you can see, each x-data="Explore"
component contains two x-refs
: canvas
and list
. When the component is initialized, it will populate these two refs with random values using the methods, fillCanvas()
and fillList()
, respectively.
Here's my JavaScript / Alpine.js component:
/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Math module.
*/
function randFloatWithMath() {
return Math.random();
}
/**
* I return a random float between 0 (inclusive) and 1 (exclusive) using the Crypto module.
*/
function randFloatWithCrypto() {
// This method works by filling the given array with random values of the given type.
// In our case, we only need one random value, so we're going to pass in an array of
// length 1.
// --
// Note: For better performance, we could cache the typed array and just keep passing
// the same reference in (cuts performance in half). But, we're exploring the
// randomness, not the performance.
var [ randomInt ] = crypto.getRandomValues( new Uint32Array( 1 ) );
var maxInt = 4294967295;
// Unlike Math.random(), crypto is giving us an integer. To feed this back into the
// same kind of math equation, we have to convert the integer into decimal so that we
// can figure out where in our range our randomness leads us.
return ( randomInt / ( maxInt + 1 ) );
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
function Explore( algorithm ) {
// Each instance of this Alpine.js component is assigned a different randomization
// strategy for floats (0..1). Other than that, the component instances behave exactly
// the same.
var randFloat = ( algorithm === "math" )
? randFloatWithMath
: randFloatWithCrypto
;
return {
duration: 0,
// Public methods.
init: init,
// Private methods.
fillCanvas: fillCanvas,
fillList: fillList,
randRange: randRange
}
// ---
// PUBLIC METHODS.
// ---
/**
* I initialize the Alpine.js component.
*/
function init() {
var startedAt = Date.now();
this.fillCanvas();
this.fillList();
this.duration = ( Date.now() - startedAt );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I populate the canvas with random {X,Y} pixels.
*/
function fillCanvas() {
var pixelCount = 200000;
var canvas = this.$refs.canvas;
var width = canvas.width;
var height = canvas.height;
var context = canvas.getContext( "2d" );
context.fillStyle = "deeppink";
for ( var i = 0 ; i < pixelCount ; i++ ) {
var x = this.randRange( 0, width );
var y = this.randRange( 0, height );
// As we add more pixels, let's make the pixel colors increasingly opaque. I
// was hoping that this might help show potential clustering of values.
context.globalAlpha = ( i / pixelCount );
context.fillRect( x, y, 1, 1 );
}
}
/**
* I populate the list with random 0-9 values.
*/
function fillList() {
var list = this.$refs.list;
var valueCount = 105;
var values = [];
for ( var i = 0 ; i < valueCount ; i++ ) {
values.push( this.randRange( 0, 9 ) );
}
list.textContent = values.join( " " );
}
/**
* I generate a random integer in between the given min and max, inclusive.
*/
function randRange( min, max ) {
return ( min + Math.floor( randFloat() * ( max - min + 1 ) ) );
}
}
When we run this demo, we get the following output:
Like I said above, randomness from a human perspective is very fuzzy. It's more about feelings than it is about mathematical probabilities. For example, having two of the same values generated in a row is the same exact probability of having any two values generated in a row. But, to a human, it stands-out—it feels different.
That said, looking at these side-by-side randomly generated visualizations, neither of them seem substantively different from a distribution perspective. Of course, the Crypto
module is significantly slower (half of which is all the Typed Array allocation cost). But, from a "feels" perspective, one isn't clearly better than the other.
All to say, when generating a random color palette, there probably wasn't any need for me to use the Crypto
module—I probably should have just stuck with Math
. It's much faster and feels to be just as random. I'll leave the Crypto
stuff to any client-side cryptography work (which I've never had to do).
Want to use code from this post? Check out the license.
Reader Comments
How you sample the color space (RGB, HSV, HSL, Y'UV etc) could have more of an impact on the perception of the randomness than the random number generator itself. For example, see this XKCD post about the fully saturated color perception map in RGB space, which seems to be majority "green".
https://blog.xkcd.com/2010/05/03/color-survey-results/
@EJ,
I know next-to-nothing about color theory. But, in this case, I'm only using one color and changing its opacity; so, I'm not sure if a different color space would have much of an impact.
Honestly, evaluating the randomness of an algorithm is a strange thing. Especially when humans don't necessarily like true randomness. Consider flipping a coin.
HHHH
,TTTT
, andHTHT
all have exactly the same probability of occurring. But, somehow, four in a row of the same value don't feel random. But that's because we humans are silly.Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →