Technical guide

Card games

Bullshit

A bluffing card game where you claim your cards face down, sneak a few extra under the pile if you dare, and call out anyone who’s lying.

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

On this page
  1. At a glance
  2. The rules in code
    1. What the engine provides
    2. The deal and the ranks
    3. A play
    4. The call window
    5. The showdown
    6. Handing over
    7. Going out
    8. State
  3. Turn flow and timers
  4. Your move
  5. Secrets
    1. How big a hand looks
  6. The sneak
    1. Caught sneaking: a worked play
  7. Bots
    1. Styles
    2. Playing
    3. Calling
    4. Measured strength
  8. Tuning the sneak by simulation
  9. Rating
  10. The interface
    1. The table
    2. Playing a card, and sneaking
    3. Calling
    4. Assists and helpers
    5. The result sheet, and phones
  11. Its look
  12. Testing
  13. Known limitations and known issues
  14. Changing it

Bullshit (also called BS, Cheat or I Doubt It) is a bluffing card game for three to ten players. The ranks are called in turn. You play cards face down, claiming they’re the rank called, and anyone may shout “Bullshit!” to turn your play over. The first to get rid of every card wins.

This version adds David’s twist: you can sneak extra cards under your play, unclaimed. What gives a sneak away is what would at a real table: a hand shrinking faster than it should. That rule shapes most of the code. Hand sizes are shown only as roughly as a table would see them, bots read those counts, and the penalty for a caught sneak was tuned by simulation. The engine implements docs/bullshit.md (“if the two disagree, one of them is a bug”).

It’s a table game that, unlike the other card games here, runs on a clock, with a call window after every play. Its screens use the room and card kits, and it’s built for phones as much as desktops (ADR 0016).

At a glance#

What Where
Rules: deal, claim, call window, showdown, penalty, going out tables/bullshit/rules.py
Bots: styles and levels tables/bullshit/bots.py
The tuning simulation tables/bullshit/sim.py
The package the service registers tables/bullshit/__init__.py
The table: seats, the pile, the claim, the reveal, the hand, the dock games/static/games/bullshit/table.js
The front door and the settings games/static/games/bullshit/app.js
How to play games/static/games/bullshit/rules.js
Its look games/static/games/bullshit/bullshit.css
Its page games/templates/games/bullshit.html
Tests tests/test_bullshit.py, plus the contract in tests/table_contract.py
Design notes: the rules, the tuning tables, open questions for David docs/bullshit.md
Worked examples in this chapter guide/worked/bullshit.py, with guide/worked/cards.py

It’s registered in tables/games.py and listed in games/catalog.py; its anchors are in ratings/tables.py.

The rules in code#

What the engine provides#

Part Bullshit’s
min_players, max_players 3 and 10.
options How many cards you may sneak, the time to call, and bot speed.
terms A round is a “game” and low wins: it’s scored by cards left.
tick, deadline The call window, the reveal and the hand-over end by the clock; tick closes whichever is due.
holds_up Only the play decision holds the table up.
result, note Places by cards left, fewest first, ties sharing. There’s no round_no: it’s one deal.
bot_think play and give, and four listen kinds (below).

The deal and the ranks#

Up to six players use one deck; seven or more use two, so a rank has eight cards. Every card is dealt, one at a time from the dealer’s left, so some hands are a card bigger. The dealer is the seed (the value that fixes the shuffle) read as a number, and the player on their left plays first, calling aces.

tables/bullshit/rules.pyrank_at · lines 78–79
def rank_at(i: int) -> str:
    return RANKS[i % len(RANKS)]

Ranks go round one per play, whoever plays: aces, 2s … kings, then aces again. rank rises by one after every play that stands or is settled.

A play#

On your turn you put one or more cards face down and claim that some of them are the rank called. Anything beyond the claim is sneaked. You can’t pass.

tables/bullshit/rules.pyplay · lines 148–189
def play(state, seat, action, now):
    """Put cards face down, claiming `claim` of them are the rank called.
    Any more than the claim are sneaked under it."""
    if state["phase"] != "play" or state["turn"] != seat:
        raise Illegal("It isn't your turn.")
    cards = action.get("cards")
    claim = action.get("claim")
    hand = state["hands"][seat]
    if not isinstance(cards, list) or not cards or not all(isinstance(c, str) for c in cards):
        raise Illegal("Pick the cards to play.")
    if len(set(cards)) != len(cards) or any(c not in hand for c in cards):
        raise Illegal("That card isn't in your hand.")
    if claim is None:
        claim = len(cards)
    most_claim = max_claim(state)
    if type(claim) is not int or not 1 <= claim <= most_claim:
        raise Illegal(f"Claim 1 to {most_claim} cards.")
    sneaked = len(cards) - claim
    if sneaked < 0:
        raise Illegal("You can't claim more cards than you put down.")
    most = state["rules"]["sneak"]
    if sneaked > most:
        raise Illegal("Sneaking is off at this table." if not most else
                      f"You can sneak {most} extra card{'' if most == 1 else 's'} at most.")
    # What the table saw of the hand and the pile, before and after.
    hand_before, pile_before = seen(len(hand)), seen(len(state["pile"]), "pile")
    for c in cards:
        hand.remove(c)
    state["pile"] += cards
    rank = rank_at(state["rank"])
    state["plays"] += 1
    sizes = {"hand": [hand_before, seen(len(hand))], "pile": [pile_before, seen(len(state["pile"]), "pile")]}
    state["last"] = {"seat": seat, "rank": rank, "claim": claim, "cards": list(cards), "out": not hand,
                     "sizes": sizes, "no": state["plays"]}
    log.event(state, "play", seat=seat, rank=rank, claim=claim, out=not hand, sizes=sizes, no=state["plays"])
    log.event(state, "mine", to=[seat], seat=seat, cards=list(cards), claim=claim)
    # Everyone else may call it, for a few seconds. (Not a sealed pick: a
    # call is a shout, and the first one settles it. Letting the time run
    # out is letting it go, never a missed turn.)
    state["phase"] = "call"
    state["window"] = {"no": state["plays"], "seats": [s for s in range(state["n"]) if s != seat], "passed": [],
                       "closes": now + state["rules"]["window"]}
  • A claim is 1 to 4 cards per deck (up to 8 with two). The sneak is len(cards) − claim, up to the table’s limit (0 to 3; standard 2).
  • The play records the sizes the table will see, already turned into ranges (below): sizes = {hand: [before, after], pile: [before, after]}, in the public log line and in last.
  • The cards are held in last["cards"], and only the player gets a private mine log line with them until the play is called.

The call window#

After every play the phase becomes call, for the table’s seconds (4, 6 or 10; standard 6). Every other seat may call (shout “Bullshit!”) or let it go, and the first shout settles it. If everyone lets it go or time runs out, the play stands and the next player plays the next rank.

tables/bullshit/rules.pycall · lines 200–214
def call(state, seat, action, now):
    """Shout "Bullshit!": the last play is turned up."""
    if _stale(state, action):
        raise Illegal("Too late: that play was already settled.")
    if state["phase"] in ("reveal", "give") and state["last"]["seat"] != seat:
        # (A shout sent twice is told it's already done, not that someone beat you to it.)
        raise Illegal("You've called it." if state["last"].get("caller") == seat else "Someone beat you to it.")
    if state["phase"] != "call":
        raise Illegal("There's nothing to call right now.")
    if seat == state["last"]["seat"]:
        raise Illegal("You can't call your own play.")
    if seat in state["window"]["passed"]:
        raise Illegal("You let that one go.")
    state["window"] = None
    _showdown(state, seat, now)

A shout or a let-it-go carries the play’s number (no), so a late one is refused (“Too late: that play was already settled”) instead of landing on the next play. A call isn’t a sealed pick (commit): it’s a shout, and the first one wins.

The showdown#

tables/bullshit/rules.py_showdown · lines 255–296
def _showdown(state: dict, caller: int, now: float) -> None:
    """Turn the last play up. A lie (a card not the rank, or anything sneaked
    under it) and the player takes the pile, and if they sneaked, the caller
    hands them cards of the caller's choosing; the truth and the caller takes
    the pile."""
    last = state["last"]
    seat, rank, cards = last["seat"], last["rank"], last["cards"]
    sneaked = len(cards) - last["claim"]
    truth = sneaked == 0 and all(c[0] == rank for c in cards)
    loser = caller if truth else seat
    t = state["tally"]
    t["calls"][caller] += 1
    if not truth:
        t["right"][caller] += 1
        t["lies"][seat] += 1
    if sneaked:
        t["caught"][seat] += 1
    pile = state["pile"]
    took = seen(len(pile), "pile")
    state["hands"][loser] += pile
    t["pickups"][loser] += 1
    state["pile"] = []
    # The caller keeps one card at least (a call never puts anyone out).
    give = max(0, min(owed(sneaked), len(state["hands"][caller]) - 1))
    log.event(state, "called", caller=caller, seat=seat, rank=rank, claim=last["claim"], cards=list(cards),
              sneaked=sneaked, truth=truth, loser=loser, took=took, given=give, no=last["no"])
    last["shown"] = True
    last["caller"] = caller
    beats = [
        {"cue": "shout", "ms": REVEAL_MS["shout"], "say": "Bullshit!", "caller": caller},
        {"cue": "flip", "ms": REVEAL_MS["flip"], "say": f"The play is turned up: {', '.join(deck.name(c) for c in cards)}.",
         "cards": list(cards), "claim": last["claim"], "rank": rank},
        {"cue": "verdict", "ms": REVEAL_MS["verdict"],
         "say": "It was the truth." if truth else ("Caught sneaking!" if sneaked else "It was a lie."),
         "truth": truth, "sneaked": sneaked},
        {"cue": "take", "ms": REVEAL_MS["take"], "say": f"The pile goes to the {'caller' if truth else 'player'}.",
         "loser": loser, "took": took, "given": give},
    ]
    ends = reveal.play(state, beats, now)
    state["phase"], state["until"] = "reveal", ends
    state["_after"] = "win" if (truth and last["out"]) else "next"
    state["_give"] = give

The play is turned up. It’s the truth only if nothing was sneaked and every card is the rank, so any sneak makes it a lie. A liar takes the whole pile; if the play was true, the caller does. Only the last play is shown; the rest of the pile goes into the hand unseen. If the lie had a sneak, the caller also hands the sneaker a card of the caller’s choosing:

tables/bullshit/rules.pyowed · lines 248–252
def owed(sneaked: int) -> int:
    """Cards a caller hands a caught sneaker, before keeping one back."""
    if not sneaked:
        return 0
    return PENALTY * sneaked if PENALTY_MODE == "per" else PENALTY

The penalty is one card however many were sneaked (PENALTY = 1, mode flat), and a caller always keeps one card. These are constants, not settings, chosen by simulation. The code also supports “a card per card sneaked” for the sim’s sweeps.

The server times the reveal in four beats, which every browser plays at the same moment (reveals):

BeatMilliseconds
shout650
flip1100
verdict1000
take900
Live from tables/bullshit/rules.py, REVEAL_MS

Handing over#

After a caught sneak, the phase is give for 12 seconds while the caller picks a card. If time runs out, least_useful picks it. Only the two of them see which card; everyone sees that one changed hands.

tables/bullshit/rules.pyleast_useful · lines 325–331
def least_useful(hand: list[str], first: int, players: int, k: int) -> list[str]:
    """The k cards a player would most like rid of: the ranks they'll be
    asked for last (bluffs waiting to happen). `first`: the rank index of
    their next turn."""
    def wait(card):
        return next((m for m in range(len(RANKS)) if rank_at(first + m * players) == card[0]), len(RANKS))
    return sorted(hand, key=lambda c: (-wait(c), _order(c)))[:k]

wait(card) is how many laps of the ranks until the caller is next asked for that rank. The card asked for last is the least useful, because it’s the bluff needed last. Bots hand over the same way.

Going out#

You win by getting rid of every card, but the last card has to survive the call. Everyone can see a play is the last (out in the log, “their last!” on screen). If nobody calls, or the play was true, you win, even when called (_after = "win"). If it was a lie, you take the pile and play on, so you can’t bluff or sneak your way out. The game stops at the first one out; docs/bullshit.md asks David whether it should run on for second place.

Everyone else is placed by cards left, fewest first, ties sharing (result). As a safety net, a game reaching 500 plays ends with fewest cards winning; no measured bot game came near it.

State#

Key Meaning
hands, pile, decks, dealer Every hand, the pile (public as a size only), one or two decks, the dealer.
phase, turn, rank, plays play, call, reveal, give, over; whose turn; the rank index (rank = RANKS[rank % 13]); plays made.
last The last play {seat, rank, claim, cards, out, sizes, no}, with shown and caller once called.
window, give, until The call window {no, seats, passed, closes}; the hand-over {caller, seat, count, closes}; when the reveal ends.
personas Each seat’s bot style, fixed by the seed.
tally Public counts per seat: caught, calls, right, pickups, lies.
sneaks Cards slipped by uncaught, per seat. Secret until the game is over.

Turn flow and timers#

Play cards down, a claim, a sneak Call window a clock; the first shout wins It stands or the player wins, if it was their last Reveal shout, flip, verdict, take Hand over only if a sneak was caught The next player calls the next rank all let it go, or time is up first "Bullshit!" no sneak after the card is handed over
A play from the player's turn to the next. The call window, the reveal and the caught sneaker's hand-over each run on the clock.

deciding() names the seat to move. In call it names every seat that hasn’t let the play go, each with its own think-time kind, so who shouts first varies:

KindSeconds
play1.4
give1.6
listen01.1
listen11.6
listen22.2
listen32.9
Live from tables/bullshit/rules.py, bot_think

A seat’s listening speed is listen{(seat*7 + play*3) % 4}, so it changes every play and no bot is always the quick one. The window, the reveal and the hand-over are deadlines in the state: tick handles the due ones, and deadline tells the service when to wake. A person’s own turn waits for them. The fallback move, if a bot’s choice is refused, is always legal: let it go, hand over the least useful card, or play every card of the rank (else one card).

Your move#

Your first claim in a game gets a card from the room kit’s YourMove (Your move at a table), Make your claim, ringing Claim if your cards are already picked, else your hand. So does your first hand-over (Hand over some cards). A call gets no card: its window is only seconds long, and the dock already says it in its own color.

Secrets#

The state holds every hand and the pile’s cards, and view(state, seat) is the only way out. A seat sees what a real table shows: every claim, and how big each hand and the pile look.

Own seat Other seats Spectator
Hand Its cards, sorted by rank; its size exactly Only how big it looks (sizes): exact up to 8, else a range Same as another seat
The pile How big it looks: exact up to 3, else a range Same Same
The last play’s cards Your own, as you put them (mine) Hidden until called Hidden until called
How many were sneaked Yours, while it’s yours Hidden until called Hidden until called
A called play Turned up for everyone, sneaked cards marked Same Same
A card handed over The card, if you gave or got it That one card changed hands, not which The same
Uncaught sneaks (sneaks) After the game After the game After the game
Tallies (tally) Caught, called, right, pickups: public Same Same

At game over the view adds left (real counts), sneaks and the last play’s cards. The contract test’s scramble checks the secrecy. It re-deals the other hands, the pile and a face-down play’s cards, and moves a card between someone else’s face-down play and their hand, so the amount sneaked changes where no visible count shows it. Every view and every bot level’s choice must come out unchanged:

tests/test_bullshit.pyBullshitContract · lines 518–556
class BullshitContract(TableGameContract, SimpleTestCase):
    """Bullshit meets what every table game must (tests/table_contract.py)."""

    game = game

    def scramble(self, state, keep, rng):
        # Everything `keep` can't see: the other hands, the pile, and the last
        # play while it's face down (but not cards anyone has seen turned up).
        # Deal them out again, each its own size; and, if the last play is
        # someone else's and face down, how much was sneaked under it.
        last = state["last"]
        down = last is not None and not last.get("shown") and state["phase"] != "over"
        top = len(last["cards"]) if down else 0  # the last play, on top of the pile
        mine = down and last["seat"] == keep  # keep's own play: keep knows it
        others = [s for s in range(state["n"]) if s != keep]
        rest = state["pile"][:len(state["pile"]) - top]
        pool = [c for s in others for c in state["hands"][s]] + rest + (last["cards"] if down and not mine else [])
        rng.shuffle(pool)
        for s in others:
            size = len(state["hands"][s])
            state["hands"][s], pool = pool[:size], pool[size:]
        rest, pool = pool[:len(rest)], pool[len(rest):]
        if down and not mine:
            last["cards"] = pool
            # Move a card between the play and its player's hand: a sneak of
            # another size, which nobody else could tell, as long as the
            # hand and the pile still look the size they did.
            hand = state["hands"][last["seat"]]
            sneaked = len(last["cards"]) - last["claim"]
            pile = len(rest) + len(last["cards"])
            unseen = lambda dh: (rules.seen(len(hand)) == rules.seen(len(hand) + dh)  # noqa: E731
                                 and rules.seen(pile, "pile") == rules.seen(pile - dh, "pile"))
            if sneaked and hand and rng.random() < 0.5 and unseen(1):
                hand.append(last["cards"].pop())
            elif len(hand) > 1 and sneaked < 3 and unseen(-1):
                last["cards"].append(hand.pop())
        state["pile"] = rest + (list(last["cards"]) if down else [])
        if state["phase"] != "over":
            state["sneaks"] = [rng.randrange(4) for _ in state["sneaks"]]

How big a hand looks#

You can’t count a hand of more than eight cards across a table, so the server turns every size into what you’d see. seen(n) is the only function that does it, for hands and, more roughly, the pile:

tables/bullshit/rules.pyseen · lines 82–91
def seen(n: int, kind: str = "hand") -> list:
    """How `n` cards look from across the table: [lo, hi] (equal when it can
    be counted; hi None for "or more"). Pure and public."""
    exact, buckets = (HAND_EXACT, HAND_BUCKETS) if kind == "hand" else (PILE_EXACT, PILE_BUCKETS)
    if n <= exact:
        return [n, n]
    for lo, hi in buckets:
        if hi is None or n <= hi:
            return [lo, hi]
    raise AssertionError(n)

A hand is exact up to 8 cards, then falls in a bucket (HAND_BUCKETS, the last open: “30+”). The pile is exact up to 3, then uses PILE_BUCKETS. The “looks like” columns are seen() run live:

Cards in a hand Looks like Cards in the pile Looks like
0 to 8 exactly that 0 to 3 exactly that
9 or 10 9–10 4 to 6 4–6
11 to 13 11–13 7 to 10 7–10
14 to 17 14–17 11 to 15 11–15
18 to 22 18–22 16 to 22 16–22
23 to 29 23–29 23 to 32 23–32
30 or more 30+ 33 or more 33+

You always know your own hand exactly. The claim panel shows how the last play changed the others: “Ada’s hand 9–10 → 7 · pile 4–6 → 7–10”.

The sneak#

A sneak looks and times exactly like any play. Its only public effect is arithmetic: the hand goes from seen(n) to seen(n − claim − sneak) and the pile from seen(p) to seen(p + claim + sneak). So everyone asks: could that change have been the claim alone? The bots, the tests and the screen’s hint all answer with one function:

tables/bullshit/rules.pylooks_clean · lines 94–103
def looks_clean(before: list, after: list, claim: int, kind: str = "hand") -> bool:
    """Whether a size seen as `before`, then `after`, could have changed by
    just the claim (the hand down, the pile up). False: anyone watching
    could tell more went down than was claimed."""
    lo, hi = before
    for n in range(lo, (hi if hi is not None else lo + 40) + 1):
        m = n - claim if kind == "hand" else n + claim
        if m >= 0 and seen(m, kind) == after:
            return True
    return False

From a hand small enough to count, a sneak always shows. From a big one it hides in the range, unless it pushes the hand or the pile over a line. These rows are looks_clean run live, for the hand only; the pile is checked the same way and can show a sneak the hand hides.

Hand Claims Sneaks The table sees The sneak
7 3 0 7 → 4 hides (no sneak, nothing to show)
7 3 1 7 → 3 shows
12 2 1 11–13 → 9–10 hides
11 2 2 11–13 → 7 shows: 11–13 can’t become 7 by two
16 3 2 14–17 → 11–13 hides
25 2 2 23–29 → 18–22 hides

There are no random tells, such as a hand lingering. An early version rolled one every play: in simulation, a player sneaking every play beat the bots until they counted each player’s tells, and David found it less real than watching hands. Counting hands makes sneaking a skill: you sneak when your hand is big enough to hide it.

Caught sneaking: a worked play#

Four players, sneaking up to 2, aces called. Seat 0 holds 10 cards and plays three, claiming two aces with a 5 slipped under. The pile has 6. Run through the real rules:

Seat 0 (the sneaker) Seat 1 (the caller) Pile
At the start 10 9 6
The play (3 cards down) 7 9 9
Seat 1 calls: the sneaker takes the pile 16 9 0
Seat 1 hands over 1 card 17 8 0

The table saw the hand go 9–10 → 7 and the pile 4–6 → 7–10. Ten looks like “9–10”, and “9–10 → 7” is what two cards from nine would look like, so the sneak hid: seat 1 caught it on a hunch. Seat 1 plays next, rank 2.

Bots#

A bot decides from its own rules.view(state, seat), through bots.choose, so it can’t peek. Each has a style, fixed by the seed and shown on its seat, and a level, how well it plays that style.

Styles#

sneakmostpadweightfloorreckless
cautious0.310.050.850.620
bold0.820.0810.550
caller0.310.11.30.50
honest000.110.550
careful0.620.110.550
sometimes0.2510.110.551
always130.110.551
Live from tables/bullshit/bots.py, STYLES

sneak is how often it slips cards under, most how many, pad how often it adds a wrong card to a true claim, weight how much a liar picking up the pile matters against picking it up itself (higher calls more readily), floor how sure it must be to call, and reckless how often it sneaks where the counts might show.

The site’s three styles are cautious (lies when it must, sneaks now and then where it won’t show, calls when fairly sure), bold (pads, sneaks often, sometimes two) and caller (plays straight, calls on a hunch). Four more exist only for the simulation: honest never sneaks, careful sneaks only where it won’t show, sometimes sneaks now and then without minding, and always sneaks all it may, every play.

Playing#

A bot plays its cards of the rank, up to the claim limit, padding a true claim with a wrong card at its pad rate. Without the rank it bluffs one card, now and then two: easy parts with any card, the others keep the ranks they’ll be asked for soonest (soon). Then it may sneak (_sneaks), never its last card (a play that empties a hand is always called) and never past the style’s, the table’s or its spare cards’ limit. Unless it’s easy or reckless, it sneaks only what shows() says won’t show in the hand’s or the pile’s counts.

Calling#

Anyone may shout, so a bot works out how badly it wants the play called (0 to 1) and leaves some of that to the others. The table as a whole then calls about as often as one bot would, however many listen:

tables/bullshit/bots.py_calls · lines 149–161
def _calls(v: dict, level: str, style: dict, rng) -> bool:
    """Whether to shout. A bot works out how badly it wants the play called
    (0 to 1), then, since anyone may shout, leaves some of it to the others:
    the table as a whole calls about as often as one bot wants it called,
    however many are listening."""
    last = v["last"]
    if last["out"]:
        return level != "easy" or rng.random() < 0.85
    if v["rules"]["sneak"] and _noticed(v, level, rng):
        return True
    want = _want(v, level, style, rng)
    listening = max(1, v["n"] - 1)
    return rng.random() < 1 - (1 - want) ** (1 / listening)

Every bot calls a play that empties a hand (if true, the game’s over anyway), except that easy does so 85 percent of the time (known issues). A play the counts show was short (a counted hand dropping more than claimed, or a rough count or the pile crossing a line) is noticed with a chance by level; hard always sees it:

LevelCounted handRough handPile
easy0.40.10.1
medium0.80.40.3
hard111
Live from tables/bullshit/bots.py, NOTICE
Easy Medium Hard
Weighs the claim By its size alone (bigger claim, more suspect) Counts the rank in its own hand; a fixed 8% chance of a sneak Counts the rank; works out the chance of a sneak by Bayes (updating a probability from evidence)
Hands over a card The first in its hand The cards it’ll be asked for last The same
Sneaks Without thinking whether it shows Only where it won’t show The same

Hard’s reading works like this:

  • _p_sneak: of the true hand sizes consistent with the rough “before”, how many give the rough “after” with no sneak, and how many with one or two? Each is weighted by a prior: a play in five is sneaky, more from someone caught before (0.2 + 0.06 × caught, capped at a half).
  • _p_rank_lie: how likely is it the claimant holds what they claimed, given the copies this seat hasn’t seen and how people claim (with none of the rank, one card, now and then two; with some, all of them, now and then padded)?

Together they give the chance the play is a lie, and the bot calls when that beats how sure it needs to be. That bar rises with the pile it would take if wrong, and falls when the claimant is nearest to going out. An impossible claim (it holds more of the rank than can be left) is called outright.

Measured strength#

The committed anchors come from tools/ratings/anchors.py at three seats, 300 games a pairing: medium beat easy 83.8% (+286 Elo) and hard beat medium 56.2% (+43). A 150-game rerun (October 2026) gave 83.3% and 55.0%. Hard is a slightly better reader, but luck dominates.

Tuning the sneak by simulation#

tables/bullshit/sim.py plays bot-only games without clocks (after each play, the others answer in think-time order). It prints the report in docs/bullshit.md, and … sim sweep tries every penalty against every sneaking limit. David’s goals:

  • sneaking pays a little for a careful sneaker against casual readers, doesn’t beat sharp ones, and never pays for someone who sneaks without looking;
  • the leader can be caught;
  • a game lasts about ten minutes.

Hard bots stand in for sharp readers (they always notice) and medium bots for casual ones. A play is reckoned at about 7 seconds. The tables below were rerun at 150 games a cell (October 2026); the doc’s use 800 and 1,000, so these are noisier.

Length and balance (the site’s bots, medium and hard, in their three styles; standard settings):

Seats Plays (mean) p90 ≈ minutes Plays called Calls right First down to 3 didn’t win
3 54.7 100 6.4 31% 67% 39%
4 60.5 109 7.1 38% 62% 55%
6 64.1 94 7.5 38% 56% 81%
8 71.9 91 8.4 22% 51% 75%
10 81.1 102 9.5 22% 51% 86%

None reached the 500-play cap. The leader can be caught: the first down to three cards loses 39% of three-seat games and three in four at six or more.

Is sneaking worth it? One sneaker among honest players, with its wins as a share of its fair share (1.00 is no better or worse than playing straight):

Readers Seats Careful Sometimes Always
Casual (medium) 4 1.33 (caught 13%) 0.13 (55%) 0.03 (48%)
Casual (medium) 6 1.04 (11%) 0.32 (81%) 0.00 (64%)
Sharp (hard) 4 0.99 (37%) 0.35 (69%) 0.00 (70%)
Sharp (hard) 6 1.04 (23%) 0.28 (84%) 0.00 (79%)

A careful sneaker beats casual readers by a third at four seats and breaks even against sharp ones. One who sneaks without looking is ruined. That’s the shape David asked for.

The knobs. A careful sneaker at four seats, against casual / sharp readers, for each penalty and sneak limit (200 games a cell, so a cell is easily 0.15 off either way; the doc’s use 600):

Caught sneaking: the caller hands over Up to 1 Up to 2 Up to 3
nothing (just the pile) 1.16 / 1.26 1.40 / 0.92 1.14 / 1.16
1 card 1.16 / 0.72 1.22 / 0.74 1.38 / 0.80
2 cards 0.96 / 0.86 1.08 / 0.68 1.28 / 0.84
1 for every card sneaked 1.34 / 0.96 1.42 / 0.88 1.08 / 0.92

The every-play sneaker lost everything (0.00 to 0.04) in every cell. As in the doc, with nothing to hand over sneaking costs nothing against sharp readers (0.9 to 1.3); with a penalty, a careful sneaker gains against casual readers and loses a little to sharp ones. The chosen cell reads 1.22 / 0.74 here, against the doc’s 1.27 / 0.98 from three times the games.

What was chosen: one card of the caller’s choosing, up to 2 sneaked, 6 seconds to call. With no hand-over, sneaking would cost nothing against sharp readers. A card per card sneaked, or two cards, change little for a careful sneaker but make one caught sneak a bigger swing, and so does allowing 3. Six seconds is long enough to glance at a hand on a phone.

Rating#

A game is a field of 3 to 10 sides, ranked by cards left, ties sharing, and rated as any table game. Each seat meets every other once at 2/k for k seats, so a field is worth 2 − 2/k games: five seats is one and a fifth (fields). The anchors:

LevelRating
easy1210
medium1500
hard1540
Live from ratings/tables.py, ANCHORS.bullshit

Medium is 1500, easy 1210 (the measured gap, a little under 300) and hard 1540 (reading counts is a modest edge).

Worked example. A settled 1500 ± 70 player at a table of five with an easy, two medium and a hard bot, by where they finish (the bots in order of strength around them):

You finish Change
1st (out first) +18.2
2nd (behind the hard bot) +7.7
3rd −2.9
4th −13.4
5th (last) −23.9

A tie counts as a draw in that meeting: level with a medium bot for second, the same player gains +2.4. A game the host ended early is never kept, a seat a bot covered isn’t rated, and guests count as 1500 ± 350 and never move.

The interface#

app.js calls the room’s start() with the three settings, devices: 'any' (phone first, window second; ADR 0016), and a visible clock (deadline) for the call window and the hand-over:

games/static/games/bullshit/app.jslines 1–48
// Bullshit (BS, Cheat, I Doubt It) with David's twist: sneak extra cards
// under your play, and watch their hands shrink (docs/bullshit.md). The lobby and
// waiting room are the room kit's (../room/kit/), the cards and table styles
// the card room's (../cards/kit/); this game brings its rules and its table.

import { start } from '../room/kit/room.js';
import { look } from '../cards/kit/looks.js';
import { rules } from './rules.js';
import { play, bullshitOptions, shareText, progress, deadline } from './table.js';

start({
  game: 'bullshit',
  title: 'Bullshit',
  kicker: 'Card game · 3 to 10 players',
  blurb: 'Claim your cards face down, slip a few extra under the pile if you dare, and shout when someone’s lying.',
  min: 3,
  max: 10,
  defaultBots: 3,
  fill: true,
  rules,
  play,
  look,
  // A party game: played round a room, each on a phone (docs/adr/0016).
  devices: 'any',
  spectate: true,
  firstRun: true,
  cues: true,
  progress,
  shareText,
  deadline,
  shortcuts: 'Single keys, shown on the buttons: C to claim, S to sneak, B for Bullshit!, G to let a play go, O to sort.',
  options: () => bullshitOptions(),
  // Every engine setting (tables/bullshit/rules.py options()), in plain
  // words; the standard ones were chosen by simulation (docs/bullshit.md).
  settings: [{
    key: 'sneak', label: 'Sneaking', value: 2,
    hint: 'The most extra cards you may slip under a play.',
    choices: [[0, 'Off'], [1, '1'], [2, '2'], [3, '3']],
  }, {
    key: 'window', label: 'Time to call', value: 6,
    hint: 'Seconds to shout after each play.',
    choices: [[4, '4s'], [6, '6s'], [10, '10s']],
  }, {
    key: 'bot_pace', label: 'Bot speed', value: 'brisk',
    hint: 'Relaxed is easier to follow.',
    choices: [['brisk', 'Brisk'], ['relaxed', 'Relaxed']],
  }],
});

The table#

BullshitTable (games/static/games/bullshit/table.js) draws, top to bottom:

  • a bar;
  • a strip of seats, from the player on your left round to your right (ADR 0053); on a phone held upright, five or more go in a horseshoe of two rows round the pile instead (horseshoe), up from your left, across the top and down to your right. Each has how big its hand looks (“7” or “9–10”), a bubble for what it’s doing (“Bullshit!”, “Out!”; 12px even on a phone on its side) and, for a bot, its style;
  • the middle: the rank called, the pile (“Pile · 7–10 cards”), the claim (“Ada claims 2 kings”), the sizes line, a hint when a claim can’t be true, and the clock, a bar emptying in a fixed space and said aloud at two seconds;
  • your hand;
  • the dock: a status line, two fixed move slots (the main one on the right) and the sneak switch. In a call window they read “Let it go” and a big “Bullshit!”; on your turn, “Claim 2 kings” (“+1” with a sneak).

Playing a card, and sneaking#

Your hand is the card room’s Hand, sorted in calling order (by rank, the shared sortBy, aces high). On your turn the helpers (below) light and pick the cards of the rank, and you pick the rest. To sneak, pick extra cards and set the Sneak switch (key S) to how many go under: with 4 picked and Sneak 1, the claim is 3. Cards of the rank go first, so they’re the ones claimed. While you choose, the status line says whether the counts will show it, judging your hand’s count only (known issues).

Calling#

On someone else’s play, “Bullshit!” (B) shouts and “Let it go” (G) lets it stand. Every screen plays the server’s reveal together: the shout, the cards turned one by one, the verdict (“Truth!”, “Lie!”, “Caught sneaking!”), and the pile sweeping to whoever takes it. The shout says who called it on whom (“Ada called it on you”); taking the pile is done to you, so it’s an announcement on your screen once the sweep lands (“You take the pile: 14 cards come to your hand.”). Counts hold still until the sweep lands. The pile’s size is the house’s Figure (ADR 0054) and lights up when it grows. Every state change is in a cue’s end, so reduced motion and a late arrival lose nothing (reveals). After a caught sneak, the caller picks a card to hand over, with least_useful’s choice already picked.

Assists and helpers#

All are on by default, each with its own row in Options (uo:cards:bullshit:assist-<key>):

games/static/games/bullshit/table.jsHELPERS · lines 80–86
// The helpers, on by default (docs/design-language.md, "Options"), each with
// its own row to turn it off; and Banners, a preference (Playing).
const HELPERS = [
  { key: 'light', label: 'Light up the rank called', hint: 'Lifts the cards in your hand that you can claim truthfully.' },
  { key: 'pick', label: 'Pick them for me', hint: 'When your turn comes, the cards of the rank called are picked, ready to claim.' },
  { key: 'count', label: 'Say when a claim can’t be true', hint: 'When someone claims more of a rank than there can be (given the ones you hold), or a hand you can count drops by more than was claimed.' },
];
  • Light up the rank called lifts those cards.
  • Pick them for me selects up to the claim limit of the rank at the start of your turn, and the card you’d be asked for last when you hand one over.
  • Say when a claim can’t be true says “Can’t be true: you hold 3 of the 4 kings” when your cards plus the claim exceed the copies. With sneaking on, it also flags a countable hand that dropped more than claimed (“9 → 5”). A rough count crossing a line is yours to spot.

The result sheet, and phones#

The game ends on the house’s result sheet (state over) after the last call plays out. Its columns are place, player, cards left, Sneaked (uncaught sneaks, revealed now), Caught and Calls (right of called).

Since it’s played round a room on phones, 390×844 is a design size. Below 640 px the bar’s tools fold into “⋯”, the seat strip gets one cell per seat (two rows past four), the rank and pile sit side by side, and the dock is two buttons at least 44 px high (Bullshit! is 52). A move too long for its slot (“Pick cards to claim” at 320 px) wraps to two lines inside that height, never cut short: the slots’ thin padding above and below leaves room, so the dock doesn’t grow. At 360 px the sneak switch drops its label.

Its look#

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

Family Cards, and a party game: the room kit and the card room, checked on phones as closely as on a computer. Everything else is the card room’s (Card games).
Palette None of its own: the table style the player picks in Options (Clubhouse to begin with).
Faces The table style’s
Corners and edges The table style’s
Board A strip of seat tiles across the top, from the player on your left round to your right (on a phone held upright, five or more in a horseshoe round the pile), the pile in the middle, your hand along the bottom.
Emblem and art Its box on the shelf
Voice “Bullshit!”, the call that turns a play up; “Playing…” for whose turn
Payoff The room’s win celebration when your place is first, else best for a rating high, behind the result sheet.
The glance Now: the rank playing, biggest, and whose turn (“Playing…” in ink, ringed in the turn color). The countdown: the pile, a Figure that lights up as it grows. The score: each seat’s cards, bold.
Exceptions The card room’s

Testing#

tests/test_bullshit.py covers:

  • dealing and playing: every card dealt, two decks from seven, claims, the window closing itself;
  • calling and sneaking: the first call settles it, a late shout is refused, a called sneak is a lie even if every card is the rank, the hand-over and its default;
  • sizes and going out: buckets cover every size, a sneak shows from a small hand and hides in a big one, the last card must survive the call, the 500-play net;
  • secrecy: a seat sees only rough counts, a play stays face down until called;
  • bots: they bluff, call the impossible and the last card, never sneak their last cards, and thirty whole games from three to ten seats lose or make no card.

BullshitContract adds the shared secrecy and bot contract, and check:layout and check:details cover the play screen and the result sheet.

Run uv run python manage.py test tests.test_bullshit, and uv run python -m tables.bullshit.sim -g 150 to re-measure the report (testing).

Known limitations and known issues#

  • Easy bots don’t always call a last play. docs/bullshit.md and the bots module’s docstring say “Bots always call a last play”, but the code says level != "easy" or rng.random() < 0.85. The test covers only medium. With two easy bots listening, a lie to go out goes uncalled about 2% of the time; with a human who doesn’t call, more.
  • The screen’s “won’t show” hint ignores the pile. It judges only your hand’s count (looksClean on the hand), while the bots (shows) and a hard bot’s reading also check the pile. So the hint can say “it won’t show” when the pile would give it away.
  • The client’s copy of seen and looks_clean isn’t pinned by a Node test. table.js mirrors the buckets for its two hints, while the sizes it draws come from the server. A bucket change must be made in both places, and only the server side is tested.
  • How to play has its own copy of the penalty. rules.js hardcodes PENALTY = 1, though the view carries penalty.
  • Open questions for David (in docs/bullshit.md): playing on for second place, the starting rank and first player, who leads after a call, the exact/rough boundaries (8 and 3), and how often bots call (about two plays in five at four to six seats).
  • No away. A person who walks away holds up the table only on their own turn, since the window and hand-over close themselves; then a bot can be handed their seat.

Changing it#

To change Needs
A rule rules.py, docs/bullshit.md (they must agree), rules.js. If places could change, bump the ratings VERSION.
The buckets HAND_BUCKETS or PILE_BUCKETS in rules.py and BUCKETS in table.js; rerun the sim; SizesTests; the doc’s tables.
The penalty or the sneak limit PENALTY, PENALTY_MODE, SNEAKS, DEFAULTS in rules.py; rerun sim sweep; update rules.js’s copy and the doc.
A bot bots.py; rerun the sim and tools/ratings/anchors.py 300 bullshit; update ANCHORS and bump VERSION.
What a seat sees view() and scramble(). A new key must not carry a real count, the pile’s cards or the sneak.
The look bullshit.css and the table; check:tokens, check:layout, check:details.

Then reread this chapter against the change and npm run guide:stamp bullshit.