DEV Community

Miki Stanger
Miki Stanger

Posted on

Randomness and Random Numbers in CSS

Recently, I created a Missile Command kinda-clone, using HTML and CSS only. It was my first CSS-only game, and a really fun project to work on.

One of the most useful tools when creating games is randomness. Randomness can help by making every game different - different enemies and collectibles spawning in different locations, random cards for your deck, and much more. It’s also helpful for creating special effects (consider particle-based effects, where many particles move in different directions and speeds to create explosions for example) and game worlds (in my Missile Command clone, for example, you’ll have buildings with different heights at different positions every time, and each will have different windows whose light is randomly on or off).

As of the time of writing, CSS doesn’t have a random number generator.
There might be one in the future, but for now, we’ll have to create our own (kind of).

But let’s begin at the beginning:

Who am I?

I’m Miki Stanger, a freelance front-end developer and architect with 15 years experience in the industry.
I like creating code that’s easy to maintain, and to work on both UX and the underlying stuff that makes the whole project work (so, for example, both perfecting the edge cases of a table behavior, and maybe implementing the underlying table logic myself).
You can learn more about me and what I do in my about.me page.

A Note About Range Notations

My math is very rusty, but among my few memories there’s the knowledge that there’s a way to write ranges.
A range is written in as a coma-separated pair of numbers, with either square brackets, round parentheses, or one of each around them. A square bracket next to a number marks that the range includes that number. A round parenthesis next to a number means that number is excluded from that range.

So, the notation: [1, 6) means “All numbers between 1 and 6, excluding 6”. So, everything from 1 to 5.99999…

My rusty math knowledge also means that I might lack terms or not use the most efficient way to do things here. But it works.

Random Numbers 101

Programming languages usually provide you with a random number generator. In JavaScript, for example, that would be the Math.random function, which returns a number at the range of [0, 1) (that’s a number between 0 and 1, including 0 and excluding 1).

Using such a number, we can get a random number of any range, by applying some math.
For example, given a minimum number m, a maximum number n, and a random number r in the range of [0, 1), you could get to a number of the range of [m, n) with the following formula:

m + r * (n - m)

So, to get a number between 1 and 6 (excluding 6), we’ll do:

1 + Math.random() * (6 - 1)

In order to get an integer, we’ll simply round down the result, thus removing the fraction part from the number. We’ll also add 1 to the multiplier of r, to include all numbers that are in the range of [m, m+1), which will be rounded down to m:

floor(m + r * (n - m + 1))

So in order to do a standard 1d6 (that’s a six-sided dice, or [1, 6] with integers only), we’ll do:

Math.floor(1 + Math.random() * (6 - 1 + 1))
(or simply Math.floor(1 + Math.random() * 6))

Ma, They Took My Random Number Generator

Back to CSS and it’s lack of a random number generator.

Because we don’t have that in CSS, we’ll have to create one of our own.
And because there’s no way I know to do that, we’ll have to create a pseudo-random number generator.

A pseudo-random number generator is a deterministic function that produces a sequence of numbers that seem random. Optimally, this number is within a specific range, so we could work it into any range we want.

CSS comes with some math functions which you can use. Among those functions (at least those that I recognized) I found two that I thought could be useful: sin and cos. Those are the best matches I found, but they aren’t perfect.

The sin of using trigonometric functions (eh?)

The sine and cosine functions are great for us in several ways:

  1. They accept any and all numbers. This makes our life easy when providing input.
  2. They output a number within the range of [-1, 1]. This could easily be converted to [0, 1], by doing (1 + sin(r)) / 2, which can then be converted to any range of numbers.

However:

  1. The output is continuous. Close values will give you close numbers. This could create patterns that don’t feel random.
  2. The output is cyclical. sin(x) == sin(x % 360deg) (The % operator is the modulo operator. a % b returns the remainder of a / b). This, together with the previous bullet, mean that not only close numbers give you close value, but numbers whose modulo 360 results are close will also behave the same way.
  3. The output has a non-random shape (of a wave). This means that by putting in numbers with a constant difference or a constant modulo difference, you’ll get numbers that reflect the shape of the function, which will, again, not feel random.
  4. [0, 1] is not [0, 1). The fact that the resulting range includes the number 1, means that when using the random integers formula, there’s a tiny chance you’ll get a number you don’t want.
  5. The function’s distribution is not linear.

For most of these issues, there are workarounds. The final result will not be perfect, but for our needs it’s good enough. Let’s move on and see how our implementation brings us to a good enough solution.

But what do we pass to the sine function?

The sine function alone doesn’t help us; we’ll need to give it an input. That input is an important part of the pseudo-random secret sauce.

The CSS tl;dr is:

—random-number: calc(var(--seed) * var(--index) * var(--index) * noise);

Let’s discuss.

calc

calc is a CSS function that allows us to combine different variables, operators etc. to do a complex calculation.

var

var is a function that allows us to retreive the value of a CSS variable/custom property

--seed

--seed is a CSS variable. It is set at the top of the project (under the :root or body selectors) and it equals to a large number. By using a large number, we prevent the numbers from being close to each other, and solve problem #1.
Changing the seed will change the results of all of the random numbers in your project.

--index

--index is another CSS variable. When there’s a series of objects (In my game that could be, for example, the stars in the sky), each of the objects gets an index. You could do it with several nth-child pseudo-selectors, but I was lazy and used the style tag:

<div class="star" style="--index: 1"></div>
<div class="star" style="--index: 2"></div>
<div class="star" style="--index: 3"></div>
...
Enter fullscreen mode Exit fullscreen mode

Another --index

This handles some of our problems. A simple seed * index * noise, where the index increments by 1 between objects, would give us a series of numbers with a constant difference between them. This also translate to a very visible pattern when doing a modulo operator on them, which means that it’ll show in a sine or a cosine function. As we discussed above, this will create a not-random-feeling pattern, which is the opposite of what we want.
However, doing seed * index * index * noise, which is actually seed * index² * noise, means that the linear differences between indices will not translate to linear differences in the sine/cosine function’s output. Problems #2 and #3 - solved!

noise

noise is a hard-coded number (could be -1, 17, 8, 7003, anything). It’s different for every number generated, just so you won’t get the same number for the same seed and index. You don’t have to multiply the noise - you can add, subtract or divide it. You can also add multiple noise numbers (e.g. r * 17 - 5).
The noise is also an opportunity to introduce a unit to the number. By multiplying the random number by 39deg, 8px, 1% etc., we add that unit to the generic random number.

A Range of Possibilities

Now that we have a random number, let’s convert it to the range we’d like.
Our pseudo-random number is in the range of [-1, 1] (the possible range of outputs from both sin and cos). This is workable and easy for many ranges, but by converting the range to [0, 1] we could (almost) use the simple generic formulas from above to generate a number of any range.
In order to do that:

  1. We add 1 to the result, bringing its range to [-1 + 1, 1 + 1] = [0, 2]
  2. We divide the result by two, bringing its range to [0, 1]

So:

--random-number: calc((1 + sin(var(--seed) * var(--index) * var(--index) * 17)) / 2); // 17 is the noise
Enter fullscreen mode Exit fullscreen mode

And if we’d like to use this number for the range [1, 10], we could use our formula from above, which will result in:

--from-one-to-ten: calc(1 + var(--random-number) * 9)
Enter fullscreen mode Exit fullscreen mode

Removing the Top

There’s still one important difference between our pseudo-random numbers and the random numbers we get from Math.random - the range is not exactly the same. Instead of a number within [0, 1). we get a number within [0, 1]. This means there’s a (tiny, tiny) chance of getting the number on the top of the range, which is especially problematic if you’d like to round down the the results to get an integer.

I don’t have a perfect solution for this. All of the solutions I found will mean losing a small range of numbers. There’s one, though, where you only replace the 1 with 0, doubling the chance for the input value to be 0 but otherwise not changing the equation.

The sign CSS function returns the sign of the number. You get -1 for a negative number, +1 for a positive number, 0 for zero (and -0 for a negative zero, which is a thing in programming, but doesn’t matter to us right now).

So, by doing sign(1 - var(--random-number)), we’ll get a positive sign (the number 1) for any random number but exactly 1, for which we'll get 0.
Multiplying this with our random number will set the number to 0 if --random-number equals to one, which brings us to - [0, 1):

--capped-random-number: var(sign(1 - var(--random-number)) * var(--random-number));
Enter fullscreen mode Exit fullscreen mode

Having a double chance to get one number over the others is not perfect. However, we’re talking a lot of numbers and a very small chance. For many, probably most, cases - that should be good enough, and definitely better from the chance to get a 7 in a six-sided dice roll.

Now we can use the random integers function from above. For a random integer between 1 and 10, in CSS, it'll look like this:

--random-integer: round(down, calc(1 + var(--capped-random-number) * (9 + 1));
Enter fullscreen mode Exit fullscreen mode

Note that in order for round to work, you either have to round a number without a unit, or pass a 3rd argument of 1<unit> (e.g. 1px), which tells it to round by this unit. You can read more about the round CSS function here.

What About the Non-Linear Distribution?

While squaring --index might help here, and while this might be visible mainly with a large number of random numbers with the same noise, I don’t have a true solution for this. However, for everything in my game - 80 stars, 12 buildings with 15 windows each, all affected by randomness - this was, again, good enough.

A New Experience Every Time

So far, we managed to generate random numbers. But these will remain the same random numbers every time you refresh, as --seed is a constant for the whole project, --index is a constant per element and the noise is a constant for every random number formula.

If we could change the --seed for every session, though, we’ll have a different experience every time - all of the random numbers will change.

In order to do that, I piggybacked on a very important part of the game - the “Start Game” button.

The HTML part looks something like this:

<body>
    <div class="screen screen-main">
    <!-- Title screen stuff -->
        <div class="start-game-button-wrapper">
            <input class="start-game-button" type="checkbox"/>
            <input class="start-game-button" type="checkbox"/>
            <input class="start-game-button" type="checkbox"/>
            <input class="start-game-button" type="checkbox"/>
            <input class="start-game-button" type="checkbox"/>
            ...
        </div>
    </div>
    <div class="screen screen-game">
    <!-- Game stuff -->
    </div>
</body>
Enter fullscreen mode Exit fullscreen mode

Then, in CSS, we do the following:

body {
    .screen-game {
        display: none;
    }

    /* If there's any checked start-game-button, start the game */
    &:has(.start-game-button:checked) {
        .screen-main {
            display: none;
        }

        .screen-game {
            display: block;
        }
    }

    /* The start game button wrapper is a grid containing all of the checkboxes, the "Start Game" label etc. */
    .start-game-button-wrapper {
        display: grid;
        grid-template-columns: repeat(1fr, 20);
        height: 80px;
        width: 200px;

        &:after {
            align-items: center;
            content: "Start";
            display: flex;
            height: 80px;
            justify-content: center;
            pointer-events: none;
            position: absolute;
            width: 300px;
        }
    }

    /* The checkbox is now a tiny square. Those squares fill the start game button wrapper. */
    .start-game-button {
        appearance: none; /* Hides the system checkbox UI */
        background-color: #3355ff;
        display: block;
        height: 10px;
        margin: 0;
        width: 15px;
    }

    /* Checking a checkbox selects two base seed numbers according to the position of the element within the parent */

    /* That's the first base seed: */

    &:has(.start-game-button:checked:nth-child(20n + 1) {
        --seed1: 4306301;
    }

    &:has(.start-game-button:checked:nth-child(20n + 2) {
        --seed1: 5612987;
    }

    ...

    &:has(.start-game-button:checked:nth-child(20n + 19) {
        --seed1: 1834393;
    }

    &:has(.start-game-button:checked:nth-child(20n) {
        --seed1: 9229177;
    }

    /* That's the second base seed: */

    &:has(.start-game-button:checked:nth-child(17n + 1) {
        --seed2: 4306301;
    }

    &:has(.start-game-button:checked:nth-child(17n + 2) {
        --seed2: 5612987;
    }

    ...

    &:has(.start-game-button:checked:nth-child(17n + 16) {
        --seed2: 1834393;
    }

    &:has(.start-game-button:checked:nth-child(17n) {
        --seed2: 9229177;
    }

    /* By having a seed generated from two base seeds like this, we add much more options by adding a relatively small amount of rules and numbers. */
    --seed: calc(var(--seed1) + var(--seed2));
}
Enter fullscreen mode Exit fullscreen mode

There's some stuff going on here, so let’s explain it:

The start button is made of many tiny checkboxes (designed to look like the button’s background). When you check one of those checkboxes, in addition to hiding the main screen and showing the game screen, the CSS variables --seed1 and --seed2 are set to two different numbers, which are added together to create -seed.
This means seed has 20 * 17 = 340 different options for a seed, which means that, depending on where the player clicked on the start button, they’ll get one of 340 different sets of random numbers.

To Sum It Up

Using a pseudo-random number generator - even with a single seed - opens up interesting options. You could randomize positions, colors - anything that can accept that calc function.

I hope you found this article as interesting as it was to me to write and implement it.

If you have any question, comments, ideas or ways to improve upon these ideas - please let me know in the comments.

Top comments (0)