Tenzies
A dice game I first built while learning React, rebuilt as a dice tray. The rules are the same. The dice are real, every hold is a commitment, and the game tells you where you stand before you roll.

- Made
- 2024, rebuilt in 2026
- Stack
- React 19, three.js, Tailwind CSS 4, Motion
Tenzies is ten dice. You hold the ones that match and roll the rest until all ten show the same number, and fewer rolls is better. I first built it in 2024 as a React exercise, and gave it a darker coat in early 2025: flat red squares on a black grid, pips that scrambled when you rolled, and a held die that turned white.
It worked, and it looked like a form. This rebuild keeps the rules and changes everything around them.
A dice tray
The page is now the felt of a dice tray: deep petrol green with a fine grain and a soft vignette, edge to edge. I kept two things from before. The dice are still casino red with ivory pips, and a held die still changes colour, now ivory with ink pips instead of plain white.
The bigger change is that the dice are dice. Each one is a rounded cube in three.js, lit from the top left, with its shadow falling down and to the right on the felt. Getting ten of them to look like they lie on the same green table was most of the work.
A roll used to swap the pips on a flat square. Now the loose dice leave one after another, 26 ms apart, lift off the felt, turn past their new face and settle back onto it. Held dice don’t move.
The rule that was already there
I thought the rebuild added a rule: once you roll, the dice you held stay held. Reading the old code again, it was already there. Rolling moved every held die into a locked set, and a locked die ignored taps. What the old game never did was tell you. Lock a two next to your fours on the second roll and you could keep rolling for another ten turns. You only lost once all ten dice were held and they didn’t match, long after the mistake.
So the rules moved into small pure functions, and the check now runs on every move, not just at the end:
function settle(game) {
const keep = kept(game);
if (keep.length === COUNT && values(keep).length === 1) {
return { ...game, status: 'won' };
}
if (values(game.dice.filter((d) => d.locked)).length > 1) {
return { ...game, status: 'lost' };
}
return game;
}The second if ends the game on the roll that locks a mismatch. That would be harsh with nothing on screen, so the line under the dice reads the same state before you roll. Most of the time it just counts: “5 of 10 on fours. Roll the other 5.” Hold a die that doesn’t match and it warns you instead, and the button turns into Roll anyway.
The loss isn’t announced on the click. The roll is already in the air, so the shake, the red ring, the low two-note sound and the buzz wait until the last loose die has landed, about 576 ms plus 26 ms for every die that moved. The result arrives when the dice do.
Keeping time
The old version counted rolls and nothing else. A clock is easy to get wrong, because a tab in the background keeps counting. Here the game stores the milliseconds already played plus the moment the current stretch started. The clock on screen adds the two together, and the stored number only changes when the stretch ends:
document.addEventListener('visibilitychange', () => {
if (visible()) {
resume(gameStore.get());
} else {
commit();
runStart = null;
}
});
addEventListener('pagehide', commit);Leave the tab and the time you played is written down and the clock stops. Close it mid game and the same write keeps your place. A saved game is checked field by field before it’s restored, so an old or broken save starts a fresh game instead of a broken board. And pressing N mid game doesn’t just throw it away. It says “Dropped the last one at 4 rolls” with an Undo, which puts the old game back exactly as it was.
One screen at every size
The game never scrolls, from a 320 px phone to a 2560 px monitor. That shaped the layout more than anything else. On a phone Roll sits at the bottom where your thumb is. On a short landscape phone there’s no room for two rows, so the ten dice line up in one.


On a computer the keys are listed under the button: Space rolls or starts again, 1 to 0 hold dice one to ten, and N starts a new game with undo.
Nothing jumps
In the old version the board moved the moment you first rolled, because the roll counter only appeared then and pushed everything down. Here every piece that changes has a fixed box. The counters are fixed width, which is the same one line fix as the odometer in my labs. The hint line is sized for its longest message, and undo and copied score messages borrow that same space instead of opening a toast on top. Buttons that come and go swap in place.
I checked it by recording every layout shift while a script played a whole game. It stays at 0, on load and while you play.
Sounds without files
There are no audio files. Every sound is built in the Web Audio API from one second of white noise and a few oscillators. A die hitting the table is a “clack”: 35 ms of noise through a narrow band pass somewhere between 2.6 and 4.4 kHz, with a tiny falling triangle tone under it. Everything else is clacks arranged in time.
A roll is three layers. A soft 340 ms rattle of filtered noise, up to sixteen clacks scattered through it for dice knocking in the air, and then one clack per die at 340 ms, 28 ms apart, so the landings line up with the dice touching down. A win is a C major arpeggio over ten quick clacks, roughly one for each die as the wave goes past.
Nothing plays before your first tap, since browsers won’t start audio before then anyway. Nothing plays in a hidden tab either, and the session is marked ambient so the iPhone’s silent switch mutes it. On Android each sound has a matching buzz: 8 ms for a hold, 4 ms for a release, and a short three-pulse pattern for a win.
Rough edges
- The dice need WebGL. Without it you get flat dice that play the same but don’t tumble.
- three.js is most of the download. It’s about 258 KB gzipped for the whole app, and it can’t load lazily because the dice have to be on the very first frame.
- Your games stay on one device. Clearing site data clears them.
A daily board where everyone starts from the same first roll is the next thing I’d like to add.