├── .eslintignore ├── client ├── .eslintignore ├── .env ├── src │ ├── png.d.ts │ ├── micro-sentry.tsx │ ├── Components │ │ ├── Grid.tsx │ │ ├── UnderlineLink.tsx │ │ ├── GameArea │ │ │ ├── Header │ │ │ │ ├── HeaderContents │ │ │ │ │ ├── Loading.tsx │ │ │ │ │ ├── Home.tsx │ │ │ │ │ ├── InPlay.tsx │ │ │ │ │ ├── Finished.tsx │ │ │ │ │ └── Share.tsx │ │ │ │ ├── styles.tsx │ │ │ │ ├── MiniBoard.tsx │ │ │ │ └── GameHeader.tsx │ │ │ ├── TurnList │ │ │ │ ├── RestartButton.tsx │ │ │ │ ├── TurnListItem.tsx │ │ │ │ └── TurnList.tsx │ │ │ ├── LocalBoard │ │ │ │ ├── Cell.tsx │ │ │ │ └── LocalBoard.tsx │ │ │ ├── Cell │ │ │ │ └── Cell.tsx │ │ │ ├── BoardSVG.tsx │ │ │ └── GlobalBoard │ │ │ │ └── GlobalBoard.tsx │ │ ├── Button.tsx │ │ ├── ErrorModal.tsx │ │ └── ErrorBoundary.tsx │ ├── styles │ │ ├── Utils.tsx │ │ ├── mixins.ts │ │ └── global.ts │ ├── utils │ │ └── palette.tsx │ ├── hooks │ │ ├── useDocumentTitle.ts │ │ ├── useNavigatorOnline.ts │ │ ├── useTracking.ts │ │ ├── useGameReducer.ts │ │ └── useLobbyReducer.ts │ ├── index.tsx │ ├── Containers │ │ ├── Lobby │ │ │ ├── Reconnecting.tsx │ │ │ ├── LobbyHeader.tsx │ │ │ └── index.tsx │ │ ├── HotSeat │ │ │ └── index.tsx │ │ ├── Home │ │ │ ├── CodeInputForm.tsx │ │ │ └── index.tsx │ │ ├── PlayAI │ │ │ └── index.tsx │ │ ├── Footer.tsx │ │ ├── App.tsx │ │ ├── Rules │ │ │ └── index.tsx │ │ ├── Header.tsx │ │ ├── About │ │ │ └── About.tsx │ │ └── Contact │ │ │ └── contact.tsx │ ├── types.d.ts │ ├── service-worker.ts │ └── registerServiceWorker.ts ├── assets │ ├── logo.png │ ├── learn1.png │ ├── learn2.png │ └── learn3.png ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── maskable_icon_x192.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── fonts │ │ ├── poppins-v15-latin-700.woff │ │ ├── poppins-v15-latin-700.woff2 │ │ ├── poppins-v15-latin-regular.woff │ │ └── poppins-v15-latin-regular.woff2 │ ├── browserconfig.xml │ ├── manifest.json │ ├── index.ejs │ └── safari-pinned-tab.svg ├── webpack │ ├── helpers.js │ ├── config.dev.js │ └── config.prod.js ├── babel.config.js ├── index.js ├── .eslintrc.js ├── package.json └── tsconfig.json ├── .gitignore ├── server ├── .env ├── src │ ├── entities │ │ ├── index.ts │ │ ├── Game.ts │ │ └── Lobby.ts │ ├── handlers │ │ ├── index.ts │ │ ├── create-lobby.ts │ │ ├── forfeit.ts │ │ ├── resync.ts │ │ ├── play-turn.ts │ │ ├── request-restart.ts │ │ └── join-lobby.ts │ ├── errors.ts │ ├── contact.ts │ ├── logger.ts │ └── sockets.ts ├── index.ts ├── .eslintrc.js ├── package.json └── tsconfig.json ├── common ├── index.ts ├── babel.config.js ├── package.json ├── types.ts ├── tsconfig.json ├── game.ts └── game.test.ts ├── deploy.sh ├── .vscode └── settings.json ├── .prettierrc ├── ecosystem.config.js ├── .github └── workflows │ ├── deploy.yaml │ └── pull-request.yaml ├── LICENSE ├── package.json ├── lighthouse.svg └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | index.js -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | SENTRY_DSN= 2 | GA_TRACKING_ID= -------------------------------------------------------------------------------- /client/src/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | logs 5 | -------------------------------------------------------------------------------- /server/.env: -------------------------------------------------------------------------------- 1 | EMAIL_USER= 2 | EMAIL_PASS= 3 | IO_ADMIN_PASS= 4 | SENTRY_DSN= -------------------------------------------------------------------------------- /client/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/assets/logo.png -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/assets/learn1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/assets/learn1.png -------------------------------------------------------------------------------- /client/assets/learn2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/assets/learn2.png -------------------------------------------------------------------------------- /client/assets/learn3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/assets/learn3.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /common/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './game'; 2 | export * from './game'; 3 | export * from './types'; 4 | -------------------------------------------------------------------------------- /client/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/favicon-16x16.png -------------------------------------------------------------------------------- /client/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/favicon-32x32.png -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | npm install 4 | npm run build-all 5 | pm2 startOrReload ecosystem.config.js 6 | -------------------------------------------------------------------------------- /client/public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/mstile-150x150.png -------------------------------------------------------------------------------- /client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /client/public/maskable_icon_x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/maskable_icon_x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | }, 5 | "eslint.alwaysShowStatus": true 6 | } -------------------------------------------------------------------------------- /client/public/fonts/poppins-v15-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/fonts/poppins-v15-latin-700.woff -------------------------------------------------------------------------------- /client/public/fonts/poppins-v15-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/fonts/poppins-v15-latin-700.woff2 -------------------------------------------------------------------------------- /client/public/fonts/poppins-v15-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/fonts/poppins-v15-latin-regular.woff -------------------------------------------------------------------------------- /client/public/fonts/poppins-v15-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chacestew/u3t-public/HEAD/client/public/fonts/poppins-v15-latin-regular.woff2 -------------------------------------------------------------------------------- /server/src/entities/index.ts: -------------------------------------------------------------------------------- 1 | import LobbyManager from './Lobby'; 2 | 3 | export const lobbies = new LobbyManager(); 4 | 5 | export { Lobby } from './Lobby'; 6 | -------------------------------------------------------------------------------- /common/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/micro-sentry.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserMicroSentryClient } from '@micro-sentry/browser'; 2 | 3 | const client = new BrowserMicroSentryClient({ 4 | dsn: process.env.SENTRY_DSN, 5 | }); 6 | 7 | export default client; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 90, 3 | "singleQuote": true, 4 | "overrides": [ 5 | { 6 | "files": [".prettierrc", ".babelrc", ".eslintrc", "*.json"], 7 | "options": { "parser": "json" } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /client/src/Components/Grid.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Grid = styled.div` 4 | display: grid; 5 | grid-template: repeat(3, 1fr) / repeat(3, 1fr); 6 | position: relative; 7 | `; 8 | 9 | export default Grid; 10 | -------------------------------------------------------------------------------- /client/src/styles/Utils.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { flexColumns } from './mixins'; 4 | 5 | export const RelativeBox = styled.div` 6 | ${flexColumns} 7 | height: calc(100vh - 172px); 8 | position: relative; 9 | overflow: auto; 10 | `; 11 | -------------------------------------------------------------------------------- /client/src/utils/palette.tsx: -------------------------------------------------------------------------------- 1 | const palette = { 2 | primaryDark: '#3f3142', 3 | primaryLight: '#594b5c', 4 | background: '#dddeda', 5 | red: '#dc685a', 6 | yellow: '#ecaf4f', 7 | white: 'white', 8 | }; 9 | 10 | export const gridSize = '580px'; 11 | 12 | export default palette; 13 | -------------------------------------------------------------------------------- /client/public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2b5797 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/Components/UnderlineLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import styled from 'styled-components'; 3 | 4 | const UnderlineLink = styled(Link)` 5 | text-decoration: underline; 6 | 7 | &:hover { 8 | text-decoration: none; 9 | } 10 | `; 11 | 12 | export default UnderlineLink; 13 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'u3t', 5 | script: './build/index.js', 6 | cwd: './server/', 7 | env: { 8 | NODE_ENV: 'production', 9 | DOTENV_CONFIG_PATH: '../../u3t-configs/.env-server' 10 | }, 11 | node_args: '-r dotenv/config', 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /server/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as createLobby } from './create-lobby'; 2 | export { default as forfeit } from './forfeit'; 3 | export { default as joinLobby } from './join-lobby'; 4 | export { default as playTurn } from './play-turn'; 5 | export { default as requestRestart } from './request-restart'; 6 | export { default as resync } from './resync'; 7 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/HeaderContents/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Bar } from '../styles'; 5 | 6 | const SkeletonContent = styled.div` 7 | height: 2em; 8 | `; 9 | 10 | const Loading = () => ( 11 | 12 | 13 | 14 | ); 15 | 16 | export default Loading; 17 | -------------------------------------------------------------------------------- /client/src/hooks/useDocumentTitle.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function (initalTitle: string) { 4 | const [title, setTitle] = useState(initalTitle); 5 | 6 | useEffect(() => { 7 | if (title) document.title = `U3T - ${title}`; 8 | else document.title = 'U3T - Ultimate Tic-Tac-Toe'; 9 | }, [title]); 10 | 11 | return setTitle; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/handlers/create-lobby.ts: -------------------------------------------------------------------------------- 1 | import { Ack, CreateLobbyResponse } from '@u3t/common'; 2 | 3 | import { lobbies } from '../entities'; 4 | 5 | async function createLobby(cb: Ack) { 6 | const lobby = lobbies.create(); 7 | const playerId = lobby.addPlayer(); 8 | const data = { lobbyId: lobby.id, playerId }; 9 | cb(data); 10 | return data; 11 | } 12 | 13 | export default createLobby; 14 | -------------------------------------------------------------------------------- /server/src/handlers/forfeit.ts: -------------------------------------------------------------------------------- 1 | import { Events, ForfeitRequestArgs, ServerManager } from '@u3t/common'; 2 | 3 | import { lobbies } from '../entities'; 4 | 5 | async function Forfeit(data: ForfeitRequestArgs, io: ServerManager) { 6 | const lobby = lobbies.get(data.lobbyId); 7 | const state = lobby.forfeit(data.playerId); 8 | 9 | io.to(lobby.id).emit(Events.Sync, { state }); 10 | } 11 | 12 | export default Forfeit; 13 | -------------------------------------------------------------------------------- /server/src/handlers/resync.ts: -------------------------------------------------------------------------------- 1 | import { Events, ResyncArgs, ServerSocket } from '@u3t/common'; 2 | 3 | import { lobbies } from '../entities'; 4 | 5 | async function Resync(data: ResyncArgs, socket: ServerSocket) { 6 | const lobby = lobbies.get(data.lobbyId); 7 | const game = lobby.getGame(); 8 | 9 | socket.emit(Events.Sync, { state: game.getState(), seat: game.getSeat(data.playerId) }); 10 | } 11 | 12 | export default Resync; 13 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/HeaderContents/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import UnderlineLink from '../../../UnderlineLink'; 4 | import { Bar } from '../styles'; 5 | 6 | const InPlay = () => { 7 | return ( 8 | 9 |

10 | Welcome! Start a game or{' '} 11 | learn the rules. 12 |

13 |
14 | ); 15 | }; 16 | 17 | export default InPlay; 18 | -------------------------------------------------------------------------------- /client/webpack/helpers.js: -------------------------------------------------------------------------------- 1 | const loaders = { 2 | JS: (options) => ({ 3 | test: /\.(ts|js)x?$/, 4 | exclude: /node_modules/, 5 | use: [{ loader: 'babel-loader', options }], 6 | }), 7 | Images: (options = {}) => ({ 8 | test: /\.(jpe?g|png|gif|ico)$/, 9 | use: [ 10 | { 11 | loader: 'file-loader', 12 | options: { name: '[name].[ext]', outputPath: 'images', ...options }, 13 | }, 14 | ], 15 | }), 16 | }; 17 | 18 | module.exports = { 19 | loaders, 20 | }; 21 | -------------------------------------------------------------------------------- /server/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { IdFields } from '@u3t/common'; 2 | 3 | enum Errors { 4 | NotFoundError = 'NotFoundError', 5 | BadRequestError = 'BadRequestError', 6 | } 7 | 8 | export class NotFoundError extends Error { 9 | data: Partial = {}; 10 | 11 | constructor(message: string, data: Partial) { 12 | super(message); 13 | this.name = Errors.NotFoundError; 14 | this.data = data; 15 | } 16 | } 17 | 18 | export class BadRequestError extends Error { 19 | constructor(message: string) { 20 | super(message); 21 | this.name = Errors.BadRequestError; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import { Router } from 'react-router-dom'; 5 | 6 | import App from './Containers/App'; 7 | import client from './micro-sentry'; 8 | import registerSW from './registerServiceWorker'; 9 | 10 | const history = createBrowserHistory(); 11 | 12 | const rootElement = document.getElementById('root'); 13 | 14 | render( 15 | 16 | 17 | , 18 | rootElement 19 | ); 20 | 21 | registerSW({ onError: (error) => client.report(error) }); 22 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | push: 4 | branches: [master] 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: executing remote ssh commands using password 11 | uses: appleboy/ssh-action@master 12 | with: 13 | host: ${{ secrets.DO_HOST }} 14 | username: ${{ secrets.DO_USERNAME }} 15 | password: ${{ secrets.DO_PASSWORD }} 16 | script: | 17 | cd /home/u3t 18 | git clean -fd 19 | git pull -f 20 | bash deploy.sh 21 | script_stop: true 22 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "U3T", 3 | "short_name": "U3T", 4 | "icons": [ 5 | { 6 | "src": "android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "maskable_icon_x192.png", 17 | "type": "image/png", 18 | "sizes": "192x192", 19 | "purpose": "maskable" 20 | } 21 | ], 22 | "start_url": ".", 23 | "theme_color": "#594b5c", 24 | "background_color": "#594b5c", 25 | "display": "standalone" 26 | } 27 | -------------------------------------------------------------------------------- /client/src/hooks/useNavigatorOnline.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export default function () { 4 | const [isOnline, setIsOnline] = useState(window.navigator.onLine); 5 | 6 | useEffect(() => { 7 | function handleOnlineStatus() { 8 | setIsOnline(window.navigator.onLine); 9 | } 10 | 11 | window.addEventListener('online', handleOnlineStatus); 12 | window.addEventListener('offline', handleOnlineStatus); 13 | 14 | return () => { 15 | window.removeEventListener('online', handleOnlineStatus); 16 | window.removeEventListener('offline', handleOnlineStatus); 17 | }; 18 | }, []); 19 | 20 | return isOnline; 21 | } 22 | -------------------------------------------------------------------------------- /client/src/hooks/useTracking.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | 4 | const trackingId = process.env.GA_TRACKING_ID; 5 | 6 | declare global { 7 | interface Window { 8 | gtag?: (key: string, trackingId: string, config: { page_path: string }) => void; 9 | } 10 | } 11 | 12 | export const useTracking = () => { 13 | const { listen } = useHistory(); 14 | 15 | useEffect(() => { 16 | const unlisten = listen((location) => { 17 | if (!window.gtag) return; 18 | if (!trackingId) return; 19 | 20 | window.gtag('config', trackingId, { page_path: location.pathname }); 21 | }); 22 | 23 | return unlisten; 24 | }, [listen]); 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | on: 3 | pull_request: 4 | branches: [master] 5 | jobs: 6 | unit-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out source code 10 | uses: actions/checkout@v2 11 | - name: Set up node 12 | uses: actions/setup-node@v2 13 | with: 14 | node-version: '15' 15 | - name: use cache 16 | uses: actions/cache@v2 17 | with: 18 | path: | 19 | node_modules 20 | **/node_modules 21 | key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} 22 | - name: Install dependencies 23 | run: npm install 24 | - name: Run unit tests 25 | run: npm run test 26 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/TurnList/RestartButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Button } from '../../Button'; 5 | 6 | interface Props { 7 | disabled?: boolean; 8 | onClick: () => void; 9 | text: string; 10 | icon: JSX.Element; 11 | } 12 | 13 | const StyledButton = styled(Button)` 14 | border: 1px solid #dbdbdb; 15 | border-bottom: 0; 16 | :hover { 17 | filter: none; 18 | } 19 | & > svg { 20 | margin-left: 0.5em; 21 | } 22 | `; 23 | 24 | export default function RestartButton({ onClick, text, icon, disabled }: Props) { 25 | return ( 26 | 27 | {text} 28 | {icon} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/HeaderContents/InPlay.tsx: -------------------------------------------------------------------------------- 1 | import { Cell as CellType, IBoardState } from '@u3t/common'; 2 | import React from 'react'; 3 | 4 | import MiniBoard from '../MiniBoard'; 5 | import { Bar, Cell, Text } from '../styles'; 6 | 7 | interface Props { 8 | cell: 1 | 2; 9 | text?: string; 10 | boards: IBoardState[]; 11 | activeBoard: CellType[]; 12 | } 13 | 14 | const InPlay = ({ cell, text, boards, activeBoard }: Props) => { 15 | return ( 16 | 17 | 18 | 19 | 20 | {text && {text}} 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default InPlay; 29 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import palette from '../../../utils/palette'; 4 | import BaseCell from '../Cell/Cell'; 5 | 6 | export const Text = styled.div<{ 7 | fontSize?: string; 8 | justify?: string; 9 | opacity?: number; 10 | }>` 11 | font-weight: bold; 12 | display: flex; 13 | align-items: center; 14 | opacity: ${({ opacity }) => opacity || 1}; 15 | justify-content: ${({ justify }) => justify}; 16 | `; 17 | 18 | export const Bar = styled.div` 19 | display: flex; 20 | justify-content: space-between; 21 | background-color: ${palette.primaryLight}; 22 | padding: 1em; 23 | color: ${palette.white}; 24 | margin-bottom: 0.5em; 25 | width: 100%; 26 | `; 27 | 28 | export const Cell = styled(BaseCell).attrs({ forwardedAs: 'div' })` 29 | width: 2em; 30 | height: 2em; 31 | `; 32 | -------------------------------------------------------------------------------- /client/src/styles/mixins.ts: -------------------------------------------------------------------------------- 1 | import { css } from 'styled-components'; 2 | 3 | export const flexColumns = ` 4 | display: flex; 5 | flex-direction: column; 6 | `; 7 | 8 | export const boxShadow = ` 9 | box-shadow: 0px 2px 2px rgba(204, 197, 185, 0.5); 10 | `; 11 | 12 | export const media = { 13 | aboveMobileS: (styles: TemplateStringsArray) => css` 14 | @media (min-width: 321px) { 15 | ${css(styles)} 16 | } 17 | `, 18 | aboveMobileM: (styles: TemplateStringsArray) => css` 19 | @media (min-width: 375px) { 20 | ${css(styles)} 21 | } 22 | `, 23 | aboveMobileL: (styles: TemplateStringsArray) => css` 24 | @media (min-width: 426px) { 25 | ${css(styles)} 26 | } 27 | `, 28 | aboveTablet: (styles: TemplateStringsArray) => css` 29 | @media (min-width: 767px) { 30 | ${css(styles)} 31 | } 32 | `, 33 | }; 34 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache.using(() => process.env.NODE_ENV); 3 | 4 | const presets = [ 5 | '@babel/preset-typescript', 6 | '@babel/preset-react', 7 | [ 8 | '@babel/preset-env', 9 | { 10 | bugfixes: true, 11 | useBuiltIns: 'usage', 12 | corejs: 3.15, 13 | }, 14 | ], 15 | ]; 16 | 17 | const plugins = [ 18 | [ 19 | '@babel/plugin-transform-runtime', 20 | { 21 | version: '^7.14.6', 22 | }, 23 | ], 24 | [ 25 | 'babel-plugin-styled-components', 26 | { 27 | ...(api.env('production') && { pure: true, displayName: false, filename: false }), 28 | }, 29 | ], 30 | !api.env('production') && 'react-refresh/babel', 31 | ].filter(Boolean); 32 | 33 | return { 34 | presets, 35 | plugins, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /server/src/handlers/play-turn.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Ack, 3 | Events, 4 | PlayTurnRequestArgs, 5 | PlayTurnResponse, 6 | ServerManager, 7 | } from '@u3t/common'; 8 | 9 | import { lobbies } from '../entities'; 10 | 11 | async function PlayTurn( 12 | data: PlayTurnRequestArgs, 13 | io: ServerManager, 14 | cb: Ack 15 | ) { 16 | const lobby = lobbies.get(data.lobbyId); 17 | const seat = lobby.getGame().getSeat(data.playerId); 18 | 19 | const payload = { 20 | player: seat, 21 | board: data.board, 22 | cell: data.cell, 23 | }; 24 | 25 | const error = lobby.playTurn(payload).error; 26 | 27 | const state = lobby.getGame().getState(); 28 | 29 | if (error) { 30 | cb({ valid: false, state }); 31 | throw new Error(error); 32 | } 33 | 34 | cb({ valid: true }); 35 | io.to(lobby.id).emit(Events.Sync, { state }); 36 | } 37 | 38 | export default PlayTurn; 39 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/MiniBoard.tsx: -------------------------------------------------------------------------------- 1 | import { Board, IBoardState } from '@u3t/common'; 2 | import React from 'react'; 3 | 4 | import palette from '../../../utils/palette'; 5 | import BoardSVG from '../BoardSVG'; 6 | 7 | const MiniBoard = ({ 8 | activeBoard, 9 | boards, 10 | }: { 11 | activeBoard: Board[]; 12 | boards: IBoardState[]; 13 | }) => { 14 | const pathAttributes = boards.map((board, index) => { 15 | const baseAttrs = { fill: 'white', fillOpacity: 1 }; 16 | switch (board.winner) { 17 | case 1: 18 | return { ...baseAttrs, fill: palette.red }; 19 | case 2: 20 | return { ...baseAttrs, fill: palette.yellow }; 21 | default: 22 | return { 23 | ...baseAttrs, 24 | fillOpacity: activeBoard.includes(index as Board) ? 1 : 0.5, 25 | }; 26 | } 27 | }); 28 | return ; 29 | }; 30 | 31 | export default MiniBoard; 32 | -------------------------------------------------------------------------------- /client/src/Containers/Lobby/Reconnecting.tsx: -------------------------------------------------------------------------------- 1 | import { faCloud } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import React from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import palette from '../../utils/palette'; 7 | 8 | const StyledReconnecting = styled.div` 9 | position: absolute; 10 | margin: 0px; 11 | padding: 1em; 12 | border-radius: 4px; 13 | background-color: ${palette.primaryLight}; 14 | color: ${palette.white}; 15 | z-index: 2; 16 | font-weight: bold; 17 | 18 | svg { 19 | margin-left: 0.5em; 20 | animation: pulse 1s ease infinite; 21 | } 22 | 23 | @keyframes pulse { 24 | 0%, 25 | 100% { 26 | opacity: 1; 27 | } 28 | 50% { 29 | opacity: 0.5; 30 | } 31 | } 32 | `; 33 | 34 | export const Reconnecting = () => ( 35 | 36 | Reconnecting 37 | 38 | ); 39 | -------------------------------------------------------------------------------- /server/src/handlers/request-restart.ts: -------------------------------------------------------------------------------- 1 | import { Events, RestartRequestArgs, ServerManager, ServerSocket } from '@u3t/common'; 2 | 3 | import { lobbies } from '../entities'; 4 | 5 | async function RequestRestart( 6 | data: RestartRequestArgs, 7 | socket: ServerSocket, 8 | io: ServerManager 9 | ) { 10 | const lobby = lobbies.get(data.lobbyId); 11 | 12 | const hasRestarted = lobby.requestRestart(data.playerId, socket.id); 13 | 14 | if (!hasRestarted) { 15 | io.to(lobby.id).emit(Events.RestartRequested); 16 | } else { 17 | const game = lobby.getGame(); 18 | 19 | // Send the game state and seat to players individually 20 | lobby.players.forEach((id) => { 21 | io.to(id).emit(Events.Sync, { state: game.getState(), seat: game.getSeat(id) }); 22 | }); 23 | 24 | // Send the game state to the whole room (for spectators) 25 | io.to(lobby.id).emit(Events.Sync, { state: game.getState() }); 26 | } 27 | } 28 | 29 | export default RequestRestart; 30 | -------------------------------------------------------------------------------- /client/src/styles/global.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle, css } from 'styled-components'; 2 | import styledNormalize from 'styled-normalize'; 3 | 4 | import palette from '../utils/palette'; 5 | 6 | const globalStyles = css` 7 | html { 8 | box-sizing: border-box; 9 | } 10 | 11 | *, 12 | *:before, 13 | *:after { 14 | box-sizing: inherit; 15 | } 16 | 17 | a { 18 | color: inherit; 19 | text-decoration: none; 20 | } 21 | 22 | ul { 23 | padding: 0; 24 | } 25 | 26 | p, 27 | h1, 28 | h2, 29 | h3, 30 | h4 { 31 | margin: 0; 32 | } 33 | 34 | body { 35 | font-family: 'Poppins', sans-serif; 36 | 37 | #root { 38 | background-color: ${palette.background}; 39 | min-height: 100vh; 40 | margin: 0 auto; 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | } 45 | `; 46 | 47 | const GlobalStyle = createGlobalStyle` 48 | ${styledNormalize} 49 | ${globalStyles} 50 | `; 51 | 52 | export default GlobalStyle; 53 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/GameHeader.tsx: -------------------------------------------------------------------------------- 1 | import { IGameState } from '@u3t/common'; 2 | import React from 'react'; 3 | 4 | import Finished from './HeaderContents/Finished'; 5 | import InPlay from './HeaderContents/InPlay'; 6 | 7 | interface Props { 8 | seat: 1 | 2; 9 | state: IGameState; 10 | lobbyId?: string; 11 | restartRequested?: boolean; 12 | onPlayAgainConfirm: () => void; 13 | isOnline?: boolean; 14 | } 15 | 16 | export default function LocalHeader({ 17 | state, 18 | onPlayAgainConfirm, 19 | restartRequested, 20 | isOnline, 21 | }: Props) { 22 | if (state.finished) { 23 | return ( 24 | 30 | ); 31 | } 32 | 33 | return ( 34 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import { json } from 'body-parser'; 3 | import cors from 'cors'; 4 | import express from 'express'; 5 | import { createValidator } from 'express-joi-validation'; 6 | import http from 'http'; 7 | 8 | import contact, { schema } from './src/contact'; 9 | import logger from './src/logger'; 10 | import attachSockets from './src/sockets'; 11 | 12 | Sentry.init({ dsn: process.env.SENTRY_DSN }); 13 | 14 | const app = express(); 15 | const server = http.createServer(app); 16 | attachSockets(server); 17 | 18 | if (process.env.NODE_ENV === 'development') app.use(cors()); 19 | 20 | const jsonParser = json(); 21 | const validator = createValidator(); 22 | 23 | app.use(Sentry.Handlers.requestHandler()); 24 | 25 | app.post('/send-contact', jsonParser, validator.body(schema), contact); 26 | 27 | app.use(Sentry.Handlers.errorHandler()); 28 | 29 | const mode = process.env.NODE_ENV; 30 | const port = 8001; 31 | server.listen(8001, () => { 32 | logger.info(`Server listening on :${port} [${mode}]`); 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/Components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { Link as ReactRouterLink } from 'react-router-dom'; 2 | import styled, { css } from 'styled-components'; 3 | 4 | import { boxShadow } from '../styles/mixins'; 5 | import palette from '../utils/palette'; 6 | 7 | interface ButtonStyleProps { 8 | $shadow?: boolean; 9 | $rounded?: boolean; 10 | disabled?: boolean; 11 | } 12 | 13 | const buttonStyles = css` 14 | display: flex; 15 | justify-content: center; 16 | cursor: pointer; 17 | border: 0; 18 | text-decoration: none; 19 | padding: 0.5em; 20 | font-weight: bold; 21 | background-color: ${palette.white}; 22 | color: ${palette.primaryDark}; 23 | ${({ $shadow }) => $shadow && boxShadow} 24 | ${({ $rounded }) => $rounded && 'border-radius: 4px;'} 25 | ${({ disabled }) => disabled && 'opacity: 0.5; pointer-events: none;'} 26 | :active, :hover, :focus { 27 | filter: brightness(90%); 28 | } 29 | `; 30 | 31 | export const Button = styled.button` 32 | ${buttonStyles} 33 | `; 34 | 35 | export const ButtonLink = styled(ReactRouterLink)` 36 | ${buttonStyles} 37 | `; 38 | -------------------------------------------------------------------------------- /client/src/Components/ErrorModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import palette from '../utils/palette'; 5 | import { ButtonLink } from './Button'; 6 | 7 | const ModalContainer = styled.div` 8 | padding: 2em; 9 | border-radius: 4px; 10 | position: absolute; 11 | top: 50%; 12 | left: 50%; 13 | transform: translate(-50%, -50%); 14 | z-index: 10; 15 | display: flex; 16 | flex-direction: column; 17 | background-color: ${palette.primaryLight}; 18 | color: ${palette.white}; 19 | `; 20 | 21 | const Paragraph = styled.p` 22 | white-space: nowrap; 23 | `; 24 | 25 | const ButtonContainer = styled.div` 26 | margin-top: 1em; 27 | width: 100%; 28 | display: flex; 29 | justify-content: center; 30 | `; 31 | 32 | export default function ErrorModal() { 33 | return ( 34 | 35 | Game not found or has expired. 36 | 37 | 38 | Back to home 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Chace Stewart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u3t/common", 3 | "version": "1.0.0", 4 | "description": "Common module for U3T", 5 | "main": "./build/index.js", 6 | "module": "./index.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "build": "del-cli build && tsc", 10 | "build-prod": "npm i && npm run build" 11 | }, 12 | "keywords": [ 13 | "u3t", 14 | "u3t-app", 15 | "ultimate tic-tac-toe", 16 | "ultimate", 17 | "tic-tac-toe", 18 | "tic tac toe" 19 | ], 20 | "author": "Chace Stewart", 21 | "license": "MIT", 22 | "browserslist": [ 23 | "last 2 versions", 24 | "> 1%", 25 | "not dead" 26 | ], 27 | "dependencies": { 28 | "@types/clone-deep": "^4.0.1", 29 | "clone-deep": "^4.0.1", 30 | "core-js": "^3.15.2", 31 | "del-cli": "^3.0.1", 32 | "socket.io": "^4.1.2", 33 | "socket.io-client": "^4.1.2", 34 | "typescript": "^4.2.4" 35 | }, 36 | "devDependencies": { 37 | "@babel/core": "^7.14.3", 38 | "@babel/preset-env": "^7.14.4", 39 | "@babel/preset-typescript": "^7.13.0", 40 | "@types/jest": "^26.0.23", 41 | "babel-jest": "^27.0.2", 42 | "jest": "^27.0.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "u3t", 3 | "version": "1.0.0", 4 | "description": "Monorepo for U3T (u3t.app) - The online multiplayer ultimate tic-tac-toe", 5 | "workspaces": [ 6 | "common" 7 | ], 8 | "keywords": [ 9 | "u3t", 10 | "u3t-app", 11 | "ultimate tic-tac-toe", 12 | "ultimate", 13 | "tic-tac-toe", 14 | "tic tac toe" 15 | ], 16 | "author": "Chace Stewart", 17 | "license": "MIT", 18 | "scripts": { 19 | "install-all": "(cd common && npm install && npm run build) && (cd client && npm install) && (cd server && npm install) && npm install", 20 | "start": "(cd client && npm start) & (cd server && npm start)", 21 | "build": "(cd server && npm run build) & (cd client && npm run build)", 22 | "build-all": "export NODE_ENV=production && (cd common && npm run build-prod) && (cd server && npm run build-prod) && (cd client && npm run build-prod)", 23 | "test": "npm run test -w=common" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/chacestew/u3t.git" 28 | }, 29 | "bugs": { 30 | "url": "https://github.com/chacestew/u3t/issues" 31 | }, 32 | "homepage": "https://github.com/chacestew/u3t#readme" 33 | } 34 | -------------------------------------------------------------------------------- /client/webpack/config.dev.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require('dotenv-webpack'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 5 | const CopyPlugin = require('copy-webpack-plugin'); 6 | const path = require('path'); 7 | 8 | const { loaders } = require('./helpers'); 9 | 10 | module.exports = { 11 | resolve: { 12 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 13 | }, 14 | entry: { 15 | main: ['webpack-hot-middleware/client', path.resolve(process.cwd(), 'src/index.tsx')], 16 | }, 17 | mode: 'development', 18 | devtool: 'eval-source-map', 19 | output: { 20 | filename: 'js/[name].js', 21 | publicPath: '/', 22 | }, 23 | module: { 24 | rules: [loaders.JS({ cacheDirectory: true }), loaders.Images()], 25 | }, 26 | plugins: [ 27 | new Dotenv(), 28 | new HtmlWebpackPlugin({ template: '/public/index.ejs' }), 29 | new CopyPlugin({ 30 | patterns: [{ from: 'public', to: '.', filter: (f) => !f.includes('index.ejs') }], 31 | }), 32 | new webpack.HotModuleReplacementPlugin(), 33 | new ReactRefreshWebpackPlugin({ overlay: { sockIntegration: 'whm' } }), 34 | ], 35 | }; 36 | -------------------------------------------------------------------------------- /lighthouse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | lighthouse 20 | 21 | 24 | 25 | 97% 26 | 27 | 28 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const http = require('http'); 3 | const historyFallback = require('connect-history-api-fallback'); 4 | 5 | const app = express(); 6 | const server = http.createServer(app); 7 | 8 | app.use(historyFallback({ index: '/index.html' })); 9 | 10 | const start = () => { 11 | const port = 8000; 12 | server.listen(8000, () => { 13 | console.info(`Development server listening on http://localhost:${port}`); 14 | }); 15 | }; 16 | 17 | if (process.env.NODE_ENV === 'development') { 18 | const webpack = require('webpack'); 19 | const webpackDevMiddleware = require('webpack-dev-middleware'); 20 | const webpackHotMiddleware = require('webpack-hot-middleware'); 21 | const config = require('./webpack/config.dev'); 22 | const compiler = webpack(config); 23 | 24 | const webpackDevServer = webpackDevMiddleware(compiler, { 25 | stats: { 26 | warnings: true, 27 | colors: true, 28 | timings: true, 29 | }, 30 | publicPath: config.output.publicPath, 31 | }); 32 | app.use(webpackDevServer); 33 | app.use(webpackHotMiddleware(compiler)); 34 | 35 | webpackDevServer.waitUntilValid(start); 36 | } else { 37 | console.error('Should not be run in production (use nginx)'); 38 | process.exitCode = 1; 39 | } 40 | -------------------------------------------------------------------------------- /client/src/types.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | /** 5 | * The BeforeInstallPromptEvent is fired at the Window.onbeforeinstallprompt handler 6 | * before a user is prompted to "install" a web site to a home screen on mobile. 7 | */ 8 | interface BeforeInstallPromptEvent extends Event { 9 | /** 10 | * Returns an array of DOMString items containing the platforms on which the event was dispatched. 11 | * This is provided for user agents that want to present a choice of versions to the user such as, 12 | * for example, "web" or "play" which would allow the user to chose between a web version or 13 | * an Android version. 14 | */ 15 | readonly platforms: Array; 16 | 17 | /** 18 | * Returns a Promise that resolves to a DOMString containing either "accepted" or "dismissed". 19 | */ 20 | readonly userChoice: Promise<{ 21 | outcome: 'accepted' | 'dismissed'; 22 | platform: string; 23 | }>; 24 | 25 | /** 26 | * Allows a developer to show the install prompt at a time of their own choosing. 27 | * This method returns a Promise. 28 | */ 29 | prompt(): Promise; 30 | } 31 | 32 | interface WindowEventMap { 33 | beforeinstallprompt: BeforeInstallPromptEvent; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/LocalBoard/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { Cell as CellType, Player } from '@u3t/common'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import palette from '../../../utils/palette'; 6 | import Cell from '../Cell/Cell'; 7 | 8 | const DimmableHoverableCell = styled(Cell)>` 9 | ${({ shouldDim }) => shouldDim && `opacity: 0.5;`} 10 | ${({ cellType, inPlayableArea }) => 11 | !cellType && 12 | inPlayableArea && 13 | `@media (hover: hover) { 14 | &:hover { 15 | background-color: ${palette.primaryLight} 16 | } 17 | } 18 | `} 19 | `; 20 | 21 | interface Props { 22 | cellType: null | Player; 23 | inPlayableArea: boolean; 24 | cellIndex: CellType; 25 | onClick: (cellIndex: CellType) => void; 26 | shouldDim: boolean; 27 | } 28 | 29 | const InteractiveCell = ({ 30 | cellType, 31 | inPlayableArea, 32 | cellIndex, 33 | onClick, 34 | shouldDim, 35 | }: Props) => { 36 | function handleClick() { 37 | onClick(cellIndex); 38 | } 39 | return ( 40 | 46 | ); 47 | }; 48 | 49 | export default InteractiveCell; 50 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:prettier/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 12, 17 | sourceType: 'module', 18 | }, 19 | plugins: ['simple-import-sort', '@typescript-eslint'], 20 | rules: { 21 | 'import/extensions': [ 22 | 'error', 23 | 'ignorePackages', 24 | { 25 | js: 'never', 26 | jsx: 'never', 27 | ts: 'never', 28 | tsx: 'never', 29 | }, 30 | ], 31 | '@typescript-eslint/explicit-module-boundary-types': 0, 32 | 'no-console': 0, 33 | 'simple-import-sort/imports': 'error', 34 | 'simple-import-sort/exports': 'error', 35 | 'import/first': 'error', 36 | "import/newline-after-import": "error", 37 | "import/no-duplicates": "error", 38 | }, 39 | settings: { 40 | 'import/parsers': { 41 | '@typescript-eslint/parser': ['.ts', '.tsx'], 42 | }, 43 | 'import/resolver': { 44 | typescript: {}, 45 | }, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /server/src/contact.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/node'; 2 | import { Response } from 'express'; 3 | import { 4 | ContainerTypes, 5 | ValidatedRequest, 6 | ValidatedRequestSchema, 7 | } from 'express-joi-validation'; 8 | import Joi from 'joi'; 9 | import * as nodemailer from 'nodemailer'; 10 | 11 | import logger from './logger'; 12 | 13 | const transporter = nodemailer.createTransport({ 14 | service: 'gmail', 15 | auth: { 16 | user: process.env.EMAIL_USER, 17 | pass: process.env.EMAIL_PASS, 18 | }, 19 | }); 20 | 21 | export const schema = Joi.object({ 22 | name: Joi.string().required(), 23 | email: Joi.string().required().email(), 24 | message: Joi.string().required(), 25 | }); 26 | 27 | interface ContactRequestSchema extends ValidatedRequestSchema { 28 | [ContainerTypes.Body]: { 29 | name: string; 30 | email: string; 31 | message: string; 32 | }; 33 | } 34 | 35 | export default function (req: ValidatedRequest, res: Response) { 36 | transporter 37 | .sendMail({ 38 | to: process.env.EMAIL_USER, 39 | subject: `Message from U3T`, 40 | text: `Name: ${req.body.name}\nEmail: ${req.body.email}\nMessage ${req.body.message}`, 41 | }) 42 | .then(() => { 43 | res.sendStatus(200); 44 | logger.info('Contact submission sent'); 45 | }) 46 | .catch((e) => { 47 | Sentry.captureException(e); 48 | logger.error(`Contact submission failed: ${e.message}`); 49 | res.sendStatus(500); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /client/webpack/config.prod.js: -------------------------------------------------------------------------------- 1 | const Dotenv = require('dotenv-webpack'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const CompressionPlugin = require('compression-webpack-plugin'); 4 | const BrotliPlugin = require('brotli-webpack-plugin'); 5 | const { InjectManifest } = require('workbox-webpack-plugin'); 6 | const CopyPlugin = require('copy-webpack-plugin'); 7 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 8 | 9 | const { loaders } = require('./helpers'); 10 | 11 | module.exports = { 12 | resolve: { 13 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 14 | }, 15 | mode: 'production', 16 | output: { 17 | filename: 'js/[name].[contenthash].js', 18 | publicPath: '/', 19 | }, 20 | optimization: { 21 | splitChunks: { 22 | chunks: 'all', 23 | }, 24 | }, 25 | module: { 26 | rules: [loaders.JS(), loaders.Images({ name: '[contenthash].[ext]' })], 27 | }, 28 | plugins: [ 29 | new Dotenv({ path: '../../u3t-configs/.env-client' }), 30 | new HtmlWebpackPlugin({ template: '/public/index.ejs' }), 31 | new CopyPlugin({ 32 | patterns: [{ from: 'public', to: '.', filter: (f) => !f.includes('index.ejs') }], 33 | }), 34 | new CompressionPlugin({ 35 | test: /\.(js|css)$/, 36 | }), 37 | // new BrotliPlugin({ 38 | // test: /\.(js|css)$/, 39 | // }), 40 | new InjectManifest({ 41 | swSrc: '/src/service-worker.ts', 42 | dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./, 43 | exclude: [/\.map$/, /LICENSE/], 44 | }), 45 | // new BundleAnalyzerPlugin(), 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/Containers/HotSeat/index.tsx: -------------------------------------------------------------------------------- 1 | import { faRedo } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { ITurnInput } from '@u3t/common'; 4 | import React from 'react'; 5 | 6 | import Board from '../../Components/GameArea/GlobalBoard/GlobalBoard'; 7 | import GameHeader from '../../Components/GameArea/Header/GameHeader'; 8 | import RestartButton from '../../Components/GameArea/TurnList/RestartButton'; 9 | import TurnList from '../../Components/GameArea/TurnList/TurnList'; 10 | import useDocumentTitle from '../../hooks/useDocumentTitle'; 11 | import useGameReducer from '../../hooks/useGameReducer'; 12 | import { RelativeBox } from '../../styles/Utils'; 13 | 14 | export default function HotSeat() { 15 | const [state, { playTurn, restart }] = useGameReducer(); 16 | useDocumentTitle('Hotseat'); 17 | 18 | const onValidTurn = ({ board, cell }: ITurnInput) => { 19 | const player = state.currentPlayer; 20 | 21 | playTurn({ player, board, cell }); 22 | }; 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | } 37 | /> 38 | } 39 | /> 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Cell/Cell.tsx: -------------------------------------------------------------------------------- 1 | import { faCircle } from '@fortawesome/free-regular-svg-icons'; 2 | import { faTimes } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { Player } from '@u3t/common'; 5 | import React from 'react'; 6 | import styled from 'styled-components'; 7 | 8 | import { boxShadow } from '../../../styles/mixins'; 9 | import palette from '../../../utils/palette'; 10 | 11 | export const getCellBg = (cellType: null | Player) => { 12 | switch (cellType) { 13 | case 1: 14 | return palette.red; 15 | case 2: 16 | return palette.yellow; 17 | default: 18 | return palette.white; 19 | } 20 | }; 21 | 22 | export interface Props { 23 | cellType: null | Player; 24 | size?: string; 25 | className?: string; 26 | } 27 | 28 | const CellContainer = styled.button` 29 | border-radius: 4px; 30 | color: ${palette.primaryLight}; 31 | display: inline-flex; 32 | justify-content: center; 33 | align-items: center; 34 | cursor: pointer; 35 | border: 0; 36 | outline: 0; 37 | background-color: ${({ cellType }) => getCellBg(cellType)}; 38 | ${({ size }) => size && `width: ${size}; height: ${size};`} 39 | ${boxShadow} 40 | && > svg { 41 | width: 50%; 42 | height: 50%; 43 | } 44 | `; 45 | 46 | export default function Cell({ ...rest }: Props) { 47 | const label = rest.cellType ? 'Empty Cell' : rest.cellType === 1 ? 'X Cell' : 'O Cell'; 48 | return ( 49 | 50 | {rest.cellType && ( 51 | 52 | )} 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/HeaderContents/Finished.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Button } from '../../../Button'; 5 | import { Bar, Cell, Text } from '../styles'; 6 | 7 | interface PlayAgainProps { 8 | isOnline?: boolean; 9 | onPlayAgainConfirm: () => void; 10 | restartRequested?: boolean; 11 | } 12 | 13 | const PlayAgain = ({ 14 | isOnline, 15 | onPlayAgainConfirm, 16 | restartRequested, 17 | }: PlayAgainProps) => { 18 | const [confirmed, setConfirmed] = useState(false); 19 | const onClick = () => { 20 | setConfirmed(!confirmed); 21 | onPlayAgainConfirm(); 22 | }; 23 | 24 | return ( 25 | 26 | 29 | 30 | ); 31 | }; 32 | 33 | const Span = styled.span` 34 | margin-left: 0.5em; 35 | `; 36 | 37 | interface Props extends PlayAgainProps { 38 | winner: 1 | 2 | null; 39 | } 40 | 41 | const Finished = ({ isOnline, winner, restartRequested, onPlayAgainConfirm }: Props) => { 42 | return ( 43 | 44 | 45 | {winner ? ( 46 | <> 47 | 48 | wins! 49 | 50 | ) : ( 51 | 'Stalemate!' 52 | )} 53 | 54 | 59 | 60 | ); 61 | }; 62 | 63 | export default Finished; 64 | -------------------------------------------------------------------------------- /client/src/hooks/useGameReducer.ts: -------------------------------------------------------------------------------- 1 | import playTurn, { getInitialState, IGameState, ITurnInput } from '@u3t/common'; 2 | import { useMemo, useReducer } from 'react'; 3 | 4 | const PLAY_TURN = 'play-turn'; 5 | const SET_STATE = 'set-state'; 6 | const RESTART = 'restart'; 7 | const UNDO = 'undo'; 8 | 9 | type Action = 10 | | { type: typeof PLAY_TURN; payload: ITurnInput } 11 | | { type: typeof SET_STATE; payload: IGameState } 12 | | { type: typeof RESTART } 13 | | { type: typeof UNDO }; 14 | 15 | function reducer(state: IGameState, action: Action): IGameState { 16 | switch (action.type) { 17 | case PLAY_TURN: { 18 | const turnInput = action.payload; 19 | return playTurn(state, turnInput).state; 20 | } 21 | case SET_STATE: { 22 | return action.payload; 23 | } 24 | case RESTART: { 25 | return getInitialState(); 26 | } 27 | default: 28 | return state; 29 | } 30 | } 31 | 32 | interface Dispatchers { 33 | playTurn: (payload: ITurnInput) => void; 34 | setState: (payload: IGameState) => void; 35 | restart: () => void; 36 | } 37 | 38 | export default function (): [IGameState, Dispatchers] { 39 | const [state, dispatch] = useReducer(reducer, getInitialState()); 40 | const dispatchers = useMemo( 41 | () => ({ 42 | playTurn: (payload: ITurnInput) => { 43 | dispatch({ type: PLAY_TURN, payload }); 44 | }, 45 | setState: (payload: IGameState) => { 46 | dispatch({ type: SET_STATE, payload }); 47 | }, 48 | restart: () => { 49 | dispatch({ type: RESTART }); 50 | }, 51 | undo: () => { 52 | dispatch({ type: UNDO }); 53 | }, 54 | }), 55 | [] 56 | ); 57 | return [state, dispatchers]; 58 | } 59 | -------------------------------------------------------------------------------- /client/src/Components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary'; 3 | import styled from 'styled-components'; 4 | 5 | import { Main } from '../Containers/App'; 6 | import client from '../micro-sentry'; 7 | import palette from '../utils/palette'; 8 | import { Button } from './Button'; 9 | 10 | const Section = styled.section` 11 | padding: 1em; 12 | background-color: ${palette.primaryLight}; 13 | color: white; 14 | 15 | > * + * { 16 | margin-top: 1em; 17 | } 18 | `; 19 | 20 | const Buttons = styled.div` 21 | display: flex; 22 | 23 | > * + * { 24 | margin-left: 1em; 25 | } 26 | `; 27 | 28 | function ErrorFallback({ error }: { error: Error }) { 29 | return ( 30 |
31 |
32 |

Something went wrong

33 | {process.env.NODE_ENV === 'development' &&
{error.message}
} 34 |

The error has been recorded and sent to me.

35 |

Please try one of the links below to continue.

36 | 37 | 40 | 43 | 44 |
45 |
46 | ); 47 | } 48 | 49 | interface Props { 50 | children: React.ReactNode; 51 | } 52 | 53 | export default function ErrorBoundary(props: Props) { 54 | return ( 55 | client.report(error)} 58 | {...props} 59 | /> 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:import/recommended', 10 | 'plugin:import/typescript', 11 | 'plugin:jsx-a11y/recommended', 12 | 'plugin:react/recommended', 13 | 'plugin:react-hooks/recommended', 14 | 'plugin:@typescript-eslint/eslint-recommended', 15 | 'plugin:@typescript-eslint/recommended', 16 | 'plugin:prettier/recommended', 17 | ], 18 | parser: '@typescript-eslint/parser', 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | ecmaVersion: 12, 24 | sourceType: 'module', 25 | }, 26 | plugins: ['simple-import-sort', 'react', '@typescript-eslint'], 27 | rules: { 28 | 'simple-import-sort/imports': 'error', 29 | 'simple-import-sort/exports': 'error', 30 | 'import/extensions': [ 31 | 'error', 32 | 'ignorePackages', 33 | { 34 | js: 'never', 35 | jsx: 'never', 36 | ts: 'never', 37 | tsx: 'never', 38 | }, 39 | ], 40 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx', '.ts'] }], 41 | 'react/self-closing-comp': 1, 42 | 'react/prop-types': 0, 43 | 'react/no-unescaped-entities': ['error', { forbid: ['>', '}'] }], 44 | '@typescript-eslint/explicit-module-boundary-types': 0, 45 | '@typescript-eslint/no-non-null-assertion': 0, 46 | 'no-console': 0, 47 | 'import/default': 0, 48 | }, 49 | settings: { 50 | react: { 51 | version: 'latest', 52 | }, 53 | 'import/parsers': { 54 | '@typescript-eslint/parser': ['.ts', '.tsx'], 55 | }, 56 | 'import/resolver': { 57 | typescript: {}, 58 | }, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /server/src/logger.ts: -------------------------------------------------------------------------------- 1 | import 'winston-daily-rotate-file'; 2 | 3 | import * as winston from 'winston'; 4 | 5 | const logger = winston.createLogger({ 6 | level: 'info', 7 | }); 8 | 9 | const timestamp = winston.format.timestamp({ 10 | format: 'DD-MM-YYYY-HH:MM', 11 | }); 12 | 13 | const format = winston.format.printf( 14 | ({ level, message, timestamp, data }) => 15 | `${timestamp} (${level}) ${message} ${data ? JSON.stringify(data) : ''}` 16 | ); 17 | 18 | const logToFiles = () => { 19 | const dailyRotateFile = (filename: string, level: string) => { 20 | return new winston.transports.DailyRotateFile({ 21 | filename, 22 | level, 23 | dirname: '../logs', 24 | datePattern: 'YYYY-MM-DD', 25 | zippedArchive: true, 26 | maxSize: '20m', 27 | maxFiles: '14d', 28 | format: winston.format.combine(timestamp, format), 29 | }); 30 | }; 31 | 32 | const errors = dailyRotateFile('error-%DATE%.log', 'error'); 33 | const combined = dailyRotateFile('combined-%DATE%.log', 'info'); 34 | 35 | logger.add(errors); 36 | logger.add(combined); 37 | }; 38 | 39 | const logToConsole = () => { 40 | const consoleTransport = new winston.transports.Console({ 41 | format: winston.format.combine( 42 | timestamp, 43 | format, 44 | winston.format.colorize({ 45 | all: true, 46 | }) 47 | ), 48 | }); 49 | 50 | logger.add(consoleTransport); 51 | }; 52 | 53 | if (process.env.NODE_ENV === 'production') { 54 | logToFiles(); 55 | } else { 56 | logToConsole(); 57 | } 58 | 59 | export default { 60 | info: (message: string, data?: { [key: string]: unknown }) => 61 | logger.info(message, data ? { data } : undefined), 62 | error: (message: string, data?: { [key: string]: unknown }) => 63 | logger.error(message, data ? { data } : undefined), 64 | }; 65 | -------------------------------------------------------------------------------- /server/src/entities/Game.ts: -------------------------------------------------------------------------------- 1 | import play, { 2 | Errors, 3 | forfeit, 4 | generateRandomMove, 5 | getInitialState, 6 | IGameState, 7 | ITurnInput, 8 | Player, 9 | } from '@u3t/common'; 10 | 11 | import { BadRequestError } from '../errors'; 12 | 13 | function instantEnd(state: IGameState): IGameState { 14 | const turn = generateRandomMove(state); 15 | 16 | const nextState = play(state, turn).state; 17 | 18 | if (nextState.finished) return nextState; 19 | 20 | return instantEnd(nextState); 21 | } 22 | 23 | export default class Game { 24 | // The internal game state 25 | private state: IGameState = getInitialState(); 26 | // Seats 27 | readonly seats: string[] = []; 28 | // Last updated timestamp 29 | readonly onUpdate: () => void; 30 | 31 | constructor({ players, onUpdate }: { players: string[]; onUpdate: () => void }) { 32 | this.seats = [...players]; 33 | if (Math.floor(Math.random() * 2)) this.seats.reverse(); 34 | this.onUpdate = onUpdate; 35 | } 36 | 37 | playTurn(payload: ITurnInput): { error?: Errors; state: IGameState } { 38 | const nextState = play(this.state, payload, true); 39 | 40 | if (!nextState.error) { 41 | this.state = nextState.state; 42 | } 43 | 44 | this.onUpdate(); 45 | 46 | return nextState; 47 | } 48 | 49 | getSeat(id: string) { 50 | const seat = this.seats.indexOf(id); 51 | if (seat === -1) 52 | throw new BadRequestError(`No seat for player ${id}. Current seats: ${this.seats}`); 53 | return (seat + 1) as Player; 54 | } 55 | 56 | getState() { 57 | return this.state; 58 | } 59 | 60 | forfeit(player: string) { 61 | const seat = this.getSeat(player); 62 | return (this.state = forfeit(this.state, seat)); 63 | } 64 | 65 | restart() { 66 | this.state = getInitialState(); 67 | this.onUpdate(); 68 | } 69 | 70 | instantEnd = () => { 71 | const nextState = instantEnd(this.state); 72 | this.state = nextState; 73 | this.onUpdate(); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /client/src/Containers/Home/CodeInputForm.tsx: -------------------------------------------------------------------------------- 1 | import { faCheck } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import React, { useState } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import { Button } from '../../Components/Button'; 7 | import palette from '../../utils/palette'; 8 | 9 | export type CodeInputMode = null | 'join' | 'spectate'; 10 | 11 | const Container = styled.div` 12 | grid-area: join-code; 13 | `; 14 | 15 | const Label = styled.label` 16 | display: block; 17 | margin-bottom: 0.25em; 18 | `; 19 | 20 | const Form = styled.form` 21 | display: flex; 22 | 23 | & > input { 24 | width: 0; 25 | flex: 1; 26 | align-self: stretch; 27 | margin-right: 1em; 28 | color: ${palette.primaryDark}; 29 | padding: 0 0.5em; 30 | } 31 | `; 32 | 33 | export default function CodeInputForm({ 34 | disabled, 35 | mode, 36 | onInputSubmit, 37 | }: { 38 | disabled: boolean; 39 | onInputSubmit: (value: string) => void; 40 | mode: CodeInputMode; 41 | }) { 42 | const [code, setCode] = useState(''); 43 | 44 | const setText = (value: string) => { 45 | const text = value.toUpperCase().trim(); 46 | setCode(text); 47 | }; 48 | 49 | return ( 50 | 51 | 52 |
{ 54 | e.preventDefault(); 55 | onInputSubmit(code); 56 | }} 57 | > 58 | setText(e.target.value)} 64 | onPaste={(e) => { 65 | setText(e.clipboardData.getData('text').trim().slice(-4)); 66 | }} 67 | /> 68 | 71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@u3t/server", 3 | "version": "1.0.0", 4 | "description": "Socket.IO API for U3T", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "export NODE_ENV=development && ts-node-dev --transpile-only -r dotenv/config ./index.ts", 8 | "build": "del-cli build && tsc", 9 | "build-prod": "npm i && npm run build", 10 | "tsc": "tsc --noEmit", 11 | "lint": "eslint src --fix" 12 | }, 13 | "keywords": [ 14 | "u3t", 15 | "u3t-app", 16 | "ultimate tic-tac-toe", 17 | "ultimate", 18 | "tic-tac-toe", 19 | "tic tac toe" 20 | ], 21 | "author": "Chace Stewart", 22 | "license": "MIT", 23 | "browserslist": [ 24 | "last 1 version", 25 | "> 1%", 26 | "not dead" 27 | ], 28 | "dependencies": { 29 | "@sentry/node": "^6.8.0", 30 | "@socket.io/admin-ui": "^0.2.0", 31 | "@types/clone-deep": "^4.0.1", 32 | "@types/express": "^4.17.8", 33 | "@types/node": "^14.6.4", 34 | "@types/nodemailer": "^6.4.2", 35 | "body-parser": "^1.19.0", 36 | "clone-deep": "^4.0.1", 37 | "del-cli": "^3.0.1", 38 | "dotenv": "^10.0.0", 39 | "express": "^4.17.1", 40 | "express-joi-validation": "^5.0.0", 41 | "express-static-gzip": "^2.0.8", 42 | "joi": "^17.4.0", 43 | "nanoid": "^3.1.12", 44 | "nodemailer": "^6.6.2", 45 | "socket.io": "^4.1.2", 46 | "typescript": "^4.0.2", 47 | "winston": "^3.3.3", 48 | "winston-daily-rotate-file": "^4.5.5" 49 | }, 50 | "devDependencies": { 51 | "@typescript-eslint/eslint-plugin": "^4.13.0", 52 | "@typescript-eslint/parser": "^4.13.0", 53 | "babel-eslint": "^10.1.0", 54 | "cors": "^2.8.5", 55 | "eslint": "^7.17.0", 56 | "eslint-config-airbnb": "^18.2.1", 57 | "eslint-config-prettier": "^7.1.0", 58 | "eslint-import-resolver-typescript": "^2.3.0", 59 | "eslint-plugin-import": "^2.22.1", 60 | "eslint-plugin-jsx-a11y": "^6.4.1", 61 | "eslint-plugin-prettier": "^3.3.1", 62 | "eslint-plugin-react": "^7.22.0", 63 | "eslint-plugin-react-hooks": "^4.2.0", 64 | "eslint-plugin-simple-import-sort": "^7.0.0", 65 | "prettier": "2.2.1", 66 | "ts-node-dev": "^1.1.6" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/Containers/PlayAI/index.tsx: -------------------------------------------------------------------------------- 1 | import { faRedo } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import { generateRandomMove, ITurnInput, Player } from '@u3t/common'; 4 | import React, { useEffect, useState } from 'react'; 5 | 6 | import Board from '../../Components/GameArea/GlobalBoard/GlobalBoard'; 7 | import GameHeader from '../../Components/GameArea/Header/GameHeader'; 8 | import RestartButton from '../../Components/GameArea/TurnList/RestartButton'; 9 | import TurnList from '../../Components/GameArea/TurnList/TurnList'; 10 | import useDocumentTitle from '../../hooks/useDocumentTitle'; 11 | import useGameReducer from '../../hooks/useGameReducer'; 12 | import { RelativeBox } from '../../styles/Utils'; 13 | 14 | const PlayAI = () => { 15 | const [gameState, { playTurn, restart }] = useGameReducer(); 16 | const [seat, setSeat] = useState(null); 17 | useDocumentTitle('AI'); 18 | 19 | useEffect(() => { 20 | const yourSeat = Math.ceil(Math.random() * 2) as 1 | 2; 21 | setSeat(yourSeat); 22 | }, []); 23 | 24 | useEffect(() => { 25 | if (seat === null || gameState.currentPlayer === seat) return; 26 | setTimeout(() => { 27 | const randomTurn = generateRandomMove(gameState); 28 | playTurn(randomTurn); 29 | }, 500); 30 | }, [gameState, seat, playTurn]); 31 | 32 | const play = ({ board, cell }: ITurnInput) => { 33 | playTurn({ player: seat!, board, cell }); 34 | }; 35 | 36 | const restartGame = () => { 37 | const yourSeat = Math.ceil(Math.random() * 2) as 1 | 2; 38 | setSeat(yourSeat); 39 | restart(); 40 | }; 41 | 42 | return ( 43 | <> 44 | 45 | 46 | 47 | } 55 | /> 56 | } 57 | /> 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default PlayAI; 64 | -------------------------------------------------------------------------------- /client/src/Containers/Lobby/LobbyHeader.tsx: -------------------------------------------------------------------------------- 1 | import { IGameState } from '@u3t/common'; 2 | import React from 'react'; 3 | 4 | import Finished from '../../Components/GameArea/Header/HeaderContents/Finished'; 5 | import InPlay from '../../Components/GameArea/Header/HeaderContents/InPlay'; 6 | import Loading from '../../Components/GameArea/Header/HeaderContents/Loading'; 7 | import Share from '../../Components/GameArea/Header/HeaderContents/Share'; 8 | import { IMultiplayerState } from '../../hooks/useLobbyReducer'; 9 | 10 | export type Mode = 'home' | 'loading' | 'share' | 'local' | 'online' | 'spectator'; 11 | 12 | interface Props { 13 | seat: 1 | 2; 14 | lobbyState: IMultiplayerState; 15 | state: IGameState; 16 | restartRequested?: boolean; 17 | onPlayAgainConfirm: () => void; 18 | } 19 | 20 | function SpectatorHeader({ state }: { state: IGameState }) { 21 | return ( 22 | 28 | ); 29 | } 30 | 31 | function ShareHeader({ lobbyId }: { lobbyId: string }) { 32 | return ; 33 | } 34 | 35 | function ActiveGame({ seat, state }: { seat: 1 | 2; state: IGameState }) { 36 | return ( 37 | 43 | ); 44 | } 45 | 46 | export default function LobbyHeader({ 47 | lobbyState, 48 | state, 49 | seat, 50 | onPlayAgainConfirm, 51 | restartRequested, 52 | }: Props) { 53 | // As spectator 54 | if (lobbyState.isSpectator) return ; 55 | 56 | // Before game 57 | if (lobbyState.lobbyId && !lobbyState.started && lobbyState.hasJoined) { 58 | return ; 59 | } 60 | 61 | // After game 62 | if (state.finished) 63 | return ( 64 | 70 | ); 71 | 72 | // During game 73 | if (lobbyState.playerSeat) return ; 74 | 75 | // While loading 76 | return ; 77 | } 78 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/Header/HeaderContents/Share.tsx: -------------------------------------------------------------------------------- 1 | import { faCopy } from '@fortawesome/free-solid-svg-icons'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 3 | import React, { useState } from 'react'; 4 | import styled, { css, keyframes } from 'styled-components'; 5 | 6 | import { Bar, Text } from '../styles'; 7 | 8 | const FLASH_DURATION = 500; 9 | 10 | const flash = keyframes` 11 | 0%, 30% { 12 | color: black; 13 | } 14 | 100% { 15 | color: inherit; 16 | } 17 | `; 18 | 19 | const animation = css` 20 | animation: ${FLASH_DURATION}ms ${flash}; 21 | `; 22 | 23 | const Container = styled(Text)` 24 | height: 2em; 25 | width: 100%; 26 | 27 | svg { 28 | margin-left: 0.5em; 29 | } 30 | `; 31 | 32 | const LinkContainer = styled.div` 33 | max-width: 50%; 34 | display: flex; 35 | font-weight: normal; 36 | `; 37 | 38 | const CopyToClipboard = styled.button<{ flashing: boolean }>` 39 | cursor: pointer; 40 | color: inherit; 41 | background: transparent; 42 | outline: none; 43 | border: none; 44 | text-align: end; 45 | max-width: 50%; 46 | 47 | ${({ flashing }) => flashing && animation} 48 | 49 | span { 50 | white-space: pre; 51 | } 52 | `; 53 | 54 | export default function Share({ lobbyId }: { lobbyId: string }) { 55 | const [clicked, setClicked] = useState(false); 56 | const handleCopy = () => { 57 | const el = document.createElement('textarea'); 58 | el.value = window.location.href; 59 | el.setAttribute('readonly', ''); 60 | el.style.position = 'absolute'; 61 | el.style.left = '-9999px'; 62 | document.body.appendChild(el); 63 | el.select(); 64 | document.execCommand('copy'); 65 | document.body.removeChild(el); 66 | 67 | setClicked(true); 68 | 69 | setTimeout(() => { 70 | setClicked(false); 71 | }, FLASH_DURATION); 72 | }; 73 | 74 | return ( 75 | 76 | 77 | 78 | u3t.app/game/ 79 | {lobbyId} 80 | 81 | 82 | Copy to{' '} 83 | 84 | clipboard 85 | 86 | 87 | 88 | 89 | 90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /server/src/handlers/join-lobby.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Ack, 3 | Events, 4 | JoinLobbyRequestArgs, 5 | JoinLobbyResponses, 6 | ServerManager, 7 | ServerSocket, 8 | } from '@u3t/common'; 9 | 10 | import { lobbies } from '../entities'; 11 | import logger from '../logger'; 12 | 13 | export default async function joinLobby( 14 | socket: ServerSocket, 15 | io: ServerManager, 16 | data: JoinLobbyRequestArgs, 17 | cb: Ack 18 | ) { 19 | const lobby = lobbies.get(data.lobbyId); 20 | 21 | // Handle spectator joining 22 | if ((!data.playerId && lobby.hasGame()) || data.spectator) { 23 | cb({ 24 | lobbyId: lobby.id, 25 | state: lobby.getGame().getState(), 26 | role: 'spectator', 27 | }); 28 | // Handle player rejoining a game 29 | } else if (data.playerId) { 30 | logger.info('Rejoining player to lobby', { 31 | lobbyId: lobby.id, 32 | playerId: data.playerId, 33 | }); 34 | 35 | if (lobby.hasGame()) { 36 | const game = lobby.getGame(); 37 | 38 | cb({ 39 | playerId: data.playerId, 40 | lobbyId: lobby.id, 41 | seat: game.getSeat(data.playerId), 42 | state: game.getState(), 43 | role: 'reconnected-player', 44 | started: true, 45 | }); 46 | } else { 47 | cb({ 48 | playerId: data.playerId, 49 | lobbyId: lobby.id, 50 | role: 'reconnected-player', 51 | started: false, 52 | }); 53 | } 54 | } else { 55 | // Handle first time joining a game 56 | logger.info('Joining new player to lobby', { lobbyId: lobby.id }); 57 | const playerId = lobby.addPlayer(); 58 | 59 | socket.join(playerId); 60 | 61 | cb({ lobbyId: lobby.id, playerId, role: 'new-player' }); 62 | 63 | // Start when second player joined 64 | if (!lobby.hasGame() && lobby.players.size === 2) { 65 | logger.info('Starting new game for lobby', { lobbyId: lobby.id }); 66 | const game = lobby.initGame(); 67 | 68 | lobby.players.forEach((playerId) => { 69 | logger.info('Emitting game started message:', { 70 | lobbyId: lobby.id, 71 | playerId, 72 | seat: game.getSeat(playerId), 73 | }); 74 | io.to(playerId).emit(Events.StartGame, { 75 | lobbyId: lobby.id, 76 | playerId, 77 | seat: game.getSeat(playerId), 78 | state: game.getState(), 79 | }); 80 | }); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/public/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | U3T - Ultimate Tic-Tac-Toe 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 39 | <% if (process.env.GA_TRACKING_ID) { %> 40 | 44 | 53 | <% } %> 54 | 55 | 56 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /client/src/service-worker.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { clientsClaim } from 'workbox-core'; 4 | import { ExpirationPlugin } from 'workbox-expiration'; 5 | import { createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'; 6 | import { registerRoute } from 'workbox-routing'; 7 | import { StaleWhileRevalidate } from 'workbox-strategies'; 8 | 9 | declare const self: ServiceWorkerGlobalScope; 10 | 11 | clientsClaim(); 12 | 13 | precacheAndRoute(self.__WB_MANIFEST); 14 | 15 | // Set up App Shell-style routing, so that all navigation requests 16 | // are fulfilled with your index.html shell. Learn more at 17 | // https://developers.google.com/web/fundamentals/architecture/app-shell 18 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); 19 | registerRoute( 20 | // Return false to exempt requests from being fulfilled by index.html. 21 | ({ request, url }: { request: Request; url: URL }) => { 22 | // If this isn't a navigation, skip. 23 | if (request.mode !== 'navigate') { 24 | return false; 25 | } 26 | 27 | // If this is a URL that starts with /_, skip. 28 | if (url.pathname.startsWith('/_')) { 29 | return false; 30 | } 31 | 32 | // If this looks like a URL for a resource, because it contains 33 | // a file extension, skip. 34 | if (url.pathname.match(fileExtensionRegexp)) { 35 | return false; 36 | } 37 | 38 | // Return true to signal that we want to use the handler. 39 | return true; 40 | }, 41 | createHandlerBoundToURL('/index.html') 42 | ); 43 | 44 | // An example runtime caching route for requests that aren't handled by the 45 | // precache, in this case same-origin .png requests like those from in public/ 46 | registerRoute( 47 | // Add in any other file extensions or routing criteria as needed. 48 | ({ url }) => url.origin === self.location.origin && url.pathname.endsWith('.png'), 49 | // Customize this strategy as needed, e.g., by changing to CacheFirst. 50 | new StaleWhileRevalidate({ 51 | cacheName: 'images', 52 | plugins: [ 53 | // Ensure that once this runtime cache reaches a maximum size the 54 | // least-recently used images are removed. 55 | new ExpirationPlugin({ maxEntries: 50 }), 56 | ], 57 | }) 58 | ); 59 | 60 | // This allows the web app to trigger skipWaiting via 61 | // registration.waiting.postMessage({type: 'SKIP_WAITING'}) 62 | self.addEventListener('message', (event) => { 63 | if (event.data && event.data.type === 'SKIP_WAITING') { 64 | self.skipWaiting(); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/TurnList/TurnListItem.tsx: -------------------------------------------------------------------------------- 1 | import { Board, Cell, Player } from '@u3t/common'; 2 | import React, { memo } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { media } from '../../../styles/mixins'; 6 | import palette from '../../../utils/palette'; 7 | import BoardSVG from '../BoardSVG'; 8 | import BaseCell from '../Cell/Cell'; 9 | 10 | export const TurnListParagraph = styled.p` 11 | font-weight: normal; 12 | padding: 0 0.5em; 13 | margin-top: 0.5em; 14 | line-height: 2; 15 | 16 | &:last-child { 17 | margin-bottom: 0.5em; 18 | } 19 | 20 | > * + * { 21 | margin-left: 0.5em; 22 | } 23 | 24 | ${media.aboveTablet` 25 | > * + * { 26 | margin-left: 1em; 27 | }`} 28 | `; 29 | 30 | export const TurnListCell = styled(BaseCell).attrs({ forwardedAs: 'span' })` 31 | width: 1.5em; 32 | height: 1.5em; 33 | box-shadow: none; 34 | cursor: auto; 35 | `; 36 | 37 | const StyledBoardSVG = styled(BoardSVG)` 38 | vertical-align: middle; 39 | `; 40 | 41 | const TurnNumber = styled.span` 42 | display: inline-block; 43 | min-width: 2.15em; 44 | `; 45 | 46 | const TurnListTextMobile = styled.span` 47 | display: inline-block; 48 | 49 | ${media.aboveMobileL` 50 | display: none; 51 | `} 52 | `; 53 | 54 | const TurnListTextDesktop = styled.span` 55 | display: none; 56 | 57 | ${media.aboveMobileL` 58 | display: inline-block; 59 | `} 60 | `; 61 | 62 | const TurnListBoardIcon = memo(({ index, player }: { index: Cell; player?: Player }) => { 63 | const getPathAttributes = (i: number) => ({ 64 | fill: index !== i || !player ? 'white' : player === 1 ? palette.red : palette.yellow, 65 | fillOpacity: i === index ? 1 : 0.5, 66 | }); 67 | 68 | return ; 69 | }); 70 | 71 | TurnListBoardIcon.displayName = 'TurnListBoardIcon'; 72 | 73 | const TurnListItem = ({ 74 | turn, 75 | player, 76 | board, 77 | cell, 78 | }: { 79 | turn: number; 80 | player: Player; 81 | board: Board; 82 | cell: Cell; 83 | }) => ( 84 | 85 | #{turn + 1} 86 | 87 | 88 | played on board {board + 1} and chose cell {cell + 1} 89 | 90 | 91 | chose board {board + 1} cell {cell + 1} 92 | 93 | 94 | 95 | 96 | ); 97 | 98 | export default TurnListItem; 99 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/BoardSVG.tsx: -------------------------------------------------------------------------------- 1 | import { Board } from '@u3t/common'; 2 | import * as React from 'react'; 3 | 4 | const BoardSVG = ({ 5 | size = '1em', 6 | pathAttributes, 7 | getPathAttributes, 8 | className, 9 | ...rest 10 | }: { 11 | size?: string; 12 | getPathAttributes?: (index: Board) => { fill: string; fillOpacity: number }; 13 | pathAttributes?: Array<{ fill: string; fillOpacity: number }>; 14 | className?: string; 15 | }) => { 16 | const getAttrs = (index: Board) => { 17 | if (pathAttributes) return pathAttributes[index]; 18 | if (getPathAttributes) return getPathAttributes(index); 19 | return {}; 20 | }; 21 | 22 | return ( 23 | 30 | 34 | 38 | 42 | 46 | 50 | 54 | 58 | 62 | 66 | 67 | ); 68 | }; 69 | 70 | export default BoardSVG; 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > This repo has been replaced by https://github.com/chacestew/u3t 3 | 4 | # U3T 5 | 6 | [![Lighthouse](./lighthouse.svg)](https://github.com/emazzotta/lighthouse-badges) 7 | 8 | U3T is an online multiplayer implementation of the game [Utimate tic-tac-toe](https://en.wikipedia.org/wiki/Ultimate_tic-tac-toe) and a [Progressive Web Application](https://web.dev/progressive-web-apps/). 9 | 10 | Live link: https://u3t.app 11 | 12 | #### Features include: 13 | 14 | - Online multiplayer using [Socket.IO](https://socket.io/) 15 | - Supports rematching, reconnecting, and spectating games 16 | - Offline multiplayer on your device 17 | - Single player against a (desperately bad) AI opponent 18 | - Full mobile support 19 | - Installable as an application 20 | - Works offline via the [service worker](https://developers.google.com/web/fundamentals/primers/service-workers) 21 | 22 | ## Tech Stack 23 | 24 | The entire application is written using [TypeScript](https://www.typescriptlang.org/) and [Node.js 15](https://nodejs.org/). 25 | 26 | The frontend UI is built in [React](https://reactjs.org/) and [Styled Components](https://styled-components.com/) and transpiled with custom [Webpack](https://webpack.js.org/) and [Babel](https://babeljs.io/) configurations. [Workbox](https://developers.google.com/web/tools/workbox) provides the service worker functionality. 27 | 28 | The backend API is built in [Node.js](https://nodejs.org/) with [Express](https://expressjs.com/) and [Socket.IO](https://socket.io/) and runs via [PM2](https://pm2.keymetrics.io/) in production. Game lobbies are stored in a custom in-memory collection using ES6 Maps. 29 | 30 | Infrastructure consists of a single Digital Ocean droplet serving the application over HTTP/2 on nginx and deployed via GitHub Actions. 31 | 32 | ## Structure 33 | 34 | This mono-repo is seperated into `client`, `server`, and `common` directories. 35 | 36 | `client` contains the React UI, `server` contains the Express/Socket.IO backend, and `common` contains shared game logic and types. 37 | 38 | Follow these steps to start the application in development mode: 39 | 40 | 1. Clone this repo 41 | 2. From the repo root, run 42 | 1. `npm install-all` (installs modules in each directory) 43 | 2. `npm start` (runs the client webpack dev server and the server via nodemon) 44 | 3. Open http://localhost:8000 to view the app 45 | 4. Any code changes made will reflect immediately 46 | 47 | ## Contributing 48 | 49 | I am currently not accepting pull-requests, but you are welcome to create an issue for any type of technical question, suggestion or bug report. 50 | 51 | If you would like to support development, you can do so (with my thanks!) via the donation link on the [About page](https://u3t.app/about). 52 | 53 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/GlobalBoard/GlobalBoard.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Board, 3 | Cell, 4 | ErrorParams, 5 | Errors, 6 | IGameState, 7 | isInvalidTurn, 8 | ITurnInput, 9 | Player, 10 | } from '@u3t/common'; 11 | import React, { useState } from 'react'; 12 | import styled from 'styled-components'; 13 | 14 | import { gridSize } from '../../../utils/palette'; 15 | import Grid from '../../Grid'; 16 | import LocalBoard from '../LocalBoard/LocalBoard'; 17 | 18 | interface Props { 19 | state: IGameState; 20 | seat?: null | Player; 21 | error?: ErrorParams | null; 22 | dismissError?: () => void; 23 | onValidTurn?: (turnInput: ITurnInput) => void; 24 | onInvalidTurn?: (error: Errors) => void; 25 | Alert?: false | React.ReactElement; 26 | disabled?: boolean; 27 | Modal?: React.ReactElement | null; 28 | } 29 | 30 | const Container = styled.div` 31 | position: relative; 32 | `; 33 | 34 | const OuterGrid = styled(Grid)<{ disabled?: boolean }>` 35 | width: 100vw; 36 | height: 100vw; 37 | max-width: ${gridSize}; 38 | max-height: ${gridSize}; 39 | 40 | ${(props) => 41 | props.disabled && 42 | ` 43 | pointer-events: none; 44 | opacity: 0.6; 45 | `} 46 | `; 47 | 48 | export default function GameView({ 49 | state, 50 | seat, 51 | onValidTurn, 52 | onInvalidTurn, 53 | Modal, 54 | Alert, 55 | disabled, 56 | }: Props) { 57 | const { boards, activeBoard, winner, winningSet } = state; 58 | const [flashing, setFlashing] = useState(false); 59 | 60 | const onPlay = (turnInput: { board: Board; cell: Cell }) => { 61 | if (disabled) return; 62 | const turn = { player: seat!, ...turnInput }; 63 | const invalidTurnError = isInvalidTurn(state, turn); 64 | if (invalidTurnError) { 65 | onInvalidTurn && onInvalidTurn(invalidTurnError); 66 | if (invalidTurnError === Errors.BoardNotPlayable) { 67 | if (flashing) return; 68 | setFlashing(true); 69 | setTimeout(() => { 70 | setFlashing(false); 71 | }, 350); 72 | } 73 | } else { 74 | onValidTurn && onValidTurn(turn); 75 | } 76 | }; 77 | 78 | return ( 79 | 80 | {Modal && Modal} 81 | {Alert && Alert} 82 | 83 | {boards.map((b, i) => ( 84 | 95 | ))} 96 | 97 | 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /client/src/Containers/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { faGithub } from '@fortawesome/free-brands-svg-icons'; 2 | import { faDownload, faEnvelope } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import React from 'react'; 5 | import { Link } from 'react-router-dom'; 6 | import styled from 'styled-components'; 7 | 8 | import { media } from '../styles/mixins'; 9 | import palette, { gridSize } from '../utils/palette'; 10 | 11 | const StyledFooter = styled.footer` 12 | display: flex; 13 | justify-content: center; 14 | height: 50px; 15 | background-color: ${palette.primaryDark}; 16 | `; 17 | 18 | const FooterInner = styled.div` 19 | max-width: ${gridSize}; 20 | display: flex; 21 | align-items: stretch; 22 | width: 100%; 23 | justify-content: space-between; 24 | align-items: center; 25 | 26 | color: ${palette.white}; 27 | `; 28 | 29 | const SocialLinks = styled.div` 30 | height: 100%; 31 | display: flex; 32 | padding-right: 0.5em; 33 | 34 | > * + * { 35 | margin-left: 0.1em; 36 | } 37 | `; 38 | 39 | const IconLink = styled(Link)` 40 | display: flex; 41 | align-items: center; 42 | padding: 0.5em; 43 | `; 44 | 45 | const IconAnchor = styled.a` 46 | display: flex; 47 | align-items: center; 48 | padding: 0.5em; 49 | `; 50 | 51 | const Button = styled.button` 52 | cursor: pointer; 53 | padding: 0 0.5em; 54 | color: white; 55 | background-color: transparent; 56 | outline: none; 57 | border: none; 58 | padding: none; 59 | `; 60 | 61 | const Text = styled.span` 62 | display: flex; 63 | align-items: center; 64 | padding-left: 1em; 65 | 66 | ${media.aboveMobileL`display: none;`} 67 | 68 | &.desktop { 69 | display: none; 70 | ${media.aboveMobileL`display: flex;`} 71 | } 72 | `; 73 | 74 | interface Props { 75 | deferredInstallPrompt: BeforeInstallPromptEvent | null; 76 | } 77 | 78 | const Footer = ({ deferredInstallPrompt }: Props) => { 79 | return ( 80 | 81 | 82 | © C Stewart {new Date().getFullYear()} 83 | © Chace Stewart {new Date().getFullYear()} 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default Footer; 107 | -------------------------------------------------------------------------------- /client/src/Components/GameArea/LocalBoard/LocalBoard.tsx: -------------------------------------------------------------------------------- 1 | import { faCircle } from '@fortawesome/free-regular-svg-icons'; 2 | import { faTimes } from '@fortawesome/free-solid-svg-icons'; 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 4 | import { 5 | Board as BoardType, 6 | Cell as CellType, 7 | IBoardState, 8 | ITurnInput, 9 | Player, 10 | } from '@u3t/common'; 11 | import React, { useMemo } from 'react'; 12 | import styled from 'styled-components'; 13 | 14 | import { media } from '../../../styles/mixins'; 15 | import palette from '../../../utils/palette'; 16 | import Grid from '../../Grid'; 17 | import { getCellBg } from '../Cell/Cell'; 18 | import Cell from './Cell'; 19 | 20 | const InnerGrid = styled(Grid)<{ bgColor?: string }>` 21 | grid-gap: 0.25em; 22 | padding: 0.25em; 23 | border-style: solid; 24 | border-color: ${palette.primaryLight}; 25 | border-width: 0 2px 2px 0; 26 | 27 | ${media.aboveMobileL` 28 | border-width: 0 0.25em 0.25em 0; 29 | grid-gap: 0.5em; padding: 0.5em; 30 | `} 31 | 32 | &:nth-child(3n) { 33 | border-right: 0; 34 | } 35 | &:nth-child(n + 7) { 36 | border-bottom: 0; 37 | } 38 | &.flashing { 39 | background-color: ${(p) => p.bgColor}; 40 | } 41 | `; 42 | 43 | interface Props { 44 | flashing: boolean; 45 | gameWinner: null | Player; 46 | winningSet: Array; 47 | data: IBoardState; 48 | boardIndex: BoardType; 49 | onClick: (turnInput: Omit) => void; 50 | seat: Player; 51 | activeBoard: BoardType[]; 52 | } 53 | 54 | const Board = ({ 55 | flashing, 56 | gameWinner, 57 | winningSet, 58 | data: { cells, winner: boardWinner }, 59 | seat, 60 | boardIndex, 61 | onClick, 62 | activeBoard, 63 | }: Props) => { 64 | const handleClick = (cellIndex: CellType) => { 65 | onClick({ board: boardIndex, cell: cellIndex }); 66 | }; 67 | const active = !gameWinner && activeBoard.includes(boardIndex); 68 | const shouldDim = useMemo( 69 | () => (gameWinner ? !winningSet.includes(boardIndex) : !active), 70 | [gameWinner, winningSet, boardIndex, active] 71 | ); 72 | return ( 73 | 77 | {boardWinner && ( 78 | 92 | )} 93 | {cells.map((cell, i) => ( 94 | 102 | ))} 103 | 104 | ); 105 | }; 106 | 107 | export default Board; 108 | -------------------------------------------------------------------------------- /client/src/hooks/useLobbyReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Events, 3 | GameStartedResponse, 4 | JoinLobbyResponse_NewPlayer, 5 | JoinLobbyResponse_Reconnection, 6 | JoinLobbyResponse_Spectator, 7 | SyncResponse, 8 | } from '@u3t/common'; 9 | import { useEffect, useReducer, useRef } from 'react'; 10 | 11 | export interface IMultiplayerState { 12 | playerId: null | string; 13 | playerSeat: null | 1 | 2; 14 | lobbyId: null | string; 15 | isSpectator: boolean; 16 | restartRequested: boolean; 17 | started: boolean; 18 | hasJoined: boolean; 19 | } 20 | 21 | const initialState: IMultiplayerState = { 22 | playerId: null, 23 | playerSeat: null, 24 | lobbyId: null, 25 | isSpectator: false, 26 | restartRequested: false, 27 | hasJoined: false, 28 | started: false, 29 | }; 30 | 31 | type Action = 32 | | { event: typeof Events.StartGame; data: GameStartedResponse } 33 | | { event: typeof Events.JoinedLobby; data: JoinLobbyResponse_NewPlayer } 34 | | { event: typeof Events.Sync; data: SyncResponse } 35 | | { event: typeof Events.RejoinedGame; data: JoinLobbyResponse_Reconnection } 36 | | { event: typeof Events.JoinedAsSpectator; data: JoinLobbyResponse_Spectator } 37 | | { event: typeof Events.RestartRequested } 38 | | { event: 'set'; data: Partial } 39 | | { event: 'reset' }; 40 | 41 | export function reducer(state: IMultiplayerState, action: Action): IMultiplayerState { 42 | switch (action.event) { 43 | case Events.StartGame: { 44 | return { 45 | ...state, 46 | started: true, 47 | playerId: action.data.playerId, 48 | playerSeat: action.data.seat, 49 | }; 50 | } 51 | case Events.JoinedLobby: { 52 | return { 53 | ...state, 54 | hasJoined: true, 55 | lobbyId: action.data.lobbyId, 56 | playerId: action.data.playerId, 57 | }; 58 | } 59 | case Events.Sync: { 60 | return { 61 | ...state, 62 | playerSeat: action.data.seat || state.playerSeat, 63 | restartRequested: action.data.state.turn === 1 ? false : state.restartRequested, 64 | }; 65 | } 66 | case Events.RejoinedGame: { 67 | return { 68 | ...state, 69 | hasJoined: true, 70 | started: action.data.started, 71 | lobbyId: action.data.lobbyId, 72 | playerSeat: action.data.seat || null, 73 | }; 74 | } 75 | case Events.JoinedAsSpectator: { 76 | return { 77 | ...state, 78 | hasJoined: true, 79 | isSpectator: true, 80 | started: true, 81 | }; 82 | } 83 | case Events.RestartRequested: { 84 | return { 85 | ...state, 86 | restartRequested: true, 87 | }; 88 | } 89 | case 'set': { 90 | return { 91 | ...state, 92 | ...action.data, 93 | }; 94 | } 95 | case 'reset': { 96 | return { ...initialState }; 97 | } 98 | default: 99 | return state; 100 | } 101 | } 102 | 103 | export default function (passedState: Partial) { 104 | const [lobbyState, dispatch] = useReducer(reducer, { ...initialState, ...passedState }); 105 | const lobbyStateRef = useRef(lobbyState); 106 | 107 | useEffect(() => { 108 | lobbyStateRef.current = lobbyState; 109 | }, [lobbyState]); 110 | 111 | return { lobbyState, lobbyStateRef, dispatch }; 112 | } 113 | -------------------------------------------------------------------------------- /client/src/Containers/App.tsx: -------------------------------------------------------------------------------- 1 | import { ClientSocket } from '@u3t/common'; 2 | import React, { lazy, Suspense, useEffect, useState } from 'react'; 3 | import { Redirect, Route, Switch } from 'react-router-dom'; 4 | import { io } from 'socket.io-client'; 5 | import styled from 'styled-components'; 6 | 7 | import ErrorBoundary from '../Components/ErrorBoundary'; 8 | import { useTracking } from '../hooks/useTracking'; 9 | import GlobalStyle from '../styles/global'; 10 | import { media } from '../styles/mixins'; 11 | import { gridSize } from '../utils/palette'; 12 | import Footer from './Footer'; 13 | import Header from './Header'; 14 | 15 | const Home = lazy(() => import('./Home')); 16 | const Lobby = lazy(() => import('./Lobby')); 17 | const About = lazy(() => import('./About/About')); 18 | const Contact = lazy(() => import('./Contact/contact')); 19 | const HotSeat = lazy(() => import('./HotSeat')); 20 | const PlayAI = lazy(() => import('./PlayAI')); 21 | const Rules = lazy(() => import('./Rules')); 22 | 23 | export const Main = styled.main` 24 | display: flex; 25 | flex-direction: column; 26 | flex: 1; 27 | margin: 0 auto; 28 | max-width: ${gridSize}; 29 | width: 100%; 30 | 31 | overflow: hidden; 32 | 33 | ${media.aboveMobileL`overflow: auto`} 34 | `; 35 | 36 | const socketURL = 37 | process.env.NODE_ENV === 'development' 38 | ? 'http://localhost:8001' 39 | : window.location.protocol + '//' + window.location.host; 40 | 41 | const socket: ClientSocket = io(socketURL, { path: '/ws' }); 42 | 43 | function App() { 44 | useTracking(); 45 | const [deferredInstallPrompt, setDeferredInstallPrompt] = 46 | useState(null); 47 | 48 | useEffect(() => { 49 | window.addEventListener('beforeinstallprompt', (e) => { 50 | e.preventDefault(); 51 | setDeferredInstallPrompt(e); 52 | }); 53 | }); 54 | 55 | return ( 56 | <> 57 | 58 |
59 | 60 |
61 | 62 | 63 | 64 | 65 | 66 | } 71 | /> 72 | ( 77 | 78 | )} 79 | /> 80 | 81 | 82 | 83 | ( 86 | 87 | )} 88 | /> 89 | 90 | 91 | 92 | 93 |
94 |
95 |