A real-time, browser-based UNO game for 2–10 players, with bots to fill empty seats. Three rule sets: Normal (the classic 108-card game), No Mercy (the 168-card deck with stacking draw cards, hand swaps and the 25-card knockout) and Flip (the 112-card double-sided deck that turns the whole table over mid-game).
Built with Flask, Socket.IO and vanilla JavaScript — no build step, no front-end framework, no CDN.
- Features
- The three modes
- House rules and decisions
- Quick start
- Configuration
- Deployment
- Running on a game panel (Pterodactyl)
- Project structure
- How it works
- Socket API
- Testing
- Security
- Accessibility
- Upgrading from the first version
- Contributing
- License and trademarks
- Three rule sets. Normal, No Mercy and Flip, chosen per table. Every rule is enforced server-side.
- Multiplayer tables. 2–10 seats, public tables in the lobby or private tables joined with a code or invite link.
- Bots. Fill any seat with a bot, or use Play against bots to start a solo game instantly. Bots evaluate their hand, hold wilds back, punish short-handed opponents and sometimes catch you for forgetting to call UNO.
- Turn timer. 15, 30 or 60 seconds per table. When it runs out, the server makes a legal move so the table keeps moving.
- Reconnect-friendly. Refresh or drop off Wi-Fi and your seat is still there; the game carries on without you until you're back.
- Host controls. Pause, resume, remove a player, and start a rematch with the same seats.
- Stats and leaderboard. Wins, games and points, ranked separately from games played against bots.
- Quick chat and sound. A fixed phrase list (no free text), synthesized sound effects, optional spoken UNO calls.
- Installable (PWA), keyboard shortcuts, reduced-motion support, and a mobile layout that's designed rather than shrunk.
| Lobby | Signature scroll on the home page | Phone |
|---|---|---|
![]() |
![]() |
![]() |
UNO Flip's dark side. The small cards under each opponent are the faces they cannot see — and yours are showing to them:
| Normal | No Mercy | Flip | |
|---|---|---|---|
| Deck | 108 cards | 168 cards | 112 double-sided cards |
| Can't play? | Draw one card. Play it if it fits, or pass. | Keep drawing until something fits, then play it. | Draw one card. Play it if it fits, or pass. |
| Draw cards | +2 and Wild +4 hit the next player, who loses their turn. | +2, +4, +6 and +10 stack. Answer with an equal or higher draw card (any color) or take the whole pile. | Light side: Draw One and Wild Draw Two. Dark side: Draw Five and Wild Draw Color. |
| The twist | None. The game you grew up with. | A 7 swaps your hand with a player you choose. A 0 passes every hand to the next player. | A Flip card turns the deck, both piles and every hand over onto the dark side — and back again. |
| Extra cards | — | Skip Everyone, Discard All, colored Draw 4, Wild Reverse Draw 4, Wild Draw 6, Wild Draw 10, Wild Color Roulette. | Flip, Draw One, Draw Five, Skip Everyone, Wild Draw Two, Wild Draw Color. |
| Hidden information | Card counts only. | Card counts only. | You hold your cards facing you, so everyone else can read your dark faces — and you cannot. The underside of the discard pile is public too. |
| Knockouts | None. | Reach 25 cards and you are out. The last player standing wins. | None. |
| Forgot to call UNO? | Anyone can catch you before the next move: draw 2. | Same. | Same. |
No Mercy deck (168): 80 number cards (0–9, two per color) · 12 Draw 2 · 8 Draw 4 · 12 Skip · 8 Skip Everyone · 12 Reverse · 12 Discard All · 8 Wild Reverse Draw 4 · 4 Wild Draw 6 · 4 Wild Draw 10 · 8 Wild Color Roulette.
Normal deck (108): one 0 and two each of 1–9, Skip, Reverse and Draw 2 per color, plus 4 Wild and 4 Wild Draw 4.
Flip deck (112 double-sided): every card carries a light face and a dark face. Each side has, per color, two each of 1–9 (no zeros), two Reverse, two Flip, two of that side's penalty card (Draw One / Draw Five) and two Skip (light) or Skip Everyone (dark), plus 4 Wild and 4 Wild Draw Two (light) or Wild Draw Color (dark). Light colors are red, yellow, green and blue; dark colors are pink, teal, orange and purple.
Published rules leave some situations open. These are the choices this implementation makes, all enforced in app/game/engine.py and covered by tests:
- Starting card. Every mode flips until a number card appears; action cards go back into the deck.
- Wild Draw 4 is never challenged. It can be played at any time (no "only if you have no matching color" check).
- Stacking ignores color. Only the draw value matters: equal or higher.
- Wild Reverse Draw 4 flips the direction, then the player who is now next faces the +4 — including in a two-player game.
- Color Roulette. The player who plays it names the color in play; the next player names the color they flip for, keeps every revealed card and loses their turn. Wild cards never count as the chosen color.
- Calling UNO. You can call as you play down to your last card, or right after — until the next player acts. Getting caught costs 2 cards.
- Scoring. The winner scores the cards left in every other hand: number cards at face value, colored action cards 20, wilds 50, and in No Mercy 250 per knocked-out player.
- Flip pairings. Mattel does not publish which dark face is printed behind which light face, so the pairing is shuffled fresh for each game.
- Turning the deck over works exactly as printed: the discard pile is flipped (so the Flip card just played ends up at the bottom), then the draw pile, then every hand. The card that was underneath the pile comes up. Everyone can see that face in advance, and if it is a wild, the player flipping names the color.
- Flip scoring uses the rule sheet's table, for the side the round ended on: Draw One 10, Draw Five 20, Reverse 20, Skip 20, Skip Everyone 30, Flip 20, Wild 40, Wild Draw Two 50, Wild Draw Color 60.
- The last card. A round ends the moment a hand is empty; a penalty printed on that final card is not applied.
- Leaving. A player who leaves or is removed has their cards returned to the bottom of the deck; play continues. Last player standing wins.
- Ranked stats only count games with two or more people and no bots. Games against bots are tracked separately so the leaderboard can't be farmed.
Requires Python 3.11 or newer.
git clone https://github.com/hyperdargo/UNO.git
cd UNO
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # then set SECRET_KEY
python run.pyOpen http://127.0.0.1:5000, create an account, and hit Deal me in to play against bots straight away. To play with friends on the same network, start the server with HOST=0.0.0.0 and share your machine's address.
Generate a secret key with:
python -c "import secrets; print(secrets.token_hex(32))"Without SECRET_KEY the app generates one and stores it in instance/secret_key so sessions survive restarts. Setting it yourself is still better, especially if you ever run more than one instance.
All configuration is environment variables (see .env.example):
| Variable | Default | What it does |
|---|---|---|
SECRET_KEY |
generated into instance/secret_key |
Signs session cookies and CSRF tokens. Set this in production. |
DATABASE_URL |
sqlite:///instance/uno.db |
Any SQLAlchemy URL. |
SECURE_COOKIES |
false |
Set to true when serving over HTTPS. |
PUBLIC_URL |
request host | Canonical URL used in metadata, the sitemap and social previews. |
SOCKET_CORS_ORIGINS |
same origin only | Comma-separated extra origins allowed to open a socket. |
SIGNUPS_PER_HOUR |
5 |
New accounts allowed per IP per hour. |
HOST / PORT |
127.0.0.1 / 5000 |
Development server bind address. |
Rooms and games live in the server process's memory, so run exactly one worker with multiple threads:
gunicorn --worker-class gthread --workers 1 --threads 64 --bind 0.0.0.0:8000 run:appOr with Docker:
docker build -t uno .
docker run -p 8000:8000 -e SECRET_KEY="$(python -c 'import secrets;print(secrets.token_hex(32))')" \
-e SECURE_COOKIES=true -v uno-data:/app/instance unoBehind nginx, pass WebSocket upgrades through:
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}Scaling to several processes would need a shared message queue and a room store outside process memory; that isn't implemented.
Caching. Static files are served from /static/v/<build>/…, where the build stamp comes from the newest file in app/static. Every deploy changes every asset URL at once, including the ES modules imported inside main.js, so a CDN or browser can never pair a new page with yesterday's JavaScript. No purge step needed.
Panels using the generic Python egg clone the repository and run python app.py, so there is an app.py at the repository root that does the right thing:
- Binds to
0.0.0.0on the panel'sSERVER_PORT(falling back toPORT, then25604). - Falls back to a key stored in
instance/secret_keywhenSECRET_KEYis not set, so players are not signed out on every restart. SetSECRET_KEYyourself if your panel allows custom variables. - Keeps the SQLite database in
instance/, which survives restarts but not a reinstall. Back that folder up.
Egg settings that work as they come:
| Variable | Value |
|---|---|
GIT_ADDRESS |
https://github.com/hyperdargo/UNO |
BRANCH |
blank (uses main) |
PY_FILE |
app.py |
REQUIREMENTS_FILE |
requirements.txt |
AUTO_UPDATE |
1 to pull new commits on boot |
With AUTO_UPDATE off, the startup script skips git pull entirely, so restarting the server keeps running the commit you first installed. Turn it on, then restart, to pick up a new version.
To see which build is live:
curl https://your-domain/healthz # {"ok": true, "build": "1789535162"}The build stamp changes on every deploy.
Then open the panel's allocated address in a browser. Run one instance per game server: rooms live in that process's memory.
If the page sits on "Connecting to the table server…", the proxy in front of your panel is refusing WebSocket upgrades. You will see Invalid websocket upgrade in the server log and GET /socket.io/?...transport=websocket ... 400 in the browser console. The client falls back to HTTP long-polling on its own, so the game still works.
To check a domain from the outside:
curl "https://your-domain/socket.io/?EIO=4&transport=polling" # expect 0{"sid":...}To get WebSockets as well, forward the upgrade headers in whatever sits in front of the container — the nginx block above, mod_proxy_wstunnel on Apache, or a Cloudflare tunnel with WebSockets enabled.
UNO/
├── app/
│ ├── __init__.py # application factory, error handlers
│ ├── config.py # environment-driven settings
│ ├── extensions.py # db, login manager, CSRF, socketio
│ ├── models.py # User, PlayerStats
│ ├── security.py # validation, rate limiting, security headers
│ ├── game/
│ │ ├── cards.py # card model and all three decks
│ │ ├── engine.py # the rules: the only place game state changes
│ │ ├── bots.py # computer opponents
│ │ ├── rooms.py # rooms, turn timers, bot scheduling, presence
│ │ └── sockets.py # Socket.IO transport and stat recording
│ ├── routes/
│ │ ├── auth.py # sign up, sign in, sign out
│ │ └── main.py # pages, JSON endpoints, robots, sitemap
│ ├── static/
│ │ ├── css/ # base tokens, landing, auth, play
│ │ ├── js/lib/ # DOM helpers, card rendering, sound, settings
│ │ ├── js/play/ # main controller and table renderer
│ │ └── vendor/ # socket.io client (vendored, no CDN)
│ └── templates/ # base, landing, auth, play, error, partials
├── scripts/
│ ├── make_icons.py # renders the app icons
│ └── import_legacy_scores.py
├── tests/ # engine, rooms, HTTP and socket tests
├── app.py # entry point for hosting panels (python app.py)
├── run.py # development entry point
├── Dockerfile
└── requirements.txt
The server is authoritative. The browser never decides anything: it renders the state it is sent and asks for actions. Each player's payload contains only their own hand plus public information (other players' card counts, the discard top, the draw pile size), so an opponent's cards are not in the page at all.
Three layers, deliberately separate:
game/engine.py— pure Python rules. No Flask, no sockets, no globals. Every action validates and raisesGameErrorwith a message safe to show a player. This is what the 100+ engine tests exercise, including full simulated games in both modes.game/rooms.py— theRoomManager: tables, seats, presence, the turn deadline and bot scheduling. It reports changes through ahooksobject, so it can be driven by a fake clock in tests.game/sockets.py— transport only: authentication, rate limiting, payload validation, and recording results.
The ticker. One background task runs four times a second: it plays bot turns after a short human-like delay, takes over for players whose timer expired, lets bots catch a missed UNO, drops disconnected players from lobbies after 30 seconds, and closes tables nobody came back to.
The front end is ES modules and CSS with no build step. renderTable() redraws from the payload; animation is driven by an event log — each payload carries the events since the last one, so a card flies from a hand to the pile only when something actually happened, and a reconnecting player never replays old moves.
All events require an authenticated session. Errors come back as a toast event rather than an exception.
Client → server
| Event | Payload | Notes |
|---|---|---|
room:create |
{name, mode, turn_seconds, private} |
Name is sanitized server-side |
room:quick |
{mode, bots} |
Private table, starts immediately |
room:join |
{id} |
Table code |
room:leave |
— | Forfeits if a game is running |
room:add_bot / room:kick |
{target} for kick |
Host only |
room:start / room:rematch / room:pause |
— | Host only |
game:action |
{action, card_id?, color?, target?} |
play, draw, pass, roulette, call_uno, catch_uno |
chat |
{phrase} |
Must be one of the fixed phrases |
Server → client
| Event | Payload |
|---|---|
room |
Full room and per-player game state, or null when you're not seated |
lobby |
Public tables waiting for players |
stats |
Your lifetime record |
chat |
{player, phrase, text} |
toast |
{kind, message} |
room_closed |
{name} |
pip install -r requirements-dev.txt
pytest -q # 188 tests
ruff check .What's covered:
- Engine (156 tests): deck composition, matching, skips, reverses, two-player reverse, draw penalties, UNO calls and catches, scoring, leaving mid-turn, reshuffling, and every No Mercy rule — stacking, draw-until-playable, 7-0, Discard All, Skip Everyone, Color Roulette, and the mercy knockout. Flip has its own suite: deck composition checked against the rule sheet, the deck turning over (piles, hands and the card underneath), flipping onto a wild, Draw One, Draw Five, Skip Everyone, Wild Draw Color, per-side scoring, and the hidden-information rule that you cannot read your own backs. Includes 105 complete simulated bot games across all three modes asserting that every game ends, no card is ever created or lost, and nobody exceeds the mercy limit.
- Rooms: creating, joining, private tables, host transfer, kicks, pause freezing the clock, timeout moves, disconnect grace periods, abandoned tables and rematches, all against a fake clock.
- Web and sockets: auth flows, CSRF, login rate limiting, password hashing, security headers, socket authentication, payload validation, hidden-information checks, and a two-player game over real sockets.
Browser verification was run with Playwright against Edge: the landing page at 1440/768/390 px across eleven scroll depths, complete bot games in all three modes played through the UI on desktop and mobile, a two-human private table, rematch and leave flows — with zero console errors and no failed requests.
- Passwords hashed with Werkzeug (PBKDF2); usernames restricted to
[A-Za-z0-9_]{3,20}. - CSRF protection on every state-changing HTTP request; sign-out is a POST.
- Sockets are authenticated by session, refuse anonymous connections, and accept same-origin connections only unless you widen
SOCKET_CORS_ORIGINS. - Every socket payload is type- and length-checked; game actions are re-validated by the engine, so a forged
card_idcannot play a card you don't hold. - All user text reaches the DOM through
textContent; nothing is built withinnerHTML. - Content-Security-Policy without
unsafe-inlinefor scripts, plusnosniff,frame-ancestors 'none', a strict referrer policy and a permissions policy. - Rate limits on sign-in (8 per 5 minutes per IP and username), sign-up, socket events and chat.
- Quick chat is a fixed phrase list, so there is no free-text channel to abuse.
Found something? Open a security advisory on the repository rather than a public issue.
Semantic landmarks and headings, a skip link, visible focus rings, cards as real buttons with labels like "red Draw 2, playable", live regions for turn status and the discard pile, full keyboard play (D draw, P pass, U UNO), 44 px touch targets, and prefers-reduced-motion support with an in-app Reduce animations toggle. The scroll-driven section on the home page collapses to its finished state when motion is reduced.
The original app kept wins and points in an encrypted scores.enc.txt. Accounts carry over untouched (the user table is unchanged). To bring the scores across:
pip install cryptography
python scripts/import_legacy_scores.py --scores legacy/scores.enc.txt --key legacy/score_key.keyThe old encryption key and score file must never be committed; both are in .gitignore.
Issues and pull requests are welcome. Please run pytest -q and ruff check . before opening a PR, and add tests for rule changes — the engine is where correctness lives.
This is an unofficial, fan-made project for learning and play. It is not affiliated with, endorsed by or sponsored by Mattel, Inc. UNO is a trademark of Mattel. All card artwork here is original CSS and SVG drawn for this project; no official artwork, logos or assets are used.
No license file is included yet — the copyright holder decides the terms. Until one is added, all rights are reserved.




