├── server ├── .gitignore ├── requirements.txt ├── tests │ └── test_uno.py ├── lib │ ├── env.py │ ├── events.py │ ├── notification.py │ ├── parser.py │ └── state.py ├── Makefile ├── app.py └── core │ └── uno.py ├── web ├── .prettierignore ├── public │ ├── robots.txt │ ├── 192x192.png │ ├── 512x512.png │ ├── favicon.ico │ ├── apple-touch-icon.png │ ├── manifest.json │ └── worker.js ├── src │ ├── vite-env.d.ts │ ├── types │ │ ├── api.ts │ │ ├── routes.ts │ │ ├── game.ts │ │ └── ws.ts │ ├── config │ │ ├── web.ts │ │ ├── server.ts │ │ └── game.ts │ ├── lib │ │ ├── socket.ts │ │ ├── state.ts │ │ ├── api.ts │ │ └── image.ts │ ├── components │ │ ├── footer.tsx │ │ ├── loader.tsx │ │ ├── errors │ │ │ └── root.tsx │ │ ├── cards │ │ │ ├── blank.tsx │ │ │ ├── stack.tsx │ │ │ └── uno.tsx │ │ ├── input.tsx │ │ ├── modals │ │ │ ├── confirm.tsx │ │ │ └── start.tsx │ │ ├── avatar.tsx │ │ ├── header.tsx │ │ ├── menus │ │ │ └── info.tsx │ │ └── game.tsx │ ├── assets │ │ └── images │ │ │ ├── cards │ │ │ ├── blank.svg │ │ │ ├── red │ │ │ │ ├── 1.svg │ │ │ │ ├── 7.svg │ │ │ │ ├── 4.svg │ │ │ │ ├── 5.svg │ │ │ │ ├── 0.svg │ │ │ │ ├── 3.svg │ │ │ │ ├── 6.svg │ │ │ │ ├── 9.svg │ │ │ │ ├── reverse.svg │ │ │ │ ├── skip.svg │ │ │ │ ├── 2.svg │ │ │ │ ├── 8.svg │ │ │ │ └── draw-two.svg │ │ │ ├── blue │ │ │ │ ├── 1.svg │ │ │ │ ├── 7.svg │ │ │ │ ├── 4.svg │ │ │ │ ├── 5.svg │ │ │ │ ├── 0.svg │ │ │ │ ├── 3.svg │ │ │ │ ├── 6.svg │ │ │ │ ├── 9.svg │ │ │ │ ├── reverse.svg │ │ │ │ ├── skip.svg │ │ │ │ ├── 2.svg │ │ │ │ ├── 8.svg │ │ │ │ └── draw-two.svg │ │ │ ├── green │ │ │ │ ├── 1.svg │ │ │ │ ├── 7.svg │ │ │ │ ├── 4.svg │ │ │ │ ├── 5.svg │ │ │ │ ├── 0.svg │ │ │ │ ├── 3.svg │ │ │ │ ├── 6.svg │ │ │ │ ├── 9.svg │ │ │ │ ├── reverse.svg │ │ │ │ ├── skip.svg │ │ │ │ ├── 2.svg │ │ │ │ ├── 8.svg │ │ │ │ └── draw-two.svg │ │ │ ├── yellow │ │ │ │ ├── 1.svg │ │ │ │ ├── 7.svg │ │ │ │ ├── 4.svg │ │ │ │ ├── 5.svg │ │ │ │ ├── 0.svg │ │ │ │ ├── 3.svg │ │ │ │ ├── 6.svg │ │ │ │ ├── 9.svg │ │ │ │ ├── reverse.svg │ │ │ │ ├── skip.svg │ │ │ │ ├── 2.svg │ │ │ │ ├── 8.svg │ │ │ │ └── draw-two.svg │ │ │ ├── black │ │ │ │ ├── draw-four.svg │ │ │ │ └── wild.svg │ │ │ └── back.svg │ │ │ └── logo.svg │ ├── pages │ │ ├── won.tsx │ │ ├── home.tsx │ │ └── play.tsx │ ├── styles │ │ └── global.css │ └── app.tsx ├── postcss.config.cjs ├── vite.config.ts ├── tsconfig.node.json ├── .prettierrc ├── tailwind.config.cjs ├── .gitignore ├── tsconfig.json ├── package.json └── index.html ├── docs └── images │ ├── game.png │ ├── home.png │ ├── host.png │ └── room.png ├── LICENSE └── README.md /server/.gitignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/** -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | pnpm-lock.yaml -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: AdsBot-Google 2 | Disallow: / -------------------------------------------------------------------------------- /web/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /docs/images/game.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/game.png -------------------------------------------------------------------------------- /docs/images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/home.png -------------------------------------------------------------------------------- /docs/images/host.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/host.png -------------------------------------------------------------------------------- /docs/images/room.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/docs/images/room.png -------------------------------------------------------------------------------- /web/public/192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/192x192.png -------------------------------------------------------------------------------- /web/public/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/512x512.png -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karanpratapsingh/uno/HEAD/web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /web/src/types/api.ts: -------------------------------------------------------------------------------- 1 | export type AllowPlayerResponse = { 2 | allow: boolean; 3 | reason: string | null; 4 | }; 5 | -------------------------------------------------------------------------------- /web/src/types/routes.ts: -------------------------------------------------------------------------------- 1 | export enum Routes { 2 | Home = '/', 3 | Play = '/play', 4 | Won = '/won', 5 | } 6 | -------------------------------------------------------------------------------- /web/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==2.2.3 2 | Flask-SocketIO==5.3.3 3 | Flask-Cors==3.0.10 4 | redis==4.5.2 5 | gunicorn==20.1.0 6 | eventlet==0.30.2 -------------------------------------------------------------------------------- /web/src/config/web.ts: -------------------------------------------------------------------------------- 1 | const protocol = import.meta.env.PROD ? 'https' : 'http'; 2 | export const WEB_HTTP_URL = `${protocol}://${window.location.host}`; 3 | -------------------------------------------------------------------------------- /server/tests/test_uno.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.append('..') 3 | from uno import Game, Player 4 | 5 | 6 | def test_uno(): 7 | # TODO: write unit tests 8 | return 9 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /server/lib/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ENVIRONMENT = os.getenv("ENVIRONMENT") or 'development' 4 | REDIS_URL = os.getenv("REDIS_URL") or 'redis://localhost:6379' 5 | WEB_URL = os.getenv("WEB_URL") or 'http://localhost:3000' 6 | -------------------------------------------------------------------------------- /web/src/config/server.ts: -------------------------------------------------------------------------------- 1 | export const SERVER_HTTP_URL = 2 | import.meta.env.VITE_SERVER_HTTP_URL || 'http://localhost:5000'; 3 | export const SERVER_WS_URL = 4 | import.meta.env.VITE_SERVER_WS_URL || 'ws://localhost:5000'; 5 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss", 4 | "prettier-plugin-organize-imports" 5 | ], 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "jsxSingleQuote": true, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /web/src/lib/socket.ts: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | import { SERVER_WS_URL } from '../config/server'; 3 | 4 | const socket = io(SERVER_WS_URL, { 5 | transports: ['websocket'], 6 | autoConnect: true, 7 | }); 8 | 9 | export default socket; 10 | -------------------------------------------------------------------------------- /web/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 5 | theme: { 6 | extend: {}, 7 | }, 8 | plugins: [require('daisyui'), require('prettier-plugin-tailwindcss')], 9 | }; 10 | -------------------------------------------------------------------------------- /server/lib/events.py: -------------------------------------------------------------------------------- 1 | GAME_ROOM = "game::room" 2 | GAME_START = "game::start" 3 | GAME_STATE = "game::state" 4 | GAME_NOTIFY = "game::notify" 5 | GAME_PLAY = "game::play" 6 | GAME_DRAW = "game::draw" 7 | GAME_OVER = "game::over" 8 | 9 | PLAYER_JOIN = "player::join" 10 | PLAYER_LEAVE = "player::leave" 11 | -------------------------------------------------------------------------------- /web/src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { version } from '../../package.json'; 2 | 3 | function Footer(): React.ReactElement { 4 | return ( 5 | 8 | ); 9 | } 10 | 11 | export default Footer; 12 | -------------------------------------------------------------------------------- /server/Makefile: -------------------------------------------------------------------------------- 1 | start-redis: 2 | docker pull redis:7.0 3 | docker run -d -p 6379:6379 --name redis redis:7.0 4 | 5 | stop-redis: 6 | docker container rm redis -f 7 | 8 | install: 9 | pip3.10 install -r requirements.txt 10 | 11 | dev: 12 | flask --app app --debug run 13 | 14 | debug: 15 | ENVIRONMENT=debug flask --app app --debug run -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/src/config/game.ts: -------------------------------------------------------------------------------- 1 | import { defaultHandSize } from '../lib/state'; 2 | import { GameAction, GameConfig } from '../types/game'; 3 | 4 | export const GAME_STATE_REFETCH_INTERVAL = 5 | import.meta.env.VITE_GAME_STATE_REFETCH_INTERVAL || 10_000; 6 | 7 | export const defaultConfig: GameConfig = { 8 | action: GameAction.Join, 9 | name: '', 10 | room: '', 11 | hand_size: defaultHandSize, 12 | }; 13 | -------------------------------------------------------------------------------- /web/src/lib/state.ts: -------------------------------------------------------------------------------- 1 | import { GameConfig } from '../types/game'; 2 | 3 | export const minHandSize = 3; 4 | export const defaultHandSize = 7; 5 | export const maxHandSize = 15; 6 | 7 | export function validateGameConfig(config: GameConfig): boolean { 8 | for (const key of ['action', 'name', 'room', 'hand_size']) { 9 | if (!Object.hasOwn(config, key)) { 10 | return false; 11 | } 12 | } 13 | return true; 14 | } 15 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blank.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | Empty card 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/src/components/loader.tsx: -------------------------------------------------------------------------------- 1 | interface LoaderProps { 2 | label?: string; 3 | } 4 | 5 | function Loader(props: LoaderProps): React.ReactElement { 6 | const { label } = props; 7 | return ( 8 |
9 |
10 | 11 | {label} 12 | 13 |
14 | ); 15 | } 16 | 17 | export default Loader; 18 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /web/src/lib/api.ts: -------------------------------------------------------------------------------- 1 | import { SERVER_HTTP_URL } from '../config/server'; 2 | import { AllowPlayerResponse } from '../types/api'; 3 | 4 | function defaultHeaders(): Headers { 5 | const headers = new Headers(); 6 | headers.append('Content-Type', 'application/json'); 7 | return headers; 8 | } 9 | 10 | export async function allowPlayer( 11 | action: string, 12 | name: string, 13 | room: string 14 | ): Promise { 15 | const res = await fetch(`${SERVER_HTTP_URL}/api/game/allow`, { 16 | method: 'POST', 17 | body: JSON.stringify({ action, name, room }), 18 | headers: defaultHeaders(), 19 | }); 20 | return res.json(); 21 | } 22 | -------------------------------------------------------------------------------- /web/src/components/errors/root.tsx: -------------------------------------------------------------------------------- 1 | import { VscError } from 'react-icons/vsc'; 2 | import { Link, useRouteError } from 'react-router-dom'; 3 | import { Routes } from '../../types/routes'; 4 | 5 | function RootErrorBoundary(): React.ReactElement { 6 | const error = useRouteError() as Error; 7 | 8 | return ( 9 |
10 | 11 | Error 12 | {error?.message} 13 | 14 | Reload 15 | 16 |
17 | ); 18 | } 19 | 20 | export default RootErrorBoundary; 21 | -------------------------------------------------------------------------------- /web/src/components/cards/blank.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useMemo } from 'react'; 3 | import { getBlankCardURL } from '../../lib/image'; 4 | import { cardSizes, UnoCardSizes } from './uno'; 5 | 6 | interface BlankCardProps { 7 | size?: UnoCardSizes; 8 | hidden?: boolean; 9 | } 10 | 11 | function BlankCard(props: BlankCardProps): React.ReactElement { 12 | const { size = 'default', hidden } = props; 13 | const imageSrc = useMemo(() => getBlankCardURL(hidden), []); 14 | 15 | return ( 16 | 21 | ); 22 | } 23 | 24 | export default BlankCard; 25 | -------------------------------------------------------------------------------- /web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "/?source=pwa", 3 | "start_url": "/?source=pwa", 4 | "name": "UNO", 5 | "short_name": "UNO", 6 | "description": "UNO multiplyer game", 7 | "theme_color": "#ffffff", 8 | "background_color": "#ffffff", 9 | "display": "standalone", 10 | "icons": [ 11 | { 12 | "src": "/favicon.ico", 13 | "sizes": "64x64 32x32 24x24 16x16", 14 | "type": "image/x-icon", 15 | "purpose": "any" 16 | }, 17 | { 18 | "src": "/192x192.png", 19 | "sizes": "192x192", 20 | "type": "image/png", 21 | "purpose": "any" 22 | }, 23 | { 24 | "src": "/512x512.png", 25 | "sizes": "512x512", 26 | "type": "image/png", 27 | "purpose": "maskable" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /web/src/types/game.ts: -------------------------------------------------------------------------------- 1 | export type Hands = Record; 2 | 3 | export enum Events { 4 | GAME_ROOM = 'game::room', 5 | GAME_START = 'game::start', 6 | GAME_STATE = 'game::state', 7 | GAME_NOTIFY = 'game::notify', 8 | GAME_PLAY = 'game::play', 9 | GAME_DRAW = 'game::draw', 10 | GAME_OVER = 'game::over', 11 | PLAYER_JOIN = 'player::join', 12 | PLAYER_LEAVE = 'player::leave', 13 | } 14 | 15 | export type GameConfig = { 16 | action: GameAction; 17 | name: string; 18 | room: string; 19 | hand_size: number; 20 | }; 21 | 22 | export enum GameAction { 23 | Host = 'Host', 24 | Join = 'Join', 25 | } 26 | 27 | export type Card = { 28 | id: string; 29 | color: string; 30 | value: string; 31 | }; 32 | 33 | export type Player = { 34 | id: string; 35 | name: string; 36 | }; 37 | -------------------------------------------------------------------------------- /web/src/pages/won.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Confetti from 'react-confetti'; 3 | import { Link, useLocation, useNavigate } from 'react-router-dom'; 4 | import { Routes } from '../types/routes'; 5 | 6 | function Won(): React.ReactElement { 7 | const { state } = useLocation(); 8 | const navigate = useNavigate(); 9 | 10 | useEffect(() => { 11 | if (!state || !('winner' in state)) { 12 | navigate(Routes.Home); 13 | } 14 | }, [state]); 15 | 16 | return ( 17 |
18 | 19 |

{state?.winner} won! 🎉

20 | 21 | Play again 22 | 23 |
24 | ); 25 | } 26 | 27 | export default Won; 28 | -------------------------------------------------------------------------------- /web/src/components/input.tsx: -------------------------------------------------------------------------------- 1 | interface InputProps { 2 | label: string; 3 | value: string | number; 4 | placeholder?: string; 5 | disabled?: boolean; 6 | onChange?(event: React.ChangeEvent): void; 7 | } 8 | 9 | function Input(props: InputProps): React.ReactElement { 10 | const { label, value, placeholder, disabled = false, onChange } = props; 11 | 12 | return ( 13 |
14 | 17 | 25 |
26 | ); 27 | } 28 | export default Input; 29 | -------------------------------------------------------------------------------- /server/lib/notification.py: -------------------------------------------------------------------------------- 1 | from flask_socketio import emit 2 | import lib.events as events 3 | 4 | 5 | class Notification: 6 | def __init__(self, room: str): 7 | self.room = room 8 | 9 | def info(self, message: str) -> None: 10 | emit(events.GAME_NOTIFY, self.format('info', message), to=self.room) 11 | 12 | def success(self, message: str) -> None: 13 | emit(events.GAME_NOTIFY, self.format('success', message), to=self.room) 14 | 15 | def warn(self, message: str) -> None: 16 | emit(events.GAME_NOTIFY, self.format('warn', message), to=self.room) 17 | 18 | def error(self, message: str) -> None: 19 | emit(events.GAME_NOTIFY, self.format('error', message), to=self.room) 20 | 21 | def format(self, notification_type: str, message: str) -> None: 22 | return {'type': notification_type, 'message': str(message)} 23 | -------------------------------------------------------------------------------- /web/src/types/ws.ts: -------------------------------------------------------------------------------- 1 | import { Card, Hands, Player } from './game'; 2 | 3 | export type GameStateResponse = { 4 | hands: Hands; 5 | top_card: Card; 6 | }; 7 | 8 | export type GameNotifyResponse = { 9 | type: string; 10 | message: string; 11 | }; 12 | 13 | export type GameRoomResponse = { 14 | players: Player[]; 15 | }; 16 | 17 | export enum GameOverReason { 18 | Won = 'won', 19 | Error = 'error', 20 | InsufficientPlayers = 'insufficient-players', 21 | } 22 | 23 | export type GameOverResponse = 24 | | GameOverWonResponse 25 | | GameOverErrorResponse 26 | | GameOverInsufficientPlayersResponse; 27 | 28 | type GameOverWonResponse = { 29 | reason: GameOverReason.Won; 30 | winner: string; 31 | }; 32 | 33 | type GameOverErrorResponse = { 34 | reason: GameOverReason.Error; 35 | }; 36 | 37 | type GameOverInsufficientPlayersResponse = { 38 | reason: GameOverReason.InsufficientPlayers; 39 | }; 40 | -------------------------------------------------------------------------------- /web/src/components/cards/stack.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { Card } from '../../types/game'; 3 | import BlankCard from './blank'; 4 | import UnoCard, { UnoCardSizes } from './uno'; 5 | 6 | interface CardStackProps { 7 | className?: string; 8 | card?: Card; 9 | size?: UnoCardSizes; 10 | hidden?: boolean; 11 | onClick?(): void; 12 | } 13 | 14 | function CardStack(props: CardStackProps): React.ReactElement { 15 | const { className, card, size = 'default', hidden, onClick } = props; 16 | 17 | return ( 18 |
onClick && onClick()} 21 | > 22 | {card &&
27 | ); 28 | } 29 | 30 | export default CardStack; 31 | -------------------------------------------------------------------------------- /web/src/lib/image.ts: -------------------------------------------------------------------------------- 1 | import { Card } from '../types/game'; 2 | 3 | /** 4 | * Uno card assets are Public Domain. Free for editorial, educational, commercial, 5 | * and/or personal projects. No attribution required. More info. 6 | * 7 | * Ref: https://creazilla.com/pages/4-license-information 8 | */ 9 | export function getCardImageURL(card: Card, hidden?: boolean): string { 10 | if (hidden) { 11 | return new URL('../assets/images/cards/back.svg', import.meta.url).href; 12 | } 13 | 14 | return new URL( 15 | `../assets/images/cards/${card.color}/${card.value}.svg`, 16 | import.meta.url 17 | ).href; 18 | } 19 | 20 | export function getBlankCardURL(hidden?: boolean): string { 21 | if (hidden) { 22 | return new URL('../assets/images/cards/back.svg', import.meta.url).href; 23 | } 24 | 25 | return new URL('../assets/images/cards/blank.svg', import.meta.url).href; 26 | } 27 | 28 | export const logoURL = new URL('../assets/images/logo.svg', import.meta.url) 29 | .href; 30 | -------------------------------------------------------------------------------- /web/src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | html { 8 | font-family: 'Roboto', sans-serif; 9 | } 10 | 11 | body { 12 | margin: 0; 13 | -webkit-font-smoothing: antialiased; 14 | -moz-osx-font-smoothing: grayscale; 15 | } 16 | 17 | #root { 18 | display: flex; 19 | height: 100dvh; 20 | width: 100vw; 21 | @apply p-4; 22 | } 23 | 24 | .no-pointer { 25 | cursor: default; 26 | } 27 | 28 | .loader { 29 | @apply border-4; 30 | @apply border-gray-100; 31 | @apply border-t-4; 32 | @apply border-t-black; 33 | @apply h-10; 34 | @apply w-10; 35 | border-radius: 50%; 36 | animation: spin 0.8s linear infinite; 37 | } 38 | 39 | .btn { 40 | font-weight: 500; 41 | } 42 | 43 | @keyframes spin { 44 | 0% { 45 | transform: rotate(0deg); 46 | } 47 | 100% { 48 | transform: rotate(360deg); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/lib/parser.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Tuple 2 | 3 | 4 | def parse_data_args(data: Dict[str, Any], args: List[str]) -> List[Any]: 5 | missing_args = [] 6 | values = [] 7 | 8 | for arg in args: 9 | if arg not in data: 10 | missing_args.append(arg) 11 | else: 12 | values.append(data[arg]) 13 | 14 | if missing_args != []: 15 | raise Exception(f'missing args: {", ".join(missing_args)}') 16 | 17 | return values 18 | 19 | 20 | def parse_object(obj) -> Any: 21 | return obj.__dict__ 22 | 23 | 24 | def parse_object_list(objects) -> List[Any]: 25 | return [obj.__dict__ for obj in list(objects)] 26 | 27 | 28 | def parse_game_state(state) -> Dict[str, Any]: 29 | (hands, top_card) = state 30 | parsed_hands = {key.id: parse_object_list( 31 | value) for key, value in hands.items()} 32 | parsed_top_card = parse_object(top_card) 33 | 34 | return { 35 | 'hands': parsed_hands, 36 | 'top_card': parsed_top_card 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Karan Pratap Singh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /web/src/components/modals/confirm.tsx: -------------------------------------------------------------------------------- 1 | interface ConfirmModalProps { 2 | onConfirm(): void; 3 | } 4 | 5 | function ConfirmModal(props: ConfirmModalProps): React.ReactElement { 6 | const { onConfirm } = props; 7 | 8 | return ( 9 | <> 10 | 15 |
16 |
17 |

Leave Game

18 |

Are you sure you want to leave the game?

19 |
20 | 26 | 29 |
30 |
31 |
32 | 33 | ); 34 | } 35 | 36 | export default ConfirmModal; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [UNO](https://uno-web-4m6k.onrender.com) 2 | 3 | Classic UNO card game implemented with React and Python using [Socket.IO](https://socket.io/) for realtime multiplayer functionality. 4 | 5 | _Note: I made this for fun and personal use, please don't use this code in production._ 6 | 7 | ## 📷 Screenshots 8 | 9 | 10 | 11 | 12 | ## 💻 Development 13 | 14 | **Server** 15 | 16 | ``` 17 | $ cd server 18 | $ make start-redis 19 | $ make dev 20 | ``` 21 | 22 | _Note: Make sure docker is running._ 23 | 24 | **Web** 25 | 26 | ``` 27 | $ cd web 28 | $ pnpm install 29 | $ pnpm run dev 30 | ``` 31 | 32 | ## 📖 TODO 33 | 34 | - [ ] Better game validation rules 35 | - [ ] Implement game log 36 | - [ ] Host can kick player from room 37 | - [ ] Place chance logic 38 | - [ ] Generate random player name 39 | - [ ] Player can remove a card from hand 40 | - [ ] Migrate to a message broker like NATS? 41 | -------------------------------------------------------------------------------- /web/src/components/avatar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface AvatarProps { 4 | className?: string; 5 | name: string; 6 | size?: 'small' | 'default'; 7 | type?: 'row' | 'col'; 8 | } 9 | 10 | function Avatar(props: AvatarProps): React.ReactElement { 11 | const { className, name, size = 'default', type = 'col' } = props; 12 | 13 | return ( 14 |
21 |
28 | 31 |
32 | 35 | {name} 36 | 37 |
38 | ); 39 | } 40 | 41 | export default Avatar; 42 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 1 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 1 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 1 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 1 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 7 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | import { logoURL } from '../lib/image'; 3 | import { GameConfig } from '../types/game'; 4 | import InfoMenu from './menus/info'; 5 | import ConfirmModal from './modals/confirm'; 6 | 7 | interface HeaderProps { 8 | socket: Socket; 9 | isConnected: boolean; 10 | config: GameConfig; 11 | onLeave(): void; 12 | } 13 | 14 | function Header(props: HeaderProps): React.ReactElement { 15 | const { isConnected, config, onLeave } = props; 16 | 17 | return ( 18 |
19 |
20 | 21 |
22 |
23 | uno logo 24 |
25 |
26 | 32 |
33 | 34 |
35 | ); 36 | } 37 | 38 | export default Header; 39 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 7 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 7 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 7 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uno", 3 | "private": true, 4 | "version": "1.0.2", 5 | "type": "module", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "vite", 9 | "dev": "vite --port 3000", 10 | "build": "tsc && vite build", 11 | "preview": "pnpm run build && vite preview --port 3000", 12 | "lint": "prettier --write ." 13 | }, 14 | "dependencies": { 15 | "clsx": "1.2.1", 16 | "daisyui": "2.51.4", 17 | "react": "18.2.0", 18 | "react-confetti": "6.1.0", 19 | "react-dom": "18.2.0", 20 | "react-icons": "4.8.0", 21 | "react-router-dom": "6.9.0", 22 | "react-toastify": "9.1.1", 23 | "shortid": "2.2.16", 24 | "socket.io-client": "4.6.1" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "18.0.28", 28 | "@types/react-dom": "18.0.11", 29 | "@types/shortid": "0.0.29", 30 | "@vitejs/plugin-react": "3.1.0", 31 | "autoprefixer": "10.4.14", 32 | "postcss": "8.4.21", 33 | "prettier": "2.8.4", 34 | "prettier-plugin-organize-imports": "^3.2.2", 35 | "prettier-plugin-tailwindcss": "0.2.5", 36 | "tailwindcss": "3.2.7", 37 | "typescript": "4.9.3", 38 | "vite": "4.2.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/public/worker.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'uno'; 2 | 3 | // Add the routes you want to cache in an offline PWA 4 | const urlsToCache = ['/']; 5 | 6 | // Install a service worker 7 | self.addEventListener('install', event => { 8 | // Perform install steps 9 | event.waitUntil( 10 | caches.open(CACHE_NAME).then(cache => { 11 | console.log('Opened cache'); 12 | return cache.addAll(urlsToCache); 13 | }) 14 | ); 15 | }); 16 | 17 | // Cache and return requests 18 | self.addEventListener('fetch', event => { 19 | event.respondWith( 20 | caches.match(event.request).then(response => { 21 | // Cache hit - return response 22 | if (response) { 23 | return response; 24 | } 25 | return fetch(event.request); 26 | }) 27 | ); 28 | }); 29 | 30 | // Update service worker 31 | self.addEventListener('activate', event => { 32 | const cacheWhitelist = [CACHE_NAME]; 33 | event.waitUntil( 34 | caches.keys().then(cacheNames => { 35 | return Promise.all( 36 | cacheNames.map(cacheName => { 37 | if (cacheWhitelist.indexOf(cacheName) === -1) { 38 | return caches.delete(cacheName); 39 | } 40 | }) 41 | ); 42 | }) 43 | ); 44 | }); 45 | -------------------------------------------------------------------------------- /web/src/app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { createBrowserRouter, RouterProvider } from 'react-router-dom'; 4 | import { Slide, ToastContainer } from 'react-toastify'; 5 | import 'react-toastify/dist/ReactToastify.css'; 6 | import RootErrorBoundary from './components/errors/root'; 7 | import Home from './pages/home'; 8 | import Play from './pages/play'; 9 | import Won from './pages/won'; 10 | import './styles/global.css'; 11 | import { Routes } from './types/routes'; 12 | 13 | const router = createBrowserRouter([ 14 | { 15 | path: Routes.Home, 16 | element: , 17 | errorElement: , 18 | }, 19 | { 20 | path: Routes.Play, 21 | element: , 22 | errorElement: , 23 | }, 24 | { 25 | path: Routes.Won, 26 | element: , 27 | errorElement: , 28 | }, 29 | ]); 30 | 31 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 32 | 33 | 34 | 40 | 41 | ); 42 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 4 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 4 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 4 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 4 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 5 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 5 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 5 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/5.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 5 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/components/menus/info.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { AiOutlineInfoCircle } from 'react-icons/ai'; 3 | import { GameConfig } from '../../types/game'; 4 | 5 | interface InfoMenuProps { 6 | isConnected: boolean; 7 | config: GameConfig; 8 | } 9 | 10 | function InfoMenu(props: InfoMenuProps): React.ReactElement { 11 | const { isConnected, config } = props; 12 | 13 | return ( 14 |
15 | 16 |
20 |
21 |
22 | status 23 |
30 | {isConnected ? 'online' : 'offline'} 31 |
32 |
33 |

34 | player {config.name} 35 |

36 |

37 | room {config.room} 38 |

39 |
40 |
41 |
42 | ); 43 | } 44 | 45 | export default InfoMenu; 46 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 0 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 9 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 0 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/0.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 0 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | UNO 11 | 12 | 13 | 14 |
15 | 16 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /web/src/components/cards/uno.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { useMemo } from 'react'; 3 | import { getCardImageURL } from '../../lib/image'; 4 | import { Card, Player } from '../../types/game'; 5 | 6 | export type UnoCardSizes = 'large' | 'default'; 7 | 8 | interface UnoCardProps { 9 | card: Card; 10 | currentPlayer?: Player; 11 | hidden?: boolean; 12 | size?: UnoCardSizes; 13 | onClick?(playerId: string, cardId: string): void; 14 | } 15 | 16 | export const cardSizes: Record = { 17 | default: 'h-36 w-20 md:h-44 md:w-28', 18 | large: 'h-44 w-28 md:h-48 md:w-32', 19 | }; 20 | 21 | function UnoCard(props: UnoCardProps): React.ReactElement { 22 | const { 23 | currentPlayer, 24 | size = 'default', 25 | card, 26 | hidden = false, 27 | onClick, 28 | } = props; 29 | 30 | const allowPlay = onClick && currentPlayer; 31 | const imageSrc = useMemo( 32 | () => getCardImageURL(card, hidden), 33 | [card.id, hidden] 34 | ); 35 | 36 | return ( 37 | 48 | ); 49 | } 50 | 51 | export default UnoCard; 52 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 3 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 3 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 3 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 3 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 6 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 9 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/reverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red Reverse card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 6 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 6 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 6 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 9 card_1 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 9 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/9.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 9 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/reverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue Reverse card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/reverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Green Reverse card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/reverse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow Reverse card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/skip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red Skip card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/skip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue Skip card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/skip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Green Skip card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/skip.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow Skip card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 2 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 2 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 2 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Red 8 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 2 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Blue 8 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Gren 8 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/8.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | Yellow 8 card 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /web/src/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | import { toast } from 'react-toastify'; 3 | import Footer from '../components/footer'; 4 | import StartModal from '../components/modals/start'; 5 | import { allowPlayer } from '../lib/api'; 6 | import { logoURL } from '../lib/image'; 7 | import { maxHandSize, minHandSize } from '../lib/state'; 8 | import { GameAction, GameConfig } from '../types/game'; 9 | import { Routes } from '../types/routes'; 10 | 11 | function Home(): React.ReactElement { 12 | const navigate = useNavigate(); 13 | 14 | async function onStart( 15 | action: GameAction, 16 | name: string, 17 | room: string, 18 | hand_size: number 19 | ): Promise { 20 | try { 21 | const { allow, reason } = await allowPlayer(action, name, room); 22 | if (!allow) { 23 | toast.error(reason); 24 | return; 25 | } 26 | 27 | if (action === GameAction.Host) { 28 | if (hand_size > maxHandSize) { 29 | toast.error(`hand size should not be greater than ${maxHandSize}`); 30 | return; 31 | } 32 | 33 | if (hand_size < minHandSize) { 34 | toast.error(`hand size should not be less than ${minHandSize}`); 35 | return; 36 | } 37 | } 38 | 39 | navigate(Routes.Play, { 40 | state: { action, name, room, hand_size } satisfies GameConfig, 41 | }); 42 | } catch (error) { 43 | console.error(error); 44 | toast.error('encountered error while joining the game'); 45 | } 46 | } 47 | 48 | return ( 49 |
50 |
51 | uno logo 52 |
53 | 59 | 65 |
66 |
67 | 68 | 69 |
70 |
71 | ); 72 | } 73 | 74 | export default Home; 75 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/red/draw-two.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | Red Draw Two card 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/blue/draw-two.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | Blue Draw Two card 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/green/draw-two.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | Green Draw Two card 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/yellow/draw-two.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 24 | 25 | Yellow Draw Two card 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/black/draw-four.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 36 | 37 | Wild Draw Four card 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /web/src/components/modals/start.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useSearchParams } from 'react-router-dom'; 3 | import shortid from 'shortid'; 4 | import { defaultHandSize } from '../../lib/state'; 5 | import { GameAction } from '../../types/game'; 6 | import Input from '../input'; 7 | 8 | interface StartModalProps { 9 | action: GameAction; 10 | onStart( 11 | action: GameAction, 12 | name: string, 13 | room: string, 14 | hand_size: number 15 | ): void; 16 | } 17 | 18 | function StartModal(props: StartModalProps): React.ReactElement { 19 | const { action, onStart } = props; 20 | const [name, setName] = useState(''); 21 | const [room, setRoom] = useState(''); 22 | const [handSize, setHandSize] = useState(defaultHandSize); 23 | 24 | const [queryParams, setQueryParams] = useSearchParams(); 25 | 26 | useEffect(() => { 27 | const room = queryParams.get('join'); 28 | if (room) { 29 | setRoom(room); 30 | const modal = document.getElementById( 31 | `${GameAction.Join}-modal` 32 | ) as HTMLInputElement; 33 | if (modal) { 34 | modal.checked = true; 35 | setQueryParams(); 36 | } 37 | } 38 | }, [queryParams]); 39 | 40 | useEffect(() => { 41 | if (action === GameAction.Host) { 42 | setRoom(shortid.generate()); 43 | } 44 | }, [action]); 45 | 46 | function onNameChange(event: React.ChangeEvent): void { 47 | const { value } = event.target; 48 | setName(value); 49 | } 50 | 51 | function onRoomChange(event: React.ChangeEvent): void { 52 | const { value } = event.target; 53 | setRoom(value); 54 | } 55 | 56 | function onHandSizeChange(event: React.ChangeEvent): void { 57 | const value = Number.parseInt(event.target.value); 58 | 59 | if (Number.isNaN(value)) { 60 | setHandSize(0); 61 | } else { 62 | setHandSize(value); 63 | } 64 | } 65 | 66 | return ( 67 | <> 68 | 69 |
70 |
71 |

{action}

72 |

{action} a game by entering some details

73 | 79 | 86 | {action === GameAction.Host && ( 87 | 92 | )} 93 |
94 | 100 | 106 |
107 |
108 |
109 | 110 | ); 111 | } 112 | 113 | export default StartModal; 114 | -------------------------------------------------------------------------------- /server/lib/state.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import pickle 3 | from typing import Optional, Set 4 | 5 | from core.uno import Game, Player 6 | from lib.env import REDIS_URL 7 | from redis import Redis 8 | 9 | log = logging.getLogger('state') 10 | log.setLevel(logging.INFO) 11 | 12 | GAME_EXPIRATION_TIME = 86_400 # 1 day 13 | ROOM_EXPIRATION_TIME = 86_400 # 1 day 14 | 15 | 16 | class State: 17 | def __init__(self): 18 | self.redis = Redis.from_url(REDIS_URL) 19 | 20 | def allow_player(self, action: str, room: str, player: Player) -> (bool, Optional[str]): 21 | # Validate player 22 | if not player.name or player.name == '': 23 | return (False, f'name cannot be blank') 24 | 25 | if ' ' in player.name: 26 | return (False, f'name should not contain white spaces') 27 | 28 | # Validate room 29 | if room == '': 30 | return (False, f'room should not be empty') 31 | 32 | if action == "Join": # Check if room exists 33 | exists = bool(self.redis.exists(f'players_{room}')) 34 | if not exists: 35 | return (False, f'cannot join game, room {room} does not exist') 36 | 37 | # Validate game 38 | started = bool(self.get_game_by_room(room)) 39 | players = self.get_players_by_room(room) 40 | 41 | if len(players) == Game.MAX_PLAYERS_ALLOWED: 42 | return (False, f"room is full, max {Game.MAX_PLAYERS_ALLOWED} players are supported") 43 | 44 | if started: 45 | if player not in players: 46 | return (False, f'cannot join, game in the room {room} has already started') 47 | else: 48 | if player in players: 49 | return (False, f"name {player.name} is already taken for this room, try a different name") 50 | 51 | return (True, None) 52 | 53 | def get_game_by_room(self, room: str) -> Optional[Game]: 54 | obj = self.redis.get(f'game_{room}') 55 | if not obj: 56 | return None 57 | 58 | return pickle.loads(obj) 59 | 60 | def add_game_to_room(self, room: str, game: Game) -> None: 61 | obj = pickle.dumps(game) 62 | self.redis.set(f'game_{room}', obj, ex=GAME_EXPIRATION_TIME) 63 | 64 | def update_game_in_room(self, room: str, game: Game) -> None: 65 | obj = pickle.dumps(game) 66 | self.redis.set(f'game_{room}', obj, ex=GAME_EXPIRATION_TIME) 67 | 68 | def get_players_by_room(self, room: str) -> Set[Player]: 69 | obj = self.redis.get(f'players_{room}') 70 | if not obj: 71 | return set() 72 | 73 | return pickle.loads(obj) 74 | 75 | def add_player_to_room(self, room: str, player: Player) -> None: 76 | log.info(f"adding player {player} to room {room}") 77 | 78 | players = self.get_players_by_room(room) 79 | players.add(player) 80 | 81 | obj = pickle.dumps(players) 82 | self.redis.set(f'players_{room}', obj, ex=ROOM_EXPIRATION_TIME) 83 | 84 | def remove_player_from_room(self, room: str, player: Player) -> None: 85 | log.info(f"removing player {player} from room {room}") 86 | 87 | players = self.get_players_by_room(room) 88 | players.remove(player) 89 | 90 | obj = pickle.dumps(players) 91 | self.redis.set(f'players_{room}', obj, ex=ROOM_EXPIRATION_TIME) 92 | 93 | def delete_all(self, room: str) -> None: 94 | self.delete_room(room) 95 | self.delete_game(room) 96 | 97 | def delete_room(self, room: str) -> None: 98 | self.redis.delete(f'players_{room}') 99 | log.info(f"deleted {room}") 100 | 101 | def delete_game(self, room: str) -> None: 102 | self.redis.delete(f'game_{room}') 103 | log.info(f"deleted game for room {room}") 104 | -------------------------------------------------------------------------------- /web/src/components/game.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Socket } from 'socket.io-client'; 4 | import { GAME_STATE_REFETCH_INTERVAL } from '../config/game'; 5 | import { Card, Events, Hands, Player } from '../types/game'; 6 | import { Routes } from '../types/routes'; 7 | import { 8 | GameOverReason, 9 | GameOverResponse, 10 | GameStateResponse, 11 | } from '../types/ws'; 12 | import Avatar from './avatar'; 13 | import CardStack from './cards/stack'; 14 | import UnoCard from './cards/uno'; 15 | import Loader from './loader'; 16 | 17 | interface GameProps { 18 | currentPlayer: Player; 19 | players: Player[]; 20 | socket: Socket; 21 | started: boolean; 22 | room: string; 23 | } 24 | 25 | function Game(props: GameProps): React.ReactElement { 26 | const { socket, currentPlayer, players, started, room } = props; 27 | const navigate = useNavigate(); 28 | 29 | const [hands, setHands] = useState(null); 30 | const [topCard, setTopCard] = useState(null); 31 | 32 | useEffect(() => { 33 | const intervalId = setInterval(() => { 34 | socket.emit(Events.GAME_STATE, { room }); 35 | }, GAME_STATE_REFETCH_INTERVAL); 36 | 37 | return () => { 38 | clearInterval(intervalId); 39 | }; 40 | }, []); 41 | 42 | useEffect(() => { 43 | function onGameState(data: GameStateResponse): void { 44 | setHands(data.hands); 45 | setTopCard(data.top_card); 46 | } 47 | socket.on(Events.GAME_STATE, onGameState); 48 | 49 | function onGameOver(data: GameOverResponse): void { 50 | const { reason } = data; 51 | 52 | switch (reason) { 53 | case GameOverReason.Won: 54 | const { winner } = data; 55 | navigate(Routes.Won, { state: { winner } }); 56 | break; 57 | case GameOverReason.InsufficientPlayers: 58 | setTimeout(() => { 59 | navigate(0); // Refresh 60 | }, 5000); 61 | break; 62 | } 63 | } 64 | socket.on(Events.GAME_OVER, onGameOver); 65 | 66 | return () => { 67 | socket.off(Events.GAME_STATE, onGameState); 68 | socket.off(Events.GAME_OVER, onGameState); 69 | }; 70 | }, []); 71 | 72 | function playCard(playerId: string, cardId: string): void { 73 | socket.emit(Events.GAME_PLAY, { 74 | player_id: playerId, 75 | card_id: cardId, 76 | room, 77 | }); 78 | } 79 | 80 | function drawCard(): void { 81 | socket.emit(Events.GAME_DRAW, { player_id: currentPlayer.id, room }); 82 | } 83 | 84 | const gameLoaded = started && hands && topCard && players.length > 1; 85 | 86 | if (!gameLoaded) { 87 | return ; 88 | } 89 | 90 | const [otherPlayer] = players.filter(p => p.id !== currentPlayer.id); 91 | const otherCards = hands[otherPlayer.id]; 92 | 93 | const ownCards = hands[currentPlayer.id]; 94 | 95 | return ( 96 |
97 | {/* Other player */} 98 |
99 | 105 | 106 |
107 |
108 | {otherCards.map((card: Card, index: number) => ( 109 |
117 |
118 |
119 | 120 | {/* Card space */} 121 |
122 |
134 | 135 | {/* Current Player */} 136 |
137 |
138 |
139 | {ownCards.map((card: Card, index: number) => ( 140 | 146 | ))} 147 |
148 |
149 | 155 |
156 |
157 | ); 158 | } 159 | 160 | export default Game; 161 | -------------------------------------------------------------------------------- /web/src/pages/play.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react'; 2 | import { RiShareForwardFill } from 'react-icons/ri'; 3 | import { useLocation, useNavigate } from 'react-router-dom'; 4 | import { toast } from 'react-toastify'; 5 | import Avatar from '../components/avatar'; 6 | import Game from '../components/game'; 7 | import Header from '../components/header'; 8 | import Loader from '../components/loader'; 9 | import { defaultConfig } from '../config/game'; 10 | import { WEB_HTTP_URL } from '../config/web'; 11 | import socket from '../lib/socket'; 12 | import { validateGameConfig } from '../lib/state'; 13 | import { Events, GameConfig, Player } from '../types/game'; 14 | import { Routes } from '../types/routes'; 15 | import { GameNotifyResponse, GameRoomResponse } from '../types/ws'; 16 | 17 | function Play(): React.ReactElement { 18 | const navigate = useNavigate(); 19 | const { state } = useLocation(); 20 | const isValidState = state && validateGameConfig(state); 21 | 22 | useEffect(() => { 23 | if (!isValidState) { 24 | navigate(Routes.Home); 25 | toast.warn('no active game found, please host or join a game'); 26 | } 27 | }, [isValidState]); 28 | 29 | const [isConnected, setIsConnected] = useState(socket.connected); 30 | const [started, setStarted] = useState(false); 31 | 32 | const [players, setPlayers] = useState([]); 33 | const config = useMemo( 34 | () => ({ 35 | action: state?.action || defaultConfig.action, 36 | name: state?.name || defaultConfig.name, 37 | room: state?.room || defaultConfig.room, 38 | hand_size: state?.hand_size || defaultConfig.hand_size, 39 | }), 40 | [state] 41 | ); 42 | 43 | useEffect(() => { 44 | const { name, room } = config; 45 | if (name !== defaultConfig.name && room !== defaultConfig.room) { 46 | socket.emit(Events.PLAYER_JOIN, { name, room }); 47 | } 48 | }, [config]); 49 | 50 | useEffect(() => { 51 | function onConnect(): void { 52 | setIsConnected(true); 53 | } 54 | socket.on('connect', onConnect); 55 | 56 | function onDisconnect(): void { 57 | setIsConnected(false); 58 | } 59 | socket.on('disconnect', onDisconnect); 60 | 61 | function onGameNotify(data: GameNotifyResponse): void { 62 | const { type, message } = data; 63 | switch (type) { 64 | case 'info': 65 | toast.info(message); 66 | break; 67 | case 'success': 68 | toast.success(message); 69 | break; 70 | case 'warn': 71 | toast.warn(message); 72 | break; 73 | case 'error': 74 | toast.error(message); 75 | break; 76 | } 77 | } 78 | socket.on(Events.GAME_NOTIFY, onGameNotify); 79 | 80 | function onGameRoom(data: GameRoomResponse): void { 81 | setPlayers(data.players); 82 | } 83 | socket.on(Events.GAME_ROOM, onGameRoom); 84 | 85 | function onGameStart(): void { 86 | setStarted(true); 87 | } 88 | socket.on(Events.GAME_START, onGameStart); 89 | 90 | return () => { 91 | socket.off('connect', onConnect); 92 | socket.off('disconnect', onDisconnect); 93 | socket.off(Events.GAME_NOTIFY, onGameNotify); 94 | socket.off(Events.GAME_ROOM, onGameRoom); 95 | socket.off(Events.GAME_START, onGameStart); 96 | }; 97 | }, []); 98 | 99 | useEffect(() => { 100 | function onReconnect() { 101 | const { name, room, hand_size } = config; 102 | // Re-join on reconnect 103 | socket.emit(Events.PLAYER_JOIN, { name, room }); 104 | 105 | if (started) { 106 | // Restart the game if game was already started 107 | socket.emit(Events.GAME_START, { room, hand_size }); 108 | } 109 | } 110 | socket.io.on('reconnect', onReconnect); 111 | 112 | return () => { 113 | socket.io.off('reconnect', onReconnect); 114 | }; 115 | }, [started, config]); 116 | 117 | function onGameStart(): void { 118 | const { room, hand_size } = config; 119 | socket.emit(Events.GAME_START, { room, hand_size }); 120 | } 121 | 122 | function onLeave(): void { 123 | const { name, room } = config; 124 | socket.emit(Events.PLAYER_LEAVE, { name, room }); 125 | navigate(Routes.Home); 126 | } 127 | 128 | async function onCopyLink(): Promise { 129 | const url = `${WEB_HTTP_URL}?join=${config.room}`; 130 | 131 | if ('clipboard' in navigator) { 132 | await navigator.clipboard.writeText(url); 133 | toast.success('copied url to the clipboard'); 134 | } else { 135 | toast.error(`cannot copy to the clipboard, use url ${url}`, { 136 | draggable: false, 137 | closeOnClick: false, 138 | }); 139 | } 140 | } 141 | 142 | let content: React.ReactNode = null; 143 | const currentPlayer = players.find(player => player.name == config.name); 144 | 145 | if (started && currentPlayer) { 146 | content = ( 147 | 154 | ); 155 | } else { 156 | let status = 'Waiting for more players to the join...'; 157 | 158 | if (players.length > 1) { 159 | status = 'Waiting for the game to start...'; 160 | } 161 | 162 | content = ( 163 |
164 |
165 | {players.map((player: Player) => ( 166 | 167 | ))} 168 |
169 | {status} 170 | 173 | 177 |
178 | ); 179 | } 180 | 181 | return ( 182 |
183 |
189 | {isConnected ? content : } 190 |
191 | ); 192 | } 193 | 194 | export default Play; 195 | -------------------------------------------------------------------------------- /server/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | 4 | from flask import Flask, Response, request 5 | from flask_cors import CORS 6 | from flask_socketio import SocketIO, emit, join_room, leave_room 7 | 8 | from lib import env 9 | from lib import events 10 | from core.uno import Game, GameOverReason, Player 11 | from lib.notification import Notification 12 | from lib.parser import parse_data_args, parse_game_state, parse_object_list 13 | from lib.state import State 14 | 15 | logging.basicConfig(format='%(levelname)s[%(name)s]: %(message)s') 16 | log = logging.getLogger(__name__) 17 | log.setLevel(logging.INFO) 18 | 19 | # Server config 20 | app = Flask(__name__) 21 | CORS(app, resources={r"/*": {"origins": env.WEB_URL}}) 22 | socketio = SocketIO(app, cors_allowed_origins=env.WEB_URL) 23 | 24 | state = State() 25 | 26 | 27 | @app.get('/healthz') 28 | def healthcheck(): 29 | return Response(status=200) 30 | 31 | 32 | @app.post('/api/game/allow') 33 | def allow_player(): 34 | try: 35 | action, name, room = parse_data_args(request.json, ['action', 'name', 'room']) 36 | player = Player(name) 37 | allow, reason = state.allow_player(action, room, player) 38 | 39 | return {'allow': allow, 'reason': reason} 40 | except Exception as ex: 41 | log.error(ex) 42 | return {'allow': False, 'reason': str(ex)} 43 | 44 | 45 | @socketio.on(events.PLAYER_JOIN) 46 | def on_join(data): 47 | try: 48 | name, room = parse_data_args(data, ['name', 'room']) 49 | 50 | player = Player(name) 51 | state.add_player_to_room(room, player) 52 | join_room(room) 53 | log.info(f"{player} has joined the room {room}") 54 | 55 | # Start game if game already exists 56 | game = state.get_game_by_room(room) 57 | if game: 58 | emit(events.GAME_START, to=room) 59 | 60 | # Add extra player for debugging 61 | if env.ENVIRONMENT == "debug": 62 | dev_player = Player("developer") 63 | state.add_player_to_room(room, dev_player) 64 | 65 | players = state.get_players_by_room(room) 66 | emit(events.GAME_ROOM, {'players': parse_object_list(players)}, to=room) 67 | except Exception as ex: 68 | log.error(ex) 69 | 70 | 71 | @socketio.on(events.PLAYER_LEAVE) 72 | def on_leave(data): 73 | try: 74 | name, room = parse_data_args(data, ['name', 'room']) 75 | 76 | player = Player(name) 77 | 78 | # Remove player from game 79 | game = state.get_game_by_room(room) 80 | if game: 81 | game.remove_player(player) 82 | 83 | # Remove player from room 84 | state.remove_player_from_room(room, player) 85 | state.update_game_in_room(room, game) 86 | 87 | # Leave the room 88 | leave_room(room) 89 | 90 | log.info(f"{player} has left the room {room}") 91 | Notification(room).error(f'{player.name} has left the game') 92 | 93 | # Delete game if insufficient players 94 | players = state.get_players_by_room(room) 95 | if len(players) < 2: 96 | state.delete_game(room) 97 | emit(events.GAME_OVER, {'reason': GameOverReason.INSUFFICIENT_PLAYERS.value}, to=room) 98 | 99 | emit(events.GAME_ROOM, {'players': parse_object_list(players)}, to=room) 100 | except Exception as ex: 101 | log.error(ex) 102 | 103 | 104 | @socketio.on(events.GAME_START) 105 | def on_game_start(data): 106 | try: 107 | room, hand_size = parse_data_args(data, ['room', 'hand_size']) 108 | 109 | game = state.get_game_by_room(room) 110 | players = state.get_players_by_room(room) 111 | 112 | if not game: # Start a new game 113 | try: 114 | game = Game(room, players, hand_size) 115 | state.add_game_to_room(room, game) 116 | except Exception as ex: 117 | Notification(room).error(ex) 118 | raise Exception("failed to start the game") from ex 119 | 120 | log.info(f"starting a new game in room {room} with players {players}") 121 | 122 | try: 123 | game_state = game.get_state() 124 | emit(events.GAME_START, to=room) 125 | emit(events.GAME_STATE, parse_game_state(game_state), to=room) 126 | except Exception as ex: 127 | Notification(room).error(ex) 128 | raise Exception("failed to get game state") from ex 129 | 130 | except Exception as ex: 131 | log.error(ex) 132 | 133 | 134 | @socketio.on(events.GAME_DRAW) 135 | def on_draw_card(data): 136 | try: 137 | room, player_id = parse_data_args(data, ['room', 'player_id']) 138 | 139 | game = state.get_game_by_room(room) 140 | game.draw(player_id) 141 | game_state = game.get_state() 142 | emit(events.GAME_STATE, parse_game_state(game_state), to=room) 143 | state.update_game_in_room(room, game) 144 | except Exception as ex: 145 | log.error(ex) 146 | 147 | 148 | @socketio.on(events.GAME_PLAY) 149 | def on_play_game(data): 150 | try: 151 | room, player_id, card_id = parse_data_args(data, ['room', 'player_id', 'card_id']) 152 | 153 | game = state.get_game_by_room(room) 154 | 155 | def on_game_over(reason: GameOverReason, data: Any): 156 | nonlocal room 157 | 158 | if reason == GameOverReason.WON: 159 | log.info(f'player {data} won game {room}') 160 | emit(events.GAME_OVER, {'reason': reason.value, 'winner': data.name}, to=room) 161 | state.delete_all(room) 162 | 163 | try: 164 | game.play(player_id, card_id, on_game_over) 165 | except ValueError as e: 166 | log.error(e) 167 | 168 | game_state = game.get_state() 169 | emit(events.GAME_STATE, parse_game_state(game_state), to=room) 170 | state.update_game_in_room(room, game) 171 | except Exception as ex: 172 | log.error(ex) 173 | 174 | 175 | @socketio.on(events.GAME_STATE) 176 | def on_game_state(data): 177 | try: 178 | room = parse_data_args(data, ['room'])[0] 179 | 180 | game = state.get_game_by_room(room) 181 | if game: 182 | game_state = game.get_state() 183 | emit(events.GAME_STATE, parse_game_state(game_state), to=room) 184 | except Exception as ex: 185 | log.error(ex) 186 | 187 | 188 | if __name__ == '__main__': 189 | socketio.run(app, host="0.0.0.0", port=5000) 190 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/black/wild.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 35 | 36 | Wild card 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /server/core/uno.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import random 3 | from enum import Enum 4 | from typing import Any, Callable, DefaultDict, List, Set, Tuple 5 | 6 | from lib.notification import Notification 7 | 8 | 9 | class Player: 10 | def __init__(self, name): 11 | self.id: str = f'player-{name}' 12 | self.name: str = name 13 | 14 | def __repr__(self) -> str: 15 | return f"Player(id={self.id}, name={self.name})" 16 | 17 | def __hash__(self) -> int: 18 | return hash(self.id) 19 | 20 | def __eq__(self, obj) -> bool: 21 | return isinstance(obj, type(self)) and self.id == obj.id 22 | 23 | 24 | class Card: 25 | def __init__(self, color, value): 26 | self.id: str = f'{value}-{color}' 27 | self.color: str = color 28 | self.value: str = value 29 | 30 | def is_special(self) -> bool: 31 | special_cards = set(Deck.DRAW_TWO_CARDS + Deck.REVERSE_CARDS + 32 | Deck.SKIP_CARDS + Deck.DRAW_FOUR_CARDS + Deck.WILD_CARDS) 33 | return self.value in special_cards or self.color == 'black' 34 | 35 | def is_color_special(self) -> bool: 36 | special_cards = set(Deck.DRAW_TWO_CARDS + Deck.REVERSE_CARDS + Deck.SKIP_CARDS) 37 | return self.value in special_cards or self.color != 'black' 38 | 39 | def is_black(self) -> bool: 40 | return self.color == 'black' 41 | 42 | def is_draw_four(self) -> bool: 43 | return self.value == 'draw-four' 44 | 45 | def is_wild(self) -> bool: 46 | return self.value == 'wild' 47 | 48 | def __repr__(self) -> str: 49 | return f'Card(color={self.color}, value={self.value})' 50 | 51 | 52 | class Deck: 53 | SHUFFLE_FREQ = 50 54 | COLORS = ['red', 'blue', 'green', 'yellow'] 55 | NUMBER_CARDS = [str(i) for i in (list(range(0, 10)) + list(range(1, 10)))] 56 | DRAW_TWO_CARDS = ['draw-two'] * 2 57 | REVERSE_CARDS = ['reverse'] * 2 58 | SKIP_CARDS = ['skip'] * 2 59 | 60 | DRAW_FOUR_CARDS = ['draw-four'] * 4 61 | WILD_CARDS = ['wild'] * 4 62 | COLOR_CARDS = NUMBER_CARDS + DRAW_TWO_CARDS + REVERSE_CARDS + SKIP_CARDS 63 | 64 | def __init__(self): 65 | color_cards = [Card(color, value) for color in self.COLORS for value in self.COLOR_CARDS] 66 | black_cards = [Card('black', value) for value in (self.DRAW_FOUR_CARDS + self.WILD_CARDS)] 67 | 68 | self.cards: List[Card] = color_cards + black_cards 69 | self.shuffle() 70 | 71 | def get_cards(self) -> List[Card]: 72 | return self.cards 73 | 74 | def shuffle(self): 75 | for _ in range(self.SHUFFLE_FREQ): 76 | random.shuffle(self.cards) 77 | 78 | 79 | class GameOverReason(Enum): 80 | WON = 'won' 81 | ERROR = 'error' 82 | INSUFFICIENT_PLAYERS = 'insufficient-players' 83 | 84 | 85 | class Game: 86 | MIN_PLAYERS_ALLOWED = 2 87 | MAX_PLAYERS_ALLOWED = 2 88 | 89 | def __init__(self, room: str, players: Set[Player], hand_size: int): 90 | self.hands: DefaultDict[Player, List[Card]] = collections.defaultdict(list) 91 | self.players: Set[Player] = players 92 | self.notify = Notification(room) 93 | self.deck = Deck() 94 | 95 | self.validate_players() 96 | 97 | cards = self.deck.get_cards() 98 | 99 | TOTAL_PLAYERS = len(players) 100 | self.remaining_cards: List[Card] = cards[TOTAL_PLAYERS * hand_size:] 101 | player_cards = cards[:TOTAL_PLAYERS * hand_size] 102 | 103 | # Distribute cards (alternatively) 104 | i = 0 105 | while i < len(player_cards): 106 | for player in players: 107 | self.hands[player].append(player_cards[i]) 108 | i += 1 109 | 110 | # Pick a top card (skip special cards) 111 | top_card = random.choice(self.remaining_cards) 112 | while top_card.is_special(): 113 | top_card = random.choice(self.remaining_cards) 114 | 115 | self.game_stack: List[Card] = [top_card] 116 | 117 | def remove_player(self, player) -> None: 118 | self.players.remove(player) 119 | 120 | def validate_players(self) -> None: 121 | if len(self.players) < self.MIN_PLAYERS_ALLOWED: 122 | raise Exception(f"need at least {self.MIN_PLAYERS_ALLOWED} players to start the game") 123 | return 124 | 125 | def get_state(self) -> Tuple[DefaultDict[Player, List[Card]], Card]: 126 | self.validate_players() 127 | top_card = self.get_top_card() 128 | 129 | return (self.hands, top_card) 130 | 131 | def get_top_card(self) -> Card: 132 | return self.game_stack[-1] 133 | 134 | def transfer_played_cards(self) -> None: 135 | played_cards = self.game_stack[::] 136 | played_cards.pop() # Remove the top card 137 | 138 | random.shuffle(played_cards) # Shuffle played card 139 | self.remaining_cards = played_cards[::] 140 | self.game_stack = [self.get_top_card()] 141 | 142 | def draw(self, player_id: str) -> None: 143 | self.validate_players() 144 | 145 | player = self.find_object(self.players, player_id) 146 | player_cards = self.hands[player] 147 | 148 | if not self.remaining_cards: 149 | self.transfer_played_cards() 150 | 151 | if not self.remaining_cards: 152 | self.notify.warn('deck is empty!') 153 | return 154 | 155 | new_card = self.remaining_cards.pop() 156 | player_cards.append(new_card) 157 | 158 | def play(self, player_id: str, card_id: str, on_game_over: Callable[[GameOverReason, Any], None]) -> None: 159 | self.validate_players() 160 | 161 | player = self.find_object(self.players, player_id) 162 | player_cards = self.hands[player] 163 | card = self.find_object(player_cards, card_id) 164 | top_card = self.get_top_card() 165 | 166 | def execute_hand(): 167 | nonlocal player_cards, card 168 | 169 | # Find and remove card from the current player's hand 170 | idx = self.find_object_idx(player_cards, card.id) 171 | player_cards.pop(idx) 172 | 173 | # Insert played card top of the game stack 174 | self.game_stack.append(card) 175 | 176 | if len(player_cards) == 1: 177 | self.notify.success(f"UNO! by {player.name}") 178 | 179 | if len(player_cards) == 0: 180 | on_game_over(GameOverReason.WON, player) 181 | 182 | # Can play any card on top of black cards 183 | if not card.is_black() and top_card.is_black(): 184 | execute_hand() 185 | return 186 | 187 | if card.is_black() and top_card.is_black(): 188 | # Cannot play wild card on top of draw four and vice-versa 189 | if ((card.is_draw_four() and top_card.is_wild()) or 190 | (card.is_wild() and top_card.is_draw_four())): 191 | self.notify.error( 192 | f"cannot play a {card.value} card on top of a {top_card.value} card") 193 | return 194 | execute_hand() 195 | return 196 | 197 | same_color = card.color == top_card.color 198 | same_value = card.value == top_card.value 199 | 200 | if same_color or same_value: 201 | execute_hand() 202 | return 203 | 204 | if card.is_black(): 205 | execute_hand() 206 | return 207 | 208 | def find_object(self, objects, obj_id: str): 209 | objects = list(objects) 210 | idx = self.find_object_idx(objects, obj_id) 211 | 212 | return objects[idx] 213 | 214 | def find_object_idx(self, objects, obj_id: str): 215 | return [obj.id for obj in objects].index(obj_id) 216 | -------------------------------------------------------------------------------- /web/src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 45 | 55 | 58 | 64 | 70 | 76 | 82 | 87 | 93 | 98 | 103 | 108 | 114 | 120 | 126 | 132 | 138 | 144 | 150 | 156 | 162 | 168 | 174 | 175 | -------------------------------------------------------------------------------- /web/src/assets/images/cards/back.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 37 | 38 | Wild Draw Four card 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | --------------------------------------------------------------------------------