A multiplayer web implementation of the Euchre based card game 500 — four players, fixed partnerships, played in the browser. Rules follow the Australian Four-handed Five Hundred variant as described on pagat.com, which is the project's rules reference.
The server is a small Python/Flask app that holds any number of concurrent tables in memory, each an authoritative game in its own right, and pushes full game state to every browser connected to that table over Socket.IO. The client is a single-page jQuery UI that renders whatever the server sends — no game logic runs in the browser.
Full four-handed Australian 500 flow
seating/lobby, dealing (43-card deck with joker), bidding, kitty award and discard, trick play, and scoring, driven by a server-side state machine.
Multiple tables
pick an existing table (seeing who's already seated where) or create a new one after logging in; any number can run at once. Generic auto-numbered names ("TABLE 1", "TABLE 2", ...); a full table can't be joined by a newcomer; a table left empty for a while is automatically cleaned up.
Watch a live game
anyone logged in but not seated can watch a table in progress — every hand renders face-down, a ROTATE VIEW button cycles which seat you're looking from, and a SEATS button re-opens the seat picker mid-game in case one frees up.
Correct special-card handling
joker as highest trump, left/right bowers, follow-suit enforcement including the tricky edge cases (holding only the left bower of the led suit, joker when trumps are led, etc.). In No Trumps and Misère, leading the joker requires nominating a suit the others must follow (chosen in a temporary panel); a nominated suit must not have been led earlier in the hand, and once all four suits have been led the joker may only be led to the last trick. A contractor holding the joker may instead pre-nominate its suit before the first lead, making it the highest card of that suit for the whole hand.
Legal-move hints
the server sends each player the set of cards they may legally play; the client dims everything else.
Bidding rules
Avondale score table, re-deal when all four players pass, and the last remaining bidder gets one chance to increase their own bid before the contract locks. A toggleable bid-value reference table (including the slam rule) is available while bidding and from the scoreboard. An optional per-table "Single Bid Exit" setting (off by default, toggleable by any player from the seating screen or by an admin mid-game) lets that same lone bidder resign instead of increasing — but only if their bid is still the cheapest (six tricks, any suit); no penalty, straight re-deal, as if no-one had bid.
Scoring
Avondale table (6 Spades 40 … 10 No Trumps 520), opponents score 10 per trick, "slam" rule (winning all 10 tricks scores a minimum of 250), first team to ±500 with unequal scores ends the game.
Misère
biddable only over a bid of seven (ranks between 8 Spades and 8 Clubs per the points table, so only 8 Clubs or higher outbids it), the contractor plays alone while their partner sits out, the contract fails the moment the contractor wins a trick, and scores ±250 with the opponents scoring nothing. (Open Misère is not implemented.)
Per-hand scoreboard
running totals with each hand's contract, tricks and points, in a scrollable modal.
In-game rules reference
a scrollable rules modal covering the Australian four-handed game in plain language (credited to pagat.com), including the game's current limitations; reachable from the login page (before you've even joined a table) and the settings modal.
Persistence
every table autosaves at every safe checkpoint and is restored automatically when the service restarts, so a server bounce doesn't kill any game in progress. A separate manual checkpoint slot per table supports save/load during development.
Push notifications (optional)
pings an ntfy server (self-hosted or the public ntfy.sh) whenever a human takes a seat at a table. Off by default; see Notifications below to enable it.
Mobile-friendly
responsive layout, modals sized for phones in both orientations, iOS-specific fixes (non-emoji suit glyphs, homescreen icon).
Natural card layout
cards sit with a little random offset/rotation (re-rolled each deal and each trick) instead of pixel-perfect alignment; switchable back to "Perfect" per user.
User settings
a settings modal with the Perfect/Natural card layout toggle (persisted per browser
via localStorage), a clear-saved-settings button (wipes all web500* localStorage
keys and reloads), and logout. Admin users get an extra admin-only section in the same
modal (a table selector scoping the actions below to any table; test mode, skip
delays, checkpoint save/load/clear, table reinit, service uptime + restart), rendered
server-side only for them.
Simple auth
display name + shared game passcode login with signed session cookies; every action's identity comes from the server-side session, and the admin tools are gated to listed admin users.
Search-engine opt-out
robots.txt disallow, noindex meta tags, and a global X-Robots-Tag header keep
the site (and its assets) out of search indexes.
Bot players
an ADD BOTS button in the lobby fills the empty seats with server-side bot players so a game never has to wait on a fourth human. They bid, discard and play with human-like personalities (imperfect memory, varying confidence, occasional miscalculations, individual pacing) and only ever see what a human in their seat could see. Full behaviour model in BOTS.md.
Test mode
one human can exercise the whole game against three built-in bots that bid, discard and play random legal cards.
Runs on any Linux box with Python 3 and systemd. The steps below install it as a service on port 4030.
1. Get the code and install dependencies:
git clone https://github.com/danricho/web500.git /opt/web500
cd /opt/web500
python3 -m venv venv
venv/bin/pip install -r requirements.txt2. Create the service. Write /etc/systemd/system/web500.service (adjust User
and the paths if you installed somewhere other than /opt/web500):
[Unit]
Description=Web500 card game server
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=10
User=youruser
WorkingDirectory=/opt/web500
Environment="PYTHONUNBUFFERED=1"
ExecStart=/opt/web500/venv/bin/gunicorn -b :4030 -w 1 --threads 100 main:app
[Install]
WantedBy=multi-user.targetThe game lives in process memory, so gunicorn must keep exactly one worker
(-w 1); concurrency comes from threads.
3. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now web500.service
systemctl status web500.service
# follow the logs (every state transition and action is logged, with colour)
journalctl -u web500.service -f4. Set your passcode. The first start creates data/auth.json with a random
passcode ({"passcode": ..., "admin_users": [...]}). Edit it to set your own passcode,
add your display name to admin_users if you want the in-game admin tools, then
sudo systemctl restart web500.service.
5. (Optional) Push notifications when a player joins. The first start also creates
data/ntfy.json, disabled by default:
{
"enabled": false,
"server": "https://ntfy.example.com",
"topic": "web500",
"auth_token": null
}To get a push notification (via ntfy) whenever a human sits down at
a table, set enabled: true and point server/topic at your ntfy instance
(auth_token is only needed if that server requires one). No restart needed — it's
re-read on every notification. Bot seatings (ADD BOTS, admin test mode) never notify.
If you don't want to self-host, server can point at the public https://ntfy.sh
instead — but know the risk first: a free public topic is just a guessable name, not an
account, so anyone who knows (or guesses) your topic string can read every notification
(and publish their own to it). Pick an unguessable topic name, or use a paid ntfy.sh plan
with auth_token set, if you go this route.
6. Play. Open http://your-server:4030, log in with a display name + the
passcode, pick or create a table, and take a seat. The ADD BOTS button fills empty
seats with bot players.
Restarting the service does not lose any game in progress — every table is
restored from its own save under data/tables/ at startup.
Updating: git pull, then venv/bin/pip install -r requirements.txt (in case
dependencies changed) and restart the service.
Simple shared-passcode auth, table-agnostic — the same login works for every table.
Players log in with a display name + the passcode; a signed session cookie (90 days)
keeps them in, and their name is their game identity. Names are compared
case-insensitively but stored and displayed as first written — logging in as "HENRY"
while "Henry" is already seated at the table you join resumes that seat under the
original spelling. Socket actions take the name from the server-side session, never
from client-supplied data, so players can't act as each other. The session-signing key
is auto-generated into data/secret_key.txt so logins survive restarts. admin_users in
data/auth.json lists the names allowed to use the admin endpoints and the settings
modal's admin section.
An ntfy server (self-hosted or the public ntfy.sh) can be pinged
whenever a human takes a seat — handy if you're not watching the browser tab. Off by
default; see step 5 of the installation instructions above to turn it on via
data/ntfy.json, including the risk of using a public topic instead of self-hosting.
Most card games settle for a pseudo-random number generator: a tidy little algorithm that starts from the system clock and marches through a sequence that was, strictly speaking, decided in advance. Perfectly adequate. Deeply unromantic. When you are dealt ten cards without a bower in sight for the fourth hand running, you have merely been arithmetic'd.
Web500 aims higher. Every table, on creation, reaches out to random.org and requests a seed drawn from atmospheric noise — the raw electromagnetic hiss of the sky itself, thick with the crackle of every thunderstorm currently in progress on this planet, harvested by radio receivers and piped down the wire into the shuffling of your 43 cards.
So when the joker sits in the kitty you didn't win, and your partner leads away from the one suit you were long in, take some comfort: that was not a bug, and it was not the author. That was the weather. Somewhere out there a storm front discharged a few million volts into the void, a receiver twitched, and the universe decided you were bidding seven no trumps tonight.
Should random.org be unreachable — the internet being what it is — the table quietly falls back to your operating system's entropy pool and the game continues without ceremony. Less poetic. Still perfectly fair. But on a good day, with a clear route to the internet and a decent storm rolling through somewhere on Earth, your cards are genuinely being shuffled by the sky.
File layout and file-by-file responsibilities live in DEVELOPMENT.md. The core of it:
GameStateMachine (in game_state.py) is a six-state FSM. One instance exists per
table — each table runs its own independent copy of this machine. The state is an
integer index into the states list:
| S | State | Driven by |
|---|---|---|
| 0 | WAITING FOR PLAYERS | gui_sit() — players claiming seats |
| 1 | DEALING | auto_deal(), queued on transition |
| 2 | TAKING BIDS | gui_bid() — bid/pass submissions |
| 3 | AWARD KITTY | gui_discard() — contractor discards three |
| 4 | PLAY HAND | gui_play() — card plays, ten tricks |
| 5 | AWARD POINTS | auto_points(), queued on transition |
stateDiagram-v2
[*] --> S0
S0: 0 - WAITING FOR PLAYERS
S1: 1 - DEALING
S2: 2 - TAKING BIDS
S3: 3 - AWARD KITTY
S4: 4 - PLAY HAND
S5: 5 - AWARD POINTS
S0 --> S1: all four seats taken
S1 --> S2: hands + kitty dealt
S2 --> S1: all players pass (re-deal)
S2 --> S3: contract locked
S3 --> S4: contractor discarded three
S4 --> S5: ten tricks played, or misère fails early
S5 --> S1: next hand (rotate dealer)
S5 --> S0: a team reaches ±500 (game over, re-init)
Everything deeper — running without systemd, how multiple tables work, the server/client contract, state-machine mechanics, admin endpoints and test mode, persistence internals and the card encodings — lives in DEVELOPMENT.md.
The core of this app dates back to 2015; it was cleaned up and rewritten around a proper object-oriented state-machine framework (which was functional) in 2020; and in 2026 (with AI assistance as described below) gained Misère and a round of polish.
Built solo in my limited spare time, with Claude used throughout the recent development to speed things up — turning design concepts into code, fleshing out code faster than working alone, keeping the docs aligned as the code moves, and estimating effort/risk on Roadmap items. Every change is reviewed and tested by hand before it is commited; nothing gets merged on trust.
Planned work, ranked by value/effort, lives in its own file: ROADMAP.md.
Apache License 2.0. Vendored front-end libraries (jQuery, Socket.IO, normalize.css, skeleton.css) are MIT-licensed; in-page icons use paths from Bootstrap Icons (MIT).
