Board and party games
Last One Standing
Ask yes-or-no questions about the other player’s secret card, flip down what the answers rule out, and name it before they name yours.
On this page
Last One Standing is a guess-who game for two players, or one player against a bot. Both have the same board of 24 cards, and each secretly holds one of them. Players take turns asking a yes-or-no question about the other’s card, flipping down the cards each answer rules out. Instead of asking, you can make your one guess: right wins, wrong loses.
It plays the family classic of its kind on a recast Classic board and seven of the site’s own; docs/last-one-standing.md has the rules, boards and balance. It’s a table game on the table service, a room-kit table in the browser (the front end), and kept games are rated (Ratings). The boards answer their own questions (ADR 0044), and every question is one of the board’s, found by typing in one box (ADR 0045).
At a glance#
| Part | Where |
|---|---|
| Boards: loading, answers, dealing 24 | tables/last_one_standing/board.py |
| Rules: asking, flipping, guessing | tables/last_one_standing/rules.py |
| Bots (easy, medium, hard) | tables/last_one_standing/bots.py |
| The table: tray, question box, your move | games/static/games/last-one-standing/table.js |
| Matching typed words to the board’s questions | games/static/games/last-one-standing/match.js |
| A card, fitting its name, the close-up; board files in the browser | games/static/games/last-one-standing/cards.js |
| Room kit entry, How to play | games/static/games/last-one-standing/app.js, games/static/games/last-one-standing/rules.js |
| Look | games/static/games/last-one-standing/last-one-standing.css, games/static/games/last-one-standing/table.css |
| Pages | games/templates/games/last-one-standing.html, games/templates/last-one-standing/page.html |
| Building the boards | tools/last-one-standing/build.mjs (from boards/ and art/, with tools/last-one-standing/kit.mjs, tools/last-one-standing/faces.mjs and tools/last-one-standing/hyphens.mjs) |
| Tests | tests/test_last_one_standing.py, the matcher’s tools/last-one-standing/match.test.mjs, a whole game in Chrome tools/last-one-standing/play.mjs |
The boards#
A board is data: its cards (id, name, picture), its questions (each with its wording and its words, the synonyms and opposites that find it, so “short” finds “Is their hair long?”), three suggested words (hint), and each card’s yes answers. Boards live in tools/last-one-standing/boards/<id>.mjs, pictures in art/<id>.mjs. tools/last-one-standing/build.mjs writes them to games/static/games/last-one-standing/boards/, which the browser reads for pictures and words and the server for answers. The build also marks long words in card names with soft hyphens at dictionary breaks (hy, from tools/last-one-standing/hyphens.mjs: “Con-struc-tion”, never “Construct-ion”), and refuses a word of 7 letters or more with no breaks written down. npm run check:last-one-standing, in the PR gate, fails on an out-of-date build, an unbalanced board, or a question the matcher misreads.
tables/last_one_standing/board.py answers · lines 59–62@cache
def answers(board: str) -> dict[str, frozenset]:
"""Each card's yes answers, by card id."""
return {c["id"]: frozenset(c["yes"]) for c in load(board)["cards"]}
Classic is fixed: its 24 faces are always the board, shuffled. Other boards deal 24 of their 36, redealing (up to 200 times) until every question has at least 3 cards on each side, so no question is useless in a game:
tables/last_one_standing/board.py deal · lines 84–97def deal(board: str, seed: str) -> list[str]:
"""The 24 cards on the board this game, in the order they stand."""
pool = [c["id"] for c in load(board)["cards"]]
if load(board).get("fixed") or len(pool) <= SIZE:
return deck.shuffled(pool, f"{seed}:board")[:SIZE]
best = None
for k in range(DEALS):
cards = deck.shuffled(pool, f"{seed}:deal:{k}")[:SIZE]
miss = balanced(board, cards)
if best is None or miss < best[0]:
best = (miss, cards)
if not miss:
break
return best[1]
The tests hold every board to one balance: each question gets yes from an eighth to five-eighths of the cards, three or more questions split the board near half, and any two cards differ on at least two answers. node tools/last-one-standing/balance.mjs prints a board’s counts and says which rule a change breaks.
The pictures#
Every picture shares one style (tools/last-one-standing/kit.mjs): a sticker on a tinted 120-unit square, one ink outline, flat fills from one palette, no words. The build refuses ids, text, gradients and filters, because pictures share a page. Classic’s faces and the Jobs board’s workers come from one generator, tools/last-one-standing/faces.mjs. A Classic face’s answers come from the same spec it’s drawn from, so the two can’t disagree:
tools/last-one-standing/boards/classic.mjs yes · lines 67–85/** The questions a face answers yes to, from its spec. */
export function yes(f) {
const out = [];
if (f.hat) out.push('hat');
if (f.glasses) out.push('glasses');
if (f.earrings) out.push('earrings');
if (f.beard) out.push('beard');
if (f.mustache) out.push('mustache');
if (f.style === 'bald') out.push('bald');
else out.push(f.hair);
if (LONG.has(f.style)) out.push('long');
if (CURLY.has(f.style)) out.push('curly');
if (UP.has(f.style)) out.push('up');
if (f.freckles) out.push('freckles');
if (f.cheeks) out.push('cheeks');
if (f.smile) out.push('smile');
if (f.neck) out.push('neck');
return out;
}
The other boards’ pictures were drawn to their answers and checked card by card. node tools/last-one-standing/sheet.mjs <board> draws a contact sheet with every card’s answers under it.
The rules in code#
The state#
One JSON object. A card is its index on the board, 0 to 23, and cards[i] is its id. secret[s] is seat s’s card, down[s] the cards seat s has flipped down, and qa every question asked with its answer. pending, a question in a player’s own words awaiting an answer, only exists in games from before ADR 0045.
tables/last_one_standing/rules.py new · lines 50–71def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
if players != 2:
raise Illegal("Last One Standing is for two players.")
rules = options(settings)
cards = board.deal(rules["board"], seed)
rng = random.Random(f"{seed}:secret")
state = {
"game": slug, "n": 2, "seed": seed, "board": rules["board"], "rules": {"board": rules["board"]},
"_pace": moves.BOT_PACE[rules["bot_pace"]],
"seq": 0, "logged": 0, "events": [],
"cards": cards,
# Each draws their card from a pile of their own, so both may hold the same one.
"secret": [rng.randrange(len(cards)), rng.randrange(len(cards))],
"down": [[], []],
"phase": "ask", "first": int.from_bytes(seed.encode(), "big") % 2, "turn": None, "turns": 0,
"qa": [], "pending": None,
"winner": None, "guess": None, "final": None,
"scores": [], "totals": [0, 0], "ready": [False, False],
}
state["turn"] = state["first"]
log.event(state, "start", first=state["first"], board=rules["board"])
return state
A turn#
On its turn (phase “ask”) a seat does one of two moves. ask sends one of the board’s questions by id; the page finds it from what’s typed, and the server accepts only the board’s own. The game answers from the other seat’s card, can flip down what the answer rules out in the same move (flip, as the bots do), and passes the turn.
tables/last_one_standing/rules.py ask · lines 108–120def ask(state, seat, action, now):
"""One of the board's questions: the game answers it from the other's card."""
_my_turn(state, seat)
q = action["q"]
if q not in board.questions(state["board"]):
raise Illegal("That isn’t one of this board’s questions.")
yes = truth(state, other(seat), q)
entry = {"id": len(state["qa"]) + 1, "seat": seat, "q": q, "answer": "yes" if yes else "no"}
state["qa"].append(entry)
log.event(state, "ask", seat=seat, q=q, answer=entry["answer"])
if action.get("flip"):
_flip(state, seat, ruled_out(state, q, yes), True)
_pass(state)
guess ends the game: right, the guesser wins; wrong, the other seat does. flip is allowed at any time, on your own board only.
A question in your own words (say) has been refused since ADR 0045. A game left waiting for an answer to one still takes the reply (yes, no or unsure; unsure gives the asker their turn back), so it can finish:
tables/last_one_standing/rules.py reply · lines 129–152def reply(state, seat, action, now):
"""Yes, No or Not sure to a question typed in the other's own words,
from a game that was waiting on one when they stopped being a move. (A
bot that once read it as one of the board's said which, `as`.)"""
p = state["pending"]
if state["phase"] != "answer" or not p:
raise Illegal("There’s no question to answer.")
if seat == p["seat"]:
raise Illegal("The other player answers your question.")
answer = action["answer"]
if answer not in ANSWERS:
raise Illegal("Answer yes, no or not sure.")
read_as = action.get("as")
if read_as is not None and read_as not in board.questions(state["board"]):
read_as = None
entry = {"id": len(state["qa"]) + 1, "seat": p["seat"], "text": p["text"], "answer": answer}
if read_as:
entry["as"] = read_as
state["qa"].append(entry)
log.event(state, "reply", seat=seat, asker=p["seat"], text=p["text"], answer=answer, **({"as": read_as} if read_as else {}))
state["pending"] = None
state["phase"] = "ask"
if answer != "unsure":
_pass(state)
Who’s deciding, and the end#
tables/last_one_standing/rules.py deciding · lines 227–233def deciding(state: dict) -> list[tuple[int, str, str]]:
if state["phase"] == "ask":
return [(state["turn"], "ask", f"t{state['turns']}-{len(state['qa'])}")]
if state["phase"] == "answer":
p = state["pending"]
return [(other(p["seat"]), "answer", f"a{p['id']}")]
return []
Turns take thought, so the table waits 90 seconds before counting one as held up and letting the host hand the seat to a bot. The game is one round (terms: a game); result scores the winner 1, and Play again deals a fresh game.
What each seat sees#
view gives a seat its own card and flips, and only how many cards the other has standing, as across a real table. Every question and answer is public. At the end, both cards are shown.
tables/last_one_standing/rules.py view · lines 250–273def view(state: dict, seat) -> dict:
mine = seat is not None
phase = state["phase"]
p = state["pending"]
ends = phase == "over"
can = {
"ask": mine and phase == "ask" and state["turn"] == seat,
"reply": mine and phase == "answer" and p["seat"] != seat,
"flip": mine and not ends,
}
return {
"game": slug, "seq": state["seq"], "n": 2, "seat": seat, "board": state["board"], "cards": list(state["cards"]),
"phase": phase, "turn": state["turn"], "first": state["first"], "turns": state["turns"],
"secret": state["secret"][seat] if mine else None,
"down": list(state["down"][seat]) if mine else None,
# How many each has standing, as you'd see across the table.
"standing": [len(state["cards"]) - len(d) for d in state["down"]],
"qa": [dict(e) for e in state["qa"]],
"pending": dict(p) if p else None,
"can": can,
"winner": state["winner"],
"final": ({**state["final"], "down": [list(d) for d in state["down"]]} if ends else None),
"events": log.recent(state, seat),
}
The contract tests scramble the other seat’s card and which of its cards are down (keeping the count) and check a seat’s view and its bot’s moves don’t change.
The bots#
A bot works out which cards the other seat’s could still be from the answers it has had, and asks the question that splits them best, or close to it:
tables/last_one_standing/bots.py choose · lines 52–72def choose(v: dict, level: str, rng) -> dict:
if v["can"]["reply"]:
return answer(v)
left = candidates(v)
them = v["standing"][1 - v["seat"]]
if len(left) == 1:
return {"type": "guess", "card": left[0]}
if len(left) == 2 and ((level != "easy" and them <= 1) or (level == "easy" and rng.random() < 0.5)):
return {"type": "guess", "card": rng.choice(left)}
options = splits(v, left)
if not options:
# Nothing left to ask that tells these apart: guess among them.
return {"type": "guess", "card": rng.choice(left)}
if level == "hard":
best = options[0][0]
pick = rng.choice([q for worst, q in options if worst == best])
elif level == "medium":
pick = rng.choice(options[:5])[1]
else:
pick = rng.choice(options)[1]
return {"type": "ask", "q": pick, "flip": True}
Hard asks the most even split, medium one of the five most even, easy any question that narrows things at all. A bot guesses when one card is left, and with two left, gambles if the other seat is down to its last card. A question in a player’s own words left over from before ADR 0045 gets “Not sure” from a bot, and the asker picks one of the board’s.
The interface#
games/static/games/last-one-standing/app.js starts the room with the boards’ index, so the host can choose a board in the waiting room:
tables/last_one_standing/rules.py options · lines 39–45def options(raw: dict | None) -> dict:
"""A table's settings: the board, and how fast the bot plays."""
boards = board.ids() or (board.DEFAULT,)
return {
"board": moves.choice(raw, "board", boards, board.DEFAULT if board.DEFAULT in boards else boards[0]),
"bot_pace": moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk"),
}
The tray#
The table (games/static/games/last-one-standing/table.js) draws your tray as big as the window allows, picking the column count (3, 4, 6, 8 or 12) that gives the biggest pictures with readable names, preferring cards at least 55px wide. On a short screen the picture shrinks, never the name. Name plates take as few lines as the dealt names need, and fitName in games/static/games/last-one-standing/cards.js measures each name, shrinking a half-size at a time, so it fits in both Safari and Chrome. If pictures would still be under 30px (the smallest phones), the tray goes four across and scrolls:
games/static/games/last-one-standing/table.js StandingTable./** Size the cards to the room there is: 24 in the columns (3, 4, 6, 8 or
* 12) that give the biggest pictures with a name you can read under each.
* A card is as wide as its column and at most 1 : 1.26; where the rows are
* shorter than that (a phone), the card is shorter and its picture
* smaller, so the name keeps its room: one line, or two or three for a
* board dealt a name that needs them ("Construction worker"). */
fitTray() {
const box = this.el.traywrap.getBoundingClientRect();
if (!box.width || !box.height || !this.tiles.length) return;
const pad = 8;
const W = box.width - 2 * pad, H = box.height - 2 * pad;
const layout = (cols, lines) => {
const rows = 24 / cols;
const cw = Math.min(150, W / (cols + (cols - 1) * GAP + RIM));
const ch = Math.min(cw * RATIO, (H - cw * ((rows - 1) * GAP + RIM)) / rows);
const fs = Math.min(15, Math.max(10.5, cw * 0.105));
const pic = Math.min(cw * (1 - 2 * PAD), ch - cw * (2 * PAD + SPACE) - (lines * fs * LEAD + 4));
return { cols, lines, cw, ch, fs, pic };
};
const choose = lines => {
// (of the ways that show a picture worth seeing, those with room for a
// name if any have it, and of those the biggest picture)
const all = [3, 4, 6, 8, 12].map(cols => layout(cols, lines));
const seen = all.filter(o => o.pic >= FLOOR);
const named = seen.filter(o => o.cw >= NAMED);
const pool = named.length ? named : seen.length ? seen : all;
let best = pool.reduce((a, b) => (b.pic > a.pic ? b : a));
// (a tray keeps its columns unless another way is clearly bigger, so a
// panel coming or going beside it doesn't rearrange the cards)
const kept = pool.find(o => o.cols === this.cols);
if (kept && kept !== best && kept.pic > best.pic * 0.85) best = kept;
// (on the smallest phones there isn't room for all 24 at a size you can
// see: four across at the window's width, and the tray scrolls inside itself)
if (best.pic < FLOOR) {
const cw = Math.min(80, W / (4 + 3 * GAP + RIM));
best = { cols: 4, lines, cw, ch: cw * RATIO, fs: Math.max(10.5, cw * 0.105), pic: cw * (1 - 2 * PAD), tight: true };
best.pic = Math.min(best.pic, best.ch - cw * (2 * PAD + SPACE) - (lines * best.fs * LEAD + 4));
}
return best;
};
// The cards' size, and every name fitted to its plate, a size smaller if
// need be (one squeezed onto a single line, hardly at all). A name or two
// that still won't fit get a line or two more on their own card, its picture
// that much smaller; more than that, and it's not this many lines. Whether it worked.
const apply = (o, last) => {
this.el.traywrap.classList.toggle('tight', !!o.tight);
this.cols = o.cols;
const cw = Math.floor(o.cw);
const s = this.el.tray.style;
s.setProperty('--cols', o.cols);
s.setProperty('--cw', `${cw}px`);
s.setProperty('--ch', `${Math.floor(o.ch)}px`);
s.setProperty('--pic', `${Math.floor(o.pic)}px`);
s.setProperty('--fs', `${o.fs.toFixed(1)}px`);
s.setProperty('--gap', `${Math.round(cw * GAP)}px`);
for (const el of this.tiles) el.style.removeProperty('--pic');
const min = o.lines === 1 ? 10 : 9.5;
const long = this.tiles.filter(el => !fitName(el, { min }));
if (long.length > 2 && !last) return false;
for (const el of long) {
for (let more = 1; more <= 2; more++) {
el.style.setProperty('--pic', `${Math.floor(o.pic - more * o.fs * LEAD)}px`);
if (fitName(el, { min: 9.5 })) break;
}
}
return true;
};
// (the fewest lines the names dealt fit in at a readable size: a board of
// short names gives its pictures the room a second line would take; and
// rather than a tray that scrolls, two lines and a few cards with three)
for (const lines of [1, 2, 3]) {
const o = choose(lines);
if (apply(o, lines === 3 || (lines === 2 && !o.tight && choose(3).tight))) break;
}
}
A tap flips a card at once, with the house’s flip animation. Pressing and holding (the room kit’s hold(), games/static/games/room/kit/hold.js, 450ms, Auction House’s spaces’ hold too), right-clicking or pressing Z opens the card bigger in a sheet, with why it’s ruled out if it is, and never flips it. The hold’s lifting finger is swallowed, and iOS’s callout and text selection are off on cards. Tapping your own card opens it the same way.
Asking#
There’s one question box, whether you play a bot or a person. Empty, it shows only the board’s hint words. As you type, games/static/games/last-one-standing/match.js ranks every board question against your words:
- Each question is matched by its
words(weight 3) and the words it’s asked in (1.5), cut to stems (“glasses” to “glass”). - A word several questions share counts for less in each, so “red hair” finds red hair alone and “hair” lists them all.
- A typed word matches whole, by stem, as a prefix while still being typed (“glas”), or, if the board doesn’t know it, as a near-miss spelling (“glases”). A whole phrase (“no hair”, “black and white”) counts extra.
- Framing words (“does your person”) and negations (“not”, “no”) are ignored, so “no hat” finds the hat.
A question is offered if it scores at least 0.5 of the best and at least 0.6, up to 8 of them. A card named outright (“is it Rosa?”) is offered as a guess.
games/static/games/last-one-standing/match.js rank · lines 158–207/**
* The board's questions that what's typed so far most likely asks, best
* first: [{ q, score, hits }] (hits: the typed words that found it). And
* `cards`: cards the text names outright, for a guess. `partial` says the
* last word is still being typed (the text doesn't end in a space or a
* stop), so it may be the start of a word.
*/
export function rank(text, board, { limit = LIMIT } = {}) {
const { qs, cards, own, known } = prepare(board);
const all = words(text);
const partial = /[a-z0-9']$/i.test(String(text));
const typed = all.map((w, i) => ({ w, partial: partial && i === all.length - 1, known: known(w) }))
.filter(x => (meant(x.w) || own.has(x.w)) && (x.w.length > 1 || !x.partial));
const spaced = ` ${all.join(' ')} `;
const found = [];
for (const x of qs) {
let score = 0;
const hits = [];
for (const { w, partial: p, known: k } of typed) {
let best = 0;
for (const [s, t] of x.terms) best = Math.max(best, fit(w, p, k, s, t) * t.weight);
if (best > 0) { score += best; hits.push(w); }
}
for (const ph of x.phrases) if (spaced.includes(ph)) score += 2;
// (typed just as the board asks it)
if (all.length && x.plain === all.join(' ')) score += 10;
if (score > 0) found.push({ q: x.q, order: x.order, score, hits });
}
found.sort((a, b) => b.score - a.score || a.order - b.order);
const top = found[0]?.score || 0;
const questions = found.filter(f => f.score >= FLOOR && f.score >= REL * top).slice(0, limit)
.map(({ q, score, hits }) => ({ q, score: Math.round(score * 100) / 100, hits }));
// A card named outright: what's typed is its whole name, give or take the
// words round it ("is it the sea turtle?"), the last word perhaps begun.
const named = [];
if (typed.length) {
for (const c of cards) {
// (a name's possessive is name enough: "Frankenstein" is Frankenstein's monster)
const whose = c.words.find(n => n.endsWith("'s"));
const said = c.words.every(n => typed.some(({ w, partial: p }) => sameWord(n, w, p)))
|| (whose && typed.some(({ w, partial: p }) => sameWord(whose, w, p)));
const only = typed.every(({ w, partial: p }) => c.words.some(n => sameWord(n, w, p)));
if (said && only) named.push(c.card);
}
}
return { questions, cards: named.slice(0, 2), partial };
}
/** A few words to try on this board, for the box's hint. */
export const tryWords = board => board.hint || board.questions.slice(0, 3).map(q => (q.words || [])[0]).filter(Boolean);
The list rises over the box, best match nearest, re-ranked on every letter. Arrows choose, and Enter or a tap asks; the box then lets go so a phone’s keyboard drops and the answer shows. An asked question shows its answer and can’t be asked again. A game left waiting on a free-form question from before ADR 0045 shows it in the box’s place, for the dock to answer.
Your move#
The house’s prompt and beckon (games/static/games/house/cue.js) say what’s yours to do. The first time each kind of move falls to you in a game, a card (“Ask a question”, “Flip down who it isn’t”, “Make your guess”) shrinks into the place to do it, which then beckons. After that, only the beckon, after a few idle seconds. A prompt replaces the “Your turn” banner and any banner waiting, and goes when the moment does. The answer to your question is shown over the board (“No”, with the question) unless the prompt just said it, and the status line says the same in words. The other side’s question gets the same banner, as a beat of its own (ADR 0054): “Ada asked”, then the question, its answer from your card and how many cards they put down, and until you ask, your status line leads with it. Their guess is an announcement (“Ada guessed Anita”, “That’s your card.”), and the result sheet waits for it. Each side’s cards standing is the house’s Figure (a size smaller on the smallest phones, so both names fit), and it lights up when it drops. With one card standing, the dock’s main button is the guess.
games/static/games/last-one-standing/table.js StandingTable./** What's yours to do now and where: { kind, el, key, kicker, title, sub },
* or null. Flipping comes first (what the answers rule out), then, on
* your turn, a guess when one card's left, else a question. */
cueWant() {
const { v, t } = this;
if (!v || v.phase === 'over' || t.you === null || t.seats[t.you]?.bot || this.guessing) return null;
const c = v.can || {};
if (c.reply) return null;
const game = `${this.table.code}:${t.game_no}`;
const off = this.offer();
if (off) {
const a = off.last;
const people = ['person', 'worker'].includes(this.board.noun);
return {
kind: 'flip', el: this.slots[2].el, key: `${game}:flip:${this.answers().length}`,
kicker: a ? `They said ${a.yes ? 'yes' : 'no'}` : 'Your move',
title: people ? 'Flip down who it isn’t' : 'Flip down what it isn’t',
sub: `${off.cards.length === 1 ? 'The card' : `The ${off.cards.length} cards`} crossed in red: one press flips ${off.cards.length === 1 ? 'it' : 'them all'}.`,
};
}
if (!c.ask) return null;
const left = this.standingMine();
if (left.length === 1) {
return { kind: 'guess', el: this.slots[2].el, key: `${game}:guess:${v.turns}`, title: 'Make your guess', sub: `Only ${this.card(left[0]).name} is left standing.` };
}
const [a, b] = tryWords(this.board);
return { kind: 'ask', el: this.el.input, key: `${game}:ask:${v.turns}`, title: 'Ask a question', sub: `Type a word about their card, like ${a} or ${b}, and pick a question.` };
}
The game log is in the top bar (Log, L, or the ⋯ menu, ADR 0046). It opens as a drawer over the table; on a window at least 1440px wide it can be pinned as a column beside the table, as the card games’ can (Pin, kept in uo:cards:log-pinned for every game), and the tray refits to the room left. Until David’s playtest of 2026-10-29 it couldn’t be.
Helpers#
Each is on by default with its own row in Options. Point out what the answers rule out strikes through every standing card your answers rule out and puts Flip down in the dock’s main slot, so a postponed flip is still offered. Mark questions that can’t help marks any question all your standing cards answer alike. Show what to do is the house’s, for the prompts and beckons.
games/static/games/last-one-standing/table.js StandingTable./** The cards still standing that your answers rule out (the helper's
* offer), and whether the latest answer is what ruled any out. Every
* answer counts, so a flip put off till later is still offered. */
offer() {
const v = this.v;
if (!assistOn('offer') || this.t.you === null || v.phase === 'over') return null;
const all = this.answers();
if (!all.length) return null;
const last = all.at(-1);
const out = this.standingMine().filter(i => this.against(i).length);
if (!out.length) return null;
const fresh = out.some(i => this.card(i).yes.has(last.q) !== last.yes);
return { cards: out, last: fresh ? last : null };
}
Its look#
Last One Standing’s personality, in the design system’s personality sheet.
| Family | Board and party: the room kit in a look of its own, for two. Its foyer is the card room’s felt. Everything else is the room kit’s (Board and party games). |
| Palette | Ink #f5f1ff on its own ground; the one fill #ffd34d; yours and your turn #ffd34d; good #98e2a8 and bad #ff9a85. Game night: an indigo evening under a warm lamp; your tray in molded red plastic, the other player’s in blue. |
| Faces | Rubik for the title, banners and the cards’ names; Figtree for everything else |
| Corners and edges | Panels 12px, buttons 10px |
| Board | Your tray of 24 cards, each a white frame with a picture on its tint and a cream name plate. A name is never cut. Flipped down, a card turns through its edge to the tray’s darker plastic with the game’s mark pressed in. |
| Emblem and art | Its box on the shelf |
| Voice | Ask a question; cards standing; the last one standing |
| Payoff | The room’s celebration for a win |
| The glance | Now: the status line, led by the other side’s last question and its answer. The countdown: cards standing on each side, a Figure that lights up as it drops. Yours: your tray. |
| Exceptions | Its question box listing what it finds over the questions while you type |
Known issues#
- Taps can land on the question list. On a phone, in WebKit at 390×844, while the ask screen’s question list (
#los-found) is open over the board, about a third of presses meant for tray cards land on a question instead (check:details:webkitflags it). The list covers the board while you type, by design, so it may be fine, but nobody has decided.
Rating#
A kept game is rated by place like every table game (Ratings). Its bots sit at fixed ratings measured with tools/ratings/anchors.py: medium beats easy about two games in three, and hard beats medium a little over half the time.
1380 · 1500 · 1560 (easy, medium, hard).