Technical guide

Board and party games

Vibe Check

One clue, one dial, and a team trying to read your mind.

Checked against the code on 2026-10-30. Play Vibe Check

On this page
  1. At a glance
  2. The rules in code
    1. Players and settings
    2. The state
    3. Scoring
    4. The dial and the lock
    5. The deck
    6. The contract
  3. Your move
  4. Secrets
  5. Bots
  6. Rating
  7. The interface
    1. The room
    2. The table, in fixed places
  8. Its look
  9. Content
  10. Testing
  11. Changing it
  12. Known issues

Vibe Check is our take on Wavelength. Each round a card puts two ends on a dial, like Squishy and Rock hard, and a target hides somewhere between them. One player, the psychic, sees it and types one clue; their team turns one shared needle to where they think the clue sits, live on every screen, and locks it in. In the team game the other team then calls left or right of the needle. First to 10 wins; in co-op the table plays seven cards together for the best score it can.

It’s for two to twelve people in one room or on a call, each on their own device, with no bots (ADR 0031). It’s a table game on the table service, built like Bowl. What’s its own is the live dial and the lock teammates can stop (ADR 0056).

At a glance#

Part Where
Rules: rounds, the dial, the lock, the call, scoring, view tables/vibe_check/rules.py
The deck: 417 spectrums tables/vibe_check/content/spectrums.toml, built by tools/vibe-check/deck.py from tools/vibe-check/spectrums.txt
The tuner (the dial and the faceplate) games/static/games/vibe-check/tuner.js
The table: the score, earlier rounds, Your move, the waiting room’s teams, the result games/static/games/vibe-check/table.js
Room kit entry; How to play games/static/games/vibe-check/app.js, games/static/games/vibe-check/rules.js
Look games/static/games/vibe-check/vibe-check.css (tokens, fonts), games/static/games/vibe-check/table.css
Pages games/templates/games/vibe-check.html, games/templates/vibe-check/page.html, the box games/templates/games/thumbs/vibe-check.html
Tests; the play check tests/test_vibe_check.py, tools/vibe-check/play.mjs
Design notes docs/vibe-check.md

The rules in code#

Players and settings#

min_players, max_players = 2, 12, bots = False, chat = False (emotes only, ADR 0052), and teams = 2 with team_names Sunset and Tide: the service keeps who’s on which team and deals them to new as _teams and _team_names, as for Bowl. One setting:

tables/vibe_check/rules.pyoptions · lines 86–88
def options(raw: dict | None) -> dict:
    """A table's settings, from whatever the host asked for."""
    return {"mode": moves.choice(raw, "mode", MODES, DEFAULTS["mode"])}

Two or three players always play co-op:

tables/vibe_check/rules.pymode_for · lines 91–93
def mode_for(players: int, asked: str) -> str:
    """Two or three always play together; four or more, as the host says."""
    return "coop" if players < COOP_FROM else asked

The state#

Key What
phase clue → tune → call (teams only) → reveal → the next round’s clue … → over
mode, team, names teams or coop; each seat’s team (all 0 in co-op); the teams’ names
score, first, up, next Both teams’ points (the second team starts on 1); who went first; whose turn; each team’s next psychic
round, psychic, card, swapped, target, clue This round’s psychic, card and target (the one secret), whether they’ve taken another card, and the clue
dial, call, lock The needle (at, 0 to 1000, and by, who last turned it); the other team’s call; a pending lock (by, until, what)
rounds, catchup Every round as played (card, clue, target, needle, points, the call); whether this team goes again
cards_left, cards_total Co-op’s cards
before, used Cards seen at this table before (carried in _carry) and this game
gone, finish Seats whose players left; how it ended

Scoring#

The target’s bands, out from its middle:

tables/vibe_check/rules.pypoints_for · lines 118–121
def points_for(at: int, target: int) -> int:
    """What a needle at `at` scores on a target at `target`."""
    off = abs(at - target)
    return next((p for reach, p in BANDS if off <= reach), 0)

The reveal scores the team its band and the other team 1 for a right call, unless the needle hit the bullseye; a team that scores 4 and is still behind goes again (the box’s catch-up):

tables/vibe_check/rules.py_reveal · lines 362–395
def _reveal(state: dict, now: float) -> None:
    """The screen opens: the team scores its band, the other team its call."""
    team, at, target = state["up"], state["dial"]["at"], state["target"]
    pts = points_for(at, target)
    side = state["call"]["side"] if state["mode"] == "teams" else None
    truth = "left" if target < at else "right" if target > at else None
    # (A bullseye wins the round outright: the other team's call doesn't score.)
    call_ok = bool(side) and side == truth and pts != 4
    state["score"][team] += pts
    if call_ok:
        state["score"][1 - team] += 1
    behind = state["mode"] == "teams" and pts == 4 and state["score"][team] < state["score"][1 - team]
    record = {
        "round": state["round"], "team": team, "psychic": state["psychic"], "card": state["card"],
        "clue": state["clue"], "target": target, "at": at, "by": state["dial"]["by"], "points": pts,
        "call": side, "call_by": state["call"]["by"], "call_ok": call_ok, "truth": truth,
        "score": list(state["score"]), "catchup": behind,
    }
    state["rounds"].append(record)
    state["phase"] = "reveal"
    if state["mode"] == "coop":
        state["cards_left"] -= 1
        if pts == 4 and COOP_BONUS:
            state["cards_left"] += 1
            state["cards_total"] += 1
    log.event(state, "reveal", **{k: record[k] for k in ("round", "team", "points", "call", "call_ok", "target", "at")},
              score=list(state["score"]), catchup=behind)
    state["catchup"] = behind
    # The last reveal holds a moment before the result; any other waits for
    # the next psychic to draw (next_round), so the table can talk it over.
    last = _done(state) is not None
    state["reveal_until"] = now + REVEAL_SECS if last else None
    state["next_psychic"] = None if last else _upcoming(state)
    _bump(state)

The game ends after a round in which a team has 10 or more and the scores differ (tied, play goes on), or co-op’s last card:

tables/vibe_check/rules.py_done · lines 409–417
def _done(state: dict) -> dict | None:
    """Whether the game's over after this round: the box's first to 10 (on a
    tie at 10 or more, another round each, sudden death), or co-op's last card."""
    if state["mode"] == "coop":
        return {"why": "cards", "winner": None} if state["cards_left"] <= 0 else None
    a, b = state["score"]
    if max(a, b) >= GOAL and a != b:
        return {"why": "goal", "winner": int(b > a)}
    return None

The dial and the lock#

dial is an ordinary move (ADR 0056): anyone on the psychic’s team but the psychic, any number of times, while nothing’s being locked in. lock makes it final in 10 seconds unless a teammate sends wait; a team of one locks at once. tick makes a due lock final. A reveal stays up until the next psychic sends next (the next psychic is worked out at the reveal, without moving the rotation, and passes on if they leave; the host may send it for them); the game’s last reveal holds 7 seconds and goes on to the result by itself.

tables/vibe_check/rules.pylock · lines 320–337
def lock(state, seat, action, now):
    """Lock it in: final in LOCK_SECS unless a teammate says Wait (at once,
    with no teammate to ask)."""
    if state["phase"] not in ("tune", "call"):
        raise Illegal("There's nothing to lock in.")
    team = _deciders(state)
    if seat not in team:
        raise Illegal("That's the other team's to lock in.")
    if state["lock"]:
        return  # (a double press)
    if state["phase"] == "call" and not state["call"]["side"]:
        raise Illegal("Call left or right first.")
    what = "dial" if state["phase"] == "tune" else "call"
    log.event(state, "lock", seat=seat, what=what)
    if len(team) < 2:
        _final(state, now)
        return
    state["lock"] = {"by": seat, "until": now + LOCK_SECS, "what": what}

The deck#

Cards are drawn from the deck so that none comes back at a table until it’s used up; the cards a table has seen carry from game to game:

tables/vibe_check/rules.py_draw · lines 205–215
def _draw(state: dict) -> str:
    """A card this table hasn't seen; once it's seen them all, any this game
    hasn't (then any at all)."""
    rng = random.Random(f"{state['seed']}:card:{len(state['used'])}")
    for skip in (state["before"] + state["used"], state["used"], []):
        pool = [c["id"] for c in DECK if c["id"] not in set(skip)]
        if pool:
            pick = rng.choice(pool)
            state["used"].append(pick)
            return pick
    raise ValueError("The deck is empty.")

The contract#

As tables/engine.py asks: new, act (moves in MOVES, through tables.moves.dispatch), tick and deadline for the lock and the reveal, view, deciding (the psychic during a clue; every guesser, or every caller, while nothing’s locking), holds_up, note, round_no, result, counts, terms, idle_after (240 seconds: a clue and a dial are talked over), and bot_action and fallback for the test suite only.

Your move#

The room kit’s YourMove (Your move at a table) prompts three moments: You’re the psychic (the clue box), Tune the dial (the dial, which wears a dashed ring at the needle’s tip while it’s yours to turn) and Left or right? (the call). The Your move card at the foot of the side column wears the turn color while one of those is yours, and says what’s happening otherwise (“Ann is locking it in.”, “Team Tide is calling left or right of the needle.”). The tab’s title says “Your move” while it’s yours.

Secrets#

The target is the psychic’s alone until the reveal; a spectator never sees it early. Everything else is public: the card (the box’s psychic reads it out), the clue, the needle and who’s turning it, the call.

tables/vibe_check/rules.pyview · lines 536–559
def view(state: dict, seat) -> dict:
    phase = state["phase"]
    sees_target = phase == "reveal" or (seat is not None and seat == state["psychic"])
    rounds = [{**r, "card": card(r["card"])} for r in state["rounds"]]
    finish = state["finish"]
    return {
        "game": slug, "seq": state["seq"], "n": state["n"], "phase": phase, "mode": state["mode"],
        "round": state["round"], "team": list(state["team"]), "names": list(state["names"]),
        "score": list(state["score"]), "goal": GOAL, "first": state["first"], "up": state["up"],
        "psychic": state["psychic"], "card": card(state["card"]) if state["card"] else None,
        "target": (state["rounds"][-1]["target"] if state["rounds"] else None) if phase == "over"
        else state["target"] if sees_target else None,
        "can_swap": seat is not None and seat == state["psychic"] and phase == "clue" and not state["swapped"],
        "clue": state["clue"], "dial": dict(state["dial"]), "call": dict(state["call"]),
        "lock": dict(state["lock"]) if state["lock"] else None,
        "guessers": guessers(state) if phase in ("clue", "tune", "call") else [],
        "callers": callers(state) if phase in ("clue", "tune", "call") else [],
        "rounds": rounds, "reveal_until": state["reveal_until"], "catchup": state["catchup"],
        "next_psychic": state.get("next_psychic"),
        "cards_left": state["cards_left"], "cards_total": state["cards_total"],
        "dial_max": DIAL, "bands": [list(b) for b in BANDS], "lock_secs": LOCK_SECS,
        "gone": sorted(state["gone"]), "finish": dict(finish) if finish else None, "rated": counts(state),
        "deck": len(DECK), "events": log.recent(state, seat),
    }

Bots#

None. bot_action exists only for the test suite’s simulated people (level "sim"), who clue, turn the dial at random (they can’t see the target), call and lock from what their seat sees. A seat someone leaves is stepped aside (note): the psychic’s chair passes on, and a dial or call waiting on no one is final at the next tick.

Rating#

A two-team game played to the end rates by team, the teams the host picked kept with the result, as Bowl’s are (ratings/tables.py, Ratings). Co-op is one team, so there’s no one to rate against: it’s kept, not rated. An ended game is neither.

The interface#

The room#

games/static/games/vibe-check/app.js calls the room kit’s start() with min: 2, max: 12, bots: false, teams: true and ownSeats: the waiting room’s seats are the game’s own, two teams side by side (teamsNote), with Switch team (from the bigger team it just moves them; between even teams it asks who they swap with, since the service keeps teams within one), each team’s name to rename, the room’s Shuffle teams, and a fanned preview of the deck. The one setting, Teams (Two teams or Co-op), shows from four players.

The table, in fixed places#

The frame is the house table’s: the bar on the felt (Log L, How to play ?, Options, End game for the host), the tuner at the left as large as the height allows, and the side column at the right: the score (First to 10, both teams’ pegs on the track, each team with its players’ chips, the psychic’s pill and a live dot for who has the needle), Earlier rounds, and the Your move card pinned at its foot. On a phone: the teams’ scores under the bar, the tuner full width, who’s deciding (the team, and who has the needle), and the Your move card at the bottom. On a phone on its side, the tuner at the left, the score and the card beside it.

  • Turning the dial. A drag anywhere on the dial’s face turns the needle there (pointer capture, touch-action: none), or ← → (Shift for bigger steps) on the focused dial, or anywhere on the table while it’s yours. The page sends at most every 160 ms and where you let go; every other screen eases its needle to each place it hears.
  • The lock. The countdown is drawn from the lock’s until on the server’s clock; Wait (W) stops it. The Tuned lamp blinks while it’s counting.
  • The reveal. The target’s wedges appear (2·3·4·3·2 with big Righteous numbers), the other team’s call shows as a pill by the hub, and the house’s strong announcement (ADR 0051) says what it scored (“Three points!”, with the clue and the call in a line under it). The band crosses the table under the dial, not over it, so the target it’s talking about stays in sight. Until the next clue, everyone but the next psychic still sees that reveal on their dial.
  • Helpers. Say where the needle is (on by default) writes under the tuner where the needle points (“a little right of the middle”); the dial’s own name for a screen reader always says so.

The result sheet: the winning team (or co-op’s rating), both scores, and every round’s clue, card and points; a team win celebrates (co-op celebrates 16 or more).

Its look#

Vibe Check’s personality, in the design system’s personality sheet.

Family Board and party: the room kit in a look of its own, live, people only, checked on phones (Board and party games).
Palette Ink #f7ead6 on the den’s #20140c under an amber lamp; the one fill #ffb547; yours and your turn #ffd166; good #9fe3a8 and bad #ff9a8a. A 1970s hi-fi: walnut, brushed aluminum, amber LEDs, a cream dial face, each card’s two end colors. The teams are Sunset #ff7a45 (a sun) and Tide #38b2ac (a wave).
Faces Righteous (titles, headings, the dial’s numbers), Outfit (everything you read), Michroma (the faceplate’s engraving only, never under 12px)
Corners and edges No rail: the den runs edge to edge; panels 12px, buttons 10px
Board The tuner, the one hero: the walnut cabinet on two feet with its contact shadow, the brushed faceplate with four screws, the amber clue display (with the psychic’s mark), the dial window (cream face, the card’s band from its left color through soft yellow to its right, a striped 70s sunburst hub, a row of dots bigger at the ends and the middle, a chunky chocolate needle with a round orange tip; no ticks, no numbers until the reveal), and the card in its recessed slot.
Details The vibe readout’s two waves (they drift as the needle turns, and fall in step at a bullseye), the Screen toggle (down while the target shows) and Stereo (up for two teams), the Tuned lamp (lit once it’s locked in, blinking while it counts), the knurled knob that turns with the needle, and the engraved “VC-10 · Stereo”
Emblem and art Its box: the dial at the reveal, the needle a hair off the bullseye
Voice The psychic, the clue, the dial, the needle, tuning, lock it in, wait, the call
Payoff The reveal’s strong announcement in the turn color with the points on an orange disc; the room’s celebration for a team’s win
The glance Whose move and what to do (the Your move card), the score (both teams’ figures and the track), and what’s yours (the dial while you’re tuning, the clue box while you’re the psychic)
Exceptions The dial’s parts (the needle, its tip and the pills riding it) grow in the dial’s own units on a small dial so they stay a finger’s size and their words 13px or more; the wedges’ numbers sit on round plates in their band’s color

Content#

tools/vibe-check/spectrums.txt is the deck as written, one spectrum a line; tools/vibe-check/deck.py builds the TOML bank (ADR 0019) from it, deals each card two end colors from its PAIRS, and checks it: ends of one to three words, no end on more than two cards, no two the same, no near-twins. A card’s id comes from its ends and is fixed once shipped: retire a line, never reword it.

Testing#

tests/test_vibe_check.py:

Group Proves
Deck 400 or more, the gates pass, the built bank is the list as written, two colors each, no repeats at a table
Setup People only, co-op for two or three, the second team’s head start, the host’s teams, the teams’ names
Rounds Only the psychic sees the target; the clue; another card; the one dial; the lock and Wait; the bands; the call; the bullseye shut-out; who’s next; catch-up; first to 10; sudden death; co-op’s cards and rating; the host’s Skip and End; someone leaving
Contract, simulated games, service The shared TableGameContract; whole team games at 4, 7 and 12 and co-op at 2 and 12; a table played through the service; the catalog entry and the rating’s teams

npm run check:vibe-check:play (tools/vibe-check/play.mjs) plays a whole six-player game in headless Chrome, the browser as the host and the others over the API: the waiting room’s teams, the psychic’s clue typed, the needle dragged and locked in through the page, a teammate’s turn of the dial arriving, the call, a band each reveal, and the result sheet, with no errors and no sideways scroll (--coop plays co-op, --shots dir keeps pictures).

Changing it#

To… You need
Change a rule rules.py, docs/vibe-check.md and How to play (rules.js) together, and a test. State is plain JSON and a live table’s doesn’t migrate.
Change the bands BANDS in rules.py and BANDS in tuner.js together.
Change the lock’s wait or the last reveal’s hold LOCK_SECS, REVEAL_SECS.
Drop co-op’s bullseye card COOP_BONUS = False.
Add or retire a card tools/vibe-check/spectrums.txt, then uv run python tools/vibe-check/deck.py.

Known issues#

  • Every drag is a saved move. A few a second at one table, bounded by the page’s throttle (ADR 0056). A lighter path is the next step if tables grow.
  • Clue rules are the table’s. Only “not one of the ends” is enforced.
  • No away. A psychic whose phone dies holds up the round until the host presses Skip.