Technical guide

Board and party games

Fictionary

A party game where you make up a definition for an obscure word, fool your friends, and try to spot the real one.

Checked against the code on 2026-10-30. Play Fictionary

On this page
  1. At a glance
  2. The rules in code
    1. A round
    2. The word
    3. Writing
    4. Voting and scoring
    5. The reveal
    6. Winning
    7. The contract
  3. Dealing words
  4. Your move
  5. Secrets
  6. Bots
    1. Choosing
    2. Writing
    3. Keeping a round apart
    4. The pool
    5. Voting
    6. Rating anchors
  7. Rating
  8. The interface
    1. The room, the desk and the dock
    2. Writing
    3. The spelling check
    4. Voting, Read aloud, display
    5. The reveal
    6. The result sheet
    7. Pronunciation and the speaker
  9. The word bank
    1. Sources and licences
    2. What it holds, and how a word is rated
    3. The house voice and the hand pass
    4. Verifying the definitions against WordNet and Webster
  10. Its look
  11. Testing
  12. Changing it
  13. Known limitations

Fictionary is a party game for three to twelve players. Each round one player, the Judge, picks an obscure word, and everyone else makes up a definition that sounds real. The others then vote for the one they think is real, and you score by fooling people and by finding the truth.

Bots can fill any seat. It’s mostly played remotely. The rules are David’s: ask David before changing any of them.

It’s a table game on the table service, built from its party-game helpers: sealed picks, timed reveals and rounds. Odd One Out is the same pattern in miniature. Fictionary adds a word bank of about 2,200 hand-checked words, bots that write convincing fakes from a pool of 20,000, a spelling check so a typo can’t give a fake away, and pronunciations.

At a glance#

Part Where
The rules: a round, scoring, winning, the view tables/fictionary/rules.py
Dealing words from the bank tables/fictionary/words.py
The bots: choosing, voting tables/fictionary/bots.py
The bots’ fakes: the pool, word shapes, keeping a round’s fakes apart tables/fictionary/fakes.py, tables/fictionary/likeness.py
The bots’ pool and how it’s built tables/fictionary/content/fakes.toml, tools/fictionary/fakes.py, tools/fictionary/frames/
The word bank and the pronunciations tables/fictionary/content/ (words.toml, ipa.toml, ipa_wiktionary.toml, pron_gcide.toml)
The table: score strip, desk, catalog cards, the reveal, Read aloud, the dock games/static/games/fictionary/table.js
The spelling check and its list games/static/games/fictionary/spell.js, games/static/games/fictionary/spell/
The speaker, the respelling and its key games/static/games/fictionary/speak.js
How to play; its entry into the room kit games/static/games/fictionary/rules.js, games/static/games/fictionary/app.js
Its look games/static/games/fictionary/fictionary.css (tokens), games/static/games/fictionary/desk.css (desk and cards)
Its page games/templates/games/fictionary.html
Build tools (bank, pronunciations, spelling list, the bots’ pool) and checks tools/fictionary/
Tests tests/test_fictionary.py, tests/test_fictionary_fakes.py, tests/test_fictionary_words.py, tests/test_fictionary_pronunciation.py, tests/test_fictionary_verified.py
Design notes docs/fictionary.md

How the bank, pronunciations and spelling list are built is in Content pipelines.

The rules in code#

A round#

Phase What happens
choose The Judge picks one of five words
write Everyone else writes a definition
vote Everyone but the Judge picks the one they think is real
scored A reveal plays, then everyone says they’re ready

After the last round the phase is over. The first three are sealed picks (commit): everyone picks at once, in secret, and the picks open together when all are in or the clock runs out.

The settings, cleaned by options:

tables/fictionary/rules.pyoptions · lines 48–56
def options(raw: dict | None) -> dict:
    return {
        "target": moves.choice(raw, "target", TARGETS, TARGETS[0]),
        "timer": moves.choice(raw, "timer", TIMERS, 0),
        # Whether the shuffled definitions show on everyone's screen, or
        # only the Judge reads them out (a table in one room).
        "show": moves.choice(raw, "show", (True, False), True),
        "bot_pace": moves.choice(raw, "bot_pace", tuple(moves.BOT_PACE), "brisk"),
    }
Setting Choices Standard Effect
target 20, 10, 30 20 Points to win (10, 20 or 30: short, medium, long)
timer 0, 60, 90, 120 0 Seconds to write; 0 waits for everyone. Voting gets half (at least 30 s); the Judge’s choice gets 45 s
show true, false true Definitions on every screen, or only the Judge reads them out
bot_pace brisk, relaxed brisk Multiplier on bots’ think times

moves.choice accepts only these exact values (True doesn’t count as 1) and drops anything else.

The state is one JSON object, built by new:

tables/fictionary/rules.pynew · lines 104–129
def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
    if not min_players <= players <= max_players:
        raise Illegal(f"Fictionary is for {min_players} to {max_players} players.")
    raw = settings or {}
    cfg = options(raw)
    before = raw.get("_before") if isinstance(raw.get("_before"), dict) else {}
    played = [w for w in before.get("words", []) if isinstance(w, str) and w in words.bank()][-REMEMBERED:]
    rng = random.Random(f"{seed}:first")
    state = {
        "game": slug, "n": players, "seed": seed,
        "target": cfg["target"], "timer": cfg["timer"], "show": cfg["show"], "_pace": moves.BOT_PACE[cfg["bot_pace"]],
        "seq": 0, "logged": 0, "events": [], "phase": "choose", "round": 0, "first": rng.randrange(players),
        # Seats a bot is playing now (kept up by note()): a bot can't read
        # the definitions out, so with one as the Judge they show to everyone.
        "bots": [s for s in raw.get("_bots", []) if isinstance(s, int) and 0 <= s < players] if isinstance(raw.get("_bots"), list) else [],
        "scores": [], "totals": [0] * players, "ready": [False] * players, "tiebreaks": 0,
        # Words offered to a Judge this game (and their families), words
        # chosen, and words played at this table's earlier games.
        "offered": [], "families": [], "chosen": [], "before": played,
        # The made-up definitions written this game, and at this table's
        # last games (ids, fakes.ident), so bots don't write them again.
        "fakes": [], "fakes_before": [f for f in before.get("fakes", []) if isinstance(f, str)][-FAKES_REMEMBERED:],
        "r": None, "last": None, "history": [],
    }
    _start_round(state, now)
    return state

first is drawn from the seed, and the Judge of round r is (first + r) % n, so the Judge moves one seat each round. offered, families and chosen track this game’s words, and before the table’s earlier games’. bots is the seats a bot plays now, r the round in play, and last and history feed the result sheet.

The word#

_start_round deals the Judge five words (Dealing words). If a timer runs out before the Judge chooses, they get the first of their five:

tables/fictionary/rules.py_start_round · lines 136–157
def _start_round(state: dict, now: float) -> None:
    judge = judge_of(state)
    rng = random.Random(f"{state['seed']}:offer:{state['round']}")
    chosen = set(state["chosen"])
    chosen_families = {words.family(words.get(w)) for w in chosen}
    offer = words.offer(rng, OFFER, [
        (set(state["offered"]) | set(state["before"]), set(state["families"])),
        (chosen | set(state["before"]), chosen_families),
        (chosen, chosen_families),
    ])
    for w in offer:
        if w not in state["offered"]:
            state["offered"].append(w)
        fam = words.family(words.get(w))
        if fam not in state["families"]:
            state["families"].append(fam)
    state["r"] = {"judge": judge, "offer": offer, "word": None, "real": None, "options": None}
    state["phase"] = "choose"
    log.event(state, "round", round=state["round"], judge=judge)
    last = state.get("reveal")
    after = last["ends"] - now if last else 0.0
    commit.open(state, now, seats=[judge], kind="choose", seconds=CHOOSE_SECONDS if state["timer"] else None, after=after)

The Judge is asked only after the previous reveal ends (after).

Writing#

Everyone but the Judge writes, in secret, at the same time. The server runs every definition through tidy, so typing habits can’t give anyone away:

tables/fictionary/rules.pytidy · lines 74–87
def tidy(raw) -> str:
    """A definition as everyone will see it: cleaned like any typed text
    (tables.text), stripped of wrapping quotes, its first letter a capital
    and no final period, as the word bank writes the real ones (the house
    voice, docs/fictionary.md), so how someone types doesn't give them
    away. Refused if it's empty or too long."""
    text = typed.clean(raw, 400).strip(" \"'“”‘’`")
    if not _WORDISH.search(text.casefold()):
        raise Illegal("Write a definition first.")
    if len(text) > MAX_LEN:
        raise Illegal(f"Keep it to {MAX_LEN} characters.")
    if text[0].islower():
        text = text[0].upper() + text[1:]
    return text.rstrip(". ") or text

tidy cleans it like any typed text (tables.text), strips wrapping quotes, capitalizes the first letter and drops a final period, to match the bank’s style. It refuses text with no letters or digits, or over 140 characters. A player can resend until everyone is in; the last one counts.

A bot sends up to 8 wordings, its choice plus alternatives (alt), and the rules pick one once everyone is in. A bot can’t see the real definition or anyone else’s.

close decides whether a definition is “essentially the real one”: the same words ignoring case, punctuation and spacing, or at least 60% of the non-trivial words shared (with at least two in each). _SMALL lists the trivial words.

tables/fictionary/rules.pyclose · lines 90–99
def close(a: str, b: str) -> bool:
    """Essentially the same definition: the same words, or nearly all the
    same words that matter."""
    if key(a) == key(b):
        return True
    wa = {w for w in _WORDISH.findall(a.casefold()) if w not in _SMALL}
    wb = {w for w in _WORDISH.findall(b.casefold()) if w not in _SMALL}
    if len(wa) < 2 or len(wb) < 2:
        return False
    return len(wa & wb) / len(wa | wb) >= 0.6

When writing closes, fakes.keep_apart picks which bot wording stands (Keeping a round apart), with close as its last resort. A person who writes the real definition keeps it: it’s its own card and scores like any made-up one (David’s call).

tables/fictionary/rules.py_written · lines 248–274
def _written(state: dict, now: float) -> None:
    r = state["r"]
    picks, missed = commit.take(state, lambda seat: None)
    real = r["real"]
    seats = [seat for seat in sorted(picks) if picks[seat]]
    # A person's definition stands as written. A bot's is the first of its
    # wordings that isn't the real one, nor like another fake in the round,
    # nor one written at this table lately (fakes.keep_apart).
    stands = fakes.keep_apart([[picks[s]["text"], *picks[s].get("alt", [])] for s in seats], real, r["word"]["word"],
                              seen=state.get("fakes", []), before=state.get("fakes_before", []))
    made = []
    for seat, text in zip(seats, stands):
        # Every definition is its own card with its own author, even if two
        # are word for word the same: a vote goes to the card chosen.
        made.append({"text": text, "by": [seat], "real": False})
        state.setdefault("fakes", []).append(fakes.ident(text))
    log.event(state, "written", round=state["round"], count=len(made), missed=missed)
    if not made:
        r["options"] = [{"text": real, "by": [], "real": True}]
        _score(state, {}, now)
        return
    choices = [*made, {"text": real, "by": [], "real": True}]
    random.Random(f"{state['seed']}:shuffle:{state['round']}").shuffle(choices)
    r["options"] = choices
    state["phase"] = "vote"
    voters = [s for s in range(state["n"]) if s != r["judge"]]
    commit.open(state, now, seats=voters, kind="vote", seconds=vote_seconds(state))

Every definition is its own card with its own author, even when two players wrote the same words (David’s call), so a vote goes to a card. The cards and the real one are shuffled with a seed derived from the game’s (seed:shuffle:round), so a replay gives the same order. If nobody wrote anything, the real definition is shown alone and no one scores.

Voting and scoring#

Everyone but the Judge votes, for any card but their own (checked against the card’s by), and can change it until the pick closes.

tables/fictionary/rules.pypoints_for · lines 282–295
def points_for(n: int, judge: int, options_: list[dict], votes: dict[int, int]) -> list[int]:
    """A round's points: an author +1 for each player their definition
    fooled, a voter +2 for finding the real one, the Judge +1 for each
    voter who was fooled. A vote not cast scores no one."""
    points = [0] * n
    for voter, k in votes.items():
        option = options_[k]
        if option["real"]:
            points[voter] += 2
        else:
            for author in option["by"]:
                points[author] += 1
            points[judge] += 1
    return points
Event Points
A voter picks the real definition +2 to the voter
A voter picks a made-up card +1 to that card’s author, and +1 to the Judge
A vote not cast (time ran out, or the player was away) nothing to anyone

Worked example. Five players, seat 0 the Judge. Ben (seat 1) wrote card 1, Cleo (2) card 2, Dev (3) card 3; card 4 is real. Cleo and Dev vote for Ben’s card, and Eve (4) for the real one. Ben scores 2, the Judge 2, Eve 2, Cleo and Dev nothing. Had Dev not voted, Ben and the Judge would each have 1.

The reveal#

_score adds the points, records the round in last and history, and plays a reveal (tables.reveal): beats timed by the server, so every browser plays them together.

tables/fictionary/rules.py_beats · lines 349–378
def _beats(state: dict, summary: dict) -> list[dict]:
    """The reveal, beat by beat (docs/fictionary.md, "The reveal"): the
    votes come out as arrows, then each made-up definition's author, the
    ones that fooled the most first, then the real one, then the Judge's
    points. Each beat's line is plain counts; the page reads it out with
    names, from the beat's data (who wrote it, who it fooled)."""
    opts = summary["options"]
    real_k = next(i for i, o in enumerate(opts) if o["real"])
    beats = []
    if summary["voted"]:
        beats.append({"cue": "votes", "ms": 2200, "say": "The votes are in.",
                      "votes": {str(v): k for k, o in enumerate(opts) for v in o["votes"]}})
    fooled = sorted((k for k, o in enumerate(opts) if not o["real"] and o["votes"]), key=lambda k: (-len(opts[k]["votes"]), k))
    for k in fooled:
        o = opts[k]
        beats.append({"cue": "author", "ms": 1700, "k": k, "by": o["by"], "fooled": o["votes"],
                      "say": f"Option {k + 1} was made up. It fooled {len(o['votes'])}."})
    rest = [k for k, o in enumerate(opts) if not o["real"] and not o["votes"]]
    if rest:
        beats.append({"cue": "authors", "ms": 1500, "ks": rest,
                      "say": f"{'Option' if len(rest) == 1 else 'Options'} {', '.join(str(k + 1) for k in rest)} fooled no one."})
    found = opts[real_k]["votes"]
    beats.append({"cue": "real", "ms": 2400, "k": real_k, "found": found,
                  "say": f"The real definition was option {real_k + 1}. {len(found)} found it."})
    judge_points = sum(len(o["votes"]) for o in opts if not o["real"])
    beats.append({"cue": "judge", "ms": 1400, "judge": summary["judge"], "points": judge_points,
                  "say": f"The Judge scores {judge_points}."})
    beats.append({"cue": "totals", "ms": 1200, "points": summary["points"], "totals": list(state["totals"]),
                  "say": "The scores are up."})
    return beats
Beat Length
votes: arrows from each voter to their pick 2,200 ms
author: each made-up card that fooled someone, most-voted first 1,700 ms each
authors: the cards that fooled no one, together 1,500 ms
real: the stamp 2,400 ms
judge 1,400 ms
totals 1,200 ms

Each beat carries a plain line for screen readers. How a reveal plays is in the front end: play is only motion, and every state change is in end.

Winning#

tables/fictionary/rules.py_end_round · lines 322–344
def _end_round(state: dict) -> None:
    totals = state["totals"]
    top = max(totals)
    leaders = [s for s in range(state["n"]) if totals[s] == top]
    last = False
    if top >= state["target"]:
        if len(leaders) == 1 or state["tiebreaks"] >= TIEBREAKS:
            last = True
        else:
            # A shared lead at the target: everyone plays on a round, until
            # one player leads alone (docs/fictionary.md, "Winning").
            state["tiebreaks"] += 1
            log.event(state, "tied", seats=leaders, total=top, tiebreak=state["tiebreaks"])
    if state["round"] + 1 >= MOST_ROUNDS:
        last = True
    if last:
        winners = leaders
        log.event(state, "over", winners=winners, totals=list(totals))
        # The words played go with the table to its next game, so a rematch
        # doesn't deal them again.
        state["_carry"] = {"words": (state["before"] + state["chosen"])[-REMEMBERED:],
                           "fakes": (state.get("fakes_before", []) + state.get("fakes", []))[-FAKES_REMEMBERED:]}
    rounds.end(state, last=last)

The first player to reach the target and lead alone wins. If the lead is shared at or past the target, play goes on a round at a time until one player leads alone. After 2 such rounds, the players level at the top share the win, in history and ratings too. Any game ends after 60 rounds. The real function, with the lead at 10 shared two ways:

  • after round 1: play on (tie-break 1)
  • after round 2: play on (tie-break 2)
  • after round 3: over, 2 share the win

So the shared win comes at the third tied round-end. Points come faster at a big table, since the Judge scores per voter fooled: a game to 10 takes bots about six rounds with three players and two with twelve.

At the end, _carry passes the game’s words to the table’s next one, so Play again doesn’t repeat them (300 are remembered).

The contract#

Part Here
slug, min_players, max_players fictionary, 3, 12
act moves.dispatch over choose, write, vote, ready, with tick so a move can’t land after its window closed. tick and deadline come from commit
holds_up Only ready: a timed pick closes by itself
away, away_due While a pick is open, an away Judge gets their first word, an away writer writes nothing, an away voter doesn’t vote. No one else loses a point
ready Rounds’ shared ready, then _next_round
round_no state["round"]: someone waiting may take a bot’s seat, and its points, as a round starts
note Logs a seat changing hands and tracks whether a bot plays it (Secrets)
result, terms The shared rounds.result(high=True): places by total, ties sharing; high wins
bot_think choose 2.5 s, write 7.0 s, vote 3.5 s, ready 0 s

Dealing words#

The Judge’s five come from words.offer(rng, 5, tiers), with rng seeded from the game’s seed and the round number. Each word draws one random key, weighted by its rating, and the five best keys win. This is weighted sampling without replacement (Efraimidis and Spirakis): a heavier word tends to draw a better key, and no word is drawn twice.

tables/fictionary/words.pyweight · lines 81–88
def weight(item: dict) -> float:
    """How likely a word is to be offered: by its rating, from about a
    quarter as likely at 60 to certain-ish at 100; under the floor, hardly
    ever (it's still there if a long game needs it)."""
    rating = float(item.get("rating", 0))
    if rating < FLOOR:
        return 0.01
    return (rating - 45) / 55
tables/fictionary/words.py_weighted · lines 106–125
def _weighted(rng, pool, n) -> list[str]:
    # Weighted sampling without replacement (Efraimidis and Spirakis): each
    # word draws a key, u^(1/weight), and the highest keys win. One draw
    # per word in the file's order, so a seeded offer repeats exactly.
    keyed = sorted(((rng.random() ** (1.0 / weight(w)), i) for i, w in enumerate(pool)), reverse=True)
    picked, families, topics = [], set(), set()
    for _, i in keyed:
        w = pool[i]
        # (No two of a family, and no two plants or animals, nor two bits of
        # jargon, in one hand of five: the Judge gets a real choice.)
        topic = w.get("topic") if w.get("topic") in APART else None
        if family(w) in families or (topic and topic in topics):
            continue
        if topic:
            topics.add(topic)
        picked.append(w["id"])
        families.add(family(w))
        if len(picked) == n:
            break
    return picked
RatingWeightChance against a rating-100 word
300.0101%
590.0101%
600.27327%
700.45545%
800.63664%
900.81882%
1001.000100%
Live from guide/worked/fictionary.py, WEIGHTS

A word rated 60 is offered about a quarter as often as a perfect one. The 111 words under 60 weigh 0.01, so their key, u ** 100, is practically 0: they’re dealt only when everything better is used up. The five must also be apart: no two of one word family, and no two nature (plants and animals) or two technical words.

offer keeps words out by tiers, strictest first, using the first tier that leaves enough:

  1. Words offered this game or played at this table before, and their families;
  2. Words chosen this game or played before;
  3. Words chosen this game;
  4. Nothing.

A game never offers a word twice. With at most 60 rounds (300 words) against 2,261 in the bank, the strict tiers hold in practice; the loose ones mean a table can never run dry.

Your move#

The first time each job is yours in a game, the room kit’s YourMove (Your move at a table) shows a card: Choose a word for the Judge (the word cards ring), Write a fake definition (your writing card rings until you type), and Spot the real one when voting (the cards you can vote for ring). The cards wait for a reveal to finish. The Judge’s reading-out gets none.

Secrets#

view(state, seat) is the only way the state reaches a browser.

Who Sees
Everyone Totals, scores, who has picked (pick.picked), the previous round’s reveal
The Judge, choosing The five words with their real definitions (offer[].says)
The Judge, writing and voting The real definition (real), and while voting the cards with it marked
Writers The word, its part of speech, and their own sent draft (draft)
Voters The shuffled cards, which is theirs (mine), their own vote; not which is real, who wrote what, or how anyone voted
A spectator The word, and the cards if shown to all; no drafts, votes or real

With show off (“Judge reads them out”), voters and spectators get text: null for every card. The Judge always has the text.

A bot can’t read aloud, so when a bot is the Judge the cards show on every screen. note() keeps state["bots"] current (cover and leave add a seat; back and sit_in remove it). The same goes for a Judge who is away: away_due wakes the service when they’d count as away, and away marks the round unread so the cards show to all for the rest of it. The design notes ask David to confirm this.

tables/fictionary/rules.pyaway · lines 471–484
def away(state, seat, now):
    """Someone away doesn't hold up the table: an away Judge gets the
    first of their words; an away writer writes nothing and an away voter
    doesn't vote (neither scores, nor costs anyone else a point). A Judge
    away while the definitions wait to be read out can't read them, so,
    as with a bot for Judge, they show on every screen for the rest of the
    round."""
    if _unread(state, seat):
        state["r"]["unread"] = True
        state["seq"] = state.get("seq", 0) + 1
        log.event(state, "unread", round=state["round"], judge=seat)
    if not (state["phase"] in PICKS and seat in commit.owed(state) and bool(commit.deciding(state))):
        return
    commit.put(state, seat, state["r"]["offer"][0] if state["phase"] == "choose" else None, auto=True)

The page gets the length limit from the server (max_len), so it’s written once. Authors and votes enter last and reveal only once voting has closed. SecrecyTests checks that nobody sees a definition or vote before the reveal.

Bots#

bot_action is bots.decide(view(state, seat), seat, level, rng): a bot sees only its own seat’s view. The levels differ in how well they write and read.

Choosing#

Any of the five at random, since they were already offered by rating.

Writing#

A bot sees only the word and its part of speech (tables/fictionary/fakes.py). It writes up to 8 wordings and sends the first, with the rest as alt:

tables/fictionary/fakes.pymake · lines 292–319
def make(word: str, pos: str, rng, level: str = "medium", n: int = 6) -> list[str]:
    """`n` different made-up definitions of `word`, the first the one to use
    (docs/fictionary.md, "Bots"). Mostly from the pool, leaning toward what
    the word's ending suggests; hard bots read a telling ending most; easy
    ones now and then write something silly."""
    pos = pos if pos in ("noun", "verb", "adjective", "adverb") else "noun"
    shaped, topic = endings(word, pos), topic_of(word)
    chosen, out = [], []
    for i in range(n * 4):
        roll = rng.random()
        e = None
        if i == 0 and roll < SILLINESS.get(level, 0.0) and pos in SILLY:
            text = rng.choice(SILLY[pos])
        elif shaped and roll < SHAPED.get(level, 0.5) * (1.0 if i == 0 else 0.3):
            text = fill(rng.choice(shaped), rng, topic)
        elif roll > 1 - BORROW.get(level, 0.08):
            text = borrowed(word, pos, rng)
        else:
            e = _from_pool(word, pos, rng, level, chosen)
            text = e["text"]
        if not text or len(text) > 140 or text in out or gives_away(text, word):
            continue
        out.append(text)
        chosen.append({"frame": e.get("frame") if e else None, "cat": e["cat"] if e else None,
                       "ws": likeness.words(text)})
        if len(out) == n:
            break
    return out
  • From the pool, most of the time: 20,000 believable made-up definitions (The pool) in the word’s part of speech. It leans toward what the word’s ending suggests (18 rules in SHAPES: -er a person, -wort a plant, -itis an ailment) by LEAN: 0.3 easy, 0.5 medium, 0.65 hard. A quarter of the time it leans toward a definition that mentions a prefix’s meaning (98 prefixes, such as ichthy- fish). _from_pool makes the wordings differ in kind, frame and words.
  • From a telling ending: 19 endings that say what a word means, such as -phobia (“An unreasonable fear of {topic}”), in 38 sentences. When the word has one, the first wording comes from it by SHAPED (25% easy, 50% medium, 70% hard).
  • Another word’s real definition, by BORROW (5%, 8%, 10%). It reads exactly like the house voice.
  • Something silly, an easy bot’s first wording 15% of the time (SILLINESS; 15 jokes, such as “A hat for a goose”).

In 2,000 bot rounds, 88% of the fakes that stood came from the pool, 9% were borrowed, and 3% came from an ending or were silly.

Keeping a round apart#

Once everyone is in, keep_apart picks which wording stands for each writer. A person’s stands as written. A bot’s is the first that passes, in order of what gives way first:

tables/fictionary/fakes.pykeep_apart · lines 332–402
def keep_apart(writings, real: str, word: str, seen=(), before=()) -> list[str]:
    """Which of each writer's wordings stands in the round: `writings` is a
    list of wordings per writer, in order (a person sends one; a bot a few,
    its choice first). A person's always stands. Each bot's is the first
    that, in order of what gives way first:

    - isn't like the real definition (a Jaccard of 0.2, or two content
      words in common) and doesn't name the word;
    - hasn't been written this game (`seen`, ids) or, if it can be helped,
      at this table's last games (`before`);
    - isn't the frame or the kind of thing (category) of another fake in
      the round, nor like one (a Jaccard of 0.25, or three content words
      in common): the kind gives way first, then the table's memory, then
      the rest.

    It's never word for word one already standing. Failing all that, the
    first that isn't essentially the real one (as before there was a
    pool)."""
    real_ws = likeness.words(real)
    seen, before = set(seen), set(before)
    picks = [None] * len(writings)
    stood = []

    def facts(text):
        e = entry(text)
        frame = e.get("frame") if e else stock_frame(text)
        return {"ws": likeness.words(text), "frame": frame, "cat": e["cat"] if e else None}

    for i, texts in enumerate(writings):
        if len(texts) == 1:
            picks[i] = texts[0]
            stood.append(facts(texts[0]))
    passes = (
        dict(kind=True, memory=True, apart=True, strict=True),
        dict(kind=False, memory=True, apart=True, strict=True),
        dict(kind=False, memory=False, apart=True, strict=True),
        dict(kind=False, memory=False, apart=False, strict=True),
        dict(kind=False, memory=False, apart=False, strict=False),
    )
    for i, texts in enumerate(writings):
        if picks[i] is not None:
            continue
        options = [(t, facts(t)) for t in texts]
        choice = None
        for p in passes:
            for t, f in options:
                if _real_like(f["ws"], real_ws, t, real, p["strict"]) or gives_away(t, word):
                    continue
                if any(likeness.key(t) == likeness.key(s) for s in picks if s):
                    continue
                if ident(t) in seen:
                    continue
                if p["memory"] and ident(t) in before:
                    continue
                if p["apart"] and any(likeness.alike(f["ws"], s["ws"]) or (f["frame"] and f["frame"] == s["frame"])
                                      for s in stood):
                    continue
                if p["kind"] and f["cat"] and any(f["cat"] == s["cat"] for s in stood):
                    continue
                choice = (t, f)
                break
            if choice:
                break
        if not choice:
            from .rules import close

            t = next((t for t in texts if not close(t, real)), texts[0])
            choice = (t, facts(t))
        picks[i] = choice[0]
        stood.append(choice[1])
    return picks

Likeness compares content words (tables/fictionary/likeness.py): lower case, a plural, -ing or -ed removed, small common words left out. Two texts are scored by Jaccard similarity, the shared words divided by all the words in either. A fake is too like the real definition at two shared words or a Jaccard of 0.2, and too like another fake at three or 0.25.

The table remembers fakes by id (fakes.ident). This game’s are never written again, and _carry passes the last 500 of earlier games on, like the words. A bot never puts up the real definition, or another bot’s words.

tools/fictionary/variety.py measures this over 2,000 rounds at four to nine seats, four games in a row per table. With the pool, no round has two fakes of one sentence frame (before it, 38.8% did), two fakes sharing three content words, a fake too like the real one, or a fake repeated in a game or at the table’s next games (8.1% before).

The pool#

tables/fictionary/content/fakes.toml is a content bank (ADR 0019) of 20,000 definitions in the word bank’s proportions: 15,373 nouns, 2,893 adjectives, 1,663 verbs, 71 adverbs. Each has a kind (cat: 38 kinds of noun, 17 of adjective, 15 of verb, 5 of adverb), a frame and its content words. It loads once per process, in about half a second.

tools/fictionary/fakes.py builds it from tools/fictionary/frames/: 508 sentence frames plus 3,110 definitions written whole. A frame’s groups say which blanks’ values go together. Its gates:

  • the house voice;
  • never essentially a real definition in the bank;
  • no near-twins (a Jaccard of 0.5);
  • no content word in more than 1.5% of a part of speech (more for words real definitions use a lot), and no frame in more than 3%.

If more pass than a part of speech needs, framed ones whose length is furthest from real definitions’ go first. The build is in Content pipelines. In the review in docs/fictionary.md, 296 of 300 read at random were believable.

Voting#

Voting is where the levels differ most:

tables/fictionary/bots.pydecide · lines 111–135
def decide(v: dict, seat: int, level: str, rng) -> dict:
    phase = v["phase"]
    if phase == "scored":
        return {"type": "ready"}
    if phase == "choose":
        return {"type": "choose", "word": rng.choice(v["offer"])["id"]}
    if phase == "write":
        w = v["word"]
        texts = fakes.make(w["word"], w["pos"], rng, level, n=rules.WORDINGS)
        return {"type": "write", "text": texts[0], "alt": texts[1:]}
    if phase == "vote":
        opts = v["options"]
        ks = [k for k, o in enumerate(opts) if not o["mine"]]
        if any(opts[k]["text"] is None for k in ks):
            return {"type": "vote", "option": rng.choice(ks)}
        w = v["word"]
        if knows(w["id"], seat, level):
            real = words.says(words.get(w["id"]))
            sure = [k for k in ks if rules.close(opts[k]["text"], real)]
            if sure:
                return {"type": "vote", "option": rng.choice(sure)}
        nose = NOSE.get(level, 0.8)
        scores = [plausible(opts[k]["text"], w["word"], w["pos"]) - (nose if fakes.looks_made(opts[k]["text"]) else 0) for k in ks]
        return {"type": "vote", "option": ks[_softmax_pick(scores, NOISE.get(level, 0.9), rng)]}
    return {"type": "ready"}
  1. If the cards aren’t shown to it (a person is the Judge, reading aloud), it guesses among cards not its own.
  2. Otherwise it may know the word, as a well-read player would. This is the one thing it knows that isn’t on its screen, on purpose. knows is a deterministic hash of word, seat and level, so a bot always knows the same words. The chance is READ[level] × (1.6 − obscurity/100), with READ 0.2 hard, 0.1 medium, 0.02 easy: exactly READ at obscurity 60, about two-thirds of it at 94, 1.6 times at 0. Knowing it, the bot votes for the card close to the bank’s definition.
  3. Otherwise it scores each card by plausible, how much it reads like a dictionary: - a verb starting “To” or a noun starting “A/An/The”: +1; - 22 to 110 characters: +0.5 (under 14: −0.8); - “!” or “?”: −1.2; chatty words like “I” or “lol”: −1.3; - the word itself in the text: −0.6; - an ending matched by its promise (“-phobia” and “fear”): +0.6, otherwise −0.2.
  4. It subtracts a “nose” for the shape of an ending-made or silly fake (looks_made, one regular expression per sentence): 0.4 medium, 0.7 hard, none easy. Pool fakes and people’s bluffs have no such tell.
  5. It picks by softmax with noise (2.5 easy, 1.2 medium, 0.8 hard). Softmax turns scores into probabilities, so a higher score is likelier but not certain.

In 60 bot games at five seats, easy found the real definition in about 26% of votes, medium 36%, hard 41%, as the design notes say.

Rating anchors#

Bots are fixed players in the ratings: easy 1470, medium 1500, hard 1560. tools/ratings/anchors.py measured them at three seats, 600 games a pairing (Ratings): medium beat easy 54.5%, hard beat medium 59.2%. The tool seats levels alternately, so one level holds two of three seats; the share counts every pair of seats of different levels. A 300-game run gave 53.9% and 56.8%, so runs vary. The anchors are rounded to 10 and re-measured only by hand.

Rating#

A kept game is one result, rated by place, each player their own side (Ratings). Ties share a place, so a shared win ranks both leaders first. Bots count at their anchors and never change; a covered seat isn’t rated.

Worked example. Ada (1550 ± 80), Basil (1480 ± 120), a medium bot (1500) and a hard bot (1560). The hard bot finishes third and the medium bot last:

Ada Basil
Basil first, Ada second +7.7 +56.6
Ada first, Basil second +22.0 +24.7
Ada and Basil share the win +14.9 +40.6

Every side meets every other at 2/k (a field), so beating the hard bot and Ada at once counts for more than a heads-up win. Basil’s gains are large because his rating is the least certain.

The interface#

The room, the desk and the dock#

games/static/games/fictionary/app.js calls the room kit’s start() with min: 3, max: 12, defaultBots: 3, fill: true, spectate: true and firstRun: true (How to play opens the first time). Four settings (Play to, Definitions, Timer, Bot speed) are kept as house rules (ADR 0021), and a deadline function puts a pick’s clock in any sheet opened over the table.

The table has three parts that never change size, so nothing jumps: the score strip on top (marks, scores, ★ Judge, ✓ in, each mark of where a player is 12px in its coin), the desk (the word and the catalog cards, scrolling inside itself), and the dock at the bottom (what to do now, the clock, your moves). The room’s seat bar (Take my seat back, Replace with bot) floats under the top bar rather than in the dock, so the dock keeps its size. A timed table’s seconds are the house’s Figure, red when low, and “Time!” is a banner on every playing phone when the clock runs out (ADR 0054). After the first round, the desk moving on is a banner too (“Everyone’s in!”, “Vote for the real one.”, or the new word to define), and in the reveal your own payoff is one: “You fooled Bo and Cy” with its points as your card’s author beat lands, “You found the real one” with the real one’s.

The tab title says “● Your turn” when the table waits on you, with an optional chime when the tab is hidden. On a phone, the viewport meta’s interactive-widget=resizes-content shrinks the page above the keyboard so Send stays visible; a test checks this game has it and Get In Line doesn’t.

Writing#

The writing box is big and set in the typewriter face, with a count of what’s left of max_len (marked low at 15). Enter sends. Sent, it says “✓ Sent”; edited, “Changed: send it again”. The draft survives other players’ updates, and an empty one can’t be sent. The client’s wordsOf and the server’s tidy agree on what counts as the same words, so “Sent” is accurate.

Three helpers guard Send, all on by default, each with an Options row saved in this browser (uo:fictionary:spell, …:autosend, …:gentle):

  • Check spelling before sending (below).
  • Ask before sending strong language. A regular expression in table.js (STRONG) turns Send into “Send anyway?” on a match; nothing is refused. It holds only words with no innocent sense: cock, bitch, faggot, prick, niggard, dick and bastard are left out, since a definition may need the rooster, the female dog or the bundle of sticks.
  • Send my draft when time runs out (timed tables): the draft goes in 2.5 seconds before writing closes, skipping the spelling check.

The spelling check#

The check runs only in the browser, so the server never learns what was flagged. Its list is fetched only when someone writes (810 KiB, about 0.3 MB gzipped, then cached). If it takes over 1.5 seconds, nothing is checked rather than hold up Send.

tools/fictionary/spell.py builds the list from ENABLE, CMUdict and a hand list, in four sections that parse decodes:

Section Words What it is for
common 51,448 ENABLE words also in CMUdict, plus the hand list: everyday words, where suggestions come from
other 121,856 The rest of ENABLE
stems 2,883 Words that also pass with an ending (-s, -ed, -ing …): the hand list and the bank’s headwords
top 513 The commonest words, which win a tie between suggestions (“teh” is more likely “the” than “ten”)
games/static/games/fictionary/spell.jsparse · lines 22–48
/** The list as a lookup: `known` (every word that passes), `common` (the
 * everyday ones, which suggestions are drawn from), `top` (the commonest of
 * all, which win a tie) and `stems` (words that also pass with an ending).
 * The file's sections are, in order: common, other, stems, top. */
export function parse(text) {
  const parts = text.split('\n=\n');
  const decode = block => {
    const out = [];
    let prev = '';
    for (const line of block.split('\n')) {
      if (!line) continue;
      const w = prev.slice(0, DIGITS.indexOf(line[0])) + line.slice(1);
      out.push(w);
      prev = w;
    }
    return out;
  };
  const [header, ...first] = parts[0].split('\n');
  if (!/^spell 1$/.test(header)) throw new Error('Not a spelling list.');
  const [common, other, stems, top] = [first.join('\n'), ...parts.slice(1)].map(decode);
  const commonSet = new Set(common);
  const known = new Set(common);
  for (const w of other) known.add(w);
  const list = { known, common: commonSet, commonList: common, otherList: other, stems: new Set(stems), top: new Set(top), buckets: null };
  for (const w of stems) known.add(w);
  return list;
}

The check is gentle, so it doesn’t cry wolf and get switched off. Besides listed words and their forms, it passes contractions, possessives, hyphenated compounds (by their parts), anything with a digit, other scripts, and the round’s own word in any form (-s, -ed, un-, -ation …). It passes two everyday words run together (headscarf), except real mistakes (FUSED: alot, eachother). It also passes names: a capital mid-sentence, ALL CAPS up to five letters, or a capital inside a word. A name starting a sentence must be on the list, which has countries, states, big cities, months and days.

games/static/games/fictionary/spell.jscheck · lines 141–177
/** The words of `text` that look misspelled: [{ word, start, end,
 * suggestions }], where text.slice(start, end) is `word` as typed. `own` is
 * the round's word, which is never flagged. */
export function check(text, list, { own = '' } = {}) {
  if (!list) return [];
  const mine = String(own || '').toLowerCase();
  const found = [];
  for (const m of String(text || '').matchAll(TOKEN)) {
    let raw = m[0];
    raw = raw.replace(/['’\-‑]+$/, '');
    if (!raw || /\p{N}/u.test(raw) || /[^\p{Script=Latin}\p{M}'’\-‑]/u.test(raw)) continue;
    const letters = raw.replace(/[^\p{L}]/gu, '');
    // Names and the like: a capital mid-sentence, a capital inside a word, a short ALL CAPS.
    const shouting = letters.length > 5 && letters === letters.toUpperCase();
    if (/^\p{Lu}/u.test(raw) && !shouting && !startsSentence(text, m.index)) continue;
    if (/\p{Ll}\p{Lu}/u.test(raw) || (letters.length > 1 && letters.length <= 5 && letters === letters.toUpperCase())) continue;
    let at = m.index;
    const parts = raw.split(/[-‑]/);
    const hyphenated = parts.length > 1;
    for (const part of parts) {
      const w = part.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/’/g, "'");
      if (w && !piece(w, list, mine, hyphenated)) {
        const clean = part.replace(/^['’]+|['’]+$/g, '');
        const start = at + part.indexOf(clean);
        found.push({ word: clean, start, end: start + clean.length, key: w.replace(/^'+|'+$/g, ''), suggestions: null });
      }
      at += part.length + 1;
    }
  }
  // (Suggestions are looked for only in the first few: they take a moment.)
  const seen = new Map();
  for (const f of found) {
    if (!seen.has(f.key)) seen.set(f.key, seen.size < 3 ? suggest(f.key, list) : []);
    f.suggestions = seen.get(f.key).map(s => recase(s, f.word));
  }
  return found;
}

Suggestions are one edit away, or two among everyday words for words of five letters or more. Swapping neighboring letters is one edit (recieve):

games/static/games/fictionary/spell.jssuggest · lines 212–233
/** Up to three words within one or two edits of `word`, everyday ones first
 * (two edits only for those; any word on the list for one). */
export function suggest(word, list, limit = 3) {
  const w = word.toLowerCase();
  if (w.length < 3 || !list) return [];
  const { common, other } = byLength(list);
  const near = [];
  const scan = (bucket, tier, max) => {
    for (let len = w.length - max; len <= w.length + max; len++) {
      for (const c of bucket[len] || []) {
        const d = distance(w, c, max);
        if (d <= max) near.push({ c, d, tier, top: list.top.has(c) ? 0 : 1, first: c[0] === w[0] ? 0 : 1, gap: Math.abs(c.length - w.length) });
      }
    }
  };
  scan(common, 0, w.length > 4 ? 2 : 1);
  scan(other, 1, 1);
  const best = Math.min(...near.map(n => n.d), 9);
  return near.filter(n => n.d === best)
    .sort((a, b) => a.tier - b.tier || a.top - b.top || a.first - b.first || a.gap - b.gap || (a.c < b.c ? -1 : 1))
    .slice(0, limit).map(n => n.c);
}

Everyday words rank first, then the commonest, then the same first letter, then the closest length. It offers up to three, for the first three misspellings, as tap-to-fix chips beside Fix it myself, and Send becomes Send anyway. npm run check:fictionary tests it under Node against the real list (tools/fictionary/spell.test.mjs).

Voting, Read aloud, display#

Voters see numbered cards, their own marked and not votable. Pick one, then Vote for N; it can change until everyone is in. Keys: 1–9 pick, R reads out (the Judge), S shows scores.

The Judge’s Read aloud is for a table in one room or on a call. It steps through each definition big and numbered (tap, Space or arrows), with the word first and last (“Now, which is the real one?”). It never marks the real one, since anyone might see the screen; the Judge’s own list does, quietly. With show off it’s the only way voters hear the definitions.

The reveal#

The room kit’s script.js plays the beats on every screen together: arrows from each voter to their card, each fake turning to show its author as its points fly, the real one stamped in a librarian’s red, then the Judge’s points. A late arrival, a hidden tab, Skip and reduced motion all jump to each beat’s end state.

The result sheet#

The house result sheet opens once the last reveal has played, never during it. Besides the winner (or both names) and the standings, it shows the game’s best bluff, the fake that fooled the most. A separate Scores sheet (key S) adds each round’s word, real definition and best bluff, from history.

Pronunciation and the speaker#

Wherever the word appears, its dictionary respelling sits beside it between backslashes (\ˈbrē-zäⁿs), with a speaker button everywhere but on the Judge’s five. Tapping the respelling (a button at least 44px each way) opens the pronunciation key (KEY in games/static/games/fictionary/speak.js).

The speaker uses the browser’s voice (speechSynthesis); pickVoice prefers American English on the device. With no speech, or no voices after 2.5 seconds, the button isn’t drawn. A second press within 12,000 ms says the word sounded out from a plain respelling (“kah mih nayt”, the say field), because browser voices guess obscure words from spelling and a different second try beats the same mistake twice. A third press says it normally. Screen readers get the plain respelling.

Each respelling comes from the first source that has the word:

Source Words Notes
Webster’s 1913 (the Revised Unabridged Dictionary) through GCIDE 161 In its own respelling; only the display is changed; never filled in
Wiktionary’s enPR 49 Shown as it gives them
Worked out from the IPA by rule 1,168 One table for every word
None 883 Nothing is guessed

So 1,378 of 2,261 words (61%) show one. 1,337 have an IPA (International Phonetic Alphabet) pronunciation in the data, not shown. Content pipelines explains the builds and why the three sources sit in three files under three licenses.

The word bank#

tables/fictionary/content/words.toml holds 2,261 words: 1,738 nouns, 327 adjectives, 188 verbs, 8 adverbs, rated 36 to 100 (median 82). It’s TOML, read with the standard library (ADR 0019), and only tables/fictionary/words.py reads it. A word’s id is the word and is forever: retire a word, never rename it. Content pipelines explains how it’s built.

Sources and licences#

Source Gives Licence
Princeton WordNet 3.0 Headwords, parts of speech, senses and glosses, subject domains, word families WordNet’s own: any use, with its notice on every copy (the bank’s header carries it)
Google Books Ngram data, English 1-grams, 2020 release How often each word was printed 1950–2019, kept only as zipf CC BY 3.0
ENABLE word list (via github.com/dolph/dictionary) The spelling check’s words Public domain
CMUdict (Carnegie Mellon) IPA for 175 words; which ENABLE words are everyday BSD-style, notice in ipa.toml
Wiktionary through Wiktextract (kaikki.org) IPA for 1,162 words (1,163 entries, one with only an enPR) and enPR for 49 CC BY-SA 4.0: kept in its own file, ipa_wiktionary.toml
Webster’s 1913 through GCIDE 0.53 161 pronunciations; the senses definitions were checked against (read only) GPL 3 or later: pron_gcide.toml only

The definitions are the project’s own words, written from WordNet’s senses. wordfreq wasn’t used, because its lists are CC BY-SA 4.0.

What it holds, and how a word is rated#

A word is a single, letters-only WordNet headword of 4 to 14 letters, rare (at most 2.3 on the Zipf scale of word frequency, about one use in five million words) but printed at least 400 times since 1950. Names, inflected forms, slurs and anything rude are filtered out, and the bank keeps one word per family. Plants and animals (11.5% now) and jargon (3.5% now) are capped.

A word’s rating (0–100) is how good a Fictionary word it is, and it decides how often the word is dealt. It’s a weighted sum of four 0–100 scores from tools/fictionary/build.py, with guessability counting against it:

ScoreWeight
obscurity0.3
guessable0.35
fun0.15
definition0.2
Live from tools/fictionary/build.py, WEIGHTS

rating = 0.3 × obscurity + 0.35 × (100 − guessable) + 0.15 × fun + 0.2 × definition, rounded. A word whose guessability is over 55 isn’t a candidate. Content pipelines explains each score.

Worked example, brisance: Zipf 0.9; stored scores obscurity 94, guessable 0, fun 93, definition 100. The parts are 28.20 + 35.00 + 13.95 + 20.00 = 97.15, a rating of 97. Recomputed this way, 0 words disagree with the bank.

The house voice and the hand pass#

A real definition mustn’t stand out from the players’ inventions, so each is written as a confident player would:

  • plain everyday words (at most 15, most 5–12; at most 100 characters);
  • sentence case, no final period;
  • a noun starting “A …”, “An …”, “The …” or a bare noun phrase; a verb starting “To …”;
  • no dictionary voice: no “of or relating to”, usage labels, examples, semicolons, parentheses, quotes, colons, slashes or Latin names;
  • never the word itself, or anything sharing its first five letters.

The sense is WordNet’s first for the word’s main part of speech, kept exactly: detail is trimmed, never added. The test suite checks all of it.

Every word was read by hand: 5,830 candidates in two rounds, each rewritten or dropped with a reason in tools/fictionary/house.tsv. About three in five were dropped. house.tsv is the bank’s real source: edit it, never the bank.

Verifying the definitions against WordNet and Webster#

Every definition was then checked for drift of meaning (added detail, a wider or narrower scope, another sense or part of speech) against WordNet 3.0’s gloss for its source sense and, where there’s an entry, Webster’s 1913 (through GCIDE, the first four entries of a headword). The verdict is a line per word in tools/fictionary/verified.tsv and the bank’s verified field:

verified Words Meaning
wordnet+webster 1,262 A 1913 sense agrees with the WordNet sense the definition was written from
wordnet-only 999 Matches its WordNet gloss and nothing in 1913 confirms it (no 1913 entry, or a later addition), or WordNet’s sense is not the one Webster gives

187 wordnet-only words disagree with Webster (184 with a 1913 entry, 3 with a later GCIDE one), such as hemeralopia: day blindness to WordNet, the reverse to Webster. They’re kept as WordNet has them, since it’s the recorded source, and the design notes say to ask David before a Judge is shown one. The pass fixed 62 definitions, dropped 3 words and left 97% as written. tests/test_fictionary_verified.py keeps verified.tsv, the bank and each word’s part of speech consistent.

Its look#

Fictionary’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own, checked on phones. Everything else is the room kit’s (Board and party games).
Palette Ink #f6ecd8 on its own ground; the one fill #f6ecd8; yours and your turn #ffd34d; good #a6dc8f and bad #ff9a85. A library’s reading room: an oxblood leather desk with gold tooling, and the cream stock of a card catalog with a red rule and pale blue lines.
Faces Every definition typed on one typewriter (Courier Prime), so no one’s handwriting shows; headwords in Libre Caslon Text; Figtree for everything else
Corners and edges The rail 18px, panels 12px, buttons 10px
Board The score strip under the bar, then the desk of catalog cards, which scrolls inside itself so the page never does.
Emblem and art Its box on the shelf
Voice The Judge, a fake definition, the real one; “Spot the real one”
Payoff The room’s celebration for a win
The glance Now: the word, biggest, and the dock lit when it’s you. The countdown: a timed table’s seconds, a Figure, red when low. The score: the strip, with who’s still in.
Exceptions Where a player is (★ ✓ ⋯) sitting on the corner of their mark

Testing#

What Proves
tests/test_fictionary.py Every rule above, including two identical definitions are two cards, an away Judge’s cards shown to all, a shared lead playing on, and secrecy before the reveal; whole bot games; TableGameContract; the phone-keyboard viewport
tests/test_fictionary_fakes.py The pool: proportions, tags, house voice, no near-twins, caps, none essentially real, exactly what the frames build. Bot wordings: seeded, varied, never the word. Keeping apart, over 400 bot rounds
tests/test_fictionary_words.py The bank: unique ids, ratings adding up, definitions that never give the word away, small jargon and nature shares, exactly what house.tsv keeps, five words dealt apart
tests/test_fictionary_verified.py The bank matches verified.tsv, and each word is its source sense’s part of speech
tests/test_fictionary_pronunciation.py No guessed pronunciations; respellings are whole syllables in our set; each source in its own file and license; Webster’s wins; the conversions
npm run check:fictionary The spelling check against the real list
npm run check:fictionary:play tools/fictionary/play.mjs plays a round in headless Chrome and fails on any page error; tools/fictionary/over.mjs plays a whole game and checks nothing names the winner while the last reveal plays

Changing it#

To… You need
Change a rule rules.py, docs/fictionary.md and How to play (rules.js) together, and ask David first. Saved state doesn’t migrate, so a new key needs a default.
Add or change a word Edit tools/fictionary/house.tsv and verified.tsv, then build.py bank. Never edit the bank; ids never change.
Retune the ratings or the filters build.py (the bank rebuilds from the same house.tsv); the offer weights are words.weight.
Add a pronunciation or a spelling word ipa.py fetch and build; or add to tools/fictionary/spell-extras.txt, rebuild with spell.py, and add a line to spell.test.mjs if it should stay passing.
Retune a bot NOISE, READ, NOSE in bots.py and SHAPED, LEAN, BORROW, SILLINESS in fakes.py; re-measure the anchors (tools/ratings/anchors.py) and bump ratings.VERSION.
Add to the bots’ pool A frame or hand-written lines in tools/fictionary/frames/ (fakes.py check <module> shows its yield and a sample), then fakes.py build and tests.test_fictionary_fakes. Never edit the pool. Measure with variety.py.
Add a setting options(), the room’s settings in app.js, view if it changes what’s shown, and How to play.

Known limitations#

  • A bot that knows a word reads its meaning from the bank, which isn’t on its screen. It’s deliberate: it models a well-read player (hard 20%, medium 10%, easy 2% at middling obscurity), fixed per word and seat.
  • A person’s fake is never changed, so bots’ fakes are kept apart from it, but two people can still write alike.