Picking a random element from an array comes up constantly: rolling dice, shuffling prompts, choosing a color. The whole job is one line of JavaScript, but the interesting part is proving it behaves — that the picks are spread across the array and never fall off either end. This tutorial builds that one-liner as an ES module, backs it with a Node test suite that pins down the boundary behavior, adds a distribution demo, and then draws the results on a browser canvas so you can see the spread.

Prerequisites

  • Node.js 18 or later (validated with Node 26.4.0)
  • Python 3 for the local static server (python3 ships with the Xcode Command Line Tools on macOS)
  • make (pre-installed on macOS and most Linux distributions)
  • A Unix-like terminal (macOS, Linux, or WSL on Windows)

No third-party npm packages are used; the tests run on Node’s built-in runner.

Step 1: Create the project and its package file

Make the project folder and add a package.json. The one field that matters here is "type": "module", which tells Node to treat .js files as ES modules so import/export work without a build step.

Create the file

mkdir -p js-rolldice-101
cd js-rolldice-101
touch package.json

Add the code: js-rolldice-101/package.json

{
  "name": "js-rolldice-101",
  "version": "1.0.0",
  "description": "Select a random item from a JavaScript array (Node and browser).",
  "type": "module",
  "scripts": {
    "demo": "node demo.js",
    "test": "node --test"
  },
  "license": "MIT"
}

Detailed breakdown

  • "type": "module" switches the package to ES modules. Without it, an import statement in a .js file throws SyntaxError: Cannot use import statement outside a module.
  • The demo and test scripts give you npm run demo and npm test as aliases for the commands used later; the Makefile in Step 6 wraps the same two commands.
  • There are no dependencies — the module, its tests, and the browser code all use built-in APIs only.

Step 2: Write the rollDice function

Create the file

touch rolldice.js

Add the code: js-rolldice-101/rolldice.js

// File: rolldice.js
// Return a random element from an array by generating a random 0-based index.

export const rollDice = (list) => list[Math.floor(Math.random() * list.length)];

Detailed breakdown

  • Math.random() returns a float in the range [0, 1) — zero is possible, but the value is always strictly less than one.
  • Multiplying by list.length scales that into [0, list.length), so for a 5-element array the value lands somewhere from 0 up to (but never reaching) 5.
  • Math.floor() truncates to an integer, giving a valid zero-based index from 0 to list.length - 1. Because the random value never reaches list.length, the index can never be out of bounds. Step 3’s tests pin down both ends of that range.
  • export const makes the function importable from the demo, the tests, and the browser app that follow.

Step 3: Roll the array and inspect the distribution

A quick way to sanity-check a random picker is to run it many times and count how often each item comes up. If the picks are evenly spread, every item should appear with roughly equal frequency.

Create the file

touch demo.js

Add the code: js-rolldice-101/demo.js

// File: demo.js
// Roll a source array many times and count how often each item comes up, so you
// can eyeball whether the distribution looks roughly even.

import { rollDice } from "./rolldice.js";

const source = ["A", "B", "C", "D", "E"];
const LIMIT = 100;

const rolls = Array.from({ length: LIMIT }, () => rollDice(source));
console.log(rolls);

const occurrences = rolls.reduce((acc, item) => {
  acc[item] = (acc[item] || 0) + 1;
  return acc;
}, {});
console.log(occurrences);

Detailed breakdown

  • Array.from({ length: LIMIT }, () => rollDice(source)) builds a 100-element array by calling rollDice once per slot — a compact way to collect many samples.
  • The reduce accumulates a tally object, incrementing acc[item] for each roll (starting from 0 the first time an item is seen). The counts always sum to LIMIT.
  • Both console.log calls print to the terminal: first the raw roll sequence, then the summary tally.

Run it:

node demo.js

The output varies on every run because the rolls are random. One run produced:

[
  'A', 'A', 'D', 'E', 'D', 'B', 'E', 'E', 'E', 'A', 'D',
  'C', 'D', 'A', 'C', 'C', 'B', 'A', 'E', 'A', 'E', 'D',
  'C', 'A', 'A', 'C', 'E', 'A', 'C', 'D', 'A', 'B', 'E',
  'C', 'B', 'E', 'E', 'B', 'B', 'A', 'C', 'B', 'D', 'B',
  'A', 'C', 'C', 'C', 'A', 'D', 'C', 'B', 'A', 'B', 'E',
  'E', 'E', 'E', 'B', 'B', 'D', 'C', 'E', 'B', 'E', 'E',
  'A', 'D', 'A', 'E', 'B', 'A', 'C', 'D', 'E', 'C', 'B',
  'C', 'A', 'A', 'B', 'C', 'C', 'E', 'D', 'E', 'C', 'B',
  'B', 'C', 'E', 'D', 'E', 'A', 'C', 'C', 'B', 'D', 'B',
  'C'
]
{ A: 20, D: 14, E: 23, B: 20, C: 23 }

With 5 items and 100 rolls, a perfectly even split would be 20 each. You will rarely see exactly that, but most runs land close, and every item should appear at least once. The next step turns those expectations into automated tests.

Step 4: Pin the behavior with tests

The demo shows the spread but asserts nothing. Node’s built-in test runner (node --test, stable since Node 20) covers the parts of rollDice that must always hold, with no test framework to install.

Create the file

mkdir -p test
touch test/rolldice.test.js

Add the code: js-rolldice-101/test/rolldice.test.js

// File: test/rolldice.test.js
// Runs with the built-in Node test runner: `node --test`.

import { test } from "node:test";
import assert from "node:assert/strict";

import { rollDice } from "../rolldice.js";

test("returns an element that is a member of the list", () => {
  const source = ["A", "B", "C", "D", "E"];
  for (let i = 0; i < 1000; i++) {
    assert.ok(source.includes(rollDice(source)));
  }
});

test("a single-element list always returns that element", () => {
  assert.equal(rollDice(["only"]), "only");
});

test("the low bound of Math.random selects the first item", () => {
  const original = Math.random;
  Math.random = () => 0;
  try {
    assert.equal(rollDice(["first", "second", "third"]), "first");
  } finally {
    Math.random = original;
  }
});

test("a value just below 1 selects the last item", () => {
  const original = Math.random;
  Math.random = () => 0.999999;
  try {
    assert.equal(rollDice(["first", "second", "third"]), "third");
  } finally {
    Math.random = original;
  }
});

test("every item appears at least once over many rolls", () => {
  const source = ["A", "B", "C", "D", "E"];
  const seen = new Set(Array.from({ length: 500 }, () => rollDice(source)));
  assert.equal(seen.size, source.length);
});

Detailed breakdown

  • The first test is a property check: across 1000 rolls, every result must be a member of the input array. It never inspects a specific value, only the invariant.
  • The two Math.random tests replace the global with a stub to make the pick deterministic. Stubbing it to 0 forces index 0 (the first item); stubbing it to 0.999999 forces the last index, which proves the [0, 1) range never overflows to an out-of-bounds index. Each restores the original in a finally block so a failed assertion cannot leak the stub into later tests.
  • The last test collects 500 rolls into a Set and asserts all five items showed up. With five items this is statistically near-certain; if it ever flaked, the source array or the function would be the real suspect.

Run the suite:

node --test
✔ returns an element that is a member of the list
✔ a single-element list always returns that element
✔ the low bound of Math.random selects the first item
✔ a value just below 1 selects the last item
✔ every item appears at least once over many rolls
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0

Step 5: Visualize the spread in the browser

Numbers confirm the distribution; a grid makes it obvious. The browser version fills a 10x10 grid with random color picks and paints each cell on a canvas.

Start with the page. Create index.html:

Create the file

touch index.html

Add the code: js-rolldice-101/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>js-rolldice-101</title>
    <link rel="stylesheet" href="./app.css" />
  </head>
  <body>
    <canvas id="canvas" width="300" height="300"></canvas>
    <script type="module" src="./app.js"></script>
  </body>
</html>

Detailed breakdown

  • The width and height attributes set the canvas drawing buffer to 300x300 pixels; the CSS in the next file controls how large it appears on screen.
  • <script type="module"> loads app.js as an ES module so its import of rolldice.js works in the browser, matching the "type": "module" setting from Step 1.
  • <canvas> needs an explicit closing tag; it is not a void element and cannot self-close.

Add the stylesheet to center the canvas. Create app.css:

Create the file

touch app.css

Add the code: js-rolldice-101/app.css

/* File: app.css
 * Center the canvas in the middle of the viewport.
 */

canvas {
  padding: 0;
  margin: auto;
  display: block;
  width: 400px;
  height: 400px;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
}

Detailed breakdown

  • position: absolute with all four offsets set to 0 plus margin: auto centers the canvas both horizontally and vertically.
  • The width/height here (400px) are the on-screen display size. The browser scales the 300x300 drawing buffer up to fill it, so the grid stays crisp and centered.

Now the drawing code. Create app.js:

Create the file

touch app.js

Add the code: js-rolldice-101/app.js

// File: app.js
// Fill a 10x10 grid with random color picks and draw it on the canvas.

import { rollDice } from "./rolldice.js";

const canvas = document.getElementById("canvas");

const SCREEN_SIZE = 300; // must match the canvas width/height attributes
const DIM = 10; // grid is DIM x DIM cells
const CELL_SIZE = SCREEN_SIZE / DIM;
const BORDER = 1;

const NEON_PINK = "#FF10F0";
const SOURCE = ["white", NEON_PINK, "#444444"];

// One random color per grid cell.
const cells = Array.from({ length: DIM * DIM }, () => rollDice(SOURCE));
console.log(cells);

const ctx = canvas.getContext("2d");
if (ctx) {
  ctx.clearRect(0, 0, SCREEN_SIZE, SCREEN_SIZE);
  ctx.fillStyle = "black";
  ctx.fillRect(0, 0, SCREEN_SIZE, SCREEN_SIZE);

  let cursor = 0;
  for (let i = 0; i < DIM; i++) {
    for (let j = 0; j < DIM; j++) {
      ctx.fillStyle = cells[cursor++];
      ctx.fillRect(
        i * CELL_SIZE + BORDER,
        j * CELL_SIZE + BORDER,
        CELL_SIZE - BORDER * 2,
        CELL_SIZE - BORDER * 2,
      );
    }
  }
}

Detailed breakdown

  • SOURCE is the array rollDice picks from: white, neon pink, and dark gray. DIM * DIM is 100, so cells holds one random color per grid cell.
  • SCREEN_SIZE / DIM gives each cell a 30-pixel slot in the drawing buffer; BORDER shrinks each painted square by one pixel on every side so the black background shows through as gridlines.
  • The nested loops walk the grid column by column, pulling the next color off cells with cursor++ and filling that cell.
  • console.log(cells) prints the picks to the browser DevTools console, the browser counterpart to the terminal output in Step 3.

Step 6: Serve the page and drive it with a Makefile

Browsers block ES-module imports over the file:// protocol, so open the page through a local web server rather than double-clicking the file. A short Makefile wraps the demo, the tests, and the server, and prints a help screen by default.

Create the file

touch Makefile

Add the code: js-rolldice-101/Makefile

.DEFAULT_GOAL := help
SHELL := /bin/bash
PORT ?= 8000

.PHONY: help demo test serve

help: ## Show this help screen
	@echo "Targets:"
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| sort \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  %-8s %s\n", $$1, $$2}'

demo: ## Roll the array 100 times and print the distribution
	@node demo.js

test: ## Run the Node test suite (node --test)
	@node --test

serve: ## Serve the browser visualization at http://localhost:$(PORT)/
	@echo "Serving on http://localhost:$(PORT)/ (Ctrl-C to stop)"
	@python3 -m http.server $(PORT)

Detailed breakdown

  • .DEFAULT_GOAL := help makes a bare make print the help screen instead of running the first target, so the project documents itself.
  • PORT ?= 8000 sets a default port that a caller can override, e.g. make serve PORT=9000.
  • serve runs Python’s built-in static file server from the project root, so index.html, app.js, and rolldice.js are all reachable at http://localhost:8000/.

Confirm the help screen:

make
Targets:
  demo     Roll the array 100 times and print the distribution
  help     Show this help screen
  serve    Serve the browser visualization at http://localhost:$(PORT)/
  test     Run the Node test suite (node --test)

Start the server, then open the page in a browser:

make serve

Visit http://localhost:8000/ in Chrome (or any browser) and reload a few times. Each load repaints the grid with a fresh set of random picks, and the three colors stay roughly balanced across the 100 cells. Press Ctrl-C in the terminal to stop the server.

Recap

The pick itself is list[Math.floor(Math.random() * list.length)]. The work around it is what makes it trustworthy: "type": "module" to use import/export cleanly, a node --test suite that stubs Math.random to prove both index bounds, a distribution demo for a quick terminal sanity check, and a canvas grid to see the spread. The same picker generalizes to boolean and weighted helpers, where the odds are tuned instead of the source array.

References