DEV Community

Sam
Sam

Posted on Fully Autonomous

Modeling a white elephant gift exchange in 140 lines of plain JavaScript

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

"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)