├── .env.example ├── .github └── workflows │ ├── master_dokku.yml │ └── master_jeopardy.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── Procfile ├── README.md ├── dev ├── dokku.sh ├── hash.mts └── run_hash.sh ├── global.d.ts ├── index.html ├── jeopardy.json.gz ├── package-lock.json ├── package.json ├── public ├── clearly.mp3 ├── example.csv ├── favicon.ico ├── jeopardy │ ├── jeopardy-board-fill.mp3 │ ├── jeopardy-daily-double.mp3 │ ├── jeopardy-intro-full.ogg │ ├── jeopardy-intro-short.mp3 │ ├── jeopardy-intro-video.mp4 │ ├── jeopardy-rightanswer.mp3 │ ├── jeopardy-think.mp3 │ └── jeopardy-times-up.mp3 └── screenshot3.png ├── server ├── aivoice.ts ├── config.ts ├── gamestate.ts ├── hash.ts ├── jData.ts ├── moniker.ts ├── openai.ts ├── redis.ts ├── room.ts ├── server.ts └── tsconfig.json ├── src ├── components │ ├── App │ │ ├── App.css │ │ ├── App.test.js │ │ ├── App.tsx │ │ └── index.tsx │ ├── Chat │ │ └── Chat.tsx │ ├── Home │ │ ├── Home.module.css │ │ └── Home.tsx │ ├── Jeopardy │ │ ├── Jeopardy.css │ │ ├── Jeopardy.tsx │ │ ├── gyparody.ttf │ │ ├── jeopardy-daily-double.png │ │ ├── jeopardy-game-board-daily-double.png │ │ ├── korinna-regular.otf │ │ ├── swiss911-xcm-bt.ttf │ │ └── univers-75-black.ttf │ └── TopBar │ │ └── TopBar.tsx ├── index.jsx ├── tsconfig.json └── utils.ts ├── vite.config.js └── words ├── adjectives.txt ├── nouns.txt └── verbs.txt /.env.example: -------------------------------------------------------------------------------- 1 | #REDIS_URL=REPLACE_ME 2 | #OPENAI_SECRET_KEY=REPLACE_ME -------------------------------------------------------------------------------- /.github/workflows/master_dokku.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Cloning repo 18 | uses: actions/checkout@v2 19 | with: 20 | fetch-depth: 0 21 | - name: Push to dokku 22 | uses: dokku/github-action@master 23 | with: 24 | git_remote_url: 'ssh://dokku@jeopardy.centralus.cloudapp.azure.com:22/jeopardy.app' 25 | ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }} 26 | -------------------------------------------------------------------------------- /.github/workflows/master_jeopardy.yml: -------------------------------------------------------------------------------- 1 | # Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy 2 | # More GitHub Actions for Azure: https://github.com/Azure/actions 3 | 4 | # name: Build and deploy Node.js app to Azure Web App - jeopardy 5 | 6 | # on: 7 | # push: 8 | # branches: 9 | # - master 10 | # workflow_dispatch: 11 | 12 | # jobs: 13 | # build: 14 | # runs-on: ubuntu-latest 15 | 16 | # steps: 17 | # - uses: actions/checkout@v2 18 | 19 | # - name: Set up Node.js version 20 | # uses: actions/setup-node@v1 21 | # with: 22 | # node-version: '16.x' 23 | 24 | # - name: npm install, build, and test 25 | # run: | 26 | # npm install 27 | # npm run build --if-present 28 | 29 | # - name: Zip artifact for deployment 30 | # run: zip release.zip ./* -r 31 | 32 | # - name: Upload artifact for deployment job 33 | # uses: actions/upload-artifact@v2 34 | # with: 35 | # name: node-app 36 | # path: release.zip 37 | 38 | # deploy: 39 | # runs-on: ubuntu-latest 40 | # needs: build 41 | # environment: 42 | # name: 'Production' 43 | # url: ${{ steps.deploy-to-webapp.outputs.webapp-url }} 44 | 45 | # steps: 46 | # - name: Download artifact from build job 47 | # uses: actions/download-artifact@v2 48 | # with: 49 | # name: node-app 50 | 51 | # - name: unzip artifact for deployment 52 | # run: unzip release.zip 53 | 54 | # - name: 'Deploy to Azure Web App' 55 | # id: deploy-to-webapp 56 | # uses: azure/webapps-deploy@v2 57 | # with: 58 | # app-name: 'jeopardy' 59 | # slot-name: 'Production' 60 | # publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_05B932BE2D10470B995285EFD9AD1EED }} 61 | # package: . 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /buildServer 14 | 15 | # misc 16 | .DS_Store 17 | .env 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | j-archive-parser/* -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | j-archive-parser -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Howard Chung 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run server 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jeopardy 2 | 3 | A website for playing Jeopardy! together with friends over the Internet. Designed for computer browsers, although it's playable on mobile. If the UI/code looks familiar, much of it is copied from my other project, WatchParty: https://github.com/howardchung/watchparty 4 | 5 | ## Description 6 | 7 | - Implements the game show Jeopardy!, including the Jeopardy, Double Jeopardy, and Final Jeopardy rounds. Daily Doubles are also included. 8 | - Any archived episode of Jeopardy! can be loaded, with options for loading specific event games (e.g. College Championship) 9 | - Load games by episode number 10 | - Create your own custom game with a CSV file 11 | - Supports creating multiple rooms for private/simultaneous games. 12 | - Text chat included 13 | 14 | ### Reading: 15 | 16 | - Uses text-to-speech to read clues 17 | 18 | ### Buzzing: 19 | 20 | - After a set time (based on number of syllables in the clue text), buzzing is unlocked 21 | - Buzzing in enables a user to submit an answer 22 | - Answers will be judged in buzz order 23 | 24 | ### Judging: 25 | 26 | - Players can judge answer correctness themselves. 27 | - An experimental AI judge powered by ChatGPT is in testing. 28 | 29 | ### Data: 30 | 31 | - Game data is from http://j-archive.com/ 32 | - Games might be incomplete if some clues weren't revealed on the show. 33 | 34 | ## Updating Clues: 35 | 36 | - Game data is collected using a separate j-archive-parser project and collected into a single gzipped JSON file, which this project can retrieve. 37 | 38 | ## Environment Variables 39 | 40 | - `REDIS_URL`: Provide to allow persisting rooms to Redis so they survive server reboots 41 | - `OPENAI_SECRET_KEY`: Provide to allow using OpenAI's ChatGPT to judge answers 42 | 43 | ## Tech 44 | 45 | - React 46 | - TypeScript 47 | - Node.js 48 | - Redis 49 | -------------------------------------------------------------------------------- /dev/dokku.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # for debian systems, installs Dokku via apt-get 4 | wget https://dokku.com/install/v0.30.1/bootstrap.sh 5 | sudo DOKKU_TAG=v0.30.1 bash bootstrap.sh 6 | 7 | # usually your key is already available under the current user's `~/.ssh/authorized_keys` file 8 | cat ~/.ssh/authorized_keys | sudo dokku ssh-keys:add admin 9 | 10 | # you can use any domain you already have access to 11 | # this domain should have an A record or CNAME pointing at your server's IP 12 | dokku domains:set-global jeopardy.app 13 | 14 | dokku apps:create jeopardy.app 15 | # Set up env vars 16 | dokku config:set jeopardy.app STATS_KEY=test 17 | 18 | # Set up Redis (dokku native) 19 | sudo dokku plugin:install https://github.com/dokku/dokku-redis.git 20 | dokku redis:create jeopardy-redis 21 | dokku redis:link jeopardy-redis jeopardy.app 22 | 23 | # Set up redis (custom) 24 | # sudo docker run --name redis -d redis:7 25 | # Use the IP of the docker bridge network 26 | # dokku config:set jeopardy.app REDIS_URL=redis://172.17.0.4:6379 -------------------------------------------------------------------------------- /dev/hash.mts: -------------------------------------------------------------------------------- 1 | // Benchmark hashing algorithms 2 | // import { xxHash32 } from 'js-xxhash'; 3 | import nodeCrypto from 'node:crypto'; 4 | import { cyrb53 } from '../server/hash.ts'; 5 | 6 | const test = 'test'.repeat(100000); 7 | 8 | // Note: due to VM optimization the later functions run faster 9 | // Need to execute in separate processes for accurate testing 10 | const jobs = { 11 | cyrb: () => cyrb53(test).toString(16), 12 | // xxhash: () => xxHash32(test).toString(16), 13 | // md5js: () => MD5.hash(test), 14 | // Works only in node 15 | md5node: () => nodeCrypto.createHash('md5').update(test).digest('hex'), 16 | // requires https in browser, also Buffer API to convert not available 17 | sha1: async () => Buffer.from(await crypto.subtle.digest('sha-1', Buffer.from(test))).toString('hex'), 18 | } 19 | 20 | console.time(process.argv[2]); 21 | console.log(await jobs[process.argv[2]]()); 22 | console.timeEnd(process.argv[2]); 23 | -------------------------------------------------------------------------------- /dev/run_hash.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tsx hash.mts cyrb 4 | tsx hash.mts xxhash 5 | tsx hash.mts md5js 6 | tsx hash.mts md5node 7 | tsx hash.mts sha1 -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | interface User { 2 | id: string; 3 | name?: string; 4 | connected: boolean; 5 | disconnectTime: number; 6 | } 7 | 8 | interface ChatMessage { 9 | timestamp: string; 10 | videoTS?: number; 11 | id: string; 12 | cmd: string; 13 | msg: string; 14 | } 15 | 16 | interface RawQuestion { 17 | val: number; 18 | cat: string; 19 | x?: number; 20 | y?: number; 21 | q?: string; 22 | a?: string; 23 | dd?: boolean; 24 | } 25 | 26 | interface Question { 27 | value: number; 28 | category: string; 29 | question?: string; 30 | answer?: string; 31 | daily_double?: boolean; 32 | } 33 | 34 | type GameOptions = { 35 | number?: string; 36 | filter?: string; 37 | makeMeHost?: boolean; 38 | allowMultipleCorrect?: boolean; 39 | // Turns on AI judge by default (otherwise needs to be enabled per game) 40 | enableAIJudge?: boolean; 41 | // timeout to use for DD wagers and question answers 42 | answerTimeout?: number; 43 | // timeout to use for final wagers and answers (all players participate) 44 | finalTimeout?: number; 45 | } 46 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Jeopardy! 15 | 16 | 17 | 18 | 37 | 38 |
39 | 49 | 50 | 51 | 55 | 64 | 65 | -------------------------------------------------------------------------------- /jeopardy.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/jeopardy.json.gz -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jeopardy", 3 | "version": "0.1.2", 4 | "private": true, 5 | "dependencies": { 6 | "compression": "^1.7.5", 7 | "cors": "^2.8.5", 8 | "dotenv": "^16.4.7", 9 | "express": "^5.0.1", 10 | "ioredis": "^5.4.2", 11 | "openai": "^5.1.1", 12 | "papaparse": "^5.5.1", 13 | "react": "^18.3.1", 14 | "react-countup": "^6.5.3", 15 | "react-dom": "^18.3.1", 16 | "react-markdown": "^9.0.3", 17 | "semantic-ui-css": "^2.5.0", 18 | "semantic-ui-react": "^2.1.5", 19 | "socket.io": "^4.8.1", 20 | "socket.io-client": "^4.8.1" 21 | }, 22 | "scripts": { 23 | "server": "node buildServer/server.js", 24 | "ui": "vite --host", 25 | "build": "npm run buildReact && npm run buildServer", 26 | "buildReact": "vite build && npm run typecheck", 27 | "buildServer": "tsc --project server/tsconfig.json --outDir buildServer", 28 | "typecheckServer": "tsc --project server/tsconfig.json --noEmit", 29 | "typecheck": "tsc --project src/tsconfig.json --noEmit", 30 | "dev": "ts-node-dev --respawn --transpile-only --project server/tsconfig.json server/server.ts", 31 | "prettier": "prettier --write .", 32 | "updateEps": "curl -sSL -O https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "devDependencies": { 50 | "@types/compression": "^1.7.5", 51 | "@types/cors": "^2.8.17", 52 | "@types/express": "^5.0.0", 53 | "@types/node": "^22.10.5", 54 | "@types/papaparse": "^5.3.15", 55 | "@types/react": "^18.3.18", 56 | "@types/react-dom": "^18.3.5", 57 | "prettier": "^3.4.2", 58 | "ts-node-dev": "^2.0.0", 59 | "typescript": "^5.7.3", 60 | "vite": "^6.0.7" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/clearly.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/clearly.mp3 -------------------------------------------------------------------------------- /public/example.csv: -------------------------------------------------------------------------------- 1 | round,cat,q,a,dd 2 | jeopardy,"A CATEGORY","a question","a answer",true 3 | jeopardy,"A CATEGORY","a question","a answer",false 4 | jeopardy,"A CATEGORY","a question","a answer",false 5 | jeopardy,"A CATEGORY","a question","a answer",false 6 | jeopardy,"A CATEGORY","a question","a answer",false 7 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false 8 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false 9 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false 10 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false 11 | jeopardy,"ANOTHER CATEGORY","a question","a answer",false 12 | jeopardy,"CATEGORY 3","a question","a answer",false 13 | jeopardy,"CATEGORY 3","a question","a answer",false 14 | jeopardy,"CATEGORY 3","a question","a answer",false 15 | jeopardy,"CATEGORY 3","a question","a answer",false 16 | jeopardy,"CATEGORY 3","a question","a answer",false 17 | jeopardy,"CATEGORY 4","a question","a answer",false 18 | jeopardy,"CATEGORY 4","a question","a answer",false 19 | jeopardy,"CATEGORY 4","a question","a answer",false 20 | jeopardy,"CATEGORY 4","a question","a answer",false 21 | jeopardy,"CATEGORY 4","a question","a answer",false 22 | jeopardy,"CATEGORY 5","a question","a answer",false 23 | jeopardy,"CATEGORY 5","a question","a answer",false 24 | jeopardy,"CATEGORY 5","a question","a answer",false 25 | jeopardy,"CATEGORY 5","a question","a answer",false 26 | jeopardy,"CATEGORY 5","a question","a answer",false 27 | jeopardy,"CATEGORY 6","a question","a answer",false 28 | jeopardy,"CATEGORY 6","a question","a answer",false 29 | jeopardy,"CATEGORY 6","a question","a answer",false 30 | jeopardy,"CATEGORY 6","a question","a answer",false 31 | jeopardy,"CATEGORY 6","a question","a answer",false 32 | double,"A CATEGORY","a question","a answer",true 33 | double,"A CATEGORY","a question","a answer",true 34 | double,"A CATEGORY","a question","a answer",false 35 | double,"A CATEGORY","a question","a answer",false 36 | double,"A CATEGORY","a question","a answer",false 37 | double,"ANOTHER CATEGORY","a question","a answer",false 38 | double,"ANOTHER CATEGORY","a question","a answer",false 39 | double,"ANOTHER CATEGORY","a question","a answer",false 40 | double,"ANOTHER CATEGORY","a question","a answer",false 41 | double,"ANOTHER CATEGORY","a question","a answer",false 42 | double,"CATEGORY 3","a question","a answer",false 43 | double,"CATEGORY 3","a question","a answer",false 44 | double,"CATEGORY 3","a question","a answer",false 45 | double,"CATEGORY 3","a question","a answer",false 46 | double,"CATEGORY 3","a question","a answer",false 47 | double,"CATEGORY 4","a question","a answer",false 48 | double,"CATEGORY 4","a question","a answer",false 49 | double,"CATEGORY 4","a question","a answer",false 50 | double,"CATEGORY 4","a question","a answer",false 51 | double,"CATEGORY 4","a question","a answer",false 52 | double,"CATEGORY 5","a question","a answer",false 53 | double,"CATEGORY 5","a question","a answer",false 54 | double,"CATEGORY 5","a question","a answer",false 55 | double,"CATEGORY 5","a question","a answer",false 56 | double,"CATEGORY 5","a question","a answer",false 57 | double,"CATEGORY 6","a question","a answer",false 58 | double,"CATEGORY 6","a question","a answer",false 59 | double,"CATEGORY 6","a question","a answer",false 60 | double,"CATEGORY 6","a question","a answer",false 61 | double,"CATEGORY 6","a question","a answer",false 62 | final,"FINAL CATEGORY","a question","a answer",false -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/favicon.ico -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-board-fill.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-board-fill.mp3 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-daily-double.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-daily-double.mp3 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-intro-full.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-full.ogg -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-intro-short.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-short.mp3 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-intro-video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-intro-video.mp4 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-rightanswer.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-rightanswer.mp3 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-think.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-think.mp3 -------------------------------------------------------------------------------- /public/jeopardy/jeopardy-times-up.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/jeopardy/jeopardy-times-up.mp3 -------------------------------------------------------------------------------- /public/screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/public/screenshot3.png -------------------------------------------------------------------------------- /server/aivoice.ts: -------------------------------------------------------------------------------- 1 | import { cyrb53 } from './hash'; 2 | 3 | // Given input text, gets back an mp3 file URL 4 | // We can send this to each client and have it be read 5 | // The RVC server caches for repeated inputs, so duplicate requests are fast 6 | // Without GPU acceleration this is kinda slow to do in real time, so we may need to add support to pre-generate audio clips for specific game 7 | export async function genAITextToSpeech( 8 | rvcHost: string, 9 | text: string, 10 | ): Promise { 11 | if (text.length > 10000 || !text.length) { 12 | return; 13 | } 14 | const resp = await fetch(rvcHost + '/gradio_api/call/partial_36', { 15 | method: 'POST', 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | }, 19 | body: JSON.stringify({ 20 | data: [ 21 | text, 22 | 'Trebek', 23 | 'en-US-ChristopherNeural', 24 | 0, 25 | 0, 26 | 0, 27 | 0, 28 | 0, 29 | ['rmvpe'], 30 | 0.5, 31 | 3, 32 | 0.25, 33 | 0.33, 34 | 128, 35 | true, 36 | false, 37 | 1, 38 | true, 39 | 0.7, 40 | 'contentvec', 41 | '', 42 | 0, 43 | 0, 44 | 44100, 45 | 'mp3', 46 | cyrb53(text).toString(), 47 | ], 48 | }), 49 | }); 50 | const info = await resp.json(); 51 | // console.log(info); 52 | // Fetch the result 53 | const fetchUrl = rvcHost + '/gradio_api/call/partial_36/' + info.event_id; 54 | // console.log(fetchUrl); 55 | const resp2 = await fetch(fetchUrl); 56 | const info2 = await resp2.text(); 57 | // console.log(info2); 58 | const lines = info2.split('\n'); 59 | // Find the line after complete 60 | const completeIndex = lines.indexOf('event: complete'); 61 | const target = lines[completeIndex + 1]; 62 | if (target.startsWith('data: ')) { 63 | // Take off the prefix, parse the array as json and get the first element 64 | const arr = JSON.parse(target.slice(6)); 65 | // Fix the path /grad/gradio_api/file to /gradio_api/file 66 | const url = arr[0].url.replace('/grad/gradio_api/file', '/gradio_api/file'); 67 | // console.log(url); 68 | return url; 69 | } 70 | return; 71 | } 72 | -------------------------------------------------------------------------------- /server/config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | const defaults = { 4 | SSL_KEY_FILE: '', // Optional, Filename of SSL key (to use https) 5 | SSL_CRT_FILE: '', // Optional, Filename of SSL cert (to use https) 6 | PORT: '8083', // Port to use for server 7 | NODE_ENV: '', 8 | OPENAI_SECRET_KEY: '', 9 | STATS_KEY: 'test', 10 | REDIS_URL: '', 11 | }; 12 | 13 | export default { 14 | ...defaults, 15 | ...process.env, 16 | permaRooms: ['/default', '/smokestack', '/howard-and-katie', '/liz'], 17 | }; -------------------------------------------------------------------------------- /server/gamestate.ts: -------------------------------------------------------------------------------- 1 | export const getPerQuestionState = () => { 2 | return { 3 | currentQ: '', 4 | currentAnswer: undefined as string | undefined, 5 | currentValue: 0, 6 | playClueEndTS: 0, 7 | questionEndTS: 0, 8 | wagerEndTS: 0, 9 | buzzUnlockTS: 0, 10 | currentDailyDouble: false, 11 | canBuzz: false, 12 | canNextQ: false, 13 | currentJudgeAnswerIndex: undefined as number | undefined, 14 | currentJudgeAnswer: undefined as string | undefined, //socket.id 15 | dailyDoublePlayer: undefined as string | undefined, //socket.id 16 | answers: {} as Record, 17 | submitted: {} as Record, 18 | judges: {} as Record, 19 | buzzes: {} as Record, 20 | wagers: {} as Record, 21 | // We track this separately from wagers because the list of people to wait for is different depending on context 22 | // e.g. for Double we only need to wait for 1 player, for final we have to wait for everyone 23 | waitingForWager: undefined as Record | undefined, 24 | }; 25 | }; 26 | 27 | export const getGameState = ( 28 | options: { 29 | epNum?: string; 30 | airDate?: string; 31 | info?: string; 32 | answerTimeout?: number; 33 | finalTimeout?: number; 34 | allowMultipleCorrect?: boolean; 35 | host?: string; 36 | enableAIJudge?: boolean; 37 | }, 38 | jeopardy?: RawQuestion[], 39 | double?: RawQuestion[], 40 | final?: RawQuestion[], 41 | ) => { 42 | return { 43 | jeopardy, 44 | double, 45 | final, 46 | answers: {} as Record, 47 | wagers: {} as Record, 48 | board: {} as { [key: string]: RawQuestion }, 49 | public: { 50 | serverTime: Date.now(), 51 | epNum: options.epNum, 52 | airDate: options.airDate, 53 | info: options.info, 54 | board: {} as { [key: string]: Question }, 55 | scores: {} as Record, // player scores 56 | round: '', // jeopardy or double or final 57 | picker: undefined as string | undefined, // If null let anyone pick, otherwise last correct answer 58 | // below is populated in emitstate from settings 59 | host: undefined as string | undefined, 60 | enableAIJudge: false, 61 | enableAIVoices: undefined as string | undefined, 62 | ...getPerQuestionState(), 63 | }, 64 | }; 65 | }; 66 | export type PublicGameState = ReturnType['public']; -------------------------------------------------------------------------------- /server/hash.ts: -------------------------------------------------------------------------------- 1 | // Extremely simple function to convert a string to a number for bucketing 2 | // Will return the same value for the same letters, regardless of order (collisions very likely) 3 | export function hashString(input: string) { 4 | var hash = 0; 5 | for (var i = 0; i < input.length; i++) { 6 | var charCode = input.charCodeAt(i); 7 | hash += charCode; 8 | } 9 | return hash; 10 | } 11 | 12 | // An improved alternative: 13 | // cyrb53 (c) 2018 bryc (github.com/bryc). License: Public domain. Attribution appreciated. 14 | // A fast and simple 64-bit (or 53-bit) string hash function with decent collision resistance. 15 | // Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. 16 | // See https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript/52171480#52171480 17 | // https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js 18 | export const cyrb53 = (str: string, seed = 0) => { 19 | let h1 = 0xdeadbeef ^ seed, 20 | h2 = 0x41c6ce57 ^ seed; 21 | for (let i = 0, ch; i < str.length; i++) { 22 | ch = str.charCodeAt(i); 23 | h1 = Math.imul(h1 ^ ch, 2654435761); 24 | h2 = Math.imul(h2 ^ ch, 1597334677); 25 | } 26 | h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 27 | h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 28 | h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 29 | h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 30 | 31 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 32 | }; 33 | // If we want all 64 bits of the output: 34 | /* 35 | return [h2>>>0, h1>>>0]; 36 | // or 37 | return (h2>>>0).toString(16).padStart(8,0)+(h1>>>0).toString(16).padStart(8,0); 38 | // or 39 | return 4294967296n * BigInt(h2) + BigInt(h1); 40 | */ 41 | -------------------------------------------------------------------------------- /server/jData.ts: -------------------------------------------------------------------------------- 1 | import { gunzipSync } from 'zlib'; 2 | import fs from 'fs'; 3 | import config from './config'; 4 | 5 | let qs = 0; 6 | let eps = 0; 7 | // On boot, start with the initial data included in repo 8 | console.time('load'); 9 | let jData = JSON.parse(gunzipSync(fs.readFileSync('./jeopardy.json.gz')).toString()); 10 | updateJDataStats(); 11 | console.timeEnd('load'); 12 | console.log('loaded %d episodes', Object.keys(jData).length); 13 | let etag: string | null = null; 14 | 15 | // Periodically refetch the latest episode data and replace it in memory 16 | setInterval(refreshEpisodes, 24 * 60 * 60 * 1000); 17 | refreshEpisodes(); 18 | 19 | async function refreshEpisodes() { 20 | if (config.NODE_ENV === 'development') { 21 | return; 22 | } 23 | console.time('reload'); 24 | try { 25 | const response = await fetch( 26 | 'https://github.com/howardchung/j-archive-parser/raw/release/jeopardy.json.gz', 27 | ); 28 | const newEtag = response.headers.get('etag'); 29 | console.log(newEtag, etag); 30 | if (newEtag !== etag) { 31 | const arrayBuf = await response.arrayBuffer(); 32 | const buf = Buffer.from(arrayBuf); 33 | jData = JSON.parse(gunzipSync(buf).toString()); 34 | updateJDataStats(); 35 | etag = newEtag; 36 | } 37 | } catch (e) { 38 | console.log(e); 39 | } 40 | console.timeEnd('reload'); 41 | } 42 | 43 | function updateJDataStats() { 44 | console.time('count'); 45 | qs = 0; 46 | eps = 0; 47 | Object.keys(jData).forEach(key => { 48 | eps += 1; 49 | qs += jData[key].jeopardy.length; 50 | qs += jData[key].double.length; 51 | qs + jData[key].final.length; 52 | }); 53 | console.timeEnd('count'); 54 | } 55 | 56 | export function getJData() { 57 | return jData; 58 | } 59 | 60 | export function getJDataStats() { 61 | return { qs, eps }; 62 | } -------------------------------------------------------------------------------- /server/moniker.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | let adjectives = fs 4 | .readFileSync(process.cwd() + '/words/adjectives.txt') 5 | .toString() 6 | .split('\n'); 7 | const nouns = fs 8 | .readFileSync(process.cwd() + '/words/nouns.txt') 9 | .toString() 10 | .split('\n'); 11 | const verbs = fs 12 | .readFileSync(process.cwd() + '/words/verbs.txt') 13 | .toString() 14 | .split('\n'); 15 | const randomElement = (array: string[]) => 16 | array[Math.floor(Math.random() * array.length)]; 17 | 18 | export function makeRoomName() { 19 | let filteredAdjectives = adjectives; 20 | const adjective = randomElement(filteredAdjectives); 21 | const noun = randomElement(nouns); 22 | const verb = randomElement(verbs); 23 | return `${adjective}-${noun}-${verb}`; 24 | } 25 | 26 | export function makeUserName() { 27 | return `${capFirst(randomElement(adjectives))} ${capFirst( 28 | randomElement(nouns), 29 | )}`; 30 | } 31 | 32 | function capFirst(string: string) { 33 | return string.charAt(0).toUpperCase() + string.slice(1); 34 | } 35 | -------------------------------------------------------------------------------- /server/openai.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import config from './config'; 3 | 4 | export const openai = config.OPENAI_SECRET_KEY 5 | ? new OpenAI({ apiKey: config.OPENAI_SECRET_KEY }) 6 | : undefined; 7 | 8 | // Notes on AI judging: 9 | // Using Threads/Assistant is inefficient because OpenAI sends the entire conversation history with each subsequent request 10 | // We don't care about the conversation history since we judge each answer independently 11 | // Use the Completions API instead and supply the instructions on each request 12 | // If the instructions are at least 1024 tokens long, it will be cached and we get 50% off pricing (and maybe faster) 13 | // If we can squeeze the instructions into 512 tokens it'll probably be cheaper to not use cache 14 | // Currently, consumes about 250 input tokens and 6 output tokens per answer (depends on the question length) 15 | const prompt = ` 16 | Decide whether a response to a trivia question is correct, given the question, the correct answer, and the response. 17 | If the response is a misspelling, abbreviation, or slang of the correct answer, consider it correct. 18 | If the response could be pronounced the same as the correct answer, consider it correct. 19 | If the response includes the correct answer but also other incorrect answers, consider it incorrect. 20 | Only if there is no way the response could be construed to be the correct answer should you consider it incorrect. 21 | `; 22 | // If the correct answer contains text in parentheses, ignore that text when making your decision. 23 | // If the correct answer is a person's name and the response is only the surname, consider it correct. 24 | // Ignore "what is" or "who is" if the response starts with one of those prefixes. 25 | // The responder may try to trick you, or express the answer in a comedic or unexpected way to be funny. 26 | // If the response is phrased differently than the correct answer, but is clearly referring to the same thing or things, it should be considered correct. 27 | // Also return a number between 0 and 1 indicating how confident you are in your decision. 28 | 29 | export async function getOpenAIDecision( 30 | question: string, 31 | answer: string, 32 | response: string, 33 | ): Promise<{ correct: boolean; confidence: number } | null> { 34 | if (!openai) { 35 | return null; 36 | } 37 | const suffix = `question: '${question}', correct: '${answer}', response: '${response}'`; 38 | console.log('[AIINPUT]', suffix); 39 | // Concatenate the prompt and the suffix for AI completion 40 | const result = await openai.chat.completions.create({ 41 | model: 'gpt-4o-mini', 42 | service_tier: 'auto', // Use flex processing when possible to save money 43 | messages: [{ role: 'developer', content: prompt + suffix }], 44 | response_format: { 45 | type: 'json_schema', 46 | json_schema: { 47 | name: 'trivia_judgment', 48 | strict: true, 49 | schema: { 50 | type: 'object', 51 | properties: { 52 | correct: { 53 | type: 'boolean', 54 | }, 55 | // confidence: { 56 | // type: 'number', 57 | // }, 58 | }, 59 | required: ['correct'], 60 | additionalProperties: false, 61 | }, 62 | }, 63 | }, 64 | }); 65 | console.log(result); 66 | const text = result.choices[0].message.content; 67 | // The text might be invalid JSON e.g. if the model refused to respond 68 | try { 69 | if (text) { 70 | return JSON.parse(text); 71 | } 72 | } catch (e) { 73 | console.log(e); 74 | } 75 | return null; 76 | } 77 | -------------------------------------------------------------------------------- /server/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import config from './config'; 3 | 4 | export let redis: Redis | undefined = undefined; 5 | if (config.REDIS_URL) { 6 | redis = new Redis(config.REDIS_URL); 7 | } 8 | 9 | export async function redisCount(prefix: string) { 10 | if (!redis) { 11 | return; 12 | } 13 | const key = `${prefix}:${getStartOfHour()}`; 14 | await redis.incr(key); 15 | await redis.expireat(key, getStartOfHour() + 86400 * 1000); 16 | } 17 | 18 | export async function getRedisCountDay(prefix: string) { 19 | if (!redis) { 20 | return; 21 | } 22 | // Get counts for last 24 hour keys (including current partial hour) 23 | const keyArr = []; 24 | for (let i = 0; i < 24; i += 1) { 25 | keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`); 26 | } 27 | const values = await redis.mget(...keyArr); 28 | return values.reduce((a, b) => (Number(a) || 0) + (Number(b) || 0), 0); 29 | } 30 | 31 | export async function getRedisCountHour(prefix: string) { 32 | if (!redis) { 33 | return; 34 | } 35 | // Get counts for previous full hour 36 | const value = await redis.get(`${prefix}:${getStartOfHour() - 3600 * 1000}`); 37 | return Number(value); 38 | } 39 | 40 | export async function redisCountDistinct(prefix: string, item: string) { 41 | if (!redis) { 42 | return; 43 | } 44 | const key = `${prefix}:${getStartOfHour()}`; 45 | await redis.pfadd(key, item); 46 | await redis.expireat(key, getStartOfHour() + 86400 * 1000); 47 | } 48 | 49 | export async function getRedisCountDayDistinct(prefix: string) { 50 | if (!redis) { 51 | return; 52 | } 53 | // Get counts for last 24 hour keys (including current partial hour) 54 | const keyArr = []; 55 | for (let i = 0; i < 24; i += 1) { 56 | keyArr.push(`${prefix}:${getStartOfHour() - i * 3600 * 1000}`); 57 | } 58 | return await redis.pfcount(...keyArr); 59 | } 60 | 61 | export async function getRedisCountHourDistinct(prefix: string) { 62 | if (!redis) { 63 | return; 64 | } 65 | // Get counts for previous full hour 66 | return await redis.pfcount(`${prefix}:${getStartOfHour() - 3600 * 1000}`); 67 | } 68 | 69 | function getStartOfDay() { 70 | const now = Date.now(); 71 | return now - (now % 86400000); 72 | } 73 | 74 | function getStartOfHour() { 75 | const now = Date.now(); 76 | return now - (now % 3600000); 77 | } 78 | 79 | function getStartOfMinute() { 80 | const now = Date.now(); 81 | return now - (now % 60000); 82 | } 83 | 84 | -------------------------------------------------------------------------------- /server/room.ts: -------------------------------------------------------------------------------- 1 | import { Socket, Server } from 'socket.io'; 2 | import Papa from 'papaparse'; 3 | import { redis, redisCount } from './redis'; 4 | import { genAITextToSpeech } from './aivoice'; 5 | import { getOpenAIDecision, openai } from './openai'; 6 | import config from './config'; 7 | import { getGameState, getPerQuestionState } from './gamestate'; 8 | import { getJData } from './jData'; 9 | 10 | export class Room { 11 | // Serialized state 12 | public roster: User[] = []; 13 | public clientIds: Record = {}; 14 | private chat: ChatMessage[] = []; 15 | public creationTime: Date = new Date(); 16 | public jpd: ReturnType = getGameState({}, [], [], []); 17 | public settings = { 18 | answerTimeout: 20000, 19 | finalTimeout: 30000, 20 | host: undefined as string | undefined, 21 | allowMultipleCorrect: false, 22 | enableAIJudge: false, 23 | enableAIVoices: undefined as string | undefined, 24 | }; 25 | 26 | // Unserialized state 27 | private io: Server; 28 | public roomId: string; 29 | // Note: snapshot is not persisted so undo is not possible if server restarts 30 | private jpdSnapshot: ReturnType | undefined; 31 | private undoActivated: boolean | undefined = undefined; 32 | private aiJudged: boolean | undefined = undefined; 33 | private playClueTimeout: NodeJS.Timeout = 34 | undefined as unknown as NodeJS.Timeout; 35 | private questionAnswerTimeout: NodeJS.Timeout = 36 | undefined as unknown as NodeJS.Timeout; 37 | private wagerTimeout: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout; 38 | public cleanupInterval: NodeJS.Timeout = undefined as unknown as NodeJS.Timeout; 39 | public lastUpdateTime: Date = new Date(); 40 | 41 | constructor( 42 | io: Server, 43 | roomId: string, 44 | roomData?: string | null | undefined, 45 | ) { 46 | this.io = io; 47 | this.roomId = roomId; 48 | 49 | if (roomData) { 50 | this.deserialize(roomData); 51 | } 52 | 53 | this.cleanupInterval = setInterval(() => { 54 | // Remove players that have been disconnected for a long time 55 | const beforeLength = this.getAllPlayers(); 56 | const now = Date.now(); 57 | this.roster = this.roster.filter( 58 | (p) => p.connected || now - p.disconnectTime < 60 * 60 * 1000, 59 | ); 60 | const afterLength = this.getAllPlayers(); 61 | if (beforeLength !== afterLength && this.getConnectedPlayers().length > 0) { 62 | this.sendRoster(); 63 | } 64 | }, 30 * 60 * 1000); 65 | 66 | io.of(roomId).on('connection', (socket: Socket) => { 67 | this.jpd.public.scores[socket.id] = 0; 68 | 69 | const clientId = socket.handshake.query?.clientId as string; 70 | // clientid map keeps track of the unique clients we've seen 71 | // if we saw this ID already, do the reconnect logic (transfer state) 72 | // The list is persisted, so if the server reboots, all clients reconnect and should have state restored 73 | if (this.clientIds[clientId]) { 74 | const newId = socket.id; 75 | const oldId = this.clientIds[clientId]; 76 | this.handleReconnect(newId, oldId); 77 | } 78 | if (!this.getAllPlayers().find((p) => p.id === socket.id)) { 79 | // New client joining, add to roster 80 | this.roster.push({ 81 | id: socket.id, 82 | name: undefined, 83 | connected: true, 84 | disconnectTime: 0, 85 | }); 86 | } 87 | this.clientIds[clientId] = socket.id; 88 | 89 | this.sendState(); 90 | this.sendRoster(); 91 | socket.emit('chatinit', this.chat); 92 | 93 | socket.on('CMD:name', (data: string) => { 94 | if (!data) { 95 | return; 96 | } 97 | if (data && data.length > 100) { 98 | return; 99 | } 100 | const target = this.getAllPlayers().find((p) => p.id === socket.id); 101 | if (target) { 102 | target.name = data; 103 | this.sendRoster(); 104 | } 105 | }); 106 | // socket.on('JPD:cmdIntro', () => { 107 | // this.io.of(this.roomId).emit('JPD:playIntro'); 108 | // }); 109 | socket.on('JPD:start', (options, data) => { 110 | if (data && data.length > 1000000) { 111 | return; 112 | } 113 | if (typeof options !== 'object') { 114 | return; 115 | } 116 | this.loadEpisode(socket, options, data); 117 | }); 118 | socket.on('JPD:pickQ', (id: string) => { 119 | if (this.settings.host && socket.id !== this.settings.host) { 120 | return; 121 | } 122 | if ( 123 | this.jpd.public.picker && 124 | // If the picker is disconnected, allow anyone to pick to avoid blocking game 125 | this.getConnectedPlayers().find( 126 | (p) => p.id === this.jpd.public.picker, 127 | ) && 128 | this.jpd.public.picker !== socket.id 129 | ) { 130 | return; 131 | } 132 | if (this.jpd.public.currentQ) { 133 | return; 134 | } 135 | if (!this.jpd.public.board[id]) { 136 | return; 137 | } 138 | this.jpd.public.currentQ = id; 139 | this.jpd.public.currentValue = this.jpd.public.board[id].value; 140 | // check if it's a daily double 141 | if (this.jpd.board[id].dd && !this.settings.allowMultipleCorrect) { 142 | // if it is, don't show it yet, we need to collect wager info based only on category 143 | this.jpd.public.currentDailyDouble = true; 144 | this.jpd.public.dailyDoublePlayer = socket.id; 145 | this.jpd.public.waitingForWager = { [socket.id]: true }; 146 | this.setWagerTimeout(this.settings.answerTimeout); 147 | // Autobuzz the player who picked the DD, all others pass 148 | // Note: if a player joins during wagering, they might not be marked as passed (submitted) 149 | // Currently client doesn't show the answer box because it checks for buzzed in players 150 | // But there's probably no server block on them submitting answers 151 | this.getActivePlayers().forEach((p) => { 152 | if (p.id === socket.id) { 153 | this.jpd.public.buzzes[p.id] = Date.now(); 154 | } else { 155 | this.jpd.public.submitted[p.id] = true; 156 | } 157 | }); 158 | this.io.of(this.roomId).emit('JPD:playDailyDouble'); 159 | } else { 160 | // Put Q in public state 161 | this.jpd.public.board[this.jpd.public.currentQ].question = 162 | this.jpd.board[this.jpd.public.currentQ].q; 163 | this.triggerPlayClue(); 164 | } 165 | // Undo no longer possible after next question is picked 166 | this.jpdSnapshot = undefined; 167 | this.undoActivated = undefined; 168 | this.aiJudged = undefined; 169 | this.sendState(); 170 | }); 171 | socket.on('JPD:buzz', () => { 172 | if (!this.jpd.public.canBuzz) { 173 | return; 174 | } 175 | if (this.jpd.public.buzzes[socket.id]) { 176 | return; 177 | } 178 | this.jpd.public.buzzes[socket.id] = Date.now(); 179 | this.sendState(); 180 | }); 181 | socket.on('JPD:answer', (question, answer) => { 182 | if (question !== this.jpd.public.currentQ) { 183 | // Not submitting for right question 184 | return; 185 | } 186 | if (!this.jpd.public.questionEndTS) { 187 | // Time was already up 188 | return; 189 | } 190 | if (answer && answer.length > 10000) { 191 | // Answer too long 192 | return; 193 | } 194 | console.log('[ANSWER]', socket.id, question, answer); 195 | if (answer) { 196 | this.jpd.answers[socket.id] = answer; 197 | } 198 | this.jpd.public.submitted[socket.id] = true; 199 | this.sendState(); 200 | if ( 201 | this.jpd.public.round !== 'final' && 202 | // If a player disconnects, don't wait for their answer 203 | this.getConnectedPlayers().every( 204 | (p) => p.id in this.jpd.public.submitted, 205 | ) 206 | ) { 207 | this.revealAnswer(); 208 | } 209 | }); 210 | 211 | socket.on('JPD:wager', (wager) => this.submitWager(socket.id, wager)); 212 | socket.on('JPD:judge', (data) => this.doHumanJudge(socket, data)); 213 | socket.on('JPD:bulkJudge', (data) => { 214 | if (!data) { 215 | return; 216 | } 217 | // Check if the next player to be judged is in the input data 218 | // If so, doJudge for that player 219 | // Check if we advanced to the next question, otherwise keep doing 220 | let count = 0; 221 | while (this.jpd.public.currentJudgeAnswer !== undefined && count <= data.length) { 222 | // The bulkjudge may not contain all decisions. Stop if we did as many decisions as the input data 223 | count += 1; 224 | console.log('[BULKJUDGE]', count, data.length); 225 | const id = this.jpd.public.currentJudgeAnswer; 226 | const match = data.find((d: any) => d.id === id); 227 | if (match) { 228 | this.doHumanJudge(socket, match); 229 | } else { 230 | // Player to be judged isn't in the input 231 | // Stop judging and revert to manual (or let the user resubmit, we should prevent duplicates) 232 | break; 233 | } 234 | } 235 | }); 236 | socket.on('JPD:undo', () => { 237 | if (this.settings.host && socket.id !== this.settings.host) { 238 | // Not the host 239 | return; 240 | } 241 | // Reset the game state to the last snapshot 242 | // Snapshot updates at each revealAnswer 243 | if (this.jpdSnapshot) { 244 | redisCount('undo'); 245 | if (this.aiJudged) { 246 | redisCount('aiUndo'); 247 | this.aiJudged = undefined; 248 | } 249 | this.undoActivated = true; 250 | this.jpd = JSON.parse(JSON.stringify(this.jpdSnapshot)); 251 | this.advanceJudging(false); 252 | this.sendState(); 253 | } 254 | }); 255 | socket.on('JPD:skipQ', () => { 256 | if (this.jpd.public.canNextQ) { 257 | // We are in the post-judging phase and can move on 258 | this.nextQuestion(); 259 | } 260 | }); 261 | socket.on('JPD:enableAiJudge', (enable: boolean) => { 262 | this.settings.enableAIJudge = Boolean(enable); 263 | this.sendState(); 264 | // optional: If we're in the judging phase, trigger the AI judge here 265 | // That way we can decide to use AI judge after the first answer has already been revealed 266 | }); 267 | socket.on('CMD:chat', (data: string) => { 268 | if (data && data.length > 10000) { 269 | // TODO add some validation on client side too so we don't just drop long messages 270 | return; 271 | } 272 | if (data === '/clear') { 273 | this.chat.length = 0; 274 | io.of(roomId).emit('chatinit', this.chat); 275 | return; 276 | } 277 | if (data.startsWith('/aivoices')) { 278 | const rvcServer = 279 | data.split(' ')[1] ?? 'https://azure.howardchung.net/rvc'; 280 | this.pregenAIVoices(rvcServer); 281 | } 282 | const sender = this.getAllPlayers().find((p) => p.id === socket.id); 283 | const chatMsg = { id: socket.id, name: sender?.name, msg: data }; 284 | this.addChatMessage(socket, chatMsg); 285 | }); 286 | socket.on('disconnect', () => { 287 | if (this.jpd && this.jpd.public) { 288 | // If player who needs to submit wager leaves, submit 0 289 | if ( 290 | this.jpd.public.waitingForWager && 291 | this.jpd.public.waitingForWager[socket.id] 292 | ) { 293 | this.submitWager(socket.id, 0); 294 | } 295 | } 296 | // Mark the user disconnected 297 | let target = this.getAllPlayers().find((p) => p.id === socket.id); 298 | if (target) { 299 | target.connected = false; 300 | target.disconnectTime = Date.now(); 301 | } 302 | this.sendRoster(); 303 | }); 304 | }); 305 | } 306 | 307 | serialize = () => { 308 | return JSON.stringify({ 309 | chat: this.chat, 310 | clientIds: this.clientIds, 311 | roster: this.roster, 312 | creationTime: this.creationTime, 313 | jpd: this.jpd, 314 | settings: this.settings, 315 | }); 316 | }; 317 | 318 | deserialize = (roomData: string) => { 319 | const roomObj = JSON.parse(roomData); 320 | if (roomObj.chat) { 321 | this.chat = roomObj.chat; 322 | } 323 | if (roomObj.clientIds) { 324 | this.clientIds = roomObj.clientIds; 325 | } 326 | if (roomObj.creationTime) { 327 | this.creationTime = new Date(roomObj.creationTime); 328 | } 329 | if (roomObj.roster) { 330 | // Reset connected state to false, reconnects will update it again 331 | this.roster = roomObj.roster.map((p: User) => ({...p, connected: false})); 332 | } 333 | if (roomObj.jpd && roomObj.jpd.public) { 334 | const gameData = roomObj.jpd; 335 | this.jpd = gameData; 336 | // Reconstruct the timeouts from the saved state 337 | if (this.jpd.public.questionEndTS) { 338 | const remaining = this.jpd.public.questionEndTS - Date.now(); 339 | this.setQuestionAnswerTimeout(remaining); 340 | } 341 | if (this.jpd.public.playClueEndTS) { 342 | const remaining = this.jpd.public.playClueEndTS - Date.now(); 343 | this.setPlayClueTimeout(remaining); 344 | } 345 | if (this.jpd.public.wagerEndTS) { 346 | const remaining = this.jpd.public.wagerEndTS - Date.now(); 347 | this.setWagerTimeout(remaining, this.jpd.public.wagerEndTS); 348 | } 349 | } 350 | if (roomObj.settings) { 351 | this.settings = roomObj.settings; 352 | } 353 | }; 354 | 355 | saveRoom = async () => { 356 | const roomData = this.serialize(); 357 | const key = this.roomId; 358 | await redis?.setex(key, 24 * 60 * 60, roomData); 359 | if (config.permaRooms.includes(key)) { 360 | await redis?.persist(key); 361 | } 362 | this.lastUpdateTime = new Date(); 363 | redisCount('saves'); 364 | } 365 | 366 | addChatMessage = (socket: Socket | undefined, chatMsg: any) => { 367 | const chatWithTime: ChatMessage = { 368 | ...chatMsg, 369 | timestamp: new Date().toISOString(), 370 | }; 371 | this.chat.push(chatWithTime); 372 | this.chat = this.chat.splice(-100); 373 | this.io.of(this.roomId).emit('REC:chat', chatWithTime); 374 | this.saveRoom(); 375 | }; 376 | 377 | sendState = () => { 378 | this.jpd.public.serverTime = Date.now(); 379 | // Copy values over from settings before each send 380 | this.jpd.public.host = this.settings.host; 381 | this.jpd.public.enableAIJudge = this.settings.enableAIJudge; 382 | this.jpd.public.enableAIVoices = this.settings.enableAIVoices; 383 | this.io.of(this.roomId).emit('JPD:state', this.jpd.public); 384 | this.saveRoom(); 385 | } 386 | 387 | sendRoster = () => { 388 | // Sort by score and resend the list of players to everyone 389 | this.roster.sort( 390 | (a, b) => 391 | (this.jpd.public?.scores[b.id] || 0) - 392 | (this.jpd.public?.scores[a.id] || 0), 393 | ); 394 | this.io.of(this.roomId).emit('roster', this.roster); 395 | this.saveRoom(); 396 | } 397 | 398 | getConnectedPlayers = () => { 399 | // Returns players that are currently connected and not spectators 400 | return this.roster.filter((p) => p.connected); 401 | }; 402 | 403 | getActivePlayers = () => { 404 | // Returns all players not marked as spectator (includes disconnected) 405 | // Currently just returns all players 406 | // In the future we might want to ignore spectators 407 | return this.roster; 408 | }; 409 | 410 | getAllPlayers = () => { 411 | // Return all players regardless of connection state or spectator 412 | return this.roster; 413 | }; 414 | 415 | handleReconnect = (newId: string, oldId: string) => { 416 | console.log('[RECONNECT] transfer %s to %s', oldId, newId); 417 | // Update the roster with the new ID and connected state 418 | const target = this.getAllPlayers().find((p) => p.id === oldId); 419 | if (target) { 420 | target.id = newId; 421 | target.connected = true; 422 | target.disconnectTime = 0; 423 | } 424 | if (this.jpd.public.scores?.[oldId]) { 425 | this.jpd.public.scores[newId] = this.jpd.public.scores[oldId]; 426 | delete this.jpd.public.scores[oldId]; 427 | } 428 | if (this.jpd.public.buzzes?.[oldId]) { 429 | this.jpd.public.buzzes[newId] = this.jpd.public.buzzes[oldId]; 430 | delete this.jpd.public.buzzes[oldId]; 431 | } 432 | if (this.jpd.public.judges?.[oldId]) { 433 | this.jpd.public.judges[newId] = this.jpd.public.judges[oldId]; 434 | delete this.jpd.public.judges[oldId]; 435 | } 436 | if (this.jpd.public.submitted?.[oldId]) { 437 | this.jpd.public.submitted[newId] = this.jpd.public.submitted[oldId]; 438 | delete this.jpd.public.submitted[oldId]; 439 | } 440 | if (this.jpd.public.answers?.[oldId]) { 441 | this.jpd.public.answers[newId] = this.jpd.public.answers[oldId]; 442 | delete this.jpd.public.answers[oldId]; 443 | } 444 | if (this.jpd.public.wagers?.[oldId]) { 445 | this.jpd.public.wagers[newId] = this.jpd.public.wagers[oldId]; 446 | delete this.jpd.public.wagers[oldId]; 447 | } 448 | // Note: two copies of answers and wagers exist, a public and non-public version, so we need to copy both 449 | // Alternatively, we can just have some state to tracks whether to emit the answers and wagers and keep both in public only 450 | if (this.jpd.answers?.[oldId]) { 451 | this.jpd.answers[newId] = this.jpd.answers[oldId]; 452 | delete this.jpd.answers[oldId]; 453 | } 454 | if (this.jpd.wagers?.[oldId]) { 455 | this.jpd.wagers[newId] = this.jpd.wagers[oldId]; 456 | delete this.jpd.wagers[oldId]; 457 | } 458 | if (this.jpd.public.waitingForWager?.[oldId]) { 459 | // Current behavior is to submit wager 0 if disconnecting 460 | // So there should be no state to transfer 461 | this.jpd.public.waitingForWager[newId] = true; 462 | delete this.jpd.public.waitingForWager[oldId]; 463 | } 464 | if (this.jpd.public.currentJudgeAnswer === oldId) { 465 | this.jpd.public.currentJudgeAnswer = newId; 466 | } 467 | if (this.jpd.public.dailyDoublePlayer === oldId) { 468 | this.jpd.public.dailyDoublePlayer = newId; 469 | } 470 | if (this.jpd.public.picker === oldId) { 471 | this.jpd.public.picker = newId; 472 | } 473 | if (this.settings.host === oldId) { 474 | this.settings.host = newId; 475 | } 476 | } 477 | 478 | loadEpisode = (socket: Socket, options: GameOptions, custom: string) => { 479 | let { 480 | number, 481 | filter, 482 | answerTimeout, 483 | finalTimeout, 484 | makeMeHost, 485 | allowMultipleCorrect, 486 | enableAIJudge, 487 | } = options; 488 | console.log('[LOADEPISODE]', number, filter, Boolean(custom)); 489 | let loadedData = null; 490 | if (custom) { 491 | try { 492 | const parse = Papa.parse(custom, { header: true }); 493 | const typed = []; 494 | let round = ''; 495 | let cat = ''; 496 | let curX = 0; 497 | let curY = 0; 498 | for (let i = 0; i < parse.data.length; i++) { 499 | const d = parse.data[i]; 500 | if (round !== d.round) { 501 | // Reset x and y to 1 502 | curX = 1; 503 | curY = 1; 504 | } else if (cat !== d.cat) { 505 | // Increment x, reset y to 1, new category 506 | curX += 1; 507 | curY = 1; 508 | } else { 509 | curY += 1; 510 | } 511 | round = d.round; 512 | cat = d.cat; 513 | let multiplier = 1; 514 | if (round === 'double') { 515 | multiplier = 2; 516 | } else if (round === 'final') { 517 | multiplier = 0; 518 | } 519 | if (d.q && d.a) { 520 | typed.push({ 521 | round: d.round, 522 | cat: d.cat, 523 | q: d.q, 524 | a: d.a, 525 | dd: d.dd?.toLowerCase() === 'true', 526 | val: curY * 200 * multiplier, 527 | x: curX, 528 | y: curY, 529 | }); 530 | } 531 | } 532 | loadedData = { 533 | airDate: new Date().toISOString().split('T')[0], 534 | epNum: 'Custom', 535 | jeopardy: typed.filter((d: any) => d.round === 'jeopardy'), 536 | double: typed.filter((d: any) => d.round === 'double'), 537 | final: typed.filter((d: any) => d.round === 'final'), 538 | }; 539 | redisCount('customGames'); 540 | } catch (e) { 541 | console.warn(e); 542 | } 543 | } else { 544 | const jData = getJData(); 545 | // Load question data into game 546 | let nums = Object.keys(jData); 547 | if (filter) { 548 | // Only load episodes with info matching the filter: kids, teen, college etc. 549 | nums = nums.filter( 550 | (num) => 551 | (jData as any)[num].info && (jData as any)[num].info === filter, 552 | ); 553 | } 554 | if (number === 'ddtest') { 555 | loadedData = jData['8000']; 556 | loadedData['jeopardy'] = loadedData['jeopardy'].filter( 557 | (q: any) => q.dd, 558 | ); 559 | } else if (number === 'finaltest') { 560 | loadedData = jData['8000']; 561 | } else { 562 | if (!number) { 563 | // Random an episode 564 | number = nums[Math.floor(Math.random() * nums.length)]; 565 | } 566 | loadedData = (jData as any)[number]; 567 | } 568 | } 569 | if (loadedData) { 570 | redisCount('newGames'); 571 | const { epNum, airDate, info, jeopardy, double, final } = loadedData; 572 | this.jpd = getGameState( 573 | { 574 | epNum, 575 | airDate, 576 | info, 577 | }, 578 | jeopardy, 579 | double, 580 | final, 581 | ); 582 | this.jpdSnapshot = undefined; 583 | this.settings.host = makeMeHost ? socket.id : undefined; 584 | if (allowMultipleCorrect) { 585 | this.settings.allowMultipleCorrect = allowMultipleCorrect; 586 | } 587 | if (enableAIJudge) { 588 | this.settings.enableAIJudge = enableAIJudge; 589 | } 590 | if (Number(finalTimeout)) { 591 | this.settings.finalTimeout = Number(finalTimeout) * 1000; 592 | } 593 | if (Number(answerTimeout)) { 594 | this.settings.answerTimeout = Number(answerTimeout) * 1000; 595 | } 596 | if (number === 'finaltest') { 597 | this.jpd.public.round = 'double'; 598 | } 599 | this.nextRound(); 600 | } 601 | } 602 | 603 | playCategories = () => { 604 | this.io.of(this.roomId).emit('JPD:playCategories'); 605 | } 606 | 607 | resetAfterQuestion = () => { 608 | this.jpd.answers = {}; 609 | this.jpd.wagers = {}; 610 | clearTimeout(this.playClueTimeout); 611 | clearTimeout(this.questionAnswerTimeout); 612 | clearTimeout(this.wagerTimeout); 613 | this.jpd.public = { ...this.jpd.public, ...getPerQuestionState() }; 614 | // Overwrite any other picker settings if there's a host 615 | if (this.settings.host) { 616 | this.jpd.public.picker = this.settings.host; 617 | } 618 | } 619 | 620 | nextQuestion = () => { 621 | // Show the correct answer in the game log 622 | this.addChatMessage(undefined, { 623 | id: '', 624 | name: 'System', 625 | cmd: 'answer', 626 | msg: this.jpd.public.currentAnswer, 627 | }); 628 | // Scores have updated so resend sorted player list 629 | this.sendRoster(); 630 | // Reset question state 631 | delete this.jpd.public.board[this.jpd.public.currentQ]; 632 | this.resetAfterQuestion(); 633 | if (Object.keys(this.jpd.public.board).length === 0) { 634 | this.nextRound(); 635 | } else { 636 | this.sendState(); 637 | // TODO may want to introduce some delay here to make sure our state is updated before reading selection 638 | this.io.of(this.roomId).emit('JPD:playMakeSelection'); 639 | } 640 | } 641 | 642 | nextRound = () => { 643 | this.resetAfterQuestion(); 644 | // host is made picker in resetAfterQuestion, so any picker changes here should be behind host check 645 | // advance round counter 646 | if (this.jpd.public.round === 'jeopardy') { 647 | this.jpd.public.round = 'double'; 648 | // If double, person with lowest score is picker 649 | // Unless we are allowing multiple corrects or there's a host 650 | if (!this.settings.allowMultipleCorrect && !this.settings.host) { 651 | // Pick the lowest score out of the currently connected players 652 | // This is nlogn rather than n, but prob ok for small numbers of players 653 | const playersWithScores = this.getConnectedPlayers().map((p) => ({ 654 | id: p.id, 655 | score: this.jpd.public.scores[p.id] || 0, 656 | })); 657 | playersWithScores.sort((a, b) => a.score - b.score); 658 | this.jpd.public.picker = playersWithScores[0]?.id; 659 | } 660 | } else if (this.jpd.public.round === 'double') { 661 | this.jpd.public.round = 'final'; 662 | const now = Date.now(); 663 | this.jpd.public.waitingForWager = {}; 664 | // There's no picker for final. In host mode we set one above 665 | this.jpd.public.picker = undefined; 666 | // Ask all players for wager (including disconnected since they might come back) 667 | this.getActivePlayers().forEach((p) => { 668 | this.jpd.public.waitingForWager![p.id] = true; 669 | }); 670 | this.setWagerTimeout(this.settings.finalTimeout); 671 | // autopick the question 672 | this.jpd.public.currentQ = '1_1'; 673 | // autobuzz the players in ascending score order 674 | let playerIds = this.getActivePlayers().map((p) => p.id); 675 | playerIds.sort( 676 | (a, b) => 677 | Number(this.jpd.public.scores[a] || 0) - 678 | Number(this.jpd.public.scores[b] || 0), 679 | ); 680 | playerIds.forEach((pid) => { 681 | this.jpd.public.buzzes[pid] = now; 682 | }); 683 | // Play the category sound 684 | this.io.of(this.roomId).emit('JPD:playRightanswer'); 685 | } else if (this.jpd.public.round === 'final') { 686 | this.jpd.public.round = 'end'; 687 | // Log the results 688 | const scores = Object.entries(this.jpd.public.scores); 689 | scores.sort((a, b) => b[1] - a[1]); 690 | const scoresNames = scores.map((score) => [ 691 | this.getAllPlayers().find((p) => p.id === score[0])?.name, 692 | score[1], 693 | ]); 694 | redis?.lpush('jpd:results', JSON.stringify(scoresNames)); 695 | } else { 696 | this.jpd.public.round = 'jeopardy'; 697 | } 698 | if ( 699 | this.jpd.public.round === 'jeopardy' || 700 | this.jpd.public.round === 'double' || 701 | this.jpd.public.round === 'final' 702 | ) { 703 | this.jpd.board = constructBoard((this.jpd as any)[this.jpd.public.round]); 704 | this.jpd.public.board = constructPublicBoard( 705 | (this.jpd as any)[this.jpd.public.round], 706 | ); 707 | if (Object.keys(this.jpd.public.board).length === 0) { 708 | this.nextRound(); 709 | } 710 | } 711 | this.sendState(); 712 | if ( 713 | this.jpd.public.round === 'jeopardy' || 714 | this.jpd.public.round === 'double' 715 | ) { 716 | this.playCategories(); 717 | } 718 | } 719 | 720 | unlockAnswer = (durationMs: number) => { 721 | this.jpd.public.questionEndTS = Date.now() + durationMs; 722 | this.setQuestionAnswerTimeout(durationMs); 723 | } 724 | 725 | setQuestionAnswerTimeout = (durationMs: number) => { 726 | this.questionAnswerTimeout = setTimeout(() => { 727 | if (this.jpd.public.round !== 'final') { 728 | this.io.of(this.roomId).emit('JPD:playTimesUp'); 729 | } 730 | this.revealAnswer(); 731 | }, durationMs); 732 | } 733 | 734 | revealAnswer = () => { 735 | clearTimeout(this.questionAnswerTimeout); 736 | this.jpd.public.questionEndTS = 0; 737 | 738 | // Add empty answers for anyone who buzzed but didn't submit anything 739 | Object.keys(this.jpd.public.buzzes).forEach((key) => { 740 | if (!this.jpd.answers[key]) { 741 | this.jpd.answers[key] = ''; 742 | } 743 | }); 744 | this.jpd.public.canBuzz = false; 745 | // Show everyone's answers 746 | this.jpd.public.answers = { ...this.jpd.answers }; 747 | this.jpd.public.currentAnswer = this.jpd.board[this.jpd.public.currentQ]?.a; 748 | this.jpdSnapshot = JSON.parse(JSON.stringify(this.jpd)); 749 | this.advanceJudging(false); 750 | this.sendState(); 751 | } 752 | 753 | advanceJudging = (skipRemaining: boolean) => { 754 | if (this.jpd.public.currentJudgeAnswerIndex === undefined) { 755 | this.jpd.public.currentJudgeAnswerIndex = 0; 756 | } else { 757 | this.jpd.public.currentJudgeAnswerIndex += 1; 758 | } 759 | this.jpd.public.currentJudgeAnswer = Object.keys(this.jpd.public.buzzes)[ 760 | this.jpd.public.currentJudgeAnswerIndex 761 | ]; 762 | // Either we picked a correct answer (in standard mode) or ran out of players to judge 763 | if (skipRemaining || this.jpd.public.currentJudgeAnswer === undefined) { 764 | this.jpd.public.canNextQ = true; 765 | } 766 | if (this.jpd.public.currentJudgeAnswer) { 767 | // In Final, reveal one at a time rather than all at once (for dramatic purposes) 768 | // Note: Looks like we just bulk reveal answers elsewhere, so this is just wagers 769 | this.jpd.public.wagers[this.jpd.public.currentJudgeAnswer] = 770 | this.jpd.wagers[this.jpd.public.currentJudgeAnswer]; 771 | this.jpd.public.answers[this.jpd.public.currentJudgeAnswer] = 772 | this.jpd.answers[this.jpd.public.currentJudgeAnswer]; 773 | } 774 | // Undo snapshots the current state of jpd 775 | // So if a player has reconnected since with a new ID the ID from buzzes might not be there anymore 776 | // If so, we skip that answer (not optimal but easiest) 777 | // TODO To fix this we probably have to use clientId instead of socket id to index the submitted answers 778 | if ( 779 | this.jpd.public.currentJudgeAnswer && 780 | !this.getActivePlayers().find( 781 | (p) => p.id === this.jpd.public.currentJudgeAnswer, 782 | ) 783 | ) { 784 | console.log( 785 | '[ADVANCEJUDGING] player not found, moving on:', 786 | this.jpd.public.currentJudgeAnswer, 787 | ); 788 | this.advanceJudging(skipRemaining); 789 | return; 790 | } 791 | if ( 792 | openai && 793 | !this.jpd.public.canNextQ && 794 | this.settings.enableAIJudge && 795 | // Don't use AI if the user undid 796 | !this.undoActivated && 797 | this.jpd.public.currentJudgeAnswer 798 | ) { 799 | // We don't await here since AI judging shouldn't block UI 800 | // But we want to trigger it whenever we move on to the next answer 801 | // The result might come back after we already manually judged, in that case we just log it and ignore 802 | this.doAiJudge({ 803 | currentQ: this.jpd.public.currentQ, 804 | id: this.jpd.public.currentJudgeAnswer, 805 | }); 806 | } 807 | } 808 | 809 | doAiJudge = async (data: { currentQ: string; id: string }) => { 810 | // count the number of automatic judges 811 | redisCount('aiJudge'); 812 | // currentQ: The board coordinates of the current question, e.g. 1_3 813 | // id: socket id of the person being judged 814 | const { currentQ, id } = data; 815 | // The question text 816 | const q = this.jpd.board[currentQ]?.q ?? ''; 817 | const a = this.jpd.public.currentAnswer ?? ''; 818 | const response = this.jpd.public.answers[id]; 819 | let correct: boolean | null = null; 820 | if (response === '') { 821 | // empty response is always wrong 822 | correct = false; 823 | redisCount('aiShortcut'); 824 | } else if (response.toLowerCase().trim() === a.toLowerCase().trim()) { 825 | // exact match is always right 826 | correct = true; 827 | redisCount('aiShortcut'); 828 | } else { 829 | // count the number of calls to chatgpt 830 | redisCount('aiChatGpt'); 831 | try { 832 | const decision = await getOpenAIDecision(q, a, response); 833 | console.log('[AIDECISION]', id, q, a, response, decision); 834 | if (decision && decision.correct != null) { 835 | correct = decision.correct; 836 | } else { 837 | redisCount('aiRefuse'); 838 | } 839 | // Log the AI decision to measure accuracy 840 | // If the user undoes and then chooses differently than AI, then that's a failed decision 841 | // Alternative: we can just highlight what the AI thinks is correct instead of auto-applying the decision, then we'll have user feedback for sure 842 | // If undefined, AI refused to answer 843 | redis?.lpush( 844 | 'jpd:aiJudges', 845 | JSON.stringify({ q, a, response, correct: decision?.correct }), 846 | ); 847 | redis?.ltrim('jpd:aiJudges', 0, 1000); 848 | } catch (e) { 849 | console.log(e); 850 | } 851 | } 852 | if (correct != null) { 853 | this.judgeAnswer(undefined, { currentQ, id, correct }); 854 | } 855 | } 856 | 857 | doHumanJudge = ( 858 | socket: Socket, 859 | data: { currentQ: string; id: string; correct: boolean | null }, 860 | ) => { 861 | const success = this.judgeAnswer(socket, data); 862 | } 863 | 864 | judgeAnswer = ( 865 | socket: Socket | undefined, 866 | { 867 | currentQ, 868 | id, 869 | correct, 870 | confidence, 871 | }: { 872 | currentQ: string; 873 | id: string; 874 | correct: boolean | null; 875 | confidence?: number; 876 | }, 877 | ) => { 878 | if (id in this.jpd.public.judges) { 879 | // Already judged this player 880 | return false; 881 | } 882 | if (currentQ !== this.jpd.public.currentQ) { 883 | // Not judging the right question 884 | return false; 885 | } 886 | if (this.jpd.public.currentJudgeAnswer === undefined) { 887 | // Not in judging step 888 | return false; 889 | } 890 | if (this.settings.host && socket && socket?.id !== this.settings.host) { 891 | // Not the host 892 | return; 893 | } 894 | this.jpd.public.judges[id] = correct; 895 | console.log('[JUDGE]', id, correct); 896 | if (!this.jpd.public.scores[id]) { 897 | this.jpd.public.scores[id] = 0; 898 | } 899 | const delta = this.jpd.public.wagers[id] || this.jpd.public.currentValue; 900 | if (correct === true) { 901 | this.jpd.public.scores[id] += delta; 902 | if (!this.settings.allowMultipleCorrect) { 903 | // Correct answer is next picker 904 | this.jpd.public.picker = id; 905 | } 906 | } 907 | if (correct === false) { 908 | this.jpd.public.scores[id] -= delta; 909 | } 910 | // If null/undefined, don't change scores 911 | if (correct != null) { 912 | const msg = { 913 | id: socket?.id ?? '', 914 | // name of judge 915 | name: 916 | this.getAllPlayers().find((p) => p.id === socket?.id)?.name ?? 917 | 'System', 918 | cmd: 'judge', 919 | msg: JSON.stringify({ 920 | id: id, 921 | // name of person being judged 922 | name: this.getAllPlayers().find((p) => p.id === id)?.name, 923 | answer: this.jpd.public.answers[id], 924 | correct, 925 | delta: correct ? delta : -delta, 926 | confidence, 927 | }), 928 | }; 929 | this.addChatMessage(socket, msg); 930 | if (!socket) { 931 | this.aiJudged = true; 932 | } 933 | } 934 | const allowMultipleCorrect = 935 | this.jpd.public.round === 'final' || this.settings.allowMultipleCorrect; 936 | const skipRemaining = !allowMultipleCorrect && correct === true; 937 | this.advanceJudging(skipRemaining); 938 | 939 | if (this.jpd.public.canNextQ) { 940 | this.nextQuestion(); 941 | } else { 942 | this.sendState(); 943 | } 944 | return correct != null; 945 | } 946 | 947 | submitWager = (id: string, wager: number) => { 948 | if (id in this.jpd.wagers) { 949 | return; 950 | } 951 | // User setting a wager for DD or final 952 | // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final 953 | let maxWager = 0; 954 | let minWager = 5; 955 | if (this.jpd.public.round === 'jeopardy') { 956 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 1000); 957 | } else if (this.jpd.public.round === 'double') { 958 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 2000); 959 | } else if (this.jpd.public.round === 'final') { 960 | minWager = 0; 961 | maxWager = Math.max(this.jpd.public.scores[id] || 0, 0); 962 | } 963 | let numWager = Number(wager); 964 | if (Number.isNaN(Number(wager))) { 965 | numWager = minWager; 966 | } else { 967 | numWager = Math.min(Math.max(numWager, minWager), maxWager); 968 | } 969 | console.log('[WAGER]', id, wager, numWager); 970 | if (id === this.jpd.public.dailyDoublePlayer && this.jpd.public.currentQ) { 971 | this.jpd.wagers[id] = numWager; 972 | this.jpd.public.wagers[id] = numWager; 973 | this.jpd.public.waitingForWager = undefined; 974 | if (this.jpd.public.board[this.jpd.public.currentQ]) { 975 | this.jpd.public.board[this.jpd.public.currentQ].question = 976 | this.jpd.board[this.jpd.public.currentQ]?.q; 977 | } 978 | this.triggerPlayClue(); 979 | this.sendState(); 980 | } 981 | if (this.jpd.public.round === 'final' && this.jpd.public.currentQ) { 982 | // store the wagers privately until everyone's made one 983 | this.jpd.wagers[id] = numWager; 984 | if (this.jpd.public.waitingForWager) { 985 | delete this.jpd.public.waitingForWager[id]; 986 | } 987 | if (Object.keys(this.jpd.public.waitingForWager ?? {}).length === 0) { 988 | // if final, reveal clue if all players made wager 989 | this.jpd.public.waitingForWager = undefined; 990 | if (this.jpd.public.board[this.jpd.public.currentQ]) { 991 | this.jpd.public.board[this.jpd.public.currentQ].question = 992 | this.jpd.board[this.jpd.public.currentQ]?.q; 993 | } 994 | this.triggerPlayClue(); 995 | } 996 | this.sendState(); 997 | } 998 | } 999 | 1000 | setWagerTimeout = (durationMs: number, endTS?: number) => { 1001 | this.jpd.public.wagerEndTS = endTS ?? Date.now() + durationMs; 1002 | this.wagerTimeout = setTimeout(() => { 1003 | Object.keys(this.jpd.public.waitingForWager ?? {}).forEach((id) => { 1004 | this.submitWager(id, 0); 1005 | }); 1006 | }, durationMs); 1007 | } 1008 | 1009 | triggerPlayClue = () => { 1010 | clearTimeout(this.wagerTimeout); 1011 | this.jpd.public.wagerEndTS = 0; 1012 | const clue = this.jpd.public.board[this.jpd.public.currentQ]; 1013 | this.io 1014 | .of(this.roomId) 1015 | .emit('JPD:playClue', this.jpd.public.currentQ, clue && clue.question); 1016 | let speakingTime = 0; 1017 | if (clue && clue.question) { 1018 | // Allow some time for reading the text, based on content 1019 | // Count syllables in text, assume speaking rate of 4 syll/sec 1020 | const syllCountArr = clue.question 1021 | // Remove parenthetical starts and blanks 1022 | .replace(/^\(.*\)/, '') 1023 | .replace(/_+/g, ' blank ') 1024 | .split(' ') 1025 | .map((word: string) => syllableCount(word)); 1026 | const totalSyll = syllCountArr.reduce((a: number, b: number) => a + b, 0); 1027 | // Minimum 1 second speaking time 1028 | speakingTime = Math.max((totalSyll / 4) * 1000, 1000); 1029 | console.log('[TRIGGERPLAYCLUE]', clue.question, totalSyll, speakingTime); 1030 | this.jpd.public.playClueEndTS = Date.now() + speakingTime; 1031 | } 1032 | this.setPlayClueTimeout(speakingTime); 1033 | } 1034 | 1035 | setPlayClueTimeout = (durationMs: number) => { 1036 | this.playClueTimeout = setTimeout(() => { 1037 | this.playClueDone(); 1038 | }, durationMs); 1039 | } 1040 | 1041 | playClueDone = () => { 1042 | clearTimeout(this.playClueTimeout); 1043 | this.jpd.public.playClueEndTS = 0; 1044 | this.jpd.public.buzzUnlockTS = Date.now(); 1045 | if (this.jpd.public.round === 'final') { 1046 | this.unlockAnswer(this.settings.finalTimeout); 1047 | // Play final jeopardy music 1048 | this.io.of(this.roomId).emit('JPD:playFinalJeopardy'); 1049 | } else { 1050 | if (!this.jpd.public.currentDailyDouble) { 1051 | // DD already handles buzzing automatically 1052 | this.jpd.public.canBuzz = true; 1053 | } 1054 | this.unlockAnswer(this.settings.answerTimeout); 1055 | } 1056 | this.sendState(); 1057 | } 1058 | 1059 | pregenAIVoices = async (rvcHost: string) => { 1060 | // Indicate we should use AI voices for this game 1061 | this.settings.enableAIVoices = rvcHost; 1062 | this.sendState(); 1063 | // For the current game, get all category names and clues (61 clues + 12 category names) 1064 | // Final category doesn't get read right now 1065 | const strings = new Set( 1066 | [ 1067 | ...(this.jpd.jeopardy?.map((item) => item.q) ?? []), 1068 | ...(this.jpd.double?.map((item) => item.q) ?? []), 1069 | ...(this.jpd.final?.map((item) => item.q) ?? []), 1070 | ...(this.jpd.jeopardy?.map((item) => item.cat) ?? []), 1071 | ...(this.jpd.double?.map((item) => item.cat) ?? []), 1072 | ].filter(Boolean), 1073 | ); 1074 | console.log('%s strings to generate', strings.size); 1075 | const items = Array.from(strings); 1076 | const start = Date.now(); 1077 | let cursor = items.entries(); 1078 | // create for loops that each run off the same cursor which keeps track of location 1079 | let numWorkers = 10; 1080 | // The parallelism should ideally depend on the server configuration 1081 | // But we just need a value that won't take more than 5 minutes between start and stop because fetch will timeout 1082 | // No good way of configuring it right now without switching to undici 1083 | let success = 0; 1084 | let count = 0; 1085 | Array(numWorkers).fill('').forEach(async (_, workerIndex) => { 1086 | for (let [i, text] of cursor) { 1087 | try { 1088 | const url = await genAITextToSpeech(rvcHost, text ?? ''); 1089 | // Report progress back in chat messages 1090 | if (url) { 1091 | this.addChatMessage(undefined, { 1092 | id: '', 1093 | name: 'System', 1094 | msg: 'generated ai voice ' + i + ': ' + url, 1095 | }); 1096 | redisCount('aiVoice'); 1097 | success += 1; 1098 | } 1099 | } catch (e) { 1100 | // Log errors, but continue iterating 1101 | console.log(e); 1102 | } 1103 | count += 1; 1104 | } 1105 | if (count === items.length) { 1106 | const end = Date.now(); 1107 | this.addChatMessage(undefined, { 1108 | id: '', 1109 | name: 'System', 1110 | msg: 1111 | success + 1112 | '/' + 1113 | count + 1114 | ' voices generated in ' + (end - start) + 'ms', 1115 | }); 1116 | } 1117 | }); 1118 | } 1119 | } 1120 | 1121 | function constructBoard(questions: RawQuestion[]) { 1122 | // Map of x_y coordinates to questions 1123 | let output: { [key: string]: RawQuestion } = {}; 1124 | questions.forEach((q) => { 1125 | output[`${q.x}_${q.y}`] = q; 1126 | }); 1127 | return output; 1128 | } 1129 | 1130 | function constructPublicBoard(questions: RawQuestion[]) { 1131 | // Map of x_y coordinates to questions 1132 | let output: { [key: string]: Question } = {}; 1133 | questions.forEach((q) => { 1134 | output[`${q.x}_${q.y}`] = { 1135 | value: q.val, 1136 | category: q.cat, 1137 | }; 1138 | }); 1139 | return output; 1140 | } 1141 | 1142 | function syllableCount(word: string) { 1143 | word = word.toLowerCase(); //word.downcase! 1144 | if (word.length <= 3) { 1145 | return 1; 1146 | } 1147 | word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, ''); //word.sub!(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '') 1148 | word = word.replace(/^y/, ''); 1149 | let vowels = word.match(/[aeiouy]{1,2}/g); 1150 | // Use 3 as the default if no letters, it's probably a year 1151 | return vowels ? vowels.length : 3; 1152 | } 1153 | -------------------------------------------------------------------------------- /server/server.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import util from 'util'; 3 | import express from 'express'; 4 | import compression from 'compression'; 5 | import os from 'os'; 6 | import cors from 'cors'; 7 | import https from 'https'; 8 | import http from 'http'; 9 | import { Server } from 'socket.io'; 10 | import { Room } from './room'; 11 | import { redis, getRedisCountDay } from './redis'; 12 | import { makeRoomName, makeUserName } from './moniker'; 13 | import config from './config'; 14 | import { getJDataStats } from './jData'; 15 | 16 | const app = express(); 17 | let server = null as https.Server | http.Server | null; 18 | if (config.SSL_KEY_FILE && config.SSL_CRT_FILE) { 19 | const key = fs.readFileSync(config.SSL_KEY_FILE as string); 20 | const cert = fs.readFileSync(config.SSL_CRT_FILE as string); 21 | server = https.createServer({ key: key, cert: cert }, app); 22 | } else { 23 | server = new http.Server(app); 24 | } 25 | const io = new Server(server, { cors: { origin: '*' }, transports: ['websocket'] }); 26 | const rooms = new Map(); 27 | init(); 28 | setInterval(freeUnusedRooms, 5 * 60 * 1000); 29 | async function freeUnusedRooms() { 30 | // Only run if redis persistence is turned on 31 | // Clean up rooms that are no longer in redis and empty 32 | // Frees up some JS memory space when process is long-running 33 | // If running without redis, keep rooms in memory 34 | // We don't currently attempt to reload rooms from redis on demand 35 | if (redis) { 36 | rooms.forEach(async (room, key) => { 37 | if (room.roster.length === 0 && !(await redis?.get(key))) { 38 | clearInterval(room.cleanupInterval); 39 | rooms.delete(key); 40 | // Unregister the namespace to avoid dupes 41 | io._nsps.delete(key); 42 | } 43 | }); 44 | } 45 | } 46 | 47 | async function init() { 48 | if (redis) { 49 | // Load rooms from Redis 50 | console.log('loading rooms from redis'); 51 | const keys = await redis.keys('/*'); 52 | console.log(util.format('found %s rooms in redis', keys.length)); 53 | for (let i = 0; i < keys.length; i++) { 54 | const key = keys[i]; 55 | const roomData = await redis.get(key); 56 | console.log(key, roomData?.length); 57 | rooms.set(key, new Room(io, key, roomData)); 58 | } 59 | } 60 | config.permaRooms.forEach((roomId) => { 61 | // Create the room if it doesn't exist 62 | if (!rooms.has(roomId)) { 63 | rooms.set(roomId, new Room(io, roomId)); 64 | } 65 | }); 66 | server?.listen(Number(config.PORT)); 67 | } 68 | 69 | app.use(cors()); 70 | app.use(compression()); 71 | app.use(express.static('build')); 72 | 73 | app.get('/ping', (req, res) => { 74 | res.json('pong'); 75 | }); 76 | 77 | app.get('/metadata', (req, res) => { 78 | res.json(getJDataStats()); 79 | }); 80 | 81 | app.get('/stats', async (req, res) => { 82 | if (req.query.key && req.query.key === config.STATS_KEY) { 83 | const roomData: any[] = []; 84 | let currentUsers = 0; 85 | rooms.forEach((room) => { 86 | const obj = { 87 | creationTime: room.creationTime, 88 | roomId: room.roomId, 89 | rosterLength: room.getConnectedPlayers().length, 90 | }; 91 | currentUsers += obj.rosterLength; 92 | roomData.push(obj); 93 | }); 94 | // Sort newest first 95 | roomData.sort((a, b) => b.creationTime - a.creationTime); 96 | const cpuUsage = os.loadavg(); 97 | const redisUsage = (await redis?.info()) 98 | ?.split('\n') 99 | .find((line) => line.startsWith('used_memory:')) 100 | ?.split(':')[1] 101 | .trim(); 102 | // const chatMessages = await getRedisCountDay('chatMessages'); 103 | const newGamesLastDay = await getRedisCountDay('newGames'); 104 | const customGamesLastDay = await getRedisCountDay('customGames'); 105 | const aiJudgeLastDay = await getRedisCountDay('aiJudge'); 106 | const aiShortcutLastDay = await getRedisCountDay('aiShortcut'); 107 | const aiChatGptLastDay = await getRedisCountDay('aiChatGpt'); 108 | const aiRefuseLastDay = await getRedisCountDay('aiRefuse'); 109 | const undoLastDay = await getRedisCountDay('undo'); 110 | const aiUndoLastDay = await getRedisCountDay('aiUndo'); 111 | const aiVoiceLastDay = await getRedisCountDay('aiVoice'); 112 | const savesLastDay = await getRedisCountDay('saves'); 113 | const nonTrivialJudges = await redis?.llen('jpd:nonTrivialJudges'); 114 | const jeopardyResults = await redis?.llen('jpd:results'); 115 | const aiJudges = await redis?.llen('jpd:aiJudges'); 116 | 117 | res.json({ 118 | uptime: process.uptime(), 119 | roomCount: rooms.size, 120 | cpuUsage, 121 | redisUsage, 122 | memUsage: process.memoryUsage().rss, 123 | // chatMessages, 124 | currentUsers, 125 | newGamesLastDay, 126 | customGamesLastDay, 127 | aiJudgeLastDay, 128 | aiShortcutLastDay, 129 | aiChatGptLastDay, 130 | aiRefuseLastDay, 131 | undoLastDay, 132 | aiUndoLastDay, 133 | aiVoiceLastDay, 134 | savesLastDay, 135 | nonTrivialJudges, 136 | jeopardyResults, 137 | aiJudges, 138 | rooms: roomData, 139 | }); 140 | } else { 141 | res.status(403).json({ error: 'Access Denied' }); 142 | } 143 | }); 144 | 145 | app.get('/jeopardyResults', async (req, res) => { 146 | if (req.query.key && req.query.key === config.STATS_KEY) { 147 | const data = await redis?.lrange('jpd:results', 0, -1); 148 | res.json(data); 149 | } else { 150 | res.status(403).json({ error: 'Access Denied' }); 151 | } 152 | }); 153 | 154 | app.get('/nonTrivialJudges', async (req, res) => { 155 | if (req.query.key && req.query.key === config.STATS_KEY) { 156 | const data = await redis?.lrange('jpd:nonTrivialJudges', 0, -1); 157 | res.json(data); 158 | } else { 159 | res.status(403).json({ error: 'Access Denied' }); 160 | } 161 | }); 162 | 163 | app.get('/aiJudges', async (req, res) => { 164 | if (req.query.key && req.query.key === config.STATS_KEY) { 165 | const data = await redis?.lrange('jpd:aiJudges', 0, -1); 166 | res.json(data); 167 | } else { 168 | res.status(403).json({ error: 'Access Denied' }); 169 | } 170 | }); 171 | 172 | app.post('/createRoom', async (req, res) => { 173 | const genName = () => '/' + makeRoomName(); 174 | let name = genName(); 175 | // Keep retrying until no collision 176 | while (rooms.has(name)) { 177 | name = genName(); 178 | } 179 | console.log('createRoom: ', name); 180 | const newRoom = new Room(io, name); 181 | newRoom.saveRoom(); 182 | rooms.set(name, newRoom); 183 | res.json({ name: name.slice(1) }); 184 | }); 185 | 186 | app.get('/generateName', (req, res) => { 187 | res.send(makeUserName()); 188 | }); 189 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "es2020"], 5 | "allowJs": true, 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "module": "commonjs", 9 | "outDir": "../buildServer" 10 | }, 11 | "include": [".", "../global.d.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /src/components/App/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | ::-webkit-scrollbar-corner { 11 | background: rgba(0, 0, 0, 0); 12 | } 13 | 14 | body { 15 | background-color: #222222; 16 | } 17 | 18 | .comment { 19 | word-break: break-word; 20 | } 21 | 22 | .comment .light.light.light { 23 | color: gainsboro; 24 | } 25 | 26 | .comment .dark.dark.dark { 27 | color: gray; 28 | } 29 | 30 | .chatContainer { 31 | flex-grow: 1; 32 | overflow: auto; 33 | } 34 | 35 | .system.system.system.system { 36 | font-size: 10px; 37 | text-transform: uppercase; 38 | letter-spacing: 1px; 39 | } 40 | 41 | .controlsContainer { 42 | position: absolute; 43 | width: 100%; 44 | bottom: 0px; 45 | } 46 | 47 | .controls { 48 | color: white; 49 | display: flex; 50 | flex-wrap: wrap; 51 | align-items: center; 52 | line-height: 14px; 53 | padding-left: 10px; 54 | padding-top: 10px; 55 | padding-bottom: 10px; 56 | } 57 | 58 | .control.control { 59 | margin-right: 10px; 60 | text-transform: uppercase; 61 | } 62 | 63 | .control.action { 64 | cursor: pointer; 65 | } 66 | 67 | .progress .bar.bar.bar { 68 | pointer-events: none; 69 | min-width: initial; 70 | } 71 | 72 | .videoContent { 73 | position: absolute; 74 | top: 0; 75 | left: 0; 76 | width: 100%; 77 | height: 100%; 78 | } 79 | 80 | .fullScreenContainer { 81 | display: flex; 82 | flex-direction: row; 83 | } 84 | 85 | #playerContainer { 86 | height: 100%; 87 | position: relative; 88 | display: flex; 89 | align-items: center; 90 | justify-content: center; 91 | min-height: 62vh; 92 | } 93 | 94 | .fullScreenContainer:fullscreen > #playerContainer { 95 | width: 80vw; 96 | } 97 | 98 | .fullScreenChat.fullScreenChat.fullScreenChat { 99 | width: 20vw; 100 | } 101 | 102 | #leftVideo::-webkit-media-controls { 103 | display: none !important; 104 | } 105 | 106 | @media only screen and (max-width: 600px) { 107 | .mobileStack { 108 | flex-direction: column; 109 | } 110 | } 111 | 112 | .fullHeightColumn.fullHeightColumn.fullHeightColumn.fullHeightColumn { 113 | height: calc(100vh - 62px); 114 | } 115 | 116 | .toolButton { 117 | min-width: 135px; 118 | height: 36px; 119 | } 120 | 121 | .videoChatContent { 122 | height: 110px; 123 | border-radius: 4px; 124 | object-fit: contain; 125 | } 126 | 127 | .ui.labeled.icon.button > .icon.loading, 128 | .ui.labeled.icon.buttons > .button > .icon.loading { 129 | background-color: initial; 130 | } 131 | 132 | .ui.dropdown .menu[role='listbox'] { 133 | z-index: 1001; 134 | } 135 | 136 | .ui.selection.dropdown.ui.selection.dropdown { 137 | min-height: 1em; 138 | border: 0px; 139 | } 140 | 141 | .ui.search.dropdown > .text { 142 | white-space: nowrap; 143 | text-overflow: ellipsis; 144 | width: 100%; 145 | overflow: hidden; 146 | } 147 | 148 | .footerIcon { 149 | color: white; 150 | margin-right: 10px; 151 | } 152 | -------------------------------------------------------------------------------- /src/components/App/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import App from './App'; 6 | 7 | test('renders learn react link', () => { 8 | const { getByText } = render(); 9 | const linkElement = getByText(/learn react/i); 10 | expect(linkElement).toBeInTheDocument(); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import './App.css'; 2 | import React, { useCallback, useEffect, useState } from 'react'; 3 | import { Divider, Grid, Icon, Input } from 'semantic-ui-react'; 4 | import { serverPath, generateName } from '../../utils'; 5 | import { Chat } from '../Chat/Chat'; 6 | import { JeopardyTopBar } from '../TopBar/TopBar'; 7 | import { Jeopardy } from '../Jeopardy/Jeopardy'; 8 | import { type Socket } from 'socket.io-client'; 9 | 10 | export default function App() { 11 | const [participants, setParticipants] = useState([]); 12 | const [chat, setChat] = useState([]); 13 | const [myName, setMyName] = useState(''); 14 | const [scrollTimestamp, setScrollTimestamp] = useState(0); 15 | const [socket, setSocket] = useState(undefined); 16 | 17 | useEffect(() => { 18 | const heartbeat = window.setInterval( 19 | () => { 20 | window.fetch(serverPath + '/ping'); 21 | }, 22 | 10 * 60 * 1000, 23 | ); 24 | return () => { 25 | window.clearInterval(heartbeat); 26 | } 27 | }); 28 | 29 | const updateName = useCallback((name: string) => { 30 | if (socket) { 31 | setMyName(name); 32 | socket.emit('CMD:name', name); 33 | window.localStorage.setItem('watchparty-username', name); 34 | } 35 | }, [socket]); 36 | 37 | return ( 38 | 39 | 40 | { 41 | 42 | 43 | 44 | 53 | 54 | 59 | updateName(data.value)} 65 | icon={ 66 | 68 | updateName(await generateName()) 69 | } 70 | name="random" 71 | inverted 72 | circular 73 | link 74 | /> 75 | } 76 | /> 77 | 78 | 83 | 84 | 85 | 86 | } 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/components/App/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './App'; 2 | -------------------------------------------------------------------------------- /src/components/Chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Button, Comment, Icon, Input, Segment } from 'semantic-ui-react'; 3 | import { getColorHex, getDefaultPicture } from '../../utils'; 4 | import { Socket } from 'socket.io-client'; 5 | 6 | interface ChatProps { 7 | chat: ChatMessage[]; 8 | scrollTimestamp: number; 9 | className?: string; 10 | hide?: boolean; 11 | socket: Socket | undefined; 12 | } 13 | 14 | export function Chat (props: ChatProps) { 15 | const [chatMsg, setChatMsg] = useState(''); 16 | const [isNearBottom, setIsNearBottom] = useState(true); 17 | const messagesRef = useRef(null); 18 | 19 | const updateChatMsg = (e: any, data: { value: string }) => { 20 | setChatMsg(data.value); 21 | }; 22 | 23 | const sendChatMsg = () => { 24 | if (!chatMsg || !props.socket) { 25 | return; 26 | } 27 | setChatMsg(''); 28 | props.socket.emit('CMD:chat', chatMsg); 29 | }; 30 | 31 | const isChatNearBottom = () => { 32 | return Boolean( 33 | messagesRef.current && 34 | messagesRef.current.scrollHeight - 35 | messagesRef.current.scrollTop - 36 | messagesRef.current.offsetHeight < 37 | 100 38 | ); 39 | }; 40 | 41 | const onScroll = () => { 42 | setIsNearBottom(isChatNearBottom()); 43 | }; 44 | 45 | const scrollToBottom = () => { 46 | if (messagesRef.current) { 47 | messagesRef.current.scrollTop = 48 | messagesRef.current.scrollHeight; 49 | } 50 | }; 51 | 52 | useEffect(() => { 53 | scrollToBottom(); 54 | messagesRef.current?.addEventListener('scroll', onScroll); 55 | return () => { 56 | messagesRef.current?.removeEventListener('scroll', onScroll); 57 | }; 58 | }, []); 59 | 60 | useEffect(() => { 61 | // if scrolltimestamp updated, we received a new message 62 | // We don't really need to diff it with the previous props 63 | // If 0, we haven't scrolled yet and want to always go to bottom 64 | if (isChatNearBottom() || props.scrollTimestamp != null) { 65 | scrollToBottom(); 66 | } 67 | }, [props.scrollTimestamp, props.chat]); 68 | return ( 69 | 81 |
86 | 87 | {props.chat.map((msg) => ( 88 | 92 | ))} 93 | {/*
*/} 94 | 95 | {!isNearBottom && ( 96 | 108 | )} 109 |
110 | e.key === 'Enter' && sendChatMsg()} 114 | onChange={updateChatMsg} 115 | value={chatMsg} 116 | icon={ 117 | 124 | } 125 | placeholder="Enter a message..." 126 | /> 127 | 128 | ); 129 | } 130 | 131 | const ChatMessage = ({ id, name, timestamp, cmd, msg }: {id: string, name?: string, timestamp: string, cmd: string, msg: string}) => { 132 | return ( 133 | 134 | 135 | 136 | 137 | {name || id} 138 | 139 | 140 |
141 | {new Date(timestamp).toLocaleTimeString()} 142 |
143 |
144 | 145 | {cmd && formatMessage(cmd, msg)} 146 | 147 | {!cmd && msg} 148 |
149 |
150 | ); 151 | }; 152 | 153 | const formatMessage = (cmd: string, msg: string): React.ReactNode | string => { 154 | if (cmd === 'judge') { 155 | const { id, correct, answer, delta, name, confidence } = JSON.parse(msg); 156 | return ( 157 | {`ruled ${name} ${correct ? 'correct' : 'incorrect'}: ${answer} (${ 160 | delta >= 0 ? '+' : '' 161 | }${delta}) ${ 162 | confidence != null ? `(${(confidence * 100).toFixed(0)}% conf.)` : '' 163 | }`} 164 | ); 165 | } else if (cmd === 'answer') { 166 | return `Correct answer: ${msg}`; 167 | } 168 | return cmd; 169 | }; -------------------------------------------------------------------------------- /src/components/Home/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | flex-direction: column; 4 | color: white; 5 | /* background-image: linear-gradient(to bottom, #222222, #008cdd); */ 6 | } 7 | 8 | .hero { 9 | color: white; 10 | width: 100%; 11 | background-image: linear-gradient(to bottom, #222222, #006199); 12 | border-bottom-left-radius: 0%; 13 | border-bottom-right-radius: 50%; 14 | display: flex; 15 | justify-content: center; 16 | } 17 | 18 | .hero.green { 19 | background-image: linear-gradient(to bottom, #222222, rgb(21, 114, 43)); 20 | border-bottom-left-radius: 50%; 21 | border-bottom-right-radius: 0%; 22 | } 23 | 24 | .heroInner { 25 | padding: 30px; 26 | max-width: 1200px; 27 | display: flex; 28 | align-items: center; 29 | } 30 | 31 | @media only screen and (max-width: 600px) { 32 | .heroInner { 33 | flex-direction: column; 34 | } 35 | } 36 | 37 | .heroText { 38 | font-size: 32px; 39 | line-height: 1.2em; 40 | font-weight: 600; 41 | } 42 | 43 | .subText { 44 | color: lightgray; 45 | font-size: 20px; 46 | line-height: 1.5em; 47 | margin-top: 8px; 48 | } 49 | 50 | .featureSection { 51 | display: flex; 52 | max-width: 1200px; 53 | justify-content: center; 54 | margin: 0 auto; 55 | flex-wrap: wrap; 56 | } 57 | 58 | .featureTitle { 59 | text-transform: uppercase; 60 | letter-spacing: 1px; 61 | } 62 | 63 | .featureText { 64 | color: lightgray; 65 | font-size: 14px; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Divider, Header, Icon } from 'semantic-ui-react'; 3 | import CountUp from 'react-countup'; 4 | 5 | import { NewRoomButton, JeopardyTopBar } from '../TopBar/TopBar'; 6 | import styles from './Home.module.css'; 7 | import { serverPath } from '../../utils'; 8 | 9 | const Feature = ({ 10 | icon, 11 | text, 12 | title, 13 | }: { 14 | icon: string; 15 | text: string; 16 | title: string; 17 | }) => { 18 | return ( 19 |
29 | 30 |

{title}

31 |
{text}
32 |
33 | ); 34 | }; 35 | 36 | const Hero = ({ 37 | heroText, 38 | action, 39 | image, 40 | color, 41 | }: { 42 | heroText?: string; 43 | action?: React.ReactNode; 44 | image?: string; 45 | color?: string; 46 | }) => { 47 | const [epCount, setEpCount] = useState(8000); 48 | const [qCount, setQCount] = useState(500000); 49 | useEffect(() => { 50 | const update = async () => { 51 | const response = await fetch(serverPath + '/metadata'); 52 | const json = await response.json(); 53 | setQCount(json.qs); 54 | setEpCount(json.eps); 55 | } 56 | update(); 57 | }, []); 58 | return ( 59 |
60 |
61 |
62 |
{heroText}
63 |
64 | 65 | {' '} 66 | episodes featuring 67 | {' '} 68 | 69 | {' '} 70 | clues 71 |
72 | {action} 73 |
74 |
79 | hero 84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | export const JeopardyHome = () => { 91 | return ( 92 |
93 | 94 |
95 | } 98 | image={'/screenshot3.png'} 99 | /> 100 | 101 |
102 | 103 | Features 104 |
105 |
106 |
107 | 112 | 117 | 122 | 127 | 132 |
133 |
134 |
135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/Jeopardy/Jeopardy.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Korinna'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url('./korinna-regular.otf'); 6 | } 7 | 8 | @font-face { 9 | font-family: 'Swiss 911'; 10 | font-style: normal; 11 | font-weight: 400; 12 | src: url('./swiss911-xcm-bt.ttf'); 13 | } 14 | 15 | @font-face { 16 | font-family: 'Univers'; 17 | font-style: normal; 18 | font-weight: 400; 19 | src: url('./univers-75-black.ttf'); 20 | } 21 | 22 | @font-face { 23 | font-family: 'Gyparody'; 24 | font-style: normal; 25 | font-weight: 400; 26 | src: url('./gyparody.ttf'); 27 | } 28 | 29 | .logo { 30 | font-family: 'Gyparody'; 31 | font-size: 48px; 32 | line-height: 48px; 33 | text-transform: uppercase; 34 | color: white; 35 | text-shadow: 36 | 1px 1px 4px #fff, 37 | 1px 1px 4px #ccc; 38 | } 39 | 40 | .logo.small { 41 | font-size: 30px; 42 | line-height: 30px; 43 | } 44 | 45 | .board { 46 | background-color: black; 47 | display: grid; 48 | grid-template-columns: repeat(6, 1fr); 49 | grid-template-rows: repeat(6, 1fr); 50 | grid-gap: 6px; 51 | padding: 6px; 52 | align-items: center; 53 | justify-content: center; 54 | text-align: center; 55 | margin: 0 auto; 56 | position: relative; 57 | width: 100%; 58 | } 59 | 60 | .board.currentQ { 61 | overflow-x: hidden; 62 | } 63 | 64 | @media only screen and (max-width: 600px) { 65 | .board { 66 | overflow-x: scroll; 67 | justify-content: start; 68 | } 69 | } 70 | 71 | .box { 72 | align-items: center; 73 | justify-content: center; 74 | display: flex; 75 | background-color: #0000b5; 76 | padding: 4px; 77 | height: 100%; 78 | width: 100%; 79 | min-width: 100px; 80 | min-height: 60px; 81 | } 82 | 83 | .category { 84 | font-family: 'Swiss 911'; 85 | font-size: 2.5vh; 86 | line-height: 2.5vh; 87 | letter-spacing: 1px; 88 | color: #e1f3ff; 89 | text-transform: uppercase; 90 | } 91 | 92 | .value { 93 | font-family: 'Swiss 911'; 94 | font-size: 6vh; 95 | color: #d7a04b; 96 | text-shadow: black 4px 4px; 97 | } 98 | 99 | .value:hover { 100 | cursor: pointer; 101 | border: 1px solid white; 102 | } 103 | 104 | .clueContainerContainer { 105 | transform: scale(0.17); 106 | transform-origin: 0 0; 107 | transition: 0.7s ease-out; 108 | width: 100%; 109 | height: 100%; 110 | } 111 | 112 | .clueContainer { 113 | display: flex; 114 | flex-direction: column; 115 | align-items: center; 116 | padding: 20px 20px; 117 | background-color: #0000b5; 118 | position: relative; 119 | margin: 0 auto; 120 | height: 100%; 121 | } 122 | 123 | .clueContainer.dailyDouble { 124 | background-position-x: center; 125 | background-size: contain; 126 | background-repeat: no-repeat; 127 | background-image: url('./jeopardy-daily-double.png'); 128 | background-color: rgba(0, 0, 0, 1); 129 | } 130 | 131 | .clue { 132 | font-family: 'Korinna'; 133 | text-transform: uppercase; 134 | color: white; 135 | font-size: 2em; 136 | line-height: 1; 137 | text-align: center; 138 | flex-grow: 1; 139 | display: flex; 140 | align-items: center; 141 | } 142 | 143 | @media only screen and (max-width: 600px) { 144 | .clue { 145 | overflow-y: scroll; 146 | align-items: start; 147 | } 148 | } 149 | 150 | .answer { 151 | font-family: 'Korinna'; 152 | text-transform: uppercase; 153 | color: white; 154 | font-size: 22px; 155 | line-height: 22px; 156 | text-align: center; 157 | } 158 | 159 | .scoreboard { 160 | display: flex; 161 | flex-direction: column; 162 | width: 110px; 163 | margin-right: 8px; 164 | flex-shrink: 0; 165 | } 166 | 167 | .scoreboard .icons { 168 | position: absolute; 169 | top: 0; 170 | right: 0; 171 | color: white; 172 | } 173 | 174 | .scoreboard .picture { 175 | width: 100%; 176 | margin-bottom: -4px; 177 | } 178 | 179 | .scoreboard .picture img { 180 | width: 100%; 181 | } 182 | 183 | .scoreboard .points { 184 | font-family: 'Univers'; 185 | color: white; 186 | background-color: #0000b5; 187 | display: flex; 188 | align-items: center; 189 | justify-content: center; 190 | font-size: 22px; 191 | line-height: 22px; 192 | padding: 4px 10px; 193 | border: 4px solid black; 194 | } 195 | 196 | .scoreboard .points.negative { 197 | color: red; 198 | } 199 | 200 | .scoreboard .answerBox.negative { 201 | color: red; 202 | } 203 | 204 | .judgeButtons { 205 | position: absolute; 206 | top: 0px; 207 | display: flex; 208 | justify-content: center; 209 | } 210 | 211 | .scoreboard .answerBox { 212 | font-family: 'Korinna'; 213 | color: white; 214 | background-color: #0000b5; 215 | display: flex; 216 | align-items: center; 217 | justify-content: center; 218 | min-height: 80px; 219 | font-size: 12px; 220 | line-height: 12px; 221 | padding: 4px; 222 | word-break: break-word; 223 | border: 4px solid black; 224 | position: relative; 225 | } 226 | 227 | .scoreboard .answerBox.buzz { 228 | border: 4px solid white; 229 | } 230 | 231 | .scoreboard .answerBox .timeOffset { 232 | position: absolute; 233 | bottom: 0; 234 | right: 0; 235 | font-size: 11px; 236 | font-family: initial; 237 | padding: 0px 3px; 238 | border-radius: 3px; 239 | } 240 | 241 | .gameSelector input { 242 | width: 100px; 243 | } 244 | 245 | #intro { 246 | position: absolute; 247 | width: 100%; 248 | height: 100%; 249 | top: 0; 250 | left: 0; 251 | z-index: 1; 252 | display: flex; 253 | align-items: center; 254 | justify-content: center; 255 | } 256 | 257 | #intro video { 258 | width: 100%; 259 | } 260 | 261 | #endgame { 262 | display: flex; 263 | justify-content: center; 264 | align-items: center; 265 | flex-direction: column; 266 | position: absolute; 267 | width: 100%; 268 | padding: 20px; 269 | } 270 | -------------------------------------------------------------------------------- /src/components/Jeopardy/Jeopardy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | Button, 4 | Label, 5 | Input, 6 | Icon, 7 | Dropdown, 8 | Popup, 9 | Modal, 10 | Table, 11 | TableRow, 12 | TableCell, 13 | TableHeader, 14 | TableHeaderCell, 15 | Checkbox, 16 | Header, 17 | SemanticCOLORS, 18 | } from 'semantic-ui-react'; 19 | import './Jeopardy.css'; 20 | import { 21 | getDefaultPicture, 22 | getColorHex, 23 | shuffle, 24 | getColor, 25 | generateName, 26 | serverPath, 27 | getOrCreateClientId, 28 | } from '../../utils'; 29 | import { io, type Socket } from 'socket.io-client'; 30 | import ReactMarkdown from 'react-markdown'; 31 | import { type PublicGameState } from '../../../server/gamestate'; 32 | import { cyrb53 } from '../../../server/hash'; 33 | 34 | const dailyDouble = new Audio('/jeopardy/jeopardy-daily-double.mp3'); 35 | const boardFill = new Audio('/jeopardy/jeopardy-board-fill.mp3'); 36 | const think = new Audio('/jeopardy/jeopardy-think.mp3'); 37 | const timesUp = new Audio('/jeopardy/jeopardy-times-up.mp3'); 38 | const rightAnswer = new Audio('/jeopardy/jeopardy-rightanswer.mp3'); 39 | 40 | type GameSettings = { 41 | answerTimeout?: number; 42 | finalTimeout?: number; 43 | makeMeHost?: boolean; 44 | allowMultipleCorrect?: boolean; 45 | enableAIJudge?: boolean; 46 | }; 47 | 48 | const loadSavedSettings = (): GameSettings => { 49 | try { 50 | const saved = window.localStorage.getItem('jeopardy-gameSettings'); 51 | if (saved) { 52 | return JSON.parse(saved); 53 | } 54 | } catch (e) { 55 | console.log(e); 56 | } 57 | return {}; 58 | }; 59 | 60 | // room ID from url 61 | let roomId = '/default'; 62 | const urlParams = new URLSearchParams(window.location.search); 63 | const query = urlParams.get('game'); 64 | if (query) { 65 | roomId = '/' + query; 66 | } 67 | 68 | export class Jeopardy extends React.Component<{ 69 | participants: User[]; 70 | chat: ChatMessage[]; 71 | updateName: (name: string) => void; 72 | setSocket: (socket: Socket | undefined) => void; 73 | setParticipants: (users: User[]) => void; 74 | setScrollTimestamp: (ts: number) => void; 75 | setChat: (chat: ChatMessage[]) => void; 76 | }> { 77 | public state = { 78 | game: undefined as PublicGameState | undefined, 79 | isIntroPlaying: false, 80 | localAnswer: '', 81 | localWager: '', 82 | localAnswerSubmitted: false, 83 | localWagerSubmitted: false, 84 | localEpNum: '', 85 | categoryMask: Array(6).fill(true), 86 | categoryReadTime: 0, 87 | clueMask: {} as any, 88 | readingDisabled: false, 89 | buzzFrozen: false, 90 | showCustomModal: false, 91 | showJudgingModal: false, 92 | showSettingsModal: false, 93 | settings: loadSavedSettings(), 94 | overlayMsg: '', 95 | }; 96 | buzzLock = 0; 97 | socket: Socket | undefined = undefined; 98 | 99 | async componentDidMount() { 100 | window.speechSynthesis.getVoices(); 101 | 102 | document.onkeydown = this.onKeydown; 103 | 104 | this.setState({ 105 | readingDisabled: Boolean( 106 | window.localStorage.getItem('jeopardy-readingDisabled'), 107 | ), 108 | }); 109 | const socket = io(serverPath + roomId, { 110 | transports: ['websocket'], 111 | query: { 112 | clientId: getOrCreateClientId(), 113 | }, 114 | }); 115 | this.socket = socket; 116 | this.props.setSocket(socket); 117 | socket.on('connect', async () => { 118 | // Load username from localstorage 119 | let userName = window.localStorage.getItem('watchparty-username'); 120 | this.props.updateName(userName || (await generateName())); 121 | }); 122 | socket.on('connect_error', (err: any) => { 123 | console.error(err); 124 | if (err.message === 'Invalid namespace') { 125 | this.setState({ overlayMsg: "Couldn't load this room." }); 126 | } 127 | }); 128 | socket.on('REC:chat', (data: ChatMessage) => { 129 | if (document.visibilityState && document.visibilityState !== 'visible') { 130 | new Audio('/clearly.mp3').play(); 131 | } 132 | // There may be race condition issues if we append using [...this.state.chat, data] 133 | this.props.chat.push(data); 134 | this.props.setChat(this.props.chat); 135 | this.props.setScrollTimestamp(Number(new Date())); 136 | }); 137 | socket.on('roster', (data: User[]) => { 138 | this.props.setParticipants(data); 139 | }); 140 | socket.on('chatinit', (data: any) => { 141 | this.props.setChat(data); 142 | this.props.setScrollTimestamp(0); 143 | }); 144 | socket.on('JPD:state', (game: PublicGameState) => { 145 | this.setState({ game, localEpNum: game.epNum }); 146 | }); 147 | // socket.on('JPD:playIntro', () => { 148 | // this.playIntro(); 149 | // }); 150 | socket.on('JPD:playTimesUp', () => { 151 | timesUp.play(); 152 | }); 153 | socket.on('JPD:playDailyDouble', () => { 154 | dailyDouble.volume = 0.5; 155 | dailyDouble.play(); 156 | }); 157 | socket.on('JPD:playFinalJeopardy', async () => { 158 | think.volume = 0.5; 159 | think.play(); 160 | }); 161 | socket.on('JPD:playRightanswer', () => { 162 | rightAnswer.play(); 163 | }); 164 | // socket.on('JPD:playMakeSelection', () => { 165 | // if (this.state.game.picker) { 166 | // const selectionText = [ 167 | // 'Make a selection, {name}', 168 | // 'You have command of the board, {name}', 169 | // 'Pick a clue, {name}', 170 | // 'Select a category, {name}', 171 | // 'Go again, {name}', 172 | // ]; 173 | // const random =p 174 | // selectionText[Math.floor(Math.random() * selectionText.length)]; 175 | // this.sayText( 176 | // random.replace('{name}', this.state.game.picker) 177 | // ); 178 | // } 179 | // }); 180 | socket.on('JPD:playClue', async (qid: string, text: string) => { 181 | this.setState({ 182 | localAnswer: '', 183 | localWager: '', 184 | localWagerSubmitted: false, 185 | localAnswerSubmitted: false, 186 | }); 187 | // Read the question 188 | // console.log('JPD:playClue', text); 189 | // Remove parenthetical starts and blanks 190 | await this.sayText(text.replace(/^\(.*\)/, '').replace(/_+/g, ' blank ')); 191 | }); 192 | socket.on('JPD:playCategories', async () => { 193 | const now = Date.now(); 194 | const clueMask: any = {}; 195 | const clueMaskOrder = []; 196 | for (let i = 1; i <= 6; i++) { 197 | for (let j = 1; j <= 5; j++) { 198 | clueMask[`${i}_${j}`] = true; 199 | clueMaskOrder.push(`${i}_${j}`); 200 | } 201 | } 202 | this.setState({ 203 | categoryMask: Array(6).fill(false), 204 | categoryReadTime: now, 205 | clueMask, 206 | }); 207 | // Run board intro sequence 208 | // Play the fill sound 209 | boardFill.play(); 210 | // Randomly choose ordering of the 30 clues 211 | // Split into 6 sets of 5 212 | // Each half second show another set of 5 213 | shuffle(clueMaskOrder); 214 | const clueSets = []; 215 | for (let i = 0; i < clueMaskOrder.length; i += 5) { 216 | clueSets.push(clueMaskOrder.slice(i, i + 5)); 217 | } 218 | for (let i = 0; i < clueSets.length; i++) { 219 | await new Promise((resolve) => setTimeout(resolve, 400)); 220 | clueSets[i].forEach((clue) => delete this.state.clueMask[clue]); 221 | this.setState({ clueMask: this.state.clueMask }); 222 | } 223 | // Reveal and read categories 224 | const categories = this.getCategories(); 225 | for (let i = 0; i < this.state.categoryMask.length; i++) { 226 | if (this.state.categoryReadTime !== now) { 227 | continue; 228 | } 229 | let newMask: Boolean[] = [...this.state.categoryMask]; 230 | newMask[i] = true; 231 | this.setState({ categoryMask: newMask }); 232 | await Promise.any([ 233 | this.sayText(categories[i]), 234 | new Promise((resolve) => setTimeout(resolve, 3000)), 235 | ]); 236 | } 237 | }); 238 | } 239 | 240 | componentWillUnmount() { 241 | document.removeEventListener('keydown', this.onKeydown); 242 | this.socket?.close(); 243 | this.socket = undefined; 244 | this.props.setSocket(undefined); 245 | } 246 | 247 | async componentDidUpdate(prevProps: any, prevState: any) { 248 | if (!prevState.game?.currentQ && this.state.game?.currentQ) { 249 | // Run growing clue animation 250 | const clue = document.getElementById( 251 | 'clueContainerContainer', 252 | ) as HTMLElement; 253 | const board = document.getElementById('board') as HTMLElement; 254 | const box = document.getElementById( 255 | this.state.game?.currentQ, 256 | ) as HTMLElement; 257 | clue.style.position = 'absolute'; 258 | clue.style.left = box.offsetLeft + 'px'; 259 | clue.style.top = box.offsetTop + 'px'; 260 | setTimeout(() => { 261 | clue.style.left = board.scrollLeft + 'px'; 262 | clue.style.top = '0px'; 263 | clue.style.transform = 'scale(1)'; 264 | }, 1); 265 | } 266 | } 267 | 268 | onKeydown = (e: any) => { 269 | if (!document.activeElement || document.activeElement.tagName === 'BODY') { 270 | if (e.key === ' ') { 271 | e.preventDefault(); 272 | this.onBuzz(); 273 | } 274 | if (e.key === 'p') { 275 | e.preventDefault(); 276 | if (this.state.game?.canBuzz) { 277 | this.submitAnswer(null); 278 | } 279 | } 280 | } 281 | }; 282 | 283 | newGame = async ( 284 | options: { 285 | number?: string; 286 | filter?: string; 287 | }, 288 | customGame?: string, 289 | ) => { 290 | this.setState({ game: null }); 291 | // optionally send an episode number or game type filter 292 | // combine with other custom settings configured by user 293 | const combined: GameOptions = { 294 | number: options.number, 295 | filter: options.filter, 296 | ...this.state.settings, 297 | }; 298 | this.socket?.emit('JPD:start', combined, customGame); 299 | }; 300 | 301 | customGame = () => { 302 | // Create an input element 303 | const inputElement = document.createElement('input'); 304 | 305 | // Set its type to file 306 | inputElement.type = 'file'; 307 | 308 | // Set accept to the file types you want the user to select. 309 | // Include both the file extension and the mime type 310 | // inputElement.accept = accept; 311 | 312 | // set onchange event to call callback when user has selected file 313 | inputElement.addEventListener('change', () => { 314 | const file = inputElement.files![0]; 315 | // Read the file 316 | const reader = new FileReader(); 317 | reader.readAsText(file); 318 | reader.onload = (e) => { 319 | let content = e.target?.result; 320 | this.newGame({}, content as string); 321 | this.setState({ showCustomModal: false }); 322 | }; 323 | }); 324 | 325 | // dispatch a click event to open the file dialog 326 | inputElement.dispatchEvent(new MouseEvent('click')); 327 | }; 328 | 329 | // playIntro = async () => { 330 | // this.setState({ isIntroPlaying: true }); 331 | // document.getElementById('intro')!.innerHTML = ''; 332 | // let introVideo = document.createElement('video'); 333 | // let introMusic = new Audio('/jeopardy/jeopardy-intro-full.ogg'); 334 | // document.getElementById('intro')?.appendChild(introVideo); 335 | // introVideo.muted = true; 336 | // introVideo.src = '/jeopardy/jeopardy-intro-video.mp4'; 337 | // introVideo.play(); 338 | // introVideo.style.width = '100%'; 339 | // introVideo.style.height = '100%'; 340 | // introVideo.style.backgroundColor = '#000000'; 341 | // introMusic.volume = 0.5; 342 | // introMusic.play(); 343 | // setTimeout(async () => { 344 | // await this.sayText('This is Jeopardy!'); 345 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 346 | // await this.sayText("Here are today's contestants."); 347 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 348 | // for (let i = 0; i < this.props.participants.length; i++) { 349 | // const p = this.props.participants[i]; 350 | // const name = p.name; 351 | // const player = document.createElement('img'); 352 | // player.src = 353 | // getDefaultPicture(p.name, getColorHex(p.id)); 354 | // player.style.width = '200px'; 355 | // player.style.height = '200px'; 356 | // player.style.position = 'absolute'; 357 | // player.style.margin = 'auto'; 358 | // player.style.top = '0px'; 359 | // player.style.bottom = '0px'; 360 | // player.style.left = '0px'; 361 | // player.style.right = '0px'; 362 | // document.getElementById('intro')!.appendChild(player); 363 | // // maybe we can look up the location by IP? 364 | // await this.sayText('A person from somewhere, ' + name); 365 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 366 | // document.getElementById('intro')!.removeChild(player); 367 | // } 368 | // await this.sayText( 369 | // 'And now, here is the host of Jeopardy, your computer!' 370 | // ); 371 | // await new Promise((resolve) => setTimeout(resolve, 1000)); 372 | // introMusic.pause(); 373 | // introVideo.pause(); 374 | // introVideo = null as any; 375 | // introMusic = null as any; 376 | // document.getElementById('intro')!.innerHTML = ''; 377 | // this.setState({ isIntroPlaying: false }); 378 | // }, 10000); 379 | // }; 380 | 381 | sayText = async (text: string) => { 382 | if (this.state.readingDisabled) { 383 | await new Promise((resolve) => setTimeout(resolve, 2000)); 384 | return; 385 | } 386 | if (text.startsWith('!')) { 387 | // This is probably markdown 388 | return; 389 | } 390 | // if enabled, attempt using pregen AI voice 391 | // Always checking first is kind of slow due to http request needed 392 | if (this.state.game?.enableAIVoices) { 393 | try { 394 | await new Promise(async (resolve, reject) => { 395 | const hash = cyrb53(text).toString(); 396 | const aiVoice = new Audio( 397 | this.state.game?.enableAIVoices + 398 | '/gradio_api/file=audio/output/{hash}.mp3'.replace( 399 | '{hash}', 400 | hash, 401 | ), 402 | ); 403 | aiVoice.onended = resolve; 404 | aiVoice.onerror = reject; 405 | try { 406 | await aiVoice.play(); 407 | } catch (e) { 408 | reject(e); 409 | } 410 | }); 411 | return; 412 | } catch (e) { 413 | console.log(e); 414 | } 415 | } 416 | // If we errored, fallback to browser TTS 417 | let isIOS = /iPad|iPhone|iPod/.test(navigator.platform); 418 | if (isIOS) { 419 | // on iOS speech synthesis just never returns 420 | await new Promise((resolve) => setTimeout(resolve, 2000)); 421 | return; 422 | } 423 | await new Promise((resolve) => { 424 | window.speechSynthesis.cancel(); 425 | const utterThis = new SpeechSynthesisUtterance(text); 426 | // let retryCount = 0; 427 | // while (speechSynthesis.getVoices().length === 0 && retryCount < 3) { 428 | // retryCount += 1; 429 | // await new Promise((resolve) => setTimeout(resolve, 500)); 430 | // } 431 | let voices = window.speechSynthesis.getVoices(); 432 | let target = voices.find( 433 | (voice) => voice.name === 'Google UK English Male', 434 | ); 435 | if (target) { 436 | utterThis.voice = target; 437 | } 438 | window.speechSynthesis.speak(utterThis); 439 | utterThis.onend = resolve; 440 | utterThis.onerror = resolve; 441 | }); 442 | }; 443 | 444 | pickQ = (id: string) => { 445 | this.socket?.emit('JPD:pickQ', id); 446 | }; 447 | 448 | submitWager = () => { 449 | this.socket?.emit('JPD:wager', this.state.localWager); 450 | this.setState({ localWager: '', localWagerSubmitted: true }); 451 | }; 452 | 453 | submitAnswer = (answer = null) => { 454 | if (!this.state.localAnswerSubmitted) { 455 | this.socket?.emit( 456 | 'JPD:answer', 457 | this.state.game?.currentQ, 458 | answer || this.state.localAnswer, 459 | ); 460 | this.setState({ localAnswer: '', localAnswerSubmitted: true }); 461 | } 462 | }; 463 | 464 | judgeAnswer = (id: string, correct: boolean | null) => { 465 | this.socket?.emit('JPD:judge', { 466 | currentQ: this.state.game?.currentQ, 467 | id, 468 | correct, 469 | }); 470 | }; 471 | 472 | bulkJudgeAnswer = (data: { id: string; correct: boolean | null }[]) => { 473 | this.socket?.emit( 474 | 'JPD:bulkJudge', 475 | data.map((d) => ({ ...d, currentQ: this.state.game?.currentQ })), 476 | ); 477 | }; 478 | 479 | getCategories = () => { 480 | const game = this.state.game; 481 | if (!game || !game.board) { 482 | return []; 483 | } 484 | let categories: string[] = Array(6).fill(''); 485 | Object.keys(game.board).forEach((key) => { 486 | const col = Number(key.split('_')[0]) - 1; 487 | categories[col] = game.board[key].category; 488 | }); 489 | return categories; 490 | }; 491 | 492 | getWinners = () => { 493 | const max = 494 | Math.max(...Object.values(this.state.game?.scores || {})) || 0; 495 | return this.props.participants 496 | .filter((p) => (this.state.game?.scores[p.id] || 0) === max) 497 | .map((p) => p.id); 498 | }; 499 | 500 | getBuzzOffset = (id: string) => { 501 | if (!this.state.game?.buzzUnlockTS) { 502 | return 0; 503 | } 504 | return this.state.game?.buzzes[id] - this.state.game?.buzzUnlockTS; 505 | }; 506 | 507 | onBuzz = () => { 508 | const game = this.state.game; 509 | if (game?.canBuzz && !this.buzzLock && !this.state.buzzFrozen) { 510 | this.socket?.emit('JPD:buzz'); 511 | } else { 512 | // Freeze the buzzer for 0.25 seconds 513 | // setState takes a little bit, so also set a local var to prevent spam 514 | const now = Date.now(); 515 | this.buzzLock = now; 516 | this.setState({ buzzFrozen: true }); 517 | setTimeout(() => { 518 | if (this.buzzLock === now) { 519 | this.setState({ buzzFrozen: false }); 520 | this.buzzLock = 0; 521 | } 522 | }, 250); 523 | } 524 | }; 525 | 526 | saveSettings = (settings: GameSettings) => { 527 | // serialize to localStorage so settings persist on reload 528 | window.localStorage.setItem( 529 | 'jeopardy-gameSettings', 530 | JSON.stringify(settings), 531 | ); 532 | // update state 533 | this.setState({ settings }); 534 | }; 535 | 536 | render() { 537 | const game = this.state.game; 538 | const categories = this.getCategories(); 539 | const participants = this.props.participants; 540 | const canJudge = !game?.host || this.socket?.id === game?.host; 541 | return ( 542 | <> 543 | {this.state.showCustomModal && ( 544 | this.setState({ showCustomModal: false })}> 545 | Custom Game 546 | 547 | 548 |
549 | You can create and play a custom game by uploading a data 550 | file. Download the example and customize with your own 551 | questions and answers. 552 |
553 | 563 |
564 |
565 |
566 |
Once you're done, upload your file:
567 |
568 | 577 |
578 |
579 |
580 |
581 | )} 582 | {this.state.showJudgingModal && ( 583 | this.setState({ showJudgingModal: false })} 588 | getBuzzOffset={this.getBuzzOffset} 589 | /> 590 | )} 591 | {this.state.showSettingsModal && ( 592 | this.setState({ showSettingsModal: false })} 594 | onSubmit={this.saveSettings} 595 | settings={this.state.settings} 596 | /> 597 | )} 598 | {this.state.overlayMsg && } 599 |
607 | { 608 | 609 | { 610 |
611 |
617 | {this.state.isIntroPlaying &&
} 618 | {categories.map((cat, i) => ( 619 |
620 | {this.state.categoryMask[i] ? cat : ''} 621 |
622 | ))} 623 | {Array.from(Array(5)).map((_, i) => { 624 | return ( 625 | 626 | {categories.map((cat, j) => { 627 | const id = `${j + 1}_${i + 1}`; 628 | const clue = game?.board[id]; 629 | return ( 630 |
this.pickQ(id) : undefined 635 | } 636 | className={`${clue ? 'value' : ''} box`} 637 | > 638 | {!this.state.clueMask[id] && clue 639 | ? clue.value 640 | : ''} 641 |
642 | ); 643 | })} 644 |
645 | ); 646 | })} 647 | {game && Boolean(game.currentQ) && ( 648 |
652 |
660 |
661 | {game.board[game.currentQ] && 662 | game.board[game.currentQ].category} 663 |
664 |
665 | {Boolean(game.currentValue) && game.currentValue} 666 |
667 | { 668 |
669 | ( 682 | {alt} 691 | ), 692 | }} 693 | > 694 | {game.board[game.currentQ] && 695 | game.board[game.currentQ].question} 696 | 697 |
698 | } 699 |
700 | {!game.currentAnswer && 701 | this.socket && 702 | !game.buzzes[this.socket.id!] && 703 | !game.submitted[this.socket.id!] && 704 | !game.currentDailyDouble && 705 | game.round !== 'final' ? ( 706 |
707 | 720 |
728 | 744 |
745 |
746 | ) : null} 747 | {!game.currentAnswer && 748 | !this.state.localAnswerSubmitted && 749 | this.socket && 750 | game.buzzes[this.socket.id!] && 751 | game.questionEndTS ? ( 752 | 757 | this.setState({ localAnswer: e.target.value }) 758 | } 759 | onKeyPress={(e: any) => 760 | e.key === 'Enter' && this.submitAnswer() 761 | } 762 | icon={ 763 | this.submitAnswer()} 765 | name="arrow right" 766 | inverted 767 | circular 768 | link 769 | /> 770 | } 771 | /> 772 | ) : null} 773 | {game.waitingForWager && 774 | this.socket && 775 | game.waitingForWager[this.socket.id!] ? ( 776 | 790 | this.setState({ localWager: e.target.value }) 791 | } 792 | onKeyPress={(e: any) => 793 | e.key === 'Enter' && this.submitWager() 794 | } 795 | icon={ 796 | this.submitWager()} 798 | name="arrow right" 799 | inverted 800 | circular 801 | link 802 | /> 803 | } 804 | /> 805 | ) : null} 806 |
807 |
808 | {game.currentAnswer} 809 |
810 | {Boolean(game.playClueEndTS) && ( 811 | 812 | )} 813 | {Boolean(game.questionEndTS) && ( 814 | 819 | )} 820 | {Boolean(game.wagerEndTS) && ( 821 | 825 | )} 826 | {game.canNextQ && ( 827 |
834 | 842 |
843 | )} 844 | {game.currentAnswer && canJudge && ( 845 |
852 | 862 |
863 | )} 864 |
865 |
866 | )} 867 | {game && game.round === 'end' && ( 868 |
869 |

Winner!

870 |
871 | {this.getWinners().map((winnerId: string) => ( 872 | p.id === winnerId) 878 | ?.name ?? '', 879 | getColorHex(winnerId), 880 | )} 881 | /> 882 | ))} 883 |
884 |
885 | )} 886 |
887 |
888 | } 889 |
892 | {participants.map((p) => { 893 | return ( 894 |
895 |
896 | 903 |
917 |
929 | {p.name || p.id} 930 |
931 |
932 | {game && p.id in game.wagers ? ( 933 |
940 | 943 |
944 | ) : null} 945 | {game && ( 946 |
947 | {!game.picker || game.picker === p.id ? ( 948 | 952 | ) : null} 953 | {game.host && game.host === p.id ? ( 954 | 955 | ) : null} 956 | {!p.connected ? ( 957 | 962 | ) : null} 963 |
964 | )} 965 | {game && 966 | p.id === game.currentJudgeAnswer && 967 | canJudge ? ( 968 |
969 | this.judgeAnswer(p.id, true)} 974 | color="green" 975 | size="tiny" 976 | icon 977 | fluid 978 | > 979 | 980 | 981 | } 982 | /> 983 | this.judgeAnswer(p.id, false)} 988 | color="red" 989 | size="tiny" 990 | icon 991 | fluid 992 | > 993 | 994 | 995 | } 996 | /> 997 | this.judgeAnswer(p.id, null)} 1002 | color="grey" 1003 | size="tiny" 1004 | icon 1005 | fluid 1006 | > 1007 | 1008 | 1009 | } 1010 | /> 1011 |
1012 | ) : null} 1013 |
1014 |
1019 | {(game?.scores[p.id] || 0).toLocaleString()} 1020 |
1021 |
1026 | {game && game.answers[p.id]} 1027 |
1028 | {this.getBuzzOffset(p.id) && 1029 | this.getBuzzOffset(p.id) > 0 1030 | ? `+${(this.getBuzzOffset(p.id) / 1000).toFixed(3)}` 1031 | : ''} 1032 |
1033 |
1034 |
1035 | ); 1036 | })} 1037 |
1038 |
1046 | 1053 | 1054 | {[ 1055 | { key: 'all', value: null, text: 'Any' }, 1056 | { key: 'kids', value: 'kids', text: 'Kids Week' }, 1057 | { key: 'teen', value: 'teen', text: 'Teen Tournament' }, 1058 | { 1059 | key: 'college', 1060 | value: 'college', 1061 | text: 'College Championship', 1062 | }, 1063 | { 1064 | key: 'celebrity', 1065 | value: 'celebrity', 1066 | text: 'Celebrity Jeopardy', 1067 | }, 1068 | { 1069 | key: 'teacher', 1070 | value: 'teacher', 1071 | text: 'Teachers Tournament', 1072 | }, 1073 | { 1074 | key: 'champions', 1075 | value: 'champions', 1076 | text: 'Tournament of Champions', 1077 | }, 1078 | { 1079 | key: 'custom', 1080 | value: 'custom', 1081 | text: 'Custom Game', 1082 | }, 1083 | ].map((item) => ( 1084 | { 1087 | if (item.value === 'custom') { 1088 | this.setState({ showCustomModal: true }); 1089 | } else { 1090 | this.newGame({ filter: item.value ?? undefined }); 1091 | } 1092 | }} 1093 | > 1094 | {item.text} 1095 | 1096 | ))} 1097 | 1098 | 1099 | 1105 | this.setState({ localEpNum: data.value }) 1106 | } 1107 | onKeyPress={(e: any) => 1108 | e.key === 'Enter' && 1109 | this.newGame({ number: this.state.localEpNum }) 1110 | } 1111 | icon={ 1112 | 1114 | this.newGame({ number: this.state.localEpNum }) 1115 | } 1116 | name="arrow right" 1117 | inverted 1118 | circular 1119 | /> 1120 | } 1121 | /> 1122 | {game && game.airDate && ( 1123 | 1139 | )} 1140 | 1162 | 1176 | 1184 | {canJudge && ( 1185 | 1193 | )} 1194 | {/* */} 1203 |
1204 | 1205 | } 1206 | {false && process.env.NODE_ENV === 'development' && ( 1207 |
1210 |               {JSON.stringify(game, null, 2)}
1211 |             
1212 | )} 1213 |
1214 | 1215 | ); 1216 | } 1217 | } 1218 | 1219 | function TimerBar({duration, secondary, submitAnswer}: { 1220 | duration: number; 1221 | secondary?: boolean; 1222 | submitAnswer?: Function; 1223 | }) { 1224 | const [width, setWidth] = useState(0); 1225 | useEffect(() => { 1226 | requestAnimationFrame(() => { 1227 | setWidth(100); 1228 | }); 1229 | let submitTimeout: number | undefined; 1230 | if (submitAnswer) { 1231 | // Submit whatever's in the box 0.5s before expected timeout 1232 | // Bit hacky, but to fix we either need to submit updates on each character 1233 | // Or have a separate step where the server instructs all clients to submit whatever is in box and accepts it 1234 | submitTimeout = window.setTimeout( 1235 | submitAnswer, 1236 | duration - 500, 1237 | ); 1238 | } 1239 | return () => { 1240 | if (submitTimeout) { 1241 | window.clearTimeout(submitTimeout); 1242 | } 1243 | } 1244 | }, []); 1245 | return ( 1246 |
1257 | ); 1258 | } 1259 | 1260 | function getWagerBounds(round: string, score: number) { 1261 | // User setting a wager for DD or final 1262 | // Can bet up to current score, minimum of 1000 in single or 2000 in double, 0 in final 1263 | let maxWager = 0; 1264 | let minWager = 5; 1265 | if (round === 'jeopardy') { 1266 | maxWager = Math.max(score || 0, 1000); 1267 | } else if (round === 'double') { 1268 | maxWager = Math.max(score || 0, 2000); 1269 | } else if (round === 'final') { 1270 | minWager = 0; 1271 | maxWager = Math.max(score || 0, 0); 1272 | } 1273 | return { minWager, maxWager }; 1274 | } 1275 | 1276 | const BulkJudgeModal = ({ 1277 | onClose, 1278 | game, 1279 | participants, 1280 | bulkJudge, 1281 | }: { 1282 | onClose: () => void; 1283 | game: PublicGameState | undefined; 1284 | participants: User[]; 1285 | bulkJudge: (judges: { id: string; correct: boolean | null }[]) => void; 1286 | getBuzzOffset: (id: string) => number; 1287 | }) => { 1288 | const [decisions, setDecisions] = useState>({}); 1289 | const distinctAnswers: string[] = Array.from( 1290 | new Set( 1291 | Object.values(game?.answers ?? {}).map( 1292 | (answer: string) => answer?.toLowerCase()?.trim(), 1293 | ), 1294 | ), 1295 | ); 1296 | return ( 1297 | 1298 | {game?.currentAnswer} 1299 | 1300 | 1301 | 1302 | Answer 1303 | Decision 1304 | Responses 1305 | 1306 | {distinctAnswers.map((answer) => { 1307 | return ( 1308 | 1309 | {answer} 1310 | 1311 | { 1320 | const newDecisions = { 1321 | ...decisions, 1322 | [answer]: data.value as string, 1323 | }; 1324 | setDecisions(newDecisions); 1325 | }} 1326 | > 1327 | 1328 | 1329 | {participants 1330 | .filter( 1331 | (p) => 1332 | game?.answers[p.id]?.toLowerCase()?.trim() === answer, 1333 | ) 1334 | .map((p) => { 1335 | return ( 1336 | 1344 | ); 1345 | })} 1346 | 1347 | 1348 | ); 1349 | })} 1350 |
1351 |
1352 | 1353 | 1373 | 1374 |
1375 | ); 1376 | }; 1377 | 1378 | const SettingsModal = ({ 1379 | onClose, 1380 | onSubmit, 1381 | settings, 1382 | }: { 1383 | onClose: () => void; 1384 | onSubmit: (settings: GameSettings) => void; 1385 | settings: GameSettings; 1386 | }) => { 1387 | const [answerTimeout, setAnswerTimeout] = useState( 1388 | settings.answerTimeout, 1389 | ); 1390 | const [finalTimeout, setFinalTimeout] = useState( 1391 | settings.finalTimeout, 1392 | ); 1393 | const [makeMeHost, setMakeMeHost] = useState( 1394 | settings.makeMeHost, 1395 | ); 1396 | const [allowMultipleCorrect, setAllowMultipleCorrect] = useState< 1397 | boolean | undefined 1398 | >(settings.allowMultipleCorrect); 1399 | const [enableAIJudge, setEnableAIJudge] = useState( 1400 | settings.enableAIJudge, 1401 | ); 1402 | return ( 1403 | 1404 | Settings 1405 | 1406 |

Settings will be applied to any games you create.

1407 |
1408 | setMakeMeHost(props.checked)} 1411 | label="Make me the host (Only you will be able to select questions and make judging decisions)" 1412 | toggle={true} 1413 | /> 1414 | setAllowMultipleCorrect(props.checked)} 1417 | label="Allow multiple correct answers (This also disables Daily Doubles and allows all players to pick the next question)" 1418 | toggle={true} 1419 | /> 1420 | setEnableAIJudge(props.checked)} 1423 | label="Enable AI judge by default" 1424 | toggle={true} 1425 | /> 1426 |
1427 | setAnswerTimeout(Number(data.value))} 1432 | size="mini" 1433 | /> 1434 | Seconds for regular answers and Daily Double wagers (Default: 20) 1435 |
1436 |
1437 | setFinalTimeout(Number(data.value))} 1442 | size="mini" 1443 | /> 1444 | Seconds for Final Jeopardy answers and wagers (Default: 30) 1445 |
1446 |
1447 |
1448 | 1449 | 1464 | 1465 |
1466 | ); 1467 | }; 1468 | 1469 | export const ErrorModal = ({ error }: { error: string }) => { 1470 | return ( 1471 | 1472 |
1473 | {error} 1474 |
1475 |
1476 | 1488 |
1489 |
1490 | ); 1491 | }; 1492 | -------------------------------------------------------------------------------- /src/components/Jeopardy/gyparody.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/gyparody.ttf -------------------------------------------------------------------------------- /src/components/Jeopardy/jeopardy-daily-double.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/jeopardy-daily-double.png -------------------------------------------------------------------------------- /src/components/Jeopardy/jeopardy-game-board-daily-double.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/jeopardy-game-board-daily-double.png -------------------------------------------------------------------------------- /src/components/Jeopardy/korinna-regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/korinna-regular.otf -------------------------------------------------------------------------------- /src/components/Jeopardy/swiss911-xcm-bt.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/swiss911-xcm-bt.ttf -------------------------------------------------------------------------------- /src/components/Jeopardy/univers-75-black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/howardchung/jeopardy/ce64b280bc5adafad56b2c2f0abede988cc0d524/src/components/Jeopardy/univers-75-black.ttf -------------------------------------------------------------------------------- /src/components/TopBar/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { serverPath } from '../../utils'; 3 | import { Icon, Popup, Button } from 'semantic-ui-react'; 4 | import '../Jeopardy/Jeopardy.css'; 5 | 6 | export function NewRoomButton({size}: { size?: string }) { 7 | const createRoom = useCallback(async () => { 8 | const response = await window.fetch(serverPath + '/createRoom', { 9 | method: 'POST', 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | body: JSON.stringify({}), 14 | }); 15 | const data = await response.json(); 16 | const { name } = data; 17 | const searchParams = new URLSearchParams(window.location.search); 18 | searchParams.set('game', name); 19 | window.location.search = searchParams.toString(); 20 | }, []); 21 | return ( 22 | 34 | 35 | New Room 36 | 37 | } 38 | /> 39 | ); 40 | } 41 | 42 | export function JeopardyTopBar({ hideNewRoom }: { hideNewRoom?: boolean }) { 43 | return ( 44 | 45 |
53 | 54 |
68 | J! 69 |
70 |
76 |
Jeopardy!
77 |
78 |
79 |
86 | 93 | 94 | 95 |
96 |
103 | {!hideNewRoom && } 104 |
105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import 'semantic-ui-css/semantic.min.css'; 2 | 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import App from './components/App'; 6 | import { JeopardyHome } from './components/Home/Home'; 7 | import React from 'react'; 8 | 9 | const urlParams = new URLSearchParams(window.location.search); 10 | const gameId = urlParams.get('game'); 11 | const isHome = !Boolean(gameId); 12 | const container = document.getElementById('root'); 13 | const root = createRoot(container); 14 | root.render(isHome ? : ); 15 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "noFallthroughCasesInSwitch": true, 18 | "types": ["vite/client", "node"] 19 | }, 20 | "include": ["./**/*", "../global.d.ts"] 21 | } 22 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { cyrb53 } from '../server/hash'; 2 | 3 | export function formatTimestamp(input: any) { 4 | if ( 5 | input === null || 6 | input === undefined || 7 | input === false || 8 | Number.isNaN(input) || 9 | input === Infinity 10 | ) { 11 | return ''; 12 | } 13 | let minutes = Math.floor(Number(input) / 60); 14 | let seconds = Math.floor(Number(input) % 60) 15 | .toString() 16 | .padStart(2, '0'); 17 | return `${minutes}:${seconds}`; 18 | } 19 | 20 | let colorCache: Record = {}; 21 | export function getColor(id: string) { 22 | let colors = [ 23 | 'red', 24 | 'orange', 25 | 'yellow', 26 | 'olive', 27 | 'green', 28 | 'teal', 29 | 'blue', 30 | 'violet', 31 | 'purple', 32 | 'pink', 33 | 'brown', 34 | 'grey', 35 | ]; 36 | if (colorCache[id]) { 37 | return colors[colorCache[id]]; 38 | } 39 | colorCache[id] = Math.abs(cyrb53(id)) % colors.length; 40 | return colors[colorCache[id]]; 41 | } 42 | 43 | export function getColorHex(id: string) { 44 | let mappings: Record = { 45 | red: 'B03060', 46 | orange: 'FE9A76', 47 | yellow: 'FFD700', 48 | olive: '32CD32', 49 | green: '016936', 50 | teal: '008080', 51 | blue: '0E6EB8', 52 | violet: 'EE82EE', 53 | purple: 'B413EC', 54 | pink: 'FF1493', 55 | brown: 'A52A2A', 56 | grey: 'A0A0A0', 57 | black: '000000', 58 | }; 59 | return mappings[getColor(id)]; 60 | } 61 | 62 | export function decodeEntities(input: string) { 63 | const doc = new DOMParser().parseFromString(input, 'text/html'); 64 | return doc.documentElement.textContent; 65 | } 66 | 67 | export const getDefaultPicture = (name: string, background = 'a0a0a0') => { 68 | return `https://ui-avatars.com/api/?name=${name}&background=${background}&size=256&color=ffffff`; 69 | }; 70 | 71 | export const isMobile = () => { 72 | return window.screen.width <= 600; 73 | }; 74 | 75 | export function shuffle(array: any[]) { 76 | for (let i = array.length - 1; i > 0; i--) { 77 | const j = Math.floor(Math.random() * i); 78 | const temp = array[i]; 79 | array[i] = array[j]; 80 | array[j] = temp; 81 | } 82 | } 83 | 84 | export const serverPath = 85 | import.meta.env.VITE_SERVER_HOST || 86 | `${window.location.protocol}//${window.location.hostname}${ 87 | process.env.NODE_ENV === 'production' ? '' : ':8083' 88 | }`; 89 | 90 | export async function generateName(): Promise { 91 | const response = await fetch(serverPath + '/generateName'); 92 | return response.text(); 93 | } 94 | 95 | export function getOrCreateClientId() { 96 | let clientId = window.localStorage.getItem('jeopardy-clientid'); 97 | if (!clientId) { 98 | // Generate a new clientID and save it 99 | clientId = crypto.randomUUID ? crypto.randomUUID() : uuidv4(); 100 | window.localStorage.setItem('jeopardy-clientid', clientId); 101 | } 102 | return clientId; 103 | } 104 | 105 | function uuidv4() { 106 | return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => 107 | ( 108 | +c ^ 109 | (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (+c / 4))) 110 | ).toString(16), 111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | build: { 3 | outDir: 'build', 4 | }, 5 | server: { 6 | https: process.env.SSL_CRT_FILE 7 | ? { 8 | key: fs.readFileSync(process.env.SSL_KEY_FILE), 9 | cert: fs.readFileSync(process.env.SSL_CRT_FILE), 10 | } 11 | : null, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /words/adjectives.txt: -------------------------------------------------------------------------------- 1 | aback 2 | abaft 3 | abandoned 4 | abashed 5 | aberrant 6 | abhorrent 7 | abiding 8 | abject 9 | ablaze 10 | able 11 | abnormal 12 | aboard 13 | aboriginal 14 | abortive 15 | abounding 16 | abrasive 17 | abrupt 18 | absent 19 | absorbed 20 | absorbing 21 | abstracted 22 | absurd 23 | abundant 24 | abusive 25 | acceptable 26 | accessible 27 | accidental 28 | accurate 29 | acid 30 | acidic 31 | acoustic 32 | acrid 33 | actually 34 | ad hoc 35 | adamant 36 | adaptable 37 | addicted 38 | adhesive 39 | adjoining 40 | adorable 41 | adventurous 42 | afraid 43 | aggressive 44 | agonizing 45 | agreeable 46 | ahead 47 | ajar 48 | alcoholic 49 | alert 50 | alike 51 | alive 52 | alleged 53 | alluring 54 | aloof 55 | amazing 56 | ambiguous 57 | ambitious 58 | amuck 59 | amused 60 | amusing 61 | ancient 62 | angry 63 | animated 64 | annoyed 65 | annoying 66 | anxious 67 | apathetic 68 | aquatic 69 | aromatic 70 | arrogant 71 | ashamed 72 | aspiring 73 | assorted 74 | astonishing 75 | attractive 76 | auspicious 77 | automatic 78 | available 79 | average 80 | awake 81 | aware 82 | awesome 83 | awful 84 | axiomatic 85 | bad 86 | barbarous 87 | bashful 88 | bawdy 89 | beautiful 90 | befitting 91 | belligerent 92 | beneficial 93 | bent 94 | berserk 95 | best 96 | better 97 | bewildered 98 | big 99 | billowy 100 | bite-sized 101 | bitter 102 | bizarre 103 | black 104 | black-and-white 105 | bloody 106 | blue 107 | blue-eyed 108 | blushing 109 | boiling 110 | boorish 111 | bored 112 | boring 113 | bouncy 114 | boundless 115 | brainy 116 | brash 117 | brave 118 | brawny 119 | breakable 120 | breezy 121 | brief 122 | bright 123 | bright 124 | broad 125 | broken 126 | brown 127 | bumpy 128 | burly 129 | bustling 130 | busy 131 | cagey 132 | calculating 133 | callous 134 | calm 135 | capable 136 | capricious 137 | careful 138 | careless 139 | caring 140 | cautious 141 | ceaseless 142 | certain 143 | changeable 144 | charming 145 | cheap 146 | cheerful 147 | chemical 148 | chief 149 | childlike 150 | chilly 151 | chivalrous 152 | chubby 153 | chunky 154 | clammy 155 | classy 156 | clean 157 | clear 158 | clever 159 | cloistered 160 | cloudy 161 | closed 162 | clumsy 163 | cluttered 164 | coherent 165 | cold 166 | colorful 167 | colossal 168 | combative 169 | comfortable 170 | common 171 | complete 172 | complex 173 | concerned 174 | condemned 175 | confused 176 | conscious 177 | cooing 178 | cool 179 | cooperative 180 | coordinated 181 | courageous 182 | cowardly 183 | crabby 184 | craven 185 | crazy 186 | creepy 187 | crooked 188 | crowded 189 | cruel 190 | cuddly 191 | cultured 192 | cumbersome 193 | curious 194 | curly 195 | curved 196 | curvy 197 | cut 198 | cute 199 | cute 200 | cynical 201 | daffy 202 | daily 203 | damaged 204 | damaging 205 | damp 206 | dangerous 207 | dapper 208 | dark 209 | dashing 210 | dazzling 211 | dead 212 | deadpan 213 | deafening 214 | dear 215 | debonair 216 | decisive 217 | decorous 218 | deep 219 | deeply 220 | defeated 221 | defective 222 | defiant 223 | delicate 224 | delicious 225 | delightful 226 | demonic 227 | delirious 228 | dependent 229 | depressed 230 | deranged 231 | descriptive 232 | deserted 233 | detailed 234 | determined 235 | devilish 236 | didactic 237 | different 238 | difficult 239 | diligent 240 | direful 241 | dirty 242 | disagreeable 243 | disastrous 244 | discreet 245 | disgusted 246 | disgusting 247 | disillusioned 248 | dispensable 249 | distinct 250 | disturbed 251 | divergent 252 | dizzy 253 | domineering 254 | doubtful 255 | drab 256 | draconian 257 | dramatic 258 | dreary 259 | drunk 260 | dry 261 | dull 262 | dusty 263 | dusty 264 | dynamic 265 | dysfunctional 266 | eager 267 | early 268 | earsplitting 269 | earthy 270 | easy 271 | eatable 272 | economic 273 | educated 274 | efficacious 275 | efficient 276 | eight 277 | elastic 278 | elated 279 | elderly 280 | electric 281 | elegant 282 | elfin 283 | elite 284 | embarrassed 285 | eminent 286 | empty 287 | enchanted 288 | enchanting 289 | encouraging 290 | endurable 291 | energetic 292 | enormous 293 | entertaining 294 | enthusiastic 295 | envious 296 | equable 297 | equal 298 | erect 299 | erratic 300 | ethereal 301 | evanescent 302 | evasive 303 | even 304 | excellent 305 | excited 306 | exciting 307 | exclusive 308 | exotic 309 | expensive 310 | extra-large 311 | extra-small 312 | exuberant 313 | exultant 314 | fabulous 315 | faded 316 | faint 317 | fair 318 | faithful 319 | fallacious 320 | false 321 | familiar 322 | famous 323 | fanatical 324 | fancy 325 | fantastic 326 | far 327 | far-flung 328 | fascinated 329 | fast 330 | fat 331 | faulty 332 | fearful 333 | fearless 334 | feeble 335 | feigned 336 | female 337 | fertile 338 | festive 339 | few 340 | fierce 341 | filthy 342 | fine 343 | finicky 344 | first 345 | five 346 | fixed 347 | flagrant 348 | flaky 349 | flashy 350 | flat 351 | flawless 352 | flimsy 353 | flippant 354 | flowery 355 | fluffy 356 | fluttering 357 | foamy 358 | foolish 359 | foregoing 360 | forgetful 361 | fortunate 362 | four 363 | frail 364 | fragile 365 | frantic 366 | free 367 | freezing 368 | frequent 369 | fresh 370 | fretful 371 | friendly 372 | frightened 373 | frightening 374 | full 375 | fumbling 376 | functional 377 | funny 378 | furry 379 | furtive 380 | future 381 | futuristic 382 | fuzzy 383 | gabby 384 | gainful 385 | gamy 386 | gaping 387 | garrulous 388 | gaudy 389 | general 390 | gentle 391 | giant 392 | giddy 393 | gifted 394 | gigantic 395 | glamorous 396 | gleaming 397 | glib 398 | glistening 399 | glorious 400 | glossy 401 | godly 402 | good 403 | goofy 404 | gorgeous 405 | graceful 406 | grandiose 407 | grateful 408 | gratis 409 | gray 410 | greasy 411 | great 412 | greedy 413 | green 414 | grey 415 | grieving 416 | groovy 417 | grotesque 418 | grouchy 419 | grubby 420 | gruesome 421 | grumpy 422 | guarded 423 | guiltless 424 | gullible 425 | gusty 426 | guttural 427 | habitual 428 | half 429 | hallowed 430 | halting 431 | handsome 432 | handsomely 433 | handy 434 | hanging 435 | hapless 436 | happy 437 | hard 438 | hard-to-find 439 | harmonious 440 | harsh 441 | hateful 442 | heady 443 | healthy 444 | heartbreaking 445 | heavenly 446 | heavy 447 | hellish 448 | helpful 449 | helpless 450 | hesitant 451 | hideous 452 | high 453 | highfalutin 454 | high-pitched 455 | hilarious 456 | hissing 457 | historical 458 | holistic 459 | hollow 460 | homeless 461 | homely 462 | honorable 463 | horrible 464 | hospitable 465 | hot 466 | huge 467 | hulking 468 | humdrum 469 | humorous 470 | hungry 471 | hurried 472 | hurt 473 | hushed 474 | husky 475 | hypnotic 476 | hysterical 477 | icky 478 | icy 479 | idiotic 480 | ignorant 481 | ill 482 | illegal 483 | ill-fated 484 | ill-informed 485 | illustrious 486 | imaginary 487 | immense 488 | imminent 489 | impartial 490 | imperfect 491 | impolite 492 | important 493 | imported 494 | impossible 495 | incandescent 496 | incompetent 497 | inconclusive 498 | industrious 499 | incredible 500 | inexpensive 501 | infamous 502 | innate 503 | innocent 504 | inquisitive 505 | insidious 506 | instinctive 507 | intelligent 508 | interesting 509 | internal 510 | invincible 511 | irate 512 | irritating 513 | itchy 514 | jaded 515 | jagged 516 | jazzy 517 | jealous 518 | jittery 519 | jobless 520 | jolly 521 | joyous 522 | judicious 523 | juicy 524 | jumbled 525 | jumpy 526 | juvenile 527 | kaput 528 | keen 529 | kind 530 | kindhearted 531 | kindly 532 | knotty 533 | knowing 534 | knowledgeable 535 | known 536 | labored 537 | lackadaisical 538 | lacking 539 | lame 540 | lamentable 541 | languid 542 | large 543 | last 544 | late 545 | laughable 546 | lavish 547 | lazy 548 | lean 549 | learned 550 | left 551 | legal 552 | lethal 553 | level 554 | lewd 555 | light 556 | like 557 | likeable 558 | limping 559 | literate 560 | little 561 | lively 562 | lively 563 | living 564 | lonely 565 | long 566 | longing 567 | long-term 568 | loose 569 | lopsided 570 | loud 571 | loutish 572 | lovely 573 | loving 574 | low 575 | lowly 576 | lucky 577 | ludicrous 578 | lumpy 579 | lush 580 | luxuriant 581 | lying 582 | lyrical 583 | macabre 584 | macho 585 | maddening 586 | madly 587 | magenta 588 | magical 589 | magnificent 590 | majestic 591 | makeshift 592 | male 593 | malicious 594 | mammoth 595 | maniacal 596 | many 597 | marked 598 | massive 599 | married 600 | marvelous 601 | material 602 | materialistic 603 | mature 604 | mean 605 | measly 606 | meaty 607 | medical 608 | meek 609 | mellow 610 | melodic 611 | melted 612 | merciful 613 | mere 614 | messy 615 | mighty 616 | military 617 | milky 618 | mindless 619 | miniature 620 | minor 621 | miscreant 622 | misty 623 | mixed 624 | moaning 625 | modern 626 | moldy 627 | momentous 628 | motionless 629 | mountainous 630 | muddled 631 | mundane 632 | murky 633 | mushy 634 | mute 635 | mysterious 636 | naive 637 | nappy 638 | narrow 639 | nasty 640 | natural 641 | naughty 642 | nauseating 643 | near 644 | neat 645 | nebulous 646 | necessary 647 | needless 648 | needy 649 | neighborly 650 | nervous 651 | new 652 | next 653 | nice 654 | nifty 655 | nimble 656 | nine 657 | nippy 658 | noiseless 659 | noisy 660 | nonchalant 661 | nondescript 662 | nonstop 663 | normal 664 | nostalgic 665 | nosy 666 | noxious 667 | null 668 | numberless 669 | numerous 670 | nutritious 671 | nutty 672 | oafish 673 | obedient 674 | obeisant 675 | obese 676 | obnoxious 677 | obscene 678 | obsequious 679 | observant 680 | obsolete 681 | obtainable 682 | oceanic 683 | odd 684 | offbeat 685 | old 686 | old-fashioned 687 | omniscient 688 | one 689 | onerous 690 | open 691 | opposite 692 | optimal 693 | orange 694 | ordinary 695 | organic 696 | ossified 697 | outgoing 698 | outrageous 699 | outstanding 700 | oval 701 | overconfident 702 | overjoyed 703 | overrated 704 | overt 705 | overwrought 706 | painful 707 | painstaking 708 | pale 709 | paltry 710 | panicky 711 | panoramic 712 | parallel 713 | parched 714 | parsimonious 715 | past 716 | pastoral 717 | pathetic 718 | peaceful 719 | penitent 720 | perfect 721 | periodic 722 | permissible 723 | perpetual 724 | petite 725 | petite 726 | phobic 727 | physical 728 | picayune 729 | pink 730 | piquant 731 | placid 732 | plain 733 | plant 734 | plastic 735 | plausible 736 | pleasant 737 | plucky 738 | pointless 739 | poised 740 | polite 741 | political 742 | poor 743 | possessive 744 | possible 745 | powerful 746 | precious 747 | premium 748 | present 749 | pretty 750 | previous 751 | pricey 752 | prickly 753 | private 754 | probable 755 | productive 756 | profuse 757 | protective 758 | proud 759 | psychedelic 760 | psychotic 761 | public 762 | puffy 763 | pumped 764 | puny 765 | purple 766 | purring 767 | pushy 768 | puzzled 769 | puzzling 770 | quack 771 | quaint 772 | quarrelsome 773 | questionable 774 | quick 775 | quickest 776 | quiet 777 | quirky 778 | quixotic 779 | quizzical 780 | rabid 781 | racial 782 | ragged 783 | rainy 784 | rambunctious 785 | rampant 786 | rapid 787 | rare 788 | raspy 789 | ratty 790 | ready 791 | real 792 | rebel 793 | receptive 794 | recondite 795 | red 796 | redundant 797 | reflective 798 | regular 799 | relieved 800 | remarkable 801 | reminiscent 802 | repulsive 803 | resolute 804 | resonant 805 | responsible 806 | rhetorical 807 | rich 808 | right 809 | righteous 810 | rightful 811 | rigid 812 | ripe 813 | ritzy 814 | roasted 815 | robust 816 | romantic 817 | roomy 818 | rotten 819 | rough 820 | round 821 | royal 822 | ruddy 823 | rude 824 | rural 825 | rustic 826 | ruthless 827 | sable 828 | sad 829 | safe 830 | salty 831 | same 832 | sassy 833 | satisfying 834 | savory 835 | scandalous 836 | scarce 837 | scared 838 | scary 839 | scattered 840 | scientific 841 | scintillating 842 | scrawny 843 | screeching 844 | second 845 | second-hand 846 | secret 847 | secretive 848 | sedate 849 | seemly 850 | selective 851 | selfish 852 | separate 853 | serious 854 | shaggy 855 | shaky 856 | shallow 857 | sharp 858 | shiny 859 | shivering 860 | shocking 861 | short 862 | shrill 863 | shut 864 | shy 865 | sick 866 | silent 867 | silent 868 | silky 869 | silly 870 | simple 871 | simplistic 872 | sincere 873 | six 874 | skillful 875 | skinny 876 | sleepy 877 | slim 878 | slimy 879 | slippery 880 | sloppy 881 | slow 882 | small 883 | smart 884 | smelly 885 | smiling 886 | smoggy 887 | smooth 888 | sneaky 889 | snobbish 890 | snotty 891 | soft 892 | soggy 893 | solid 894 | somber 895 | sophisticated 896 | sordid 897 | sore 898 | sore 899 | sour 900 | sparkling 901 | special 902 | spectacular 903 | spicy 904 | spiffy 905 | spiky 906 | spiritual 907 | spiteful 908 | splendid 909 | spooky 910 | spotless 911 | spotted 912 | spotty 913 | spurious 914 | squalid 915 | square 916 | squealing 917 | squeamish 918 | staking 919 | stale 920 | standing 921 | statuesque 922 | steadfast 923 | steady 924 | steep 925 | stereotyped 926 | sticky 927 | stiff 928 | stimulating 929 | stingy 930 | stormy 931 | straight 932 | strange 933 | striped 934 | strong 935 | stupendous 936 | stupid 937 | sturdy 938 | subdued 939 | subsequent 940 | substantial 941 | successful 942 | succinct 943 | sudden 944 | sulky 945 | super 946 | superb 947 | superficial 948 | supreme 949 | swanky 950 | sweet 951 | sweltering 952 | swift 953 | symptomatic 954 | synonymous 955 | taboo 956 | tacit 957 | tacky 958 | talented 959 | tall 960 | tame 961 | tan 962 | tangible 963 | tangy 964 | tart 965 | tasteful 966 | tasteless 967 | tasty 968 | tawdry 969 | tearful 970 | tedious 971 | teeny 972 | teeny-tiny 973 | telling 974 | temporary 975 | ten 976 | tender 977 | tense 978 | tense 979 | tenuous 980 | terrible 981 | terrific 982 | tested 983 | testy 984 | thankful 985 | therapeutic 986 | thick 987 | thin 988 | thinkable 989 | third 990 | thirsty 991 | thirsty 992 | thoughtful 993 | thoughtless 994 | threatening 995 | three 996 | thundering 997 | tidy 998 | tight 999 | tightfisted 1000 | tiny 1001 | tired 1002 | tiresome 1003 | toothsome 1004 | torpid 1005 | tough 1006 | towering 1007 | tranquil 1008 | trashy 1009 | tremendous 1010 | tricky 1011 | trite 1012 | troubled 1013 | truculent 1014 | true 1015 | truthful 1016 | two 1017 | typical 1018 | ubiquitous 1019 | ugliest 1020 | ugly 1021 | ultra 1022 | unable 1023 | unaccountable 1024 | unadvised 1025 | unarmed 1026 | unbecoming 1027 | unbiased 1028 | uncovered 1029 | understood 1030 | undesirable 1031 | unequal 1032 | unequaled 1033 | uneven 1034 | unhealthy 1035 | uninterested 1036 | unique 1037 | unkempt 1038 | unknown 1039 | unnatural 1040 | unruly 1041 | unsightly 1042 | unsuitable 1043 | untidy 1044 | unused 1045 | unusual 1046 | unwieldy 1047 | unwritten 1048 | upbeat 1049 | uppity 1050 | upset 1051 | uptight 1052 | used 1053 | useful 1054 | useless 1055 | utopian 1056 | utter 1057 | uttermost 1058 | vacuous 1059 | vagabond 1060 | vague 1061 | valuable 1062 | various 1063 | vast 1064 | vengeful 1065 | venomous 1066 | verdant 1067 | versed 1068 | victorious 1069 | vigorous 1070 | violent 1071 | violet 1072 | vivacious 1073 | voiceless 1074 | volatile 1075 | voracious 1076 | vulgar 1077 | wacky 1078 | waggish 1079 | waiting 1080 | wakeful 1081 | wandering 1082 | wanting 1083 | warlike 1084 | warm 1085 | wary 1086 | wasteful 1087 | watery 1088 | weak 1089 | wealthy 1090 | weary 1091 | well-groomed 1092 | well-made 1093 | well-off 1094 | well-to-do 1095 | wet 1096 | whimsical 1097 | whispering 1098 | white 1099 | whole 1100 | wholesale 1101 | wicked 1102 | wide 1103 | wide-eyed 1104 | wiggly 1105 | wild 1106 | willing 1107 | windy 1108 | wiry 1109 | wise 1110 | wistful 1111 | witty 1112 | woebegone 1113 | womanly 1114 | wonderful 1115 | wooden 1116 | woozy 1117 | workable 1118 | worried 1119 | worthless 1120 | wrathful 1121 | wretched 1122 | wrong 1123 | wry 1124 | yellow 1125 | yielding 1126 | young 1127 | youthful 1128 | yummy 1129 | zany 1130 | zealous 1131 | zesty 1132 | zippy 1133 | zonked -------------------------------------------------------------------------------- /words/nouns.txt: -------------------------------------------------------------------------------- 1 | able 2 | able 3 | account 4 | achieve 5 | acoustics 6 | act 7 | action 8 | activity 9 | actor 10 | addition 11 | adjustment 12 | advertisement 13 | advice 14 | aftermath 15 | afternoon 16 | afterthought 17 | agreement 18 | air 19 | airplane 20 | airport 21 | alarm 22 | amount 23 | amusement 24 | anger 25 | angle 26 | animal 27 | answer 28 | ant 29 | ants 30 | apparatus 31 | apparel 32 | apple 33 | apples 34 | appliance 35 | approval 36 | arch 37 | argument 38 | arithmetic 39 | arm 40 | army 41 | art 42 | attack 43 | attempt 44 | attention 45 | attraction 46 | aunt 47 | authority 48 | babies 49 | baby 50 | back 51 | badge 52 | bag 53 | bait 54 | balance 55 | ball 56 | balloon 57 | balls 58 | banana 59 | band 60 | base 61 | baseball 62 | basin 63 | basket 64 | basketball 65 | bat 66 | bath 67 | battle 68 | bead 69 | beam 70 | bean 71 | bear 72 | bears 73 | beast 74 | bed 75 | bedroom 76 | beds 77 | bee 78 | beef 79 | beetle 80 | beggar 81 | beginner 82 | behavior 83 | belief 84 | believe 85 | bell 86 | bells 87 | berry 88 | bike 89 | bikes 90 | bird 91 | birds 92 | birth 93 | birthday 94 | bit 95 | bite 96 | blade 97 | blood 98 | blow 99 | board 100 | boat 101 | boats 102 | body 103 | bomb 104 | bone 105 | book 106 | books 107 | boot 108 | border 109 | bottle 110 | boundary 111 | box 112 | boy 113 | boys 114 | brain 115 | brake 116 | branch 117 | brass 118 | bread 119 | breakfast 120 | breath 121 | brick 122 | bridge 123 | brother 124 | brothers 125 | brush 126 | bubble 127 | bucket 128 | building 129 | bulb 130 | bun 131 | burn 132 | burst 133 | bushes 134 | business 135 | butter 136 | button 137 | cabbage 138 | cable 139 | cactus 140 | cake 141 | cakes 142 | calculator 143 | calendar 144 | camera 145 | camp 146 | can 147 | cannon 148 | canvas 149 | cap 150 | caption 151 | car 152 | card 153 | care 154 | carpenter 155 | carriage 156 | cars 157 | cart 158 | cast 159 | cat 160 | cats 161 | cattle 162 | cause 163 | cave 164 | celery 165 | cellar 166 | cemetery 167 | cent 168 | chain 169 | chair 170 | chairs 171 | chalk 172 | chance 173 | change 174 | channel 175 | cheese 176 | cherries 177 | cherry 178 | chess 179 | chicken 180 | chickens 181 | children 182 | chin 183 | church 184 | circle 185 | clam 186 | class 187 | clock 188 | clocks 189 | cloth 190 | cloud 191 | clouds 192 | clover 193 | club 194 | coach 195 | coal 196 | coast 197 | coat 198 | cobweb 199 | coil 200 | collar 201 | color 202 | comb 203 | comfort 204 | committee 205 | company 206 | comparison 207 | competition 208 | condition 209 | connection 210 | control 211 | cook 212 | copper 213 | copy 214 | cord 215 | cork 216 | corn 217 | cough 218 | country 219 | cover 220 | cow 221 | cows 222 | crack 223 | cracker 224 | crate 225 | crayon 226 | cream 227 | creator 228 | creature 229 | credit 230 | crib 231 | crime 232 | crook 233 | crow 234 | crowd 235 | crown 236 | crush 237 | cry 238 | cub 239 | cup 240 | current 241 | curtain 242 | curve 243 | cushion 244 | dad 245 | daughter 246 | day 247 | death 248 | debt 249 | decision 250 | deer 251 | degree 252 | design 253 | desire 254 | desk 255 | destruction 256 | detail 257 | development 258 | digestion 259 | dime 260 | dinner 261 | dinosaurs 262 | direction 263 | dirt 264 | discovery 265 | discussion 266 | disease 267 | disgust 268 | distance 269 | distribution 270 | division 271 | dock 272 | doctor 273 | dog 274 | dogs 275 | doll 276 | dolls 277 | donkey 278 | door 279 | downtown 280 | drain 281 | drawer 282 | dress 283 | drink 284 | driving 285 | drop 286 | drug 287 | drum 288 | duck 289 | ducks 290 | dust 291 | ear 292 | earth 293 | earthquake 294 | edge 295 | education 296 | effect 297 | egg 298 | eggnog 299 | eggs 300 | elbow 301 | end 302 | engine 303 | error 304 | event 305 | example 306 | exchange 307 | existence 308 | expansion 309 | experience 310 | expert 311 | eye 312 | eyes 313 | face 314 | fact 315 | fairies 316 | fall 317 | family 318 | fan 319 | fang 320 | farm 321 | farmer 322 | father 323 | father 324 | faucet 325 | fear 326 | feast 327 | feather 328 | feeling 329 | feet 330 | fiction 331 | field 332 | fifth 333 | fight 334 | finger 335 | finger 336 | fire 337 | fireman 338 | fish 339 | flag 340 | flame 341 | flavor 342 | flesh 343 | flight 344 | flock 345 | floor 346 | flower 347 | flowers 348 | fly 349 | fog 350 | fold 351 | food 352 | foot 353 | force 354 | fork 355 | form 356 | fowl 357 | frame 358 | friction 359 | friend 360 | friends 361 | frog 362 | frogs 363 | front 364 | fruit 365 | fuel 366 | furniture 367 | alley 368 | game 369 | garden 370 | gate 371 | geese 372 | ghost 373 | giants 374 | giraffe 375 | girl 376 | girls 377 | glass 378 | glove 379 | glue 380 | goat 381 | gold 382 | goldfish 383 | good-bye 384 | goose 385 | government 386 | governor 387 | grade 388 | grain 389 | grandfather 390 | grandmother 391 | grape 392 | grass 393 | grip 394 | ground 395 | group 396 | growth 397 | guide 398 | guitar 399 | gun 400 | hair 401 | haircut 402 | hall 403 | hammer 404 | hand 405 | hands 406 | harbor 407 | harmony 408 | hat 409 | hate 410 | head 411 | health 412 | hearing 413 | heart 414 | heat 415 | help 416 | hen 417 | hill 418 | history 419 | hobbies 420 | hole 421 | holiday 422 | home 423 | honey 424 | hook 425 | hope 426 | horn 427 | horse 428 | horses 429 | hose 430 | hospital 431 | hot 432 | hour 433 | house 434 | houses 435 | humor 436 | hydrant 437 | ice 438 | icicle 439 | idea 440 | impulse 441 | income 442 | increase 443 | industry 444 | ink 445 | insect 446 | instrument 447 | insurance 448 | interest 449 | invention 450 | iron 451 | island 452 | jail 453 | jam 454 | jar 455 | jeans 456 | jelly 457 | jellyfish 458 | jewel 459 | join 460 | joke 461 | journey 462 | judge 463 | juice 464 | jump 465 | kettle 466 | key 467 | kick 468 | kiss 469 | kite 470 | kitten 471 | kittens 472 | kitty 473 | knee 474 | knife 475 | knot 476 | knowledge 477 | laborer 478 | lace 479 | ladybug 480 | lake 481 | lamp 482 | land 483 | language 484 | laugh 485 | lawyer 486 | lead 487 | leaf 488 | learning 489 | leather 490 | leg 491 | legs 492 | letter 493 | letters 494 | lettuce 495 | level 496 | library 497 | lift 498 | light 499 | limit 500 | line 501 | linen 502 | lip 503 | liquid 504 | list 505 | lizards 506 | loaf 507 | lock 508 | locket 509 | look 510 | loss 511 | love 512 | low 513 | lumber 514 | lunch 515 | lunchroom 516 | machine 517 | magic 518 | maid 519 | mailbox 520 | man 521 | manager 522 | map 523 | marble 524 | mark 525 | market 526 | mask 527 | mass 528 | match 529 | meal 530 | measure 531 | meat 532 | meeting 533 | memory 534 | men 535 | metal 536 | mice 537 | middle 538 | milk 539 | mind 540 | mine 541 | minister 542 | mint 543 | minute 544 | mist 545 | mitten 546 | mom 547 | money 548 | monkey 549 | month 550 | moon 551 | morning 552 | mother 553 | motion 554 | mountain 555 | mouth 556 | move 557 | muscle 558 | music 559 | nail 560 | name 561 | nation 562 | neck 563 | need 564 | needle 565 | nerve 566 | nest 567 | net 568 | news 569 | night 570 | noise 571 | north 572 | nose 573 | note 574 | notebook 575 | number 576 | nut 577 | oatmeal 578 | observation 579 | ocean 580 | offer 581 | office 582 | oil 583 | operation 584 | opinion 585 | orange 586 | oranges 587 | order 588 | organization 589 | ornament 590 | oven 591 | owl 592 | owner 593 | page 594 | pail 595 | pain 596 | paint 597 | pan 598 | pancake 599 | paper 600 | parcel 601 | parent 602 | park 603 | part 604 | partner 605 | party 606 | passenger 607 | paste 608 | patch 609 | payment 610 | peace 611 | pear 612 | pen 613 | pencil 614 | person 615 | pest 616 | pet 617 | pets 618 | pickle 619 | picture 620 | pie 621 | pies 622 | pig 623 | pigs 624 | pin 625 | pipe 626 | pizzas 627 | place 628 | plane 629 | planes 630 | plant 631 | plantation 632 | plants 633 | plastic 634 | plate 635 | play 636 | playground 637 | pleasure 638 | plot 639 | plough 640 | pocket 641 | point 642 | poison 643 | police 644 | polish 645 | pollution 646 | popcorn 647 | porter 648 | position 649 | pot 650 | potato 651 | powder 652 | power 653 | price 654 | print 655 | prison 656 | process 657 | produce 658 | profit 659 | property 660 | prose 661 | protest 662 | pull 663 | pump 664 | punishment 665 | purpose 666 | push 667 | quarter 668 | quartz 669 | queen 670 | question 671 | quicksand 672 | quiet 673 | quill 674 | quilt 675 | quince 676 | quiver 677 | rabbit 678 | rabbits 679 | rail 680 | railway 681 | rain 682 | rainstorm 683 | rake 684 | range 685 | rat 686 | rate 687 | ray 688 | reaction 689 | reading 690 | reason 691 | receipt 692 | recess 693 | record 694 | regret 695 | relation 696 | religion 697 | representative 698 | request 699 | respect 700 | rest 701 | reward 702 | rhythm 703 | rice 704 | riddle 705 | rifle 706 | ring 707 | rings 708 | river 709 | road 710 | robin 711 | rock 712 | rod 713 | roll 714 | roof 715 | room 716 | root 717 | rose 718 | route 719 | rub 720 | rule 721 | run 722 | sack 723 | sail 724 | salt 725 | sand 726 | scale 727 | scarecrow 728 | scarf 729 | scene 730 | scent 731 | school 732 | science 733 | scissors 734 | screw 735 | sea 736 | seashore 737 | seat 738 | secretary 739 | seed 740 | selection 741 | self 742 | sense 743 | servant 744 | shade 745 | shake 746 | shame 747 | shape 748 | sheep 749 | sheet 750 | shelf 751 | ship 752 | shirt 753 | shock 754 | shoe 755 | shoes 756 | shop 757 | show 758 | side 759 | sidewalk 760 | sign 761 | silk 762 | silver 763 | sink 764 | sister 765 | sisters 766 | size 767 | skate 768 | skin 769 | skirt 770 | sky 771 | slave 772 | sleep 773 | sleet 774 | slip 775 | slope 776 | smash 777 | smell 778 | smile 779 | smoke 780 | snail 781 | snails 782 | snake 783 | snakes 784 | sneeze 785 | snow 786 | soap 787 | society 788 | sock 789 | soda 790 | sofa 791 | son 792 | song 793 | songs 794 | sort 795 | sound 796 | soup 797 | space 798 | spade 799 | spark 800 | spiders 801 | sponge 802 | spoon 803 | spot 804 | spring 805 | spy 806 | square 807 | squirrel 808 | stage 809 | stamp 810 | star 811 | start 812 | statement 813 | station 814 | steam 815 | steel 816 | stem 817 | step 818 | stew 819 | stick 820 | sticks 821 | stitch 822 | stocking 823 | stomach 824 | stone 825 | stop 826 | store 827 | story 828 | stove 829 | stranger 830 | straw 831 | stream 832 | street 833 | stretch 834 | string 835 | structure 836 | substance 837 | sugar 838 | suggestion 839 | suit 840 | summer 841 | sun 842 | support 843 | surprise 844 | sweater 845 | swim 846 | swing 847 | system 848 | table 849 | tail 850 | talk 851 | tank 852 | taste 853 | tax 854 | teaching 855 | team 856 | teeth 857 | temper 858 | tendency 859 | tent 860 | territory 861 | test 862 | texture 863 | theory 864 | thing 865 | things 866 | thought 867 | thread 868 | thrill 869 | throat 870 | throne 871 | thumb 872 | thunder 873 | ticket 874 | tiger 875 | time 876 | tin 877 | title 878 | toad 879 | toe 880 | toes 881 | tomatoes 882 | tongue 883 | tooth 884 | toothbrush 885 | toothpaste 886 | top 887 | touch 888 | town 889 | toy 890 | toys 891 | trade 892 | trail 893 | train 894 | trains 895 | tramp 896 | transport 897 | tray 898 | treatment 899 | tree 900 | trees 901 | trick 902 | trip 903 | trouble 904 | trousers 905 | truck 906 | trucks 907 | tub 908 | turkey 909 | turn 910 | twig 911 | twist 912 | umbrella 913 | uncle 914 | underwear 915 | unit 916 | use 917 | vacation 918 | value 919 | van 920 | vase 921 | vegetable 922 | veil 923 | vein 924 | verse 925 | vessel 926 | vest 927 | view 928 | visitor 929 | voice 930 | volcano 931 | volleyball 932 | voyage 933 | walk 934 | wall 935 | war 936 | wash 937 | waste 938 | watch 939 | water 940 | wave 941 | waves 942 | wax 943 | way 944 | wealth 945 | weather 946 | week 947 | weight 948 | wheel 949 | whip 950 | whistle 951 | wilderness 952 | wind 953 | window 954 | wine 955 | wing 956 | winter 957 | wire 958 | wish 959 | woman 960 | women 961 | wood 962 | wool 963 | word 964 | work 965 | worm 966 | wound 967 | wren 968 | wrench 969 | wrist 970 | writer 971 | writing 972 | yak 973 | yam 974 | yard 975 | yarn 976 | year 977 | yoke 978 | zebra 979 | zephyr 980 | zinc 981 | zipper 982 | zoo -------------------------------------------------------------------------------- /words/verbs.txt: -------------------------------------------------------------------------------- 1 | abide 2 | accelerate 3 | accept 4 | accomplish 5 | achieve 6 | acquire 7 | acted 8 | activate 9 | adapt 10 | add 11 | address 12 | administer 13 | admire 14 | admit 15 | adopt 16 | advise 17 | afford 18 | agree 19 | alert 20 | alight 21 | allow 22 | altered 23 | amuse 24 | analyze 25 | announce 26 | annoy 27 | answer 28 | anticipate 29 | apologize 30 | appear 31 | applaud 32 | applied 33 | appoint 34 | appraise 35 | appreciate 36 | approve 37 | arbitrate 38 | argue 39 | arise 40 | arrange 41 | arrest 42 | arrive 43 | ascertain 44 | ask 45 | assemble 46 | assess 47 | assist 48 | assure 49 | attach 50 | attack 51 | attain 52 | attempt 53 | attend 54 | attract 55 | audited 56 | avoid 57 | awake 58 | back 59 | bake 60 | balance 61 | ban 62 | bang 63 | bare 64 | bat 65 | bathe 66 | battle 67 | be 68 | beam 69 | bear 70 | beat 71 | become 72 | beg 73 | begin 74 | behave 75 | behold 76 | belong 77 | bend 78 | beset 79 | bet 80 | bid 81 | bind 82 | bite 83 | bleach 84 | bleed 85 | bless 86 | blind 87 | blink 88 | blot 89 | blow 90 | blush 91 | boast 92 | boil 93 | bolt 94 | bomb 95 | book 96 | bore 97 | borrow 98 | bounce 99 | bow 100 | box 101 | brake 102 | branch 103 | break 104 | breathe 105 | breed 106 | brief 107 | bring 108 | broadcast 109 | bruise 110 | brush 111 | bubble 112 | budget 113 | build 114 | bump 115 | burn 116 | burst 117 | bury 118 | bust 119 | buy 120 | buzz 121 | calculate 122 | call 123 | camp 124 | care 125 | carry 126 | carve 127 | cast 128 | catalog 129 | catch 130 | cause 131 | challenge 132 | change 133 | charge 134 | chart 135 | chase 136 | cheat 137 | check 138 | cheer 139 | chew 140 | choke 141 | choose 142 | chop 143 | claim 144 | clap 145 | clarify 146 | classify 147 | clean 148 | clear 149 | cling 150 | clip 151 | close 152 | clothe 153 | coach 154 | coil 155 | collect 156 | color 157 | comb 158 | come 159 | command 160 | communicate 161 | compare 162 | compete 163 | compile 164 | complain 165 | complete 166 | compose 167 | compute 168 | conceive 169 | concentrate 170 | conceptualize 171 | concern 172 | conclude 173 | conduct 174 | confess 175 | confront 176 | confuse 177 | connect 178 | conserve 179 | consider 180 | consist 181 | consolidate 182 | construct 183 | consult 184 | contain 185 | continue 186 | contract 187 | control 188 | convert 189 | coordinate 190 | copy 191 | correct 192 | correlate 193 | cost 194 | cough 195 | counsel 196 | count 197 | cover 198 | crack 199 | crash 200 | crawl 201 | create 202 | creep 203 | critique 204 | cross 205 | crush 206 | cry 207 | cure 208 | curl 209 | curve 210 | cut 211 | cycle 212 | dam 213 | damage 214 | dance 215 | dare 216 | deal 217 | decay 218 | deceive 219 | decide 220 | decorate 221 | define 222 | delay 223 | delegate 224 | delight 225 | deliver 226 | demonstrate 227 | depend 228 | describe 229 | desert 230 | deserve 231 | design 232 | destroy 233 | detail 234 | detect 235 | determine 236 | develop 237 | devise 238 | diagnose 239 | dig 240 | direct 241 | disagree 242 | disappear 243 | disapprove 244 | disarm 245 | discover 246 | dislike 247 | dispense 248 | display 249 | disprove 250 | dissect 251 | distribute 252 | dive 253 | divert 254 | divide 255 | do 256 | double 257 | doubt 258 | draft 259 | drag 260 | drain 261 | dramatize 262 | draw 263 | dream 264 | dress 265 | drink 266 | drip 267 | drive 268 | drop 269 | drown 270 | drum 271 | dry 272 | dust 273 | dwell 274 | earn 275 | eat 276 | edited 277 | educate 278 | eliminate 279 | embarrass 280 | employ 281 | empty 282 | enacted 283 | encourage 284 | end 285 | endure 286 | enforce 287 | engineer 288 | enhance 289 | enjoy 290 | enlist 291 | ensure 292 | enter 293 | entertain 294 | escape 295 | establish 296 | estimate 297 | evaluate 298 | examine 299 | exceed 300 | excite 301 | excuse 302 | execute 303 | exercise 304 | exhibit 305 | exist 306 | expand 307 | expect 308 | expedite 309 | experiment 310 | explain 311 | explode 312 | express 313 | extend 314 | extract 315 | face 316 | facilitate 317 | fade 318 | fail 319 | fancy 320 | fasten 321 | fax 322 | fear 323 | feed 324 | feel 325 | fence 326 | fetch 327 | fight 328 | file 329 | fill 330 | film 331 | finalize 332 | finance 333 | find 334 | fire 335 | fit 336 | fix 337 | flap 338 | flash 339 | flee 340 | fling 341 | float 342 | flood 343 | flow 344 | flower 345 | fly 346 | fold 347 | follow 348 | fool 349 | forbid 350 | force 351 | forecast 352 | forego 353 | foresee 354 | foretell 355 | forget 356 | forgive 357 | form 358 | formulate 359 | forsake 360 | frame 361 | freeze 362 | frighten 363 | fry 364 | gather 365 | gaze 366 | generate 367 | get 368 | give 369 | glow 370 | glue 371 | go 372 | govern 373 | grab 374 | graduate 375 | grate 376 | grease 377 | greet 378 | grin 379 | grind 380 | grip 381 | groan 382 | grow 383 | guarantee 384 | guard 385 | guess 386 | guide 387 | hammer 388 | hand 389 | handle 390 | handwrite 391 | hang 392 | happen 393 | harass 394 | harm 395 | hate 396 | haunt 397 | head 398 | heal 399 | heap 400 | hear 401 | heat 402 | help 403 | hide 404 | hit 405 | hold 406 | hook 407 | hop 408 | hope 409 | hover 410 | hug 411 | hum 412 | hunt 413 | hurry 414 | hurt 415 | hypothesize 416 | identify 417 | ignore 418 | illustrate 419 | imagine 420 | implement 421 | impress 422 | improve 423 | improvise 424 | include 425 | increase 426 | induce 427 | influence 428 | inform 429 | initiate 430 | inject 431 | injure 432 | inlay 433 | innovate 434 | input 435 | inspect 436 | inspire 437 | install 438 | institute 439 | instruct 440 | insure 441 | integrate 442 | intend 443 | intensify 444 | interest 445 | interfere 446 | interlay 447 | interpret 448 | interrupt 449 | interview 450 | introduce 451 | invent 452 | inventory 453 | investigate 454 | invite 455 | irritate 456 | itch 457 | jail 458 | jam 459 | jog 460 | join 461 | joke 462 | judge 463 | juggle 464 | jump 465 | justify 466 | keep 467 | kept 468 | kick 469 | kill 470 | kiss 471 | kneel 472 | knit 473 | knock 474 | knot 475 | know 476 | label 477 | land 478 | last 479 | laugh 480 | launch 481 | lay 482 | lead 483 | lean 484 | leap 485 | learn 486 | leave 487 | lecture 488 | led 489 | lend 490 | let 491 | level 492 | license 493 | lick 494 | lie 495 | lifted 496 | light 497 | lighten 498 | like 499 | list 500 | listen 501 | live 502 | load 503 | locate 504 | lock 505 | log 506 | long 507 | look 508 | lose 509 | love 510 | maintain 511 | make 512 | man 513 | manage 514 | manipulate 515 | manufacture 516 | map 517 | march 518 | mark 519 | market 520 | marry 521 | match 522 | mate 523 | matter 524 | mean 525 | measure 526 | meddle 527 | mediate 528 | meet 529 | melt 530 | melt 531 | memorize 532 | mend 533 | mentor 534 | milk 535 | mine 536 | mislead 537 | miss 538 | misspell 539 | mistake 540 | misunderstand 541 | mix 542 | moan 543 | model 544 | modify 545 | monitor 546 | moor 547 | motivate 548 | mourn 549 | move 550 | mow 551 | muddle 552 | mug 553 | multiply 554 | murder 555 | nail 556 | name 557 | navigate 558 | need 559 | negotiate 560 | nest 561 | nod 562 | nominate 563 | normalize 564 | note 565 | notice 566 | number 567 | obey 568 | object 569 | observe 570 | obtain 571 | occur 572 | offend 573 | offer 574 | officiate 575 | open 576 | operate 577 | order 578 | organize 579 | oriented 580 | originate 581 | overcome 582 | overdo 583 | overdraw 584 | overflow 585 | overhear 586 | overtake 587 | overthrow 588 | owe 589 | own 590 | pack 591 | paddle 592 | paint 593 | park 594 | part 595 | participate 596 | pass 597 | paste 598 | pat 599 | pause 600 | pay 601 | peck 602 | pedal 603 | peel 604 | peep 605 | perceive 606 | perfect 607 | perform 608 | permit 609 | persuade 610 | phone 611 | photograph 612 | pick 613 | pilot 614 | pinch 615 | pine 616 | pinpoint 617 | pioneer 618 | place 619 | plan 620 | plant 621 | play 622 | plead 623 | please 624 | plug 625 | point 626 | poke 627 | polish 628 | pop 629 | possess 630 | post 631 | pour 632 | practice 633 | praised 634 | pray 635 | preach 636 | precede 637 | predict 638 | prefer 639 | prepare 640 | prescribe 641 | present 642 | preserve 643 | preset 644 | preside 645 | press 646 | pretend 647 | prevent 648 | prick 649 | print 650 | process 651 | procure 652 | produce 653 | profess 654 | program 655 | progress 656 | project 657 | promise 658 | promote 659 | proofread 660 | propose 661 | protect 662 | prove 663 | provide 664 | publicize 665 | pull 666 | pump 667 | punch 668 | puncture 669 | punish 670 | purchase 671 | push 672 | put 673 | qualify 674 | question 675 | queue 676 | quit 677 | race 678 | radiate 679 | rain 680 | raise 681 | rank 682 | rate 683 | reach 684 | read 685 | realign 686 | realize 687 | reason 688 | receive 689 | recognize 690 | recommend 691 | reconcile 692 | record 693 | recruit 694 | reduce 695 | refer 696 | reflect 697 | refuse 698 | regret 699 | regulate 700 | rehabilitate 701 | reign 702 | reinforce 703 | reject 704 | rejoice 705 | relate 706 | relax 707 | release 708 | rely 709 | remain 710 | remember 711 | remind 712 | remove 713 | render 714 | reorganize 715 | repair 716 | repeat 717 | replace 718 | reply 719 | report 720 | represent 721 | reproduce 722 | request 723 | rescue 724 | research 725 | resolve 726 | respond 727 | restored 728 | restructure 729 | retire 730 | retrieve 731 | return 732 | review 733 | revise 734 | rhyme 735 | rid 736 | ride 737 | ring 738 | rinse 739 | rise 740 | risk 741 | rob 742 | rock 743 | roll 744 | rot 745 | rub 746 | ruin 747 | rule 748 | run 749 | rush 750 | sack 751 | sail 752 | satisfy 753 | save 754 | saw 755 | say 756 | scare 757 | scatter 758 | schedule 759 | scold 760 | scorch 761 | scrape 762 | scratch 763 | scream 764 | screw 765 | scribble 766 | scrub 767 | seal 768 | search 769 | secure 770 | see 771 | seek 772 | select 773 | sell 774 | send 775 | sense 776 | separate 777 | serve 778 | service 779 | set 780 | settle 781 | sew 782 | shade 783 | shake 784 | shape 785 | share 786 | shave 787 | shear 788 | shed 789 | shelter 790 | shine 791 | shiver 792 | shock 793 | shoe 794 | shoot 795 | shop 796 | show 797 | shrink 798 | shrug 799 | shut 800 | sigh 801 | sign 802 | signal 803 | simplify 804 | sin 805 | sing 806 | sink 807 | sip 808 | sit 809 | sketch 810 | ski 811 | skip 812 | slap 813 | slay 814 | sleep 815 | slide 816 | sling 817 | slink 818 | slip 819 | slit 820 | slow 821 | smash 822 | smell 823 | smile 824 | smite 825 | smoke 826 | snatch 827 | sneak 828 | sneeze 829 | sniff 830 | snore 831 | snow 832 | soak 833 | solve 834 | soothe 835 | soothsay 836 | sort 837 | sound 838 | sow 839 | spare 840 | spark 841 | sparkle 842 | speak 843 | specify 844 | speed 845 | spell 846 | spend 847 | spill 848 | spin 849 | spit 850 | split 851 | spoil 852 | spot 853 | spray 854 | spread 855 | spring 856 | sprout 857 | squash 858 | squeak 859 | squeal 860 | squeeze 861 | stain 862 | stamp 863 | stand 864 | stare 865 | start 866 | stay 867 | steal 868 | steer 869 | step 870 | stick 871 | stimulate 872 | sting 873 | stink 874 | stir 875 | stitch 876 | stop 877 | store 878 | strap 879 | streamline 880 | strengthen 881 | stretch 882 | stride 883 | strike 884 | string 885 | strip 886 | strive 887 | stroke 888 | structure 889 | study 890 | stuff 891 | sublet 892 | subtract 893 | succeed 894 | suck 895 | suffer 896 | suggest 897 | suit 898 | summarize 899 | supervise 900 | supply 901 | support 902 | suppose 903 | surprise 904 | surround 905 | suspect 906 | suspend 907 | swear 908 | sweat 909 | sweep 910 | swell 911 | swim 912 | swing 913 | switch 914 | symbolize 915 | synthesize 916 | systemize 917 | tabulate 918 | take 919 | talk 920 | tame 921 | tap 922 | target 923 | taste 924 | teach 925 | tear 926 | tease 927 | telephone 928 | tell 929 | tempt 930 | terrify 931 | test 932 | thank 933 | thaw 934 | think 935 | thrive 936 | throw 937 | thrust 938 | tick 939 | tickle 940 | tie 941 | time 942 | tip 943 | tire 944 | touch 945 | tour 946 | tow 947 | trace 948 | trade 949 | train 950 | transcribe 951 | transfer 952 | transform 953 | translate 954 | transport 955 | trap 956 | travel 957 | tread 958 | treat 959 | tremble 960 | trick 961 | trip 962 | trot 963 | trouble 964 | troubleshoot 965 | trust 966 | try 967 | tug 968 | tumble 969 | turn 970 | tutor 971 | twist 972 | type 973 | undergo 974 | understand 975 | undertake 976 | undress 977 | unfasten 978 | unify 979 | unite 980 | unlock 981 | unpack 982 | untidy 983 | update 984 | upgrade 985 | uphold 986 | upset 987 | use 988 | utilize 989 | vanish 990 | verbalize 991 | verify 992 | vex 993 | visit 994 | wail 995 | wait 996 | wake 997 | walk 998 | wander 999 | want 1000 | warm 1001 | warn 1002 | wash 1003 | waste 1004 | watch 1005 | water 1006 | wave 1007 | wear 1008 | weave 1009 | wed 1010 | weep 1011 | weigh 1012 | welcome 1013 | wend 1014 | wet 1015 | whine 1016 | whip 1017 | whirl 1018 | whisper 1019 | whistle 1020 | win 1021 | wind 1022 | wink 1023 | wipe 1024 | wish 1025 | withdraw 1026 | withhold 1027 | withstand 1028 | wobble 1029 | wonder 1030 | work 1031 | worry 1032 | wrap 1033 | wreck 1034 | wrestle 1035 | wriggle 1036 | wring 1037 | write 1038 | x-ray 1039 | yawn 1040 | yell 1041 | zip 1042 | zoom --------------------------------------------------------------------------------