White elephant sounds simple until you try to keep score. Someone steals your gift, so you steal from someone else, who steals from a third person, and nobody remembers how many times the waffle maker has changed hands. I wanted a page that runs on the TV at a party and tracks all of that, so I wrote the rules as a small module with no DOM in it.
The rules the code has to follow
- Everyone brings one wrapped gift. Draw a random order.
- On your turn you either open a wrapped gift or steal one that's already open.
- If your gift is stolen, you move right away: open or steal again. So one turn can be a chain.
- The turn ends when someone opens a new gift.
- A gift is frozen after N steals (3 is the usual house rule). Nobody can take it after that.
- Optional: no stealing a gift straight back in the same turn, a cap on steals per turn, and a final swap for player 1 once every gift is open.
Two indexes, not one
One current player isn't enough, because a steal chain pulls play away from the turn order and then has to come back to it. The state keeps two numbers:
turn: 0, // whose numbered turn it is
active: 0, // who has to move right now
takenThisTurn: [] // [{ victim, gift }] for the "no steal back" rule
turn only moves forward when a gift is opened. active jumps to whoever just lost a gift:
function steal(state, id) {
const check = canSteal(state, id);
if (!check.ok) throw new Error(check.reason);
const s = clone(state), g = byId(s, id);
const victim = g.holder;
g.holder = s.active;
g.steals += 1;
s.takenThisTurn.push({ victim, gift: id });
s.active = victim; // the person who lost it moves next
return s;
}
Opening a gift is what calls endTurn(), which sets active = ++turn and clears takenThisTurn.
Every move returns a new state
open(), steal() and keep() never touch the state they're given. They clone it (JSON.parse(JSON.stringify(s)) is fine for a few dozen gifts) and return the new one. That makes undo free: the page keeps an array of past states and pops one. At a party, someone always taps the wrong name, so undo is not optional.
It also makes the rules testable without a browser.
A real run
Five players, gifts freeze after 2 steals:
let s = newGame(["Ana", "Ben", "Chloe", "Dev", "Eli"], { stealLimit: 2 });
s = open(s, "Waffle maker"); // Ana
s = steal(s, 1); // Ben takes it, Ana has to move
s = open(s, "Heated blanket"); // Ana opens, turn passes to Chloe
s = steal(s, 1); // Chloe takes the waffle maker: 2nd steal
canSteal(s, 1); // { ok: false, reason: "Frozen" }
s = open(s, "Board game"); // Ben
s = steal(s, 2); // Dev takes the blanket from Ana
canSteal(s, 2); // { ok: false, reason: "Can't steal it straight back" }
s = open(s, "Candle"); // Ana
s = open(s, "Socks"); // Eli. Every gift is open: phase is "final"
s = steal(s, 2); // Ana's final swap: candle for the blanket
summary(s) at the end:
| Player | Goes home with | Times stolen |
|---|---|---|
| Ana | Heated blanket | 2 |
| Ben | Board game | 0 |
| Chloe | Waffle maker | 2 |
| Dev | Candle | 0 |
| Eli | Socks | 0 |
Four steals in total, the final swap included. The waffle maker and the blanket tied at two each.
Two edge cases worth a test
The final swap may not exist. If every gift player 1 could take is frozen, or player 1 already holds the only open gift, there's nothing to offer. endTurn() checks before switching to the "final" phase:
const anyOpen = s.gifts.some(g => g.holder !== 0 && !isFrozen(s, g));
s.phase = s.opts.finalSwap && s.players.length > 1 && anyOpen ? "final" : "done";
"Steal back" means the same gift, same victim, same turn. Ana can't take the blanket back right after Dev took it, but she can take a different open gift, and someone else can take the blanket later in the chain. Storing { victim, gift } pairs instead of just gift ids keeps that straight.
What's not in the module
Shuffling the order (a Fisher–Yates shuffle() sits next to the rules), the number reveal for the TV, and saving to localStorage so the game survives a closed laptop. Those live in the page code. The rules file never touches the page.
If you want to see it on a screen, it runs here: white elephant generator. Free, no sign-up.
Top comments (0)