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.
On this page
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.py options · lines 48–56def 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.py new · lines 104–129def 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–157def _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.py tidy · lines 74–87def 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.py close · lines 90–99def 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–274def _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.py points_for · lines 282–295def 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–378def _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–344def _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.py weight · lines 81–88def 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–125def _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
| Rating | Weight | Chance against a rating-100 word |
|---|---|---|
| 30 | 0.010 | 1% |
| 59 | 0.010 | 1% |
| 60 | 0.273 | 27% |
| 70 | 0.455 | 45% |
| 80 | 0.636 | 64% |
| 90 | 0.818 | 82% |
| 100 | 1.000 | 100% |
guide/worked/fictionary.py, WEIGHTSA 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:
- Words offered this game or played at this table before, and their families;
- Words chosen this game or played before;
- Words chosen this game;
- 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.py away · lines 471–484def 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.py make · lines 292–319def 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) byLEAN: 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_poolmakes 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.py keep_apart · lines 332–402def 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.py decide · lines 111–135def 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"}
- If the cards aren’t shown to it (a person is the Judge, reading aloud), it guesses among cards not its own.
- 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.
knowsis a deterministic hash of word, seat and level, so a bot always knows the same words. The chance isREAD[level] × (1.6 − obscurity/100), withREAD0.2 hard, 0.1 medium, 0.02 easy: exactlyREADat obscurity 60, about two-thirds of it at 94, 1.6 times at 0. Knowing it, the bot votes for the cardcloseto the bank’s definition. - 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. - 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. - 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.js parse · 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.js check · 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.js suggest · 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:
| Score | Weight |
|---|---|
| obscurity | 0.3 |
| guessable | 0.35 |
| fun | 0.15 |
| definition | 0.2 |
tools/fictionary/build.py, WEIGHTSrating = 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.