Smear is a Canadian prairie card game that I was introduced to by my father's side of the family. It can be played with 3–8 people, but is optimally played with 4. This Wikipedia article and this GameRules article outline the general rules of the game; however, the implementation in this repository follows a specific house-ruled variant. The Rules section below documents that implemented ruleset exactly. This report attempts to find and describe optimal play based on a minimax algorithm. Given the relative lack of popularity of the game (at least compared to other solved games like chess), there is not a large—or frankly any-sized—dataset of games played, which made analysis of the model difficult.
The rules below describe the game as it is currently implemented in this repository. Where family or regional variants differ, treat this section as the source of truth for the engine.
- The engine supports 3-8 players.
- Each player is dealt 6 cards every round.
- The full deck is 52 standard cards plus 2 jokers.
- The match target score is 21.
- The engine supports arbitrary team groupings, although a 4-player 2v2 game is the most natural version of smear.
- Card rank order is
2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A.
The game does not always use the full 54-card deck. Instead it builds a functional deck by removing all cards below a chosen rank, called the deck's low, so that the number of undealt hiding cards is as close to 2 as possible after dealing 6 cards per player.
The current implementation picks the following low by player count:
| Players | Low | Hiding cards |
|---|---|---|
| 3 | 10 | 4 |
| 4 | 9 | 2 |
| 5 | 7 | 4 |
| 6 | 6 | 2 |
| 7 | 4 | 4 |
| 8 | 3 | 2 |
The deck is shuffled fresh every round. After dealing, any remaining cards are hidden and are not part of anyone's hand, but they still matter when deciding whether a trump jack exists and which trump cards count as high and low.
Each round has two phases:
- An auction to decide who leads the round.
- Six tricks of card play, one per card in hand.
The winner of the auction leads the first card of the round. The winner of each trick leads the next trick.
- The dealer rotates one seat clockwise each round.
- Bidding starts with the player to the dealer's left and proceeds clockwise, with the dealer acting last.
- Legal bids are integers from 1 through 6.
- Bids must strictly increase the current highest bid. Ties are not allowed.
- The auction is a single lap around the table: each player acts exactly once.
- If at least one player has already bid, later players may either overbid or pass.
- If nobody has bid yet, players may pass until the final player in the order. The final player is not allowed to pass; they must open the bidding with some value from 1 through 6.
- A bid of 6 does not end the auction early. Remaining players still get their one chance to act, but if 6 is already high their only legal move is to pass.
- After every player has acted once, the highest bidder wins the auction and leads the first trick.
- Trump is not chosen in a separate declaration step.
- Instead, the first card led in the round determines trump: the suit of that card becomes the round's trump suit.
- Because trump is not set until that first lead, the opening leader may not play a joker as the first card of the round.
- Once trump has been established, later trick leaders may lead any card in hand, including jokers.
Each trick is won using the following priority:
- Highest card in the round trump suit.
- If no trump suit card was played, the first joker played.
- If neither trump nor jokers were played, the highest card in the suit that was first led in the trick.
Additional details:
- Within a suit, higher rank wins using the normal rank order up to Ace.
- If multiple jokers are played and no trump suit card appears, the first joker played wins the trick.
- The trick winner captures every card in the trick, with one scoring exception described under low.
The play restrictions in the engine are:
- If you are leading a trick after trump has already been established, any card is legal.
- If trump was led, any trump card or joker is legal.
- If trump was led and you have at least one trump card or joker, you may not discard a non-trump off-suit card.
- If a non-trump, non-joker card was led, you may always play a trump suit card or a joker.
- If you choose to play a non-trump, non-joker card, it must follow the led suit if possible.
- Only if the led card was not trump and you have no card in the led suit may you discard any non-trump, non-joker off-suit card.
This means trump cards and jokers are always legal during a trick, but a trump lead blocks non-trump discards while you still hold any trump-capable response.
Examples:
- Trump is hearts, the trick is led with
AD, and your hand is9D,10H,J1,KS. Your legal plays are9D,10H, andJ1.KSis illegal because it is neither trump, nor a joker, nor a diamond. - Trump is hearts, the trick is led with
AH, and your hand is9D,10H,J1,KS. Your legal plays are10HandJ1. Because hearts were led, a non-trump, non-joker off-suit discard is not allowed while you still hold trump. - Trump is diamonds, the trick is led with
AD, and your hand isJH,QH,AH,10S,KS,J2. Your only legal play isJ2. Because trump was led, the joker is the only trump-capable response in hand. - If the trick is led with a joker, every card in hand is legal.
At the end of the round, up to 6 raw points are available:
high: 1 point for the scoring unit that possesses the highest visible trump card in the functional deck.jack: 1 point for the scoring unit that possesses the jack of trump, if that jack is not hidden.low: 1 point for the scoring unit that was originally dealt the lowest visible trump card in the functional deck.jokers: 1 point for each joker possessed by the scoring unit.game: 1 point for the unique highest total of game-value cards captured.
Important scoring details:
- "Visible trump" means trump cards that exist in the functional deck and are not among the hidden undealt cards.
- If the jack of trump is hidden, nobody gets the jack point.
- Low is special: even if another player captures the trick containing low, the low point still belongs to the player or team that originally played that lowest visible trump card.
- Game-value totals are computed from captured cards using
10 = 10,J = 1,Q = 2,K = 3,A = 4, and2-9 = 0. - The game point is awarded only for a unique highest total. If the highest game total is tied, nobody receives the game point.
After raw round points are computed, the auction winner's bid is checked:
- If the auction winner's scoring unit made its bid, it adds its full round point total to its match score.
- If the auction winner's scoring unit failed to make its bid, it loses match points equal to the amount bid, not merely the amount it scored in the round.
- Every non-bidding scoring unit adds its raw round point total to its match score.
The implementation also applies one important match rule:
- Non-bidding scoring units are capped at
target_score - 1, which is 20 in a standard game to 21.
In other words, only the scoring unit that won the auction can win the match at the end of a round. Everyone else can improve their score, but they cannot cross the finish line unless they were the bidder for that round.
This repo now carries the browser clients and the engine in one monorepo-style layout:
backend/exposes the FastAPI API around the existing Smear engine.frontend/is the internal debug UI. It still includes the inspector-heavy debug surface plus the older play mode.apps/play-ui/is the cleaner public-facing play app intended for online access.packages/web-core/contains the shared TypeScript game client, types, helpers, and card component used by both frontends.
The public app and the debug app both talk to the same backend API, but they now isolate games by browser session instead of sharing one global in-memory match.
This repo includes two local browser frontends:
npm run dev:playstarts the public play app.npm run dev:debugstarts the internal debug app.
Both expect the FastAPI backend to be running locally.
Create a virtual environment, install the backend requirements, and run the API:
python3 -m venv .venv
source .venv/bin/activate
pip install -r backend/requirements.txt
python -m uvicorn backend.server:app --reloadThe API will be available at http://127.0.0.1:8000.
Useful environment variables for the backend:
SMEAR_CORS_ORIGINSis a comma-separated allowlist of frontend origins. By default the API allows the two local Vite apps on ports5173and5174.SMEAR_STATE_DB_PATHcontrols where browser sessions are persisted. The default is.smear/sessions.sqlite3; set it tononeto use memory only.SMEAR_SESSION_TTL_HOURScontrols how long inactive browser sessions are kept in the in-memory cache. Persisted sessions can still be restored fromSMEAR_STATE_DB_PATHafter cache expiry or server restart.STRIPE_SECRET_KEYenables the donation checkout endpoint. Set it in your runtime environment or host secrets manager, not in source code.SMEAR_PUBLIC_SITE_URLis the browser URL Stripe should redirect back to after donation checkout, for examplehttps://play-smear.com. Local dev falls back to the Vite play app URL.SMEAR_DONATION_CURRENCYcontrols donation currency. It defaults tocad.
Production packaging intentionally excludes neural bot replay/checkpoint output under backend/bots/models/. Only the small runtime bundles named neural_3p_v*.json should be committed or shipped to Railway.
For the Railway backend service, set the service root directory to /backend and the custom config-as-code path to /backend/railway.json. The backend config clears Railway's build command so Railpack installs requirements.txt only once, then starts FastAPI with python -m uvicorn server:app --host 0.0.0.0 --port $PORT.
Useful endpoints:
GET /healthPOST /game/newPOST /game/auction/bidPOST /game/auction/passPOST /game/resetfor a debug reset of the current round without advancing the matchPOST /game/next-roundto advance the scored match to the next roundGET /game/stateGET /game/legal-actionsPOST /game/playGET /game/scorePOST /donations/checkout-sessioncreates a Stripe Checkout Session for a one-time site donation.WS /game/ws?session_id=...streamsgame_statemessages for the session whenever the game changes.POST /lobbiescreates a multiplayer lobby with a shareable code and host player token.POST /lobbies/{code}/joinadds a player to an open lobby seat.POST /lobbies/{code}/startstarts a full lobby as a shared human-controlled match.WS /lobbies/{code}/ws?player_token=...streams player-scoped lobby and game updates.GET /learn/challenge?bot_id=optimal-botreturns a filtered practice position, legal learner options, the selected bot action, and an explanation to reveal after the learner chooses.
Every stateful endpoint also accepts an X-Smear-Session-Id header. The browser apps generate and persist that header automatically so each browser gets its own isolated game.
Install the root workspace dependencies once:
npm installRun the public play app:
npm run dev:playRun the internal debug app:
npm run dev:debugBy default:
- the debug app runs at http://127.0.0.1:5173
- the public play app runs at http://127.0.0.1:5174
To point either frontend at a deployed backend, set VITE_API_BASE_URL before starting or building it.
For the donation page, the browser display currency defaults to CAD. Set VITE_DONATION_CURRENCY to match SMEAR_DONATION_CURRENCY if you change it.
You can also run repeated all-bot matches from the command line:
python -m backend.simulator 1000 50 greedy randomTo simulate same-model teams, set --team-size. For example, this runs a 2v2 match with two greedy bots on one team and two random bots on the other:
python -m backend.simulator --team-size 2 1000 50 greedy randomThe simulator still caps games at 8 total seats, so --team-size 2 allows up to four supplied models.
To override minimax search depth without changing bot ids, set --depth. For example, this runs the human-information and omniscient minimax bots at a 3-trick search depth:
python -m backend.simulator --depth 3 1000 50 one-trick-minmax o-one-trick-minmaxThe simulator reports the applied override as minimax_depth in its JSON output.
Simulator output now includes wall-clock timing metrics such as elapsed_seconds, average_seconds_per_game, average_seconds_per_round, games_per_second, and rounds_per_second so you can compare algorithm changes directly.
To reduce variance from fixed seat order, set --fair. This rotates and reverses seat assignments across the supplied models, and reports the tested schedule in fair_schedule:
python -m backend.simulator --fair 60 50 o-3-trick-minmax greedyFor reproducible fair comparisons, add --seed. In fair mode, each batch of rotated seat assignments shares the same RNG seed so the models are compared on matched deals:
python -m backend.simulator --fair --seed 0 60 50 o-3-trick-minmax greedyFor a long-running console-only ladder across the ready bot pool, use continuous-sim:
python continuous-sim --duration-hours 8 --workers 4By default it uses every visible ready bot, samples three distinct bots per game, runs those free-for-all matches continuously, and keeps a live console panel updated with the current Elo table plus compact per-match progress rows. The visible ready bot set is intentionally capped at the functional presets up through 3-trick depth; deeper minimax presets remain hidden because they are too slow for normal interactive use.
The default scheduler is balanced, not purely random. In balanced mode, continuous-sim cycles through every unique three-bot trio in the selected pool and rotates all seat orders before repeating, which makes the ladder much less sensitive to sampling noise and seat-position artifacts. The live panel shows the current balanced-cycle progress as Schedule | balanced | cycle ....
When only two bots are selected, continuous-sim fills the third seat with a random player so the game can still run as a three-player match. That filler seat is shown in the live match rows, but the Elo table continues to track only the selected bot pool.
The Elo update is multiplayer-aware rather than winner-only:
- Each match compares every rated bot to every other rated bot in the same three-player game.
- Higher final match score at termination counts as a win for that pairwise comparison, lower score counts as a loss, and equal scores count as a draw.
- The total Elo delta for the match is the average of those pairwise updates, scaled by the configured
--k-factor(default32).
By default continuous-sim persists ratings in continuous-sim-elo.json at the repo root and loads that file again on the next run, so later runs keep updating the same Elo history instead of starting over. Use --fresh-ratings if you want to ignore the saved ladder at startup and begin from --initial-rating, and use --elo-file /path/to/file.json to point at a different ladder file.
The leaderboard now includes a c95 column, which is the approximate 95% confidence half-width for each rating. Smaller values mean the bot's position is more settled; if two bots' ratings are close and their confidence bands overlap heavily, treat them as the same tier rather than a decisive ordering.
Useful flags:
--games 100stops after a fixed number of matches.--duration-hours 8is useful for longer unattended runs.--workers 1disables parallel workers and runs serially in the current process.--bots random greedy stupidrestricts the pool to a chosen subset.--include-hiddenalso adds the hidden legacy bot ids to the default pool.--elo-file /tmp/nightly-elo.jsonsaves and reloads Elo from a specific JSON file.--fresh-ratingsstarts from the configured initial rating instead of loading the saved Elo JSON first.--schedule balanceduses balanced trio and seat-order cycles;--schedule randomrestores simple random sampling.
In a second terminal, you can either install and run the frontend from the repo root:
npm install
npm run devOr run it directly from the frontend workspace:
cd frontend
npm install
npm run devThe UI will be available at http://127.0.0.1:5173.
By default the frontend talks to http://127.0.0.1:8000. If you need a different backend URL, set VITE_API_BASE_URL before starting Vite.