├── .prettierrc ├── src ├── vite-env.d.ts ├── Loading.tsx ├── hooks │ ├── useTypingIndicator.ts │ ├── useSessionStorage.ts │ ├── useLatestValue.ts │ ├── useSingleFlight.ts │ └── useServerSession.ts ├── index.css ├── JoinGame.tsx ├── main.tsx ├── NextButton.tsx ├── InputName.tsx ├── Submissions.tsx ├── ProfilePicture.tsx ├── Countdown.tsx ├── Lobby.tsx ├── Generate.tsx ├── GameRound.tsx ├── ProfilePicker.tsx ├── LabelStage.tsx ├── Recap.tsx ├── RevealStage.tsx ├── Game.tsx ├── CreateImage.tsx ├── App.module.scss ├── App.tsx └── GuessStage.tsx ├── .env ├── postcss.config.cjs ├── vite.config.ts ├── tsconfig.node.json ├── convex ├── crons.ts ├── lib │ ├── randomSlug.ts │ └── myFunctions.ts ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── tsconfig.json ├── shared.ts ├── schema.ts ├── README.md ├── publicGame.ts ├── submissions.ts ├── openai.ts ├── users.ts ├── game.ts └── round.ts ├── .gitignore ├── tailwind.config.cjs ├── index.html ├── tsconfig.json ├── .eslintrc.cjs ├── LICENSE ├── package.json ├── public ├── vite.svg ├── convex.svg └── faces.svg └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always" 3 | } 4 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_CONVEX_URL="https://perfect-wombat-22.convex.cloud" 2 | -------------------------------------------------------------------------------- /src/Loading.tsx: -------------------------------------------------------------------------------- 1 | export function Loading() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /convex/crons.ts: -------------------------------------------------------------------------------- 1 | import { cronJobs } from "convex/server"; 2 | import { internal } from "./_generated/api"; 3 | 4 | const cron = cronJobs(); 5 | 6 | cron.interval( 7 | "public game progress", 8 | { seconds: 10 }, 9 | internal.publicGame.progress, 10 | { 11 | fromStage: "reveal", 12 | } 13 | ); 14 | 15 | export default cron; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | fontFamily: { 6 | display: ["Roboto Flex", "sans-serif"], 7 | sans: ["Inter", "sans-serif"], 8 | }, 9 | extend: { 10 | colors: { 11 | "neutral-white": "#ffffff", 12 | "neutral-black": "#000000", 13 | }, 14 | }, 15 | }, 16 | plugins: [], 17 | }; 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Whose Prompt is it Anyways? by Convex 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/hooks/useTypingIndicator.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export default ( 4 | text: string, 5 | updateMyPresence: (p: { typing?: boolean }) => void 6 | ) => { 7 | useEffect(() => { 8 | if (text.length === 0) { 9 | updateMyPresence({ typing: false }); 10 | return; 11 | } 12 | updateMyPresence({ typing: true }); 13 | const timer = setTimeout(() => updateMyPresence({ typing: false }), 1000); 14 | return () => clearTimeout(timer); 15 | }, [updateMyPresence, text]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Roboto+Flex:wdth,wght@25..151,100..1000&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | /* These utilities are intended for use with the Roboto Flex variable font. */ 8 | .stretch-min { 9 | font-stretch: 25%; 10 | } 11 | .stretch-thin { 12 | font-stretch: 75%; 13 | } 14 | .stretch-wide { 15 | font-stretch: 125%; 16 | } 17 | .stretch-max { 18 | font-stretch: 151%; 19 | } 20 | -------------------------------------------------------------------------------- /convex/lib/randomSlug.ts: -------------------------------------------------------------------------------- 1 | const LETTERS = [ 2 | "B", 3 | "C", 4 | "D", 5 | "F", 6 | "G", 7 | "H", 8 | "J", 9 | "K", 10 | "L", 11 | "M", 12 | "N", 13 | "P", 14 | "Q", 15 | "R", 16 | "S", 17 | "T", 18 | "V", 19 | "W", 20 | "X", 21 | "Z", 22 | "2", 23 | "5", 24 | "6", 25 | "9", 26 | ]; 27 | export const randomSlug = (): string => { 28 | var acc = []; 29 | for (var i = 0; i < 4; i++) { 30 | acc.push(LETTERS[Math.floor(Math.random() * LETTERS.length)]); 31 | } 32 | return acc.join(""); 33 | }; 34 | -------------------------------------------------------------------------------- /src/JoinGame.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useSessionMutation } from "./hooks/useServerSession"; 3 | 4 | export function JoinGame(props: { gameCode: string }) { 5 | const joinGame = useSessionMutation(api.game.join); 6 | return ( 7 |
8 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.7.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ConvexProvider, ConvexReactClient } from "convex/react"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import App from "./App"; 5 | import { SessionProvider } from "./hooks/useServerSession"; 6 | import "./index.css"; 7 | 8 | const client = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); 9 | 10 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src", ".eslintrc.cjs"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /src/NextButton.tsx: -------------------------------------------------------------------------------- 1 | export const NextButton = (props: { 2 | onClick: () => unknown; 3 | title: string; 4 | disabled?: boolean; 5 | }) => { 6 | return ( 7 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/InputName.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useSessionMutation, useSessionQuery } from "./hooks/useServerSession"; 3 | import useSingleFlight from "./hooks/useSingleFlight"; 4 | 5 | export function InputName() { 6 | const profile = useSessionQuery(api.users.getMyProfile); 7 | const setName = useSingleFlight(useSessionMutation(api.users.setName)); 8 | return profile ? ( 9 | setName({ name: e.target.value })} 15 | placeholder="Enter a name" 16 | /> 17 | ) : ( 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | 11 | /* These compiler options are required by Convex */ 12 | "target": "ESNext", 13 | "lib": ["ES2021", "dom"], 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "isolatedModules": true, 19 | "noEmit": true 20 | }, 21 | "include": ["./**/*"], 22 | "exclude": ["./_generated"] 23 | } 24 | -------------------------------------------------------------------------------- /src/Submissions.tsx: -------------------------------------------------------------------------------- 1 | import { InputName } from "./InputName"; 2 | import { ProfilePicture } from "./ProfilePicture"; 3 | 4 | export function Submissions({ 5 | submitted, 6 | title, 7 | }: { 8 | submitted: { name: string; pictureUrl: string; me: boolean }[]; 9 | title: string; 10 | }) { 11 | return ( 12 |
13 | {title} 14 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | project: true, 5 | tsconfigRootDir: __dirname + "/convex", 6 | }, 7 | env: { 8 | es2020: true, 9 | }, 10 | plugins: ["@typescript-eslint"], 11 | extends: [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended-type-checked", 14 | ], 15 | rules: { 16 | // Only warn on unused variables, and ignore variables starting with `_` 17 | "@typescript-eslint/no-unused-vars": [ 18 | "warn", 19 | { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }, 20 | ], 21 | "@typescript-eslint/require-await": "off", 22 | "no-raw-importss": "error", 23 | }, 24 | settings: { 25 | "import/resolver": { 26 | node: { 27 | extensions: [".js", ".ts"], 28 | }, 29 | }, 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/ProfilePicture.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import ProfilePicker from "./ProfilePicker"; 3 | 4 | export function ProfilePicture({ 5 | url, 6 | me, 7 | small, 8 | }: { 9 | url: string; 10 | me?: boolean; 11 | small?: boolean; 12 | }) { 13 | const [pickerOpen, setPickerOpen] = useState(false); 14 | const size = small ? "24px" : "48px"; 15 | return ( 16 | <> 17 |
18 | 19 | {me && ( 20 | <> 21 | 22 | 28 | 29 | )} 30 |
31 | 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /src/Countdown.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useMutation } from "convex/react"; 3 | import { useEffect, useState } from "react"; 4 | 5 | export const Countdown: React.FC<{ start: number; end: number }> = ({ 6 | start, 7 | end, 8 | }) => { 9 | const getServerTime = useMutation(api.round.serverNow); 10 | const [, setNow] = useState(Date.now()); 11 | const [skew, setSkew] = useState(0); 12 | useEffect(() => { 13 | getServerTime().then((serverNow) => { 14 | setSkew(serverNow - Date.now()); 15 | }); 16 | const intervalId = setInterval(() => setNow(Date.now()), 100); 17 | return () => clearInterval(intervalId); 18 | }, []); 19 | const percent = (100 * (Date.now() + skew - start)) / (end - start); 20 | if (percent >= 100) return
; 21 | return ( 22 |
23 |
27 |
28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Convex 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "name-that-prompt", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "npm-run-all --parallel dev:backend dev:frontend", 8 | "build": "tsc && vite build", 9 | "dev:backend": "convex dev", 10 | "dev:frontend": "vite --open --clearScreen false", 11 | "predev": "convex dev --until-success" 12 | }, 13 | "dependencies": { 14 | "@headlessui/react": "^1.7.13", 15 | "@types/node": "^18.11.18", 16 | "classnames": "^2.3.2", 17 | "convex": "^1.6.3", 18 | "convex-helpers": "^0.1.12", 19 | "md5": "^2.3.0", 20 | "openai": "^3.1.0", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0" 23 | }, 24 | "devDependencies": { 25 | "@types/md5": "^2.3.2", 26 | "@types/react": "^18.0.26", 27 | "@types/react-dom": "^18.0.9", 28 | "@typescript-eslint/eslint-plugin": "^6.14.0", 29 | "@typescript-eslint/parser": "^6.14.0", 30 | "@vitejs/plugin-react": "^3.0.0", 31 | "autoprefixer": "^10.4.13", 32 | "eslint": "^8.55.0", 33 | "npm-run-all": "^4.1.5", 34 | "postcss": "^8.4.21", 35 | "prettier": "^2.8.4", 36 | "prettier-plugin-tailwindcss": "^0.2.3", 37 | "sass": "^1.58.1", 38 | "tailwindcss": "^3.2.6", 39 | "typescript": "^4.9.3", 40 | "vite": "^4.0.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/useSessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { convexToJson, jsonToConvex, Value } from 'convex/values'; 2 | import { useCallback, useState } from 'react'; 3 | 4 | function useSessionStorage( 5 | key: string 6 | ): [T | undefined, (value: T) => void]; 7 | function useSessionStorage( // eslint-disable-line no-redeclare 8 | key: string, 9 | defaultValue: T | (() => T) 10 | ): [T, (value: T) => void]; 11 | function useSessionStorage( // eslint-disable-line no-redeclare 12 | key: string, 13 | defaultValue?: T | (() => T) 14 | ) { 15 | const [value, setValueInternal] = useState(() => { 16 | if (typeof sessionStorage !== 'undefined') { 17 | const existing = sessionStorage.getItem(key); 18 | if (existing) { 19 | try { 20 | return jsonToConvex(JSON.parse(existing)) as T; 21 | } catch (e) { 22 | console.error(e); 23 | } 24 | } 25 | } 26 | if (typeof defaultValue === 'function') { 27 | return defaultValue(); 28 | } 29 | return defaultValue; 30 | }); 31 | const setValue = useCallback( 32 | (value: T) => { 33 | sessionStorage.setItem(key, JSON.stringify(convexToJson(value))); 34 | setValueInternal(value); 35 | }, 36 | [key] 37 | ); 38 | return [value, setValue] as const; 39 | } 40 | 41 | export default useSessionStorage; 42 | -------------------------------------------------------------------------------- /src/hooks/useLatestValue.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useRef } from 'react'; 2 | 3 | /** 4 | * Promise-based access to the latest value updated. 5 | * Every call to nextValue will return a promise to the next value. 6 | * "Next value" is defined as the latest value passed to "updateValue" that 7 | * hasn not been returned yet. 8 | * @returns a function to await for a new value, and one to update the value. 9 | */ 10 | export default function useLatestValue() { 11 | const initial = useMemo(() => { 12 | const [promise, resolve] = makeSignal(); 13 | // We won't access data until it has been updated. 14 | return { data: undefined as T, promise, resolve }; 15 | }, []); 16 | const ref = useRef(initial); 17 | const nextValue = useCallback(async () => { 18 | await ref.current.promise; 19 | const [promise, resolve] = makeSignal(); 20 | ref.current.promise = promise; 21 | ref.current.resolve = resolve; 22 | return ref.current.data; 23 | }, [ref]); 24 | 25 | const updateValue = useCallback( 26 | (data: T) => { 27 | ref.current.data = data; 28 | ref.current.resolve(); 29 | }, 30 | [ref] 31 | ); 32 | 33 | return [nextValue, updateValue] as const; 34 | } 35 | 36 | const makeSignal = () => { 37 | let resolve: () => void; 38 | const promise = new Promise((r) => (resolve = r)); 39 | return [promise, resolve!] as const; 40 | }; 41 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/Lobby.tsx: -------------------------------------------------------------------------------- 1 | import { ClientGameState, MaxPlayers } from "../convex/shared"; 2 | import { InputName } from "./InputName"; 3 | import { JoinGame } from "./JoinGame"; 4 | import { Health } from "./CreateImage"; 5 | import { ProfilePicture } from "./ProfilePicture"; 6 | 7 | export function Lobby({ game }: { game: ClientGameState }) { 8 | return ( 9 |
10 | 11 | Invite your friends! 12 | 13 |

Share this code for others to join:

14 |
15 | {game.gameCode} 16 |
17 |
18 |
Players
19 |
    20 | {game.players.map((player) => ( 21 |
  1. 25 | {player.me && "👉"} 26 | 27 | {player.me ? : player.name} 28 |
  2. 29 | ))} 30 | {!game.playing && game.players.length < MaxPlayers && ( 31 | 32 | )} 33 |
34 |
35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/Generate.tsx: -------------------------------------------------------------------------------- 1 | import { ClientGameState } from "../convex/shared"; 2 | import { Id } from "../convex/_generated/dataModel"; 3 | import { InputName } from "./InputName"; 4 | import { JoinGame } from "./JoinGame"; 5 | import { CreateImage } from "./CreateImage"; 6 | import { ProfilePicture } from "./ProfilePicture"; 7 | 8 | export function Generate({ 9 | game, 10 | addRound, 11 | }: { 12 | game: ClientGameState; 13 | addRound: (submissionId: Id<"submissions">) => any; 14 | }) { 15 | return !game.playing ? ( 16 |
17 |
18 | Create an image 19 |
20 |

21 | The game has started. Other players are entering their prompts to 22 | generate images. Want to join? 23 |

24 | 25 |
26 | ) : game.players.find((player) => player.me && player.submitted) ? ( 27 |
28 |
29 | Waiting for everyone to make an image... 30 |
31 |
Players
32 |
    33 | {game.players.map((player) => ( 34 |
  • 35 | {player.me ? "👉" : player.submitted && "✅"} 36 | 37 | {player.me ? : player.name} 38 |
  • 39 | ))} 40 |
41 |
42 | ) : ( 43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.7.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as crons from "../crons.js"; 18 | import type * as game from "../game.js"; 19 | import type * as lib_myFunctions from "../lib/myFunctions.js"; 20 | import type * as lib_randomSlug from "../lib/randomSlug.js"; 21 | import type * as openai from "../openai.js"; 22 | import type * as publicGame from "../publicGame.js"; 23 | import type * as round from "../round.js"; 24 | import type * as shared from "../shared.js"; 25 | import type * as submissions from "../submissions.js"; 26 | import type * as users from "../users.js"; 27 | 28 | /** 29 | * A utility for referencing Convex functions in your app's API. 30 | * 31 | * Usage: 32 | * ```js 33 | * const myFunctionReference = api.myModule.myFunction; 34 | * ``` 35 | */ 36 | declare const fullApi: ApiFromModules<{ 37 | crons: typeof crons; 38 | game: typeof game; 39 | "lib/myFunctions": typeof lib_myFunctions; 40 | "lib/randomSlug": typeof lib_randomSlug; 41 | openai: typeof openai; 42 | publicGame: typeof publicGame; 43 | round: typeof round; 44 | shared: typeof shared; 45 | submissions: typeof submissions; 46 | users: typeof users; 47 | }>; 48 | export declare const api: FilterApi< 49 | typeof fullApi, 50 | FunctionReference 51 | >; 52 | export declare const internal: FilterApi< 53 | typeof fullApi, 54 | FunctionReference 55 | >; 56 | -------------------------------------------------------------------------------- /public/convex.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /convex/shared.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * File shared between client & server. 3 | * Do not import any client-specific or server-specific code 4 | */ 5 | 6 | import { Id } from "./_generated/dataModel"; 7 | 8 | export const MaxPlayers = 8; 9 | 10 | export type ClientGameState = { 11 | gameCode: string; 12 | hosting: boolean; 13 | players: { 14 | me: boolean; 15 | name: string; 16 | pictureUrl: string; 17 | submitted: boolean; 18 | score: number; 19 | likes: number; 20 | }[]; 21 | playing: boolean; 22 | state: 23 | | { 24 | stage: "lobby" | "generate"; 25 | } 26 | | { 27 | stage: "rounds"; 28 | roundId: Id<"rounds">; 29 | } 30 | | { 31 | stage: "recap"; 32 | }; 33 | nextGameId: null | Id<"games">; 34 | }; 35 | 36 | export type LabelState = { 37 | stage: "label"; 38 | mine: boolean; 39 | imageUrl: string; 40 | stageStart: number; 41 | stageEnd: number; 42 | submitted: { 43 | me: boolean; 44 | name: string; 45 | pictureUrl: string; 46 | }[]; 47 | }; 48 | 49 | export type GuessState = { 50 | stage: "guess"; 51 | mine: boolean; 52 | imageUrl: string; 53 | stageStart: number; 54 | stageEnd: number; 55 | myPrompt?: string; 56 | myGuess?: string; 57 | submitted: { 58 | me: boolean; 59 | name: string; 60 | pictureUrl: string; 61 | }[]; 62 | options: string[]; 63 | }; 64 | 65 | export type RevealState = { 66 | stage: "reveal"; 67 | me: Id<"users">; 68 | authorId: Id<"users">; 69 | imageUrl: string; 70 | stageStart: number; 71 | stageEnd: number; 72 | users: { 73 | userId: Id<"users">; 74 | me: boolean; 75 | name: string; 76 | pictureUrl: string; 77 | }[]; 78 | results: { 79 | authorId: Id<"users">; 80 | prompt: string; 81 | votes: Id<"users">[]; 82 | likes: Id<"users">[]; 83 | // userid to score 84 | scoreDeltas: { userId: Id<"users">; score: number }[]; 85 | }[]; 86 | }; 87 | 88 | export const MaxPromptLength = 100; 89 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.7.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | DataModelFromSchemaDefinition, 14 | DocumentByName, 15 | TableNamesInDataModel, 16 | SystemTableNames, 17 | } from "convex/server"; 18 | import type { GenericId } from "convex/values"; 19 | import schema from "../schema.js"; 20 | 21 | /** 22 | * The names of all of your Convex tables. 23 | */ 24 | export type TableNames = TableNamesInDataModel; 25 | 26 | /** 27 | * The type of a document stored in Convex. 28 | * 29 | * @typeParam TableName - A string literal type of the table name (like "users"). 30 | */ 31 | export type Doc = DocumentByName< 32 | DataModel, 33 | TableName 34 | >; 35 | 36 | /** 37 | * An identifier for a document in Convex. 38 | * 39 | * Convex documents are uniquely identified by their `Id`, which is accessible 40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 41 | * 42 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 43 | * 44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 45 | * strings when type checking. 46 | * 47 | * @typeParam TableName - A string literal type of the table name (like "users"). 48 | */ 49 | export type Id = 50 | GenericId; 51 | 52 | /** 53 | * A type describing your Convex data model. 54 | * 55 | * This type includes information about what tables you have, the type of 56 | * documents stored in those tables, and the indexes defined on them. 57 | * 58 | * This type is used to parameterize methods like `queryGeneric` and 59 | * `mutationGeneric` to make them type-safe. 60 | */ 61 | export type DataModel = DataModelFromSchemaDefinition; 62 | -------------------------------------------------------------------------------- /src/GameRound.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useMutation } from "convex/react"; 3 | import { ClientGameState } from "../convex/shared"; 4 | import { Id } from "../convex/_generated/dataModel"; 5 | import { Countdown } from "./Countdown"; 6 | import { GuessStage } from "./GuessStage"; 7 | import { useSessionQuery } from "./hooks/useServerSession"; 8 | import { LabelStage } from "./LabelStage"; 9 | import { Loading } from "./Loading"; 10 | import { NextButton } from "./NextButton"; 11 | import { RevealStage } from "./RevealStage"; 12 | 13 | const GameRound: React.FC<{ 14 | roundId: Id<"rounds">; 15 | game?: ClientGameState; 16 | gameId?: Id<"games">; 17 | nextButton?: React.ReactElement | false; 18 | }> = ({ nextButton, roundId, game, gameId }) => { 19 | const round = useSessionQuery(api.round.getRound, { roundId }); 20 | const progress = useMutation(api.round.progress); 21 | if (!round) return ; 22 | const footer = ( 23 | <> 24 | 25 | { 26 | game?.hosting && ( 27 | // !!game.players.find((p) => p.me) ? ( 28 | ( progress({ roundId, fromStage: round.stage })} 30 | title="Next" 31 | />) 32 | ) 33 | // ) : ( 34 | // 35 | // )); 36 | } 37 | 38 | ); 39 | 40 | switch (round.stage) { 41 | case "label": 42 | return ( 43 | <> 44 | 45 | {footer} 46 | 47 | ); 48 | case "guess": 49 | return ( 50 | <> 51 | 52 | {footer} 53 | 54 | ); 55 | case "reveal": 56 | return ( 57 | <> 58 | 59 | 60 | {nextButton} 61 | 62 | ); 63 | } 64 | }; 65 | export default GameRound; 66 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | users: defineTable({ 6 | name: v.string(), 7 | pictureUrl: v.string(), 8 | tokenIdentifier: v.optional(v.string()), 9 | claimedByUserId: v.optional(v.id("users")), 10 | }).index("by_token", ["tokenIdentifier"]), 11 | 12 | // For sessions: 13 | sessions: defineTable({ 14 | userId: v.id("users"), 15 | submissionIds: v.array(v.id("submissions")), 16 | gameIds: v.array(v.id("games")), 17 | }), // Make as specific as you want 18 | // End sessions 19 | 20 | games: defineTable({ 21 | hostId: v.id("users"), 22 | playerIds: v.array(v.id("users")), 23 | slug: v.string(), 24 | roundIds: v.array(v.id("rounds")), 25 | state: v.union( 26 | v.object({ 27 | stage: v.union( 28 | v.literal("lobby"), 29 | v.literal("generate"), 30 | v.literal("recap") 31 | ), 32 | }), 33 | v.object({ 34 | stage: v.literal("rounds"), 35 | roundId: v.id("rounds"), 36 | }) 37 | ), 38 | nextGameId: v.optional(v.id("games")), 39 | }).index("s", ["slug"]), 40 | 41 | publicGame: defineTable({ 42 | roundId: v.id("rounds"), 43 | }), 44 | 45 | submissions: defineTable({ 46 | prompt: v.string(), 47 | authorId: v.id("users"), 48 | result: v.union( 49 | v.object({ 50 | status: v.literal("generating"), 51 | details: v.string(), 52 | }), 53 | v.object({ 54 | status: v.literal("failed"), 55 | reason: v.string(), 56 | elapsedMs: v.number(), 57 | }), 58 | v.object({ 59 | status: v.literal("saved"), 60 | imageStorageId: v.string(), 61 | elapsedMs: v.number(), 62 | }) 63 | ), 64 | }), 65 | 66 | rounds: defineTable({ 67 | authorId: v.id("users"), 68 | imageStorageId: v.string(), 69 | stageStart: v.number(), 70 | stageEnd: v.number(), 71 | stage: v.union(v.literal("label"), v.literal("guess"), v.literal("reveal")), 72 | options: v.array( 73 | v.object({ 74 | authorId: v.id("users"), 75 | prompt: v.string(), 76 | votes: v.array(v.id("users")), 77 | likes: v.array(v.id("users")), 78 | }) 79 | ), 80 | // For public games 81 | lastUsed: v.optional(v.number()), 82 | publicRound: v.optional(v.boolean()), 83 | }).index("public_game", ["publicRound", "stage", "lastUsed"]), 84 | }); 85 | -------------------------------------------------------------------------------- /src/hooks/useSingleFlight.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | 3 | /** 4 | * Wraps a function to single-flight invocations, using the latest args. 5 | * 6 | * Generates a function that behaves like the passed in function, 7 | * but only one execution runs at a time. If multiple calls are requested 8 | * before the current call has finished, it will use the latest arguments 9 | * for the next invocation. 10 | * 11 | * Note: some requests may never be made. If while a request is in-flight, N 12 | * requests are made, N-1 of them will never resolve or reject the promise they 13 | * returned. For most applications this is the desired behavior, but if you need 14 | * all calls to eventually resolve, you can modify this code. Some behavior you 15 | * could add, left as an exercise to the reader: 16 | * 1. Resolve with the previous result when a request is about to be dropped. 17 | * 2. Resolve all N requests with the result of the next request. 18 | * 3. Do not return anything, and use this as a fire-and-forget library only. 19 | * 20 | * @param fn - Function to be called, with only one request in flight at a time. 21 | * This must be a stable identifier, e.g. returned from useCallback. 22 | * @returns Function that can be called whenever, returning a promise that will 23 | * only resolve or throw if the underlying function gets called. 24 | */ 25 | export default function useSingleFlight< 26 | F extends (...args: any[]) => Promise 27 | >(fn: F) { 28 | const flightStatus = useRef({ 29 | inFlight: false, 30 | upNext: null as null | { 31 | fn: F; 32 | resolve: any; 33 | reject: any; 34 | args: Parameters; 35 | }, 36 | }); 37 | 38 | return useCallback( 39 | (...args: Parameters): ReturnType => { 40 | if (flightStatus.current.inFlight) { 41 | return new Promise((resolve, reject) => { 42 | flightStatus.current.upNext = { fn, resolve, reject, args }; 43 | }) as ReturnType; 44 | } 45 | flightStatus.current.inFlight = true; 46 | const firstReq = fn(...args) as ReturnType; 47 | void (async () => { 48 | try { 49 | await firstReq; 50 | } finally { 51 | // If it failed, we naively just move on to the next request. 52 | } 53 | while (flightStatus.current.upNext) { 54 | let cur = flightStatus.current.upNext; 55 | flightStatus.current.upNext = null; 56 | await cur 57 | .fn(...cur.args) 58 | .then(cur.resolve) 59 | .catch(cur.reject); 60 | } 61 | flightStatus.current.inFlight = false; 62 | })(); 63 | return firstReq; 64 | }, 65 | [fn] 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /convex/lib/myFunctions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NoOp, 3 | customCtx, 4 | customMutation, 5 | customQuery, 6 | } from "convex-helpers/server/customFunctions"; 7 | import { 8 | Rules, 9 | wrapDatabaseReader, 10 | wrapDatabaseWriter, 11 | } from "convex-helpers/server/rowLevelSecurity"; 12 | import { internalMutation, mutation, query } from "../_generated/server"; 13 | import { v } from "convex/values"; 14 | import { DataModel, Doc } from "../_generated/dataModel"; 15 | 16 | const rules: Rules<{ session: Doc<"sessions"> | null }, DataModel> = { 17 | users: { 18 | modify: async ({ session }, user) => 19 | user._id === session?.userId || 20 | !!(user.claimedByUserId === session?.userId), 21 | }, 22 | sessions: { 23 | read: async ({ session }, doc) => doc._id === session?._id, 24 | modify: async ({ session }, doc) => doc._id === session?._id, 25 | }, 26 | submissions: { 27 | modify: async ({ session }, submission) => 28 | submission.authorId === session?.userId, 29 | }, 30 | }; 31 | 32 | // Placeholders in case we want to apply RLS to this or something 33 | export const myInternalMutation = customMutation(internalMutation, NoOp); 34 | export const myMutation = customMutation( 35 | mutation, 36 | customCtx((ctx) => ({ 37 | db: wrapDatabaseWriter({ session: null }, ctx.db, rules), 38 | })) 39 | ); 40 | 41 | export const myQuery = customQuery( 42 | query, 43 | customCtx((ctx) => ({ 44 | db: wrapDatabaseReader({ session: null }, ctx.db, rules), 45 | })) 46 | ); 47 | 48 | export const sessionMutation = customMutation(mutation, { 49 | args: { 50 | sessionId: v.id("sessions"), 51 | }, 52 | input: async (ctx, args) => { 53 | const session = await ctx.db.get(args.sessionId); 54 | if (!session) throw new Error(`Session not found: ${args.sessionId}`); 55 | return { 56 | ctx: { session, db: wrapDatabaseWriter({ session }, ctx.db, rules) }, 57 | args: {}, 58 | }; 59 | }, 60 | }); 61 | 62 | export const internalSessionMutation = customMutation(internalMutation, { 63 | args: { 64 | sessionId: v.id("sessions"), 65 | }, 66 | input: async (ctx, args) => { 67 | const session = await ctx.db.get(args.sessionId); 68 | if (!session) throw new Error(`Session not found: ${args.sessionId}`); 69 | return { 70 | ctx: { session, db: wrapDatabaseWriter({ session }, ctx.db, rules) }, 71 | args: {}, 72 | }; 73 | }, 74 | }); 75 | 76 | export const sessionQuery = customQuery(query, { 77 | args: { 78 | sessionId: v.id("sessions"), 79 | }, 80 | input: async (ctx, args) => { 81 | const session = (await ctx.db.get(args.sessionId)) ?? null; 82 | return { 83 | ctx: { session, db: wrapDatabaseReader({ session }, ctx.db, rules) }, 84 | args: {}, 85 | }; 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /src/ProfilePicker.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useState } from "react"; 2 | 3 | import { Dialog, Transition } from "@headlessui/react"; 4 | import { useSessionMutation, useSessionQuery } from "./hooks/useServerSession"; 5 | import { CreateImage } from "./CreateImage"; 6 | import { ProfilePicture } from "./ProfilePicture"; 7 | import { api } from "../convex/_generated/api"; 8 | 9 | export default function ProfilePicker({ 10 | open, 11 | setOpen, 12 | }: { 13 | open: boolean; 14 | setOpen: (open: boolean) => void; 15 | }) { 16 | const profile = useSessionQuery(api.users.getMyProfile); 17 | const setPicture = useSessionMutation(api.users.setPicture); 18 | 19 | return ( 20 | 21 | 22 | 31 |
32 | 33 | 34 |
35 |
36 | 45 | 46 |
47 | {/* important to set me to false so there isn't infinite recursion! */} 48 | {profile && } 49 |
50 | { 53 | await setPicture({ submissionId }); 54 | setOpen(false); 55 | }} 56 | /> 57 |
58 |
59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. See 4 | https://docs.convex.dev/using/writing-convex-functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | hander: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | hander: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result) 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /convex/publicGame.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { internal } from "./_generated/api"; 3 | import { myInternalMutation, myQuery } from "./lib/myFunctions"; 4 | 5 | export const get = myQuery({ 6 | handler: async (ctx) => { 7 | const publicGame = await ctx.db.query("publicGame").unique(); 8 | if (!publicGame) { 9 | console.warn("No public game currently."); 10 | return null; 11 | } 12 | return publicGame.roundId; 13 | }, 14 | }); 15 | 16 | const PublicGuessMs = 15000; 17 | const PublicRevealMs = 10000; 18 | 19 | export const progress = myInternalMutation({ 20 | args: { fromStage: v.union(v.literal("guess"), v.literal("reveal")) }, 21 | handler: async (ctx, { fromStage }) => { 22 | const publicGame = await ctx.db.query("publicGame").unique(); 23 | if (!publicGame) throw new Error("No public game"); 24 | const currentRound = await ctx.db.get(publicGame.roundId); 25 | if (!currentRound) throw new Error("Round not found"); 26 | 27 | if (currentRound.stageEnd! > Date.now()) { 28 | throw new Error("Previous round not over."); 29 | } 30 | if (currentRound.stage !== fromStage) { 31 | console.log("skipping progress: already in the right stage"); 32 | return "noop"; 33 | } 34 | if (currentRound.stage === "guess") { 35 | if ( 36 | !currentRound.options.find( 37 | (option) => option.likes.length || option.votes.length 38 | ) 39 | ) { 40 | await ctx.scheduler.runAfter( 41 | PublicGuessMs, 42 | internal.publicGame.progress, 43 | { 44 | fromStage: "guess", 45 | } 46 | ); 47 | return "guess again"; 48 | } 49 | await ctx.db.patch(currentRound._id, { 50 | stage: "reveal", 51 | stageStart: Date.now(), 52 | stageEnd: Date.now() + PublicRevealMs, 53 | }); 54 | return "->reveal"; 55 | } 56 | if (currentRound.stage !== "reveal") { 57 | throw new Error(`Invalid stage: ${currentRound.stage}`); 58 | } 59 | const round = await ctx.db 60 | .query("rounds") 61 | .withIndex("public_game", (q) => 62 | q.eq("publicRound", false).eq("stage", "reveal") 63 | ) 64 | .order("asc") 65 | .first(); 66 | if (!round) throw new Error("No public round."); 67 | await ctx.db.patch(round._id, { lastUsed: Date.now() }); 68 | for (const option of round.options) { 69 | option.likes = []; 70 | option.votes = []; 71 | } 72 | round.stage = "guess"; 73 | round.stageStart = Date.now(); 74 | round.stageEnd = Date.now() + PublicGuessMs; 75 | round.publicRound = true; 76 | const { _id, _creationTime, ...rest } = round; 77 | const roundId = await ctx.db.insert("rounds", rest); 78 | await ctx.db.patch(publicGame._id, { roundId }); 79 | await ctx.scheduler.runAfter(PublicGuessMs, internal.publicGame.progress, { 80 | fromStage: "guess", 81 | }); 82 | return "->guess"; 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /src/LabelStage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { LabelState } from "../convex/shared"; 3 | import { Id } from "../convex/_generated/dataModel"; 4 | import { useSessionAction } from "./hooks/useServerSession"; 5 | import { Submissions } from "./Submissions"; 6 | import { api } from "../convex/_generated/api"; 7 | 8 | export function LabelStage({ 9 | round, 10 | roundId, 11 | gameId, 12 | }: { 13 | round: LabelState; 14 | roundId: Id<"rounds">; 15 | gameId?: Id<"games">; 16 | }) { 17 | const [error, setError] = useState(); 18 | const [prompt, setPrompt] = useState(""); 19 | const addPrompt = useSessionAction(api.openai.addOption); 20 | return ( 21 |
22 | 27 | {round.mine || round.submitted.find((submission) => submission.me) ? ( 28 | <> 29 | 37 | 38 | ) : ( 39 |
40 | 41 | {round.mine 42 | ? "This was your image. Just relax 🏝️" 43 | : "What prompt was responsible for this image?"} 44 | 45 | {error} 46 |
submission.me) 49 | } 50 | onSubmit={async (e) => { 51 | e.preventDefault(); 52 | const result = await addPrompt({ roundId, prompt, gameId }); 53 | if (!result.success) setError(result.reason); 54 | }} 55 | className="flex" 56 | aria-errormessage={error} 57 | > 58 | setPrompt(e.target.value)} 62 | className="bg-transparent border border-neutral-400 p-2 focus:outline-none placeholder:text-neutral-400 text-blue-400 focus:border-blue-400 h-12 basis-0 grow" 63 | /> 64 | 72 |
73 |

74 | (You’ll get points if someone thinks yours was the real one) 75 |

76 |
77 | )} 78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /src/Recap.tsx: -------------------------------------------------------------------------------- 1 | import { ClientGameState } from "../convex/shared"; 2 | import { ProfilePicture } from "./ProfilePicture"; 3 | 4 | export function Recap({ game }: { game: ClientGameState }) { 5 | return ( 6 |
7 |

Scores

8 |
9 |
10 |
11 | 12 | 13 | 14 | 17 | 23 | 29 | 35 | 36 | 37 | 38 | {game.players 39 | .slice() 40 | .sort((a, b) => b.score - a.score) 41 | .map((player, index) => ( 42 | 43 | 47 | 54 | 57 | 60 | 61 | ))} 62 | 63 |
15 | Place 16 | 21 | Player 22 | 27 | Score 28 | 33 | Likes 34 |
44 | #{index + 1} 45 | , {player.name} 46 | 48 | 52 | {player.name} 53 | 55 | {player.score} 56 | 58 | {player.likes} 59 |
64 |
65 |
66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/RevealStage.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { RevealState } from "../convex/shared"; 3 | import { ProfilePicture } from "./ProfilePicture"; 4 | 5 | export function RevealStage({ round }: { round: RevealState }) { 6 | const users = new Map(round.users.map((u) => [u.userId, u])); 7 | return ( 8 |
9 | 14 |
    15 | {round.results.map((option) => { 16 | const scoreDeltas = new Map( 17 | option.scoreDeltas.map((d) => [d.userId, d.score]) 18 | ); 19 | const user = users.get(option.authorId); 20 | return ( 21 |
  • 31 | 34 |
    35 | by 36 | 37 | {user!.name} 38 | {!!scoreDeltas.get(option.authorId) && ( 39 | 40 | +{scoreDeltas.get(option.authorId)} 41 | 42 | )} 43 | {option.likes.length 44 | ? option.likes.map((userId) => ( 45 | 48 | )) 49 | : null} 50 |
    51 | {option.votes.length ? ( 52 |
    53 | 54 | {option.votes.length} Vote{option.votes.length > 1 && "s"}{" "} 55 | 56 |
      57 | {option.votes.map((userId) => ( 58 |
    1. 59 | 64 | {users.get(userId)!.name || "(Anonymous)"} 65 | 66 | {scoreDeltas.has(userId) ? ( 67 | 68 | +{scoreDeltas.get(userId)} 69 | 70 | ) : null} 71 |
    2. 72 | ))} 73 |
    74 |
    75 | ) : null} 76 |
  • 77 | ); 78 | })} 79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/Game.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useCallback } from "react"; 3 | import { Id } from "../convex/_generated/dataModel"; 4 | import GameRound from "./GameRound"; 5 | import { Generate } from "./Generate"; 6 | import { useSessionMutation, useSessionQuery } from "./hooks/useServerSession"; 7 | import { Lobby } from "./Lobby"; 8 | import { NextButton } from "./NextButton"; 9 | import { Recap } from "./Recap"; 10 | 11 | const Game: React.FC<{ 12 | gameId: Id<"games">; 13 | done: (nextGameId: Id<"games"> | null) => void; 14 | }> = ({ gameId, done }) => { 15 | const game = useSessionQuery(api.game.get, { gameId }); 16 | const submit = useSessionMutation(api.game.submit); 17 | const playAgain = useSessionMutation(api.game.playAgain); 18 | const addRound = useCallback( 19 | (submissionId: Id<"submissions">) => submit({ submissionId, gameId }), 20 | [submit, gameId] 21 | ); 22 | const progress = useSessionMutation(api.game.progress); 23 | if (!game) return
; 24 | if (game.nextGameId) done(game.nextGameId); 25 | const next = game.hosting && ( 26 | progress({ gameId, fromStage: game.state.stage })} 28 | title={ 29 | game.state.stage === "lobby" 30 | ? "Start" 31 | : game.state.stage === "rounds" 32 | ? "Next" 33 | : "Skip" 34 | } 35 | disabled={!game.hosting || game.players.length <= 2} 36 | /> 37 | ); 38 | const footer = ( 39 |
40 |

41 | {game.players.length > 2 || ( 42 | You need at least 3 players to start. 43 | )} 44 | {next} 45 | 46 | {game.state.stage === "lobby" && 47 | (game.hosting 48 | ? "You are the host of this game." 49 | : "Only the host can start the game.")} 50 | 51 |

52 |
53 | ); 54 | switch (game.state.stage) { 55 | case "lobby": 56 | return ( 57 | <> 58 | 59 | {footer} 60 | 61 | ); 62 | case "generate": 63 | return ( 64 | <> 65 | 66 | {footer} 67 | 68 | ); 69 | case "rounds": 70 | return ( 71 | <> 72 | 78 | 79 | ); 80 | case "recap": 81 | return ( 82 |
83 | 84 | 91 | 101 |
102 | ); 103 | } 104 | }; 105 | export default Game; 106 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.7.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /convex/submissions.ts: -------------------------------------------------------------------------------- 1 | import { internal } from "./_generated/api"; 2 | import { Doc, Id } from "./_generated/dataModel"; 3 | import { MaxPromptLength } from "./shared"; 4 | import { v } from "convex/values"; 5 | import { 6 | myInternalMutation, 7 | myQuery, 8 | sessionMutation, 9 | sessionQuery, 10 | } from "./lib/myFunctions"; 11 | 12 | const ImageTimeoutMs = 30000; 13 | 14 | export const start = sessionMutation({ 15 | args: { prompt: v.string() }, 16 | handler: async (ctx, { prompt }) => { 17 | if (prompt.length > MaxPromptLength) throw new Error("Prompt too long"); 18 | const submissionId = await ctx.db.insert("submissions", { 19 | prompt, 20 | authorId: ctx.session.userId, 21 | result: { 22 | status: "generating", 23 | details: "Starting...", 24 | }, 25 | }); 26 | // Store the current submission in the session to associate with a 27 | // new user if we log in. 28 | ctx.session.submissionIds.push(submissionId); 29 | await ctx.db.patch(ctx.session._id, { 30 | submissionIds: ctx.session.submissionIds, 31 | }); 32 | await ctx.scheduler.runAfter(0, internal.openai.createImage, { 33 | prompt, 34 | submissionId, 35 | }); 36 | await ctx.scheduler.runAfter(ImageTimeoutMs, internal.submissions.timeout, { 37 | submissionId, 38 | }); 39 | return submissionId; 40 | }, 41 | }); 42 | 43 | export const timeout = myInternalMutation({ 44 | args: { submissionId: v.id("submissions") }, 45 | handler: async (ctx, { submissionId }) => { 46 | const submission = await ctx.db.get(submissionId); 47 | if (!submission) throw new Error("No submission found"); 48 | if (submission.result.status === "generating") { 49 | submission.result = { 50 | status: "failed", 51 | reason: "Timed out", 52 | elapsedMs: ImageTimeoutMs, 53 | }; 54 | await ctx.db.replace(submissionId, submission); 55 | } 56 | }, 57 | }); 58 | 59 | export const get = sessionQuery({ 60 | args: { submissionId: v.id("submissions") }, 61 | handler: async (ctx, { submissionId }) => { 62 | const submission = await ctx.db.get(submissionId); 63 | if (!submission) return null; 64 | if (submission.result.status === "saved") { 65 | const { imageStorageId, ...rest } = submission.result; 66 | const url = await ctx.storage.getUrl(imageStorageId); 67 | if (!url) throw new Error("Image not found"); 68 | return { url, ...rest }; 69 | } 70 | return submission.result; 71 | }, 72 | }); 73 | 74 | export const health = myQuery({ 75 | handler: async (ctx) => { 76 | const latestSubmissions = await ctx.db 77 | .query("submissions") 78 | .order("desc") 79 | .filter((q) => q.neq(q.field("result.status"), "generating")) 80 | .take(5); 81 | let totalTime = 0; 82 | let successes = 0; 83 | for (const submission of latestSubmissions) { 84 | // Appease typescript 85 | if (submission.result.status === "generating") continue; 86 | totalTime += submission.result.elapsedMs; 87 | if (submission.result.status === "saved") successes += 1; 88 | } 89 | const n = latestSubmissions.length; 90 | return n ? [totalTime / n, successes / n] : [5000, 1.0]; 91 | }, 92 | }); 93 | 94 | export const update = myInternalMutation({ 95 | handler: async ( 96 | ctx, 97 | { 98 | submissionId, 99 | result, 100 | }: { 101 | submissionId: Id<"submissions">; 102 | result: Doc<"submissions">["result"]; 103 | } 104 | ) => { 105 | const submission = await ctx.db.get(submissionId); 106 | if (!submission) throw new Error("Unknown submission"); 107 | submission.result = result; 108 | await ctx.db.replace(submissionId, submission); 109 | }, 110 | }); 111 | -------------------------------------------------------------------------------- /src/CreateImage.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useQuery } from "convex/react"; 3 | import { useState } from "react"; 4 | import { MaxPromptLength } from "../convex/shared"; 5 | import { Id } from "../convex/_generated/dataModel"; 6 | import { useSessionMutation, useSessionQuery } from "./hooks/useServerSession"; 7 | 8 | export const Health = () => { 9 | const health = useQuery(api.submissions.health) ?? null; 10 | return ( 11 | health && ( 12 |
13 | 14 | Dall-E status:{" "} 15 | {health[1] > 0.8 ? "✅" : health[1] > 0.5 ? "⚠️" : "❌"} 16 | 17 | 18 | Image generation time: {(health[0] / 1000).toFixed(1)} seconds 19 | 20 |
21 | ) 22 | ); 23 | }; 24 | 25 | const Submission = (props: { submissionId: Id<"submissions"> }) => { 26 | const result = useSessionQuery(api.submissions.get, props); 27 | switch (result?.status) { 28 | case "generating": 29 | return ( 30 |
31 |
32 | {result.details} 33 |
34 | ); 35 | case "failed": 36 | return

❗️{result.reason}

; 37 | case "saved": 38 | return ( 39 |
40 | 44 | 45 | Generated in {result.elapsedMs / 1000} seconds. 46 | 47 |
48 | ); 49 | } 50 | return null; 51 | }; 52 | 53 | export const CreateImage = ({ 54 | onSubmit, 55 | title, 56 | }: { 57 | onSubmit: (submissionId: Id<"submissions">) => any; 58 | title?: string; 59 | }) => { 60 | const [prompt, setPrompt] = useState(""); 61 | const startSubmission = useSessionMutation(api.submissions.start); 62 | const [submissionId, setSubmissionId] = useState>(); 63 | return ( 64 |
65 |
66 | {title ?? "Submit an image"} 67 |
68 |
{ 70 | e.preventDefault(); 71 | setSubmissionId(await startSubmission({ prompt })); 72 | }} 73 | className="flex flex-col gap-4" 74 | > 75 | 88 | 93 |
94 | {submissionId && ( 95 | <> 96 | 97 | 104 | 105 | )} 106 | 107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Whose Prompt is it Anyways? 2 | A fun multiplayer game built on Convex using Dall-E. 3 | 4 | # What is Convex? 5 | 6 | [Convex](https://convex.dev) is a hosted backend platform with a 7 | built-in database that lets you write your 8 | [database schema](https://docs.convex.dev/database/schemas) and 9 | [server functions](https://docs.convex.dev/functions) in 10 | [TypeScript](https://docs.convex.dev/typescript). Server-side database 11 | [queries](https://docs.convex.dev/functions/query-functions) automatically 12 | [cache](https://docs.convex.dev/functions/query-functions#caching--reactivity) and 13 | [subscribe](https://docs.convex.dev/client/react#reactivity) to data, powering a 14 | [realtime `useQuery` hook](https://docs.convex.dev/client/react#fetching-data) in our 15 | [React client](https://docs.convex.dev/client/react). There are also 16 | [Python](https://docs.convex.dev/client/python), 17 | [Rust](https://docs.convex.dev/client/rust), 18 | [ReactNative](https://docs.convex.dev/client/react-native), and 19 | [Node](https://docs.convex.dev/client/javascript) clients, as well as a straightforward 20 | [HTTP API](https://github.com/get-convex/convex-js/blob/main/src/browser/http_client.ts#L40). 21 | 22 | The database support 23 | [NoSQL-style documents](https://docs.convex.dev/database/document-storage) with 24 | [relationships](https://docs.convex.dev/database/document-ids) and 25 | [custom indexes](https://docs.convex.dev/database/indexes/) 26 | (including on fields in nested objects). 27 | 28 | The 29 | [`query`](https://docs.convex.dev/functions/query-functions) and 30 | [`mutation`](https://docs.convex.dev/functions/mutation-functions) server functions have transactional, 31 | low latency access to the database and leverage our 32 | [`v8` runtime](https://docs.convex.dev/functions/runtimes) with 33 | [determinism guardrails](https://docs.convex.dev/functions/runtimes#using-randomness-and-time-in-queries-and-mutations) 34 | to provide the strongest ACID guarantees on the market: 35 | immediate consistency, 36 | serializable isolation, and 37 | automatic conflict resolution via 38 | [optimistic multi-version concurrency control](https://docs.convex.dev/database/advanced/occ) (OCC / MVCC). 39 | 40 | The [`action` server functions](https://docs.convex.dev/functions/actions) have 41 | access to external APIs and enable other side-effects and non-determinism in 42 | either our 43 | [optimized `v8` runtime](https://docs.convex.dev/functions/runtimes) or a more 44 | [flexible `node` runtime](https://docs.convex.dev/functions/runtimes#nodejs-runtime). 45 | 46 | Functions can run in the background via 47 | [scheduling](https://docs.convex.dev/scheduling/scheduled-functions) and 48 | [cron jobs](https://docs.convex.dev/scheduling/cron-jobs). 49 | 50 | Development is cloud-first, with 51 | [hot reloads for server function](https://docs.convex.dev/cli#run-the-convex-dev-server) editing via the 52 | [CLI](https://docs.convex.dev/cli). There is a 53 | [dashbord UI](https://docs.convex.dev/dashboard) to 54 | [browse and edit data](https://docs.convex.dev/dashboard/deployments/data), 55 | [edit environment variables](https://docs.convex.dev/production/environment-variables), 56 | [view logs](https://docs.convex.dev/dashboard/deployments/logs), 57 | [run server functions](https://docs.convex.dev/dashboard/deployments/functions), and more. 58 | 59 | There are built-in features for 60 | [reactive pagination](https://docs.convex.dev/database/pagination), 61 | [file storage](https://docs.convex.dev/file-storage), 62 | [reactive search](https://docs.convex.dev/text-search), 63 | [https endpoints](https://docs.convex.dev/functions/http-actions) (for webhooks), 64 | [streaming import/export](https://docs.convex.dev/database/import-export/), and 65 | [runtime data validation](https://docs.convex.dev/database/schemas#validators) for 66 | [function arguments](https://docs.convex.dev/functions/args-validation) and 67 | [database data](https://docs.convex.dev/database/schemas#schema-validation). 68 | 69 | Everything scales automatically, and it’s [free to start](https://www.convex.dev/plans). 70 | -------------------------------------------------------------------------------- /src/App.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | padding: var(--size-xl); 3 | padding-top: var(--size-9xl); 4 | padding-bottom: 11.5rem; 5 | max-width: 1600px; 6 | margin: 0 auto; 7 | 8 | > div { 9 | flex-basis: 0; 10 | flex-grow: 1; 11 | } 12 | 13 | @media (min-width: 1024px) { 14 | padding-top: var(--size-xl); 15 | padding-bottom: var(--size-xl); 16 | display: flex; 17 | gap: var(--size-10xl); 18 | } 19 | } 20 | 21 | .username { 22 | background: var(--color-dark); 23 | border: none; 24 | border-bottom: 1px solid var(--color-light); 25 | color: var(--color-white); 26 | flex-grow: 1; 27 | font-size: var(--size-xl); 28 | height: var(--size-8xl); 29 | left: 0; 30 | padding: var(--size-xs); 31 | position: fixed; 32 | right: 0; 33 | top: 0; 34 | 35 | &:focus { 36 | outline: none; 37 | border-bottom: 1px solid var(--color-white); 38 | } 39 | 40 | @media (min-width: 1024px) { 41 | position: static; 42 | border: 1px solid var(--color-light); 43 | width: 100%; 44 | 45 | &:focus { 46 | border: 1px solid var(--color-white); 47 | } 48 | } 49 | } 50 | 51 | .startGame { 52 | padding: var(--size-lg); 53 | border-top: 1px solid var(--color-light); 54 | position: fixed; 55 | bottom: 0; 56 | left: 0; 57 | right: 0; 58 | background: var(--color-dark); 59 | 60 | @media (min-width: 1024px) { 61 | position: static; 62 | border: none; 63 | padding: 0; 64 | } 65 | } 66 | 67 | .actions { 68 | display: flex; 69 | gap: var(--size-xs); 70 | height: 4.5rem; 71 | align-items: center; 72 | 73 | @media (min-width: 1024px) { 74 | flex-direction: column; 75 | align-items: flex-start; 76 | height: auto; 77 | } 78 | } 79 | 80 | .subtitle { 81 | font-size: var(--size-2xl); 82 | font-family: var(--font-family-display); 83 | font-variation-settings: "wdth" 25; 84 | font-weight: bold; 85 | letter-spacing: var(--letter-spacing-tight); 86 | margin-bottom: var(--size-md); 87 | } 88 | 89 | .gameCodeForm { 90 | flex-basis: 0; 91 | flex-grow: 1; 92 | display: flex; 93 | height: 4rem; 94 | 95 | input { 96 | background: none; 97 | border: 1px solid var(--color-primary); 98 | padding: var(--size-xs); 99 | font-size: var(--size-md); 100 | flex-grow: 1; 101 | flex-basis: 0; 102 | width: 100px; 103 | color: var(--color-light); 104 | 105 | &:focus { 106 | outline: none; 107 | color: var(--color-white); 108 | } 109 | } 110 | 111 | button { 112 | background: var(--color-primary); 113 | color: var(--color-black); 114 | border: none; 115 | padding: var(--size-2xs); 116 | font-size: var(--size-md); 117 | } 118 | 119 | @media (min-width: 1024px) { 120 | min-width: 25rem; 121 | } 122 | } 123 | 124 | .hostButton { 125 | background: var(--color-primary); 126 | color: var(--color-black); 127 | flex-basis: 0; 128 | flex-grow: 1; 129 | font-size: var(--size-lg); 130 | height: 4rem; 131 | border: none; 132 | 133 | @media (min-width: 1024px) { 134 | padding: var(--size-xl); 135 | min-width: 25rem; 136 | } 137 | } 138 | 139 | .or { 140 | border: 1px solid var(--color-light); 141 | border-radius: 50%; 142 | width: var(--size-2xl); 143 | height: var(--size-2xl); 144 | display: flex; 145 | align-items: center; 146 | justify-content: center; 147 | font-size: var(--size-sm); 148 | background: var(--color-dark); 149 | flex-shrink: 0; 150 | } 151 | 152 | .header { 153 | margin-bottom: var(--size-5xl); 154 | 155 | @media (min-width: 1024px) { 156 | margin-bottom: var(--size-10xl); 157 | } 158 | } 159 | 160 | .faces { 161 | height: auto; 162 | width: 100%; 163 | } 164 | 165 | .title { 166 | font-family: var(--font-family-display); 167 | font-size: var(--size-8xl); 168 | font-variation-settings: "wdth" 25; 169 | font-weight: bold; 170 | letter-spacing: var(--letter-spacing-tight); 171 | line-height: var(--line-height-none); 172 | text-transform: uppercase; 173 | margin: var(--size-xl) 0; 174 | } 175 | 176 | .convex { 177 | font-size: var(--size-xl); 178 | display: flex; 179 | align-items: center; 180 | gap: var(--size-3xs); 181 | 182 | a { 183 | text-decoration: none; 184 | color: var(--color-white); 185 | 186 | &:hover { 187 | text-decoration: underline; 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /convex/openai.ts: -------------------------------------------------------------------------------- 1 | "use node"; 2 | import { internal } from "./_generated/api"; 3 | import { 4 | Configuration, 5 | CreateModerationResponseResultsInner, 6 | OpenAIApi, 7 | } from "openai"; 8 | import { action, internalAction } from "./_generated/server"; 9 | import { v } from "convex/values"; 10 | import { OptionResult } from "./round"; 11 | 12 | export const addOption = action({ 13 | args: { 14 | gameId: v.optional(v.id("games")), 15 | roundId: v.id("rounds"), 16 | prompt: v.string(), 17 | sessionId: v.id("sessions"), 18 | }, 19 | handler: async ( 20 | ctx, 21 | { gameId, roundId, prompt, sessionId } 22 | ): Promise => { 23 | const openai = makeOpenAIClient(); 24 | // Check if the prompt is offensive. 25 | const modResponse = await openai.createModeration({ 26 | input: prompt, 27 | }); 28 | const modResult = modResponse.data.results[0]; 29 | if (modResult.flagged) { 30 | return { 31 | success: false, 32 | retry: false, 33 | reason: `Your prompt was flagged: ${flaggedCategories(modResult).join( 34 | ", " 35 | )}`, 36 | } as const; 37 | } 38 | const status = (await ctx.runMutation(internal.round.addOption, { 39 | sessionId, 40 | gameId, 41 | roundId, 42 | prompt, 43 | })) as OptionResult; // Casting to avoid circular reference. 44 | return status; 45 | }, 46 | }); 47 | 48 | const makeOpenAIClient = () => { 49 | const apiKey = process.env.OPENAI_API_KEY; 50 | if (!apiKey) { 51 | throw new Error( 52 | "Add your OPENAI_API_KEY as an env variable in the " + 53 | "[dashboard](https://dasboard.convex.dev)" 54 | ); 55 | } 56 | return new OpenAIApi(new Configuration({ apiKey })); 57 | }; 58 | 59 | const flaggedCategories = ( 60 | modResult: CreateModerationResponseResultsInner 61 | ): string[] => { 62 | return Object.entries(modResult.categories) 63 | .filter(([, flagged]) => flagged) 64 | .map(([category]) => category); 65 | }; 66 | 67 | export const createImage = internalAction({ 68 | args: { prompt: v.string(), submissionId: v.id("submissions") }, 69 | handler: async (ctx, { prompt, submissionId }) => { 70 | const start = Date.now(); 71 | const elapsedMs = () => Date.now() - start; 72 | const openai = makeOpenAIClient(); 73 | 74 | const fail = (reason: string): Promise => 75 | ctx 76 | .runMutation(internal.submissions.update, { 77 | submissionId, 78 | result: { 79 | status: "failed", 80 | elapsedMs: elapsedMs(), 81 | reason, 82 | }, 83 | }) 84 | .then(() => { 85 | throw new Error(reason); 86 | }); 87 | 88 | await ctx.runMutation(internal.submissions.update, { 89 | submissionId, 90 | result: { 91 | status: "generating", 92 | details: "Moderating prompt...", 93 | }, 94 | }); 95 | // Check if the prompt is offensive. 96 | const modResponse = await openai.createModeration({ 97 | input: prompt, 98 | }); 99 | const modResult = modResponse.data.results[0]; 100 | if (modResult.flagged) { 101 | await fail( 102 | `Your prompt was flagged: ${flaggedCategories(modResult).join(", ")}` 103 | ); 104 | } 105 | 106 | await ctx.runMutation(internal.submissions.update, { 107 | submissionId, 108 | result: { 109 | status: "generating", 110 | details: "Generating image...", 111 | }, 112 | }); 113 | // Query OpenAI for the image. 114 | const opanaiResponse = await openai.createImage({ 115 | prompt, 116 | size: "512x512", 117 | }); 118 | const dallEImageUrl = opanaiResponse.data.data[0]["url"]; 119 | if (!dallEImageUrl) return await fail("No image URL returned from OpenAI"); 120 | 121 | await ctx.runMutation(internal.submissions.update, { 122 | submissionId, 123 | result: { 124 | status: "generating", 125 | details: "Storing image...", 126 | }, 127 | }); 128 | // Download the image 129 | const imageResponse = await fetch(dallEImageUrl); 130 | if (!imageResponse.ok) { 131 | await fail(`failed to download: ${imageResponse.statusText}`); 132 | } 133 | 134 | // Store it in Convex storage 135 | const storageId = await ctx.storage.store(await imageResponse.blob()); 136 | 137 | // Write storageId as the body of the message to the Convex database. 138 | await ctx.runMutation(internal.submissions.update, { 139 | submissionId, 140 | result: { 141 | status: "saved", 142 | imageStorageId: storageId, 143 | elapsedMs: elapsedMs(), 144 | }, 145 | }); 146 | }, 147 | }); 148 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import { useQuery } from "convex/react"; 3 | import { useCallback, useEffect, useState } from "react"; 4 | import type { Id } from "../convex/_generated/dataModel"; 5 | import Game from "./Game"; 6 | import GameRound from "./GameRound"; 7 | import { useSessionMutation } from "./hooks/useServerSession"; 8 | 9 | const ConvexIdLength = 31; 10 | 11 | function App() { 12 | const hostGame = useSessionMutation(api.game.create); 13 | const [gameId, setGameId] = useState(() => { 14 | if (typeof window === "undefined") return null; 15 | const id = window.location.hash.substring(1); 16 | if (!id || id.length !== ConvexIdLength) return null; 17 | return id as Id<"games">; 18 | }); 19 | useEffect(() => { 20 | if (typeof window === "undefined") return; 21 | if (gameId) window.location.hash = gameId; 22 | else window.location.hash = ""; 23 | }, [gameId]); 24 | const [gameCode, setGameCode] = useState(""); 25 | const joinGame = useSessionMutation(api.game.join); 26 | const publicRoundId = useQuery(api.publicGame.get); 27 | const done = useCallback((gameId: Id<"games"> | null) => { 28 | setGameId(gameId); 29 | }, []); 30 | 31 | return ( 32 |
33 |
34 |
35 | Cartoon faces 36 |

37 | Whose Prompt is it Anyways? 38 |

39 |
40 | by{" "} 41 | 45 | Convex logo 46 | Convex 47 | 48 |
49 |
50 | {!gameId && ( 51 |
52 |
53 | Play with friends! 54 |
55 |
56 | Try to guess what text prompt generated the image. Fool your 57 | friends to score points! 58 |
59 |
60 |
{ 62 | e.preventDefault(); 63 | setGameId(await joinGame({ gameCode })); 64 | }} 65 | className="flex grow basis-0 lg:w-full" 66 | > 67 | setGameCode(e.target.value.substring(0, 4))} 72 | className="h-12 w-0 grow border border-blue-200 bg-transparent p-2 text-blue-200 placeholder:text-blue-200" 73 | /> 74 | 80 |
81 | 82 | or 83 | 84 | 92 |
93 |
94 | )} 95 |
96 |
97 | {gameId ? ( 98 | 99 | ) : ( 100 | <> 101 |

102 | Try it out: 103 |

104 | {publicRoundId ? ( 105 | 106 | ) : ( 107 |
108 | )} 109 | 110 | )} 111 |
112 |
113 | ); 114 | } 115 | 116 | export default App; 117 | -------------------------------------------------------------------------------- /src/GuessStage.tsx: -------------------------------------------------------------------------------- 1 | import { api } from "../convex/_generated/api"; 2 | import classNames from "classnames"; 3 | import { useState } from "react"; 4 | import { GuessState } from "../convex/shared"; 5 | import { Id } from "../convex/_generated/dataModel"; 6 | import { useSessionMutation } from "./hooks/useServerSession"; 7 | import { Submissions } from "./Submissions"; 8 | 9 | export function GuessStage({ 10 | round, 11 | roundId, 12 | gameId, 13 | }: { 14 | round: GuessState; 15 | roundId: Id<"rounds">; 16 | gameId?: Id<"games">; 17 | }) { 18 | const submitGuess = useSessionMutation(api.round.guess); 19 | const addLike = useSessionMutation(api.round.like); 20 | const [error, setError] = useState(); 21 | const [likes, setLikes] = useState>(new Set()); 22 | return ( 23 |
24 | 29 |
30 | 31 | {round.mine 32 | ? "This was your image." 33 | : "What prompt was responsible for this image?"} 34 | 35 |
    36 | {round.options.map((option) => ( 37 |
  • 38 | 39 | {option === round.myGuess && error} 40 | 41 |
    42 | 72 | 105 |
    106 |
  • 107 | ))} 108 |
109 |
110 | {(round.mine || round.submitted.find((submission) => submission.me)) && ( 111 | 112 | )} 113 |
114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.7.1. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /convex/users.ts: -------------------------------------------------------------------------------- 1 | import { UserIdentity } from "convex/server"; 2 | import { myMutation, sessionMutation, sessionQuery } from "./lib/myFunctions"; 3 | import md5 from "md5"; 4 | import { DatabaseReader, DatabaseWriter } from "./_generated/server"; 5 | import { Doc, Id } from "./_generated/dataModel"; 6 | import { randomSlug } from "./lib/randomSlug"; 7 | import { v } from "convex/values"; 8 | 9 | export const loggedIn = sessionMutation({ 10 | args: {}, 11 | handler: async (ctx) => { 12 | const identity = await ctx.auth.getUserIdentity(); 13 | if (!identity) { 14 | throw new Error("Trying to store a user without authentication present."); 15 | } 16 | const userId = await getOrCreateUser(ctx.db, identity); 17 | if (userId !== ctx.session.userId) { 18 | claimSessionUser(ctx.db, ctx.session, userId); 19 | } 20 | }, 21 | }); 22 | 23 | async function claimSessionUser( 24 | db: DatabaseWriter, 25 | session: Doc<"sessions">, 26 | newUserId: Id<"users"> 27 | ) { 28 | const userToClaim = (await db.get(session.userId))!; 29 | if (!userToClaim.tokenIdentifier) { 30 | // Point the old user to the actual logged-in user. 31 | await db.patch(userToClaim._id, { claimedByUserId: newUserId }); 32 | } 33 | // Point the session at the new user going forward. 34 | await db.patch(session._id, { userId: newUserId }); 35 | for (const submissionId of session.submissionIds) { 36 | const submission = await db.get(submissionId); 37 | if (submission && submission.authorId === userToClaim._id) { 38 | await db.patch(submission?._id, { authorId: newUserId }); 39 | } 40 | } 41 | for (const gameId of session.gameIds) { 42 | const game = (await db.get(gameId))!; 43 | if (game.hostId === userToClaim._id) { 44 | await db.patch(game._id, { hostId: newUserId }); 45 | } 46 | const playerIds = game.playerIds.map((playerId) => 47 | playerId === userToClaim._id ? newUserId : playerId 48 | ); 49 | await db.patch(game._id, { playerIds }); 50 | for (const roundId of game.roundIds) { 51 | const round = (await db.get(roundId))!; 52 | if (round.authorId === userToClaim._id) { 53 | await db.patch(round._id, { authorId: newUserId }); 54 | } 55 | const options = round.options.map((option) => 56 | option.authorId === userToClaim._id 57 | ? { ...option, authorId: newUserId } 58 | : option 59 | ); 60 | await db.patch(round._id, { options }); 61 | } 62 | } 63 | } 64 | 65 | /** 66 | * Gets the name from the current session. 67 | */ 68 | export const getMyProfile = sessionQuery({ 69 | args: {}, 70 | handler: async (ctx) => { 71 | if (!ctx.session) return null; 72 | const { name, pictureUrl } = await getUserById(ctx.db, ctx.session.userId); 73 | return { name, pictureUrl }; 74 | }, 75 | }); 76 | 77 | /** 78 | * Updates the name in the current session. 79 | */ 80 | export const setName = sessionMutation({ 81 | args: { name: v.string() }, 82 | handler: async (ctx, { name }) => { 83 | const user = await getUserById(ctx.db, ctx.session.userId); 84 | if (name.length > 100) throw new Error("Name too long"); 85 | await ctx.db.patch(user._id, { name }); 86 | }, 87 | }); 88 | 89 | export const setPicture = sessionMutation({ 90 | args: { submissionId: v.id("submissions") }, 91 | handler: async (ctx, { submissionId }) => { 92 | const submission = await ctx.db.get(submissionId); 93 | if (!submission) throw new Error("No submission found"); 94 | if (submission.result.status !== "saved") throw new Error("Bad submission"); 95 | const pictureUrl = await ctx.storage.getUrl( 96 | submission.result.imageStorageId 97 | ); 98 | if (!pictureUrl) throw new Error("Picture is missing"); 99 | await ctx.db.patch(ctx.session.userId, { pictureUrl }); 100 | }, 101 | }); 102 | 103 | export const getUserById = async (db: DatabaseReader, userId: Id<"users">) => { 104 | let user = (await db.get(userId))!; 105 | while (user.claimedByUserId) { 106 | user = (await db.get(user.claimedByUserId))!; 107 | } 108 | return user; 109 | }; 110 | 111 | async function getUser(db: DatabaseReader, tokenIdentifier: string) { 112 | return await db 113 | .query("users") 114 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", tokenIdentifier)) 115 | .unique(); 116 | } 117 | 118 | export const getOrCreateUser = async ( 119 | db: DatabaseWriter, 120 | identity: UserIdentity 121 | ) => { 122 | const existing = await getUser(db, identity.tokenIdentifier); 123 | if (existing) return existing._id; 124 | return await db.insert("users", { 125 | name: identity.givenName ?? identity.name!, 126 | pictureUrl: identity.pictureUrl ?? createGravatarUrl(identity.email!), 127 | tokenIdentifier: identity.tokenIdentifier, 128 | }); 129 | }; 130 | 131 | export const createAnonymousUser = (db: DatabaseWriter) => { 132 | return db.insert("users", { 133 | // TODO: make this name fun & random 134 | name: "", 135 | pictureUrl: createGravatarUrl(randomSlug()), 136 | }); 137 | }; 138 | 139 | export const loggedOut = sessionMutation({ 140 | args: {}, 141 | handler: async (ctx) => { 142 | // Wipe the slate clean 143 | await ctx.db.replace(ctx.session._id, { 144 | userId: await createAnonymousUser(ctx.db), 145 | gameIds: [], 146 | submissionIds: [], 147 | }); 148 | }, 149 | }); 150 | 151 | function createGravatarUrl(key: string): string { 152 | key = key.trim().toLocaleLowerCase(); 153 | const hash = md5(key); 154 | // See https://en.gravatar.com/site/implement/images/ for details. 155 | // ?d=monsterid uses a default of a monster image when the hash isn't found. 156 | return `https://www.gravatar.com/avatar/${hash}?d=monsterid`; 157 | } 158 | 159 | /** 160 | * Creates a session and returns the id. For use with the SessionProvider on the 161 | * client. 162 | */ 163 | export const createSession = myMutation({ 164 | args: {}, 165 | handler: async (ctx) => { 166 | const identity = await ctx.auth.getUserIdentity(); 167 | let userId = identity && (await getOrCreateUser(ctx.db, identity)); 168 | if (!userId) { 169 | userId = await createAnonymousUser(ctx.db); 170 | } 171 | return ctx.db.insert("sessions", { 172 | userId, 173 | gameIds: [], 174 | submissionIds: [], 175 | }); 176 | }, 177 | }); 178 | -------------------------------------------------------------------------------- /src/hooks/useServerSession.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * React helpers for adding session data to Convex functions. 3 | * 4 | * !Important!: To use these functions, you must wrap your code with 5 | * ```tsx 6 | * 7 | * 8 | * 9 | * 10 | * 11 | * ``` 12 | * 13 | * With the `SessionProvider` inside the `ConvexProvider` but outside your app. 14 | */ 15 | import React, { useContext, useEffect, useState } from "react"; 16 | import { Id } from "../../convex/_generated/dataModel"; 17 | import { FunctionReference, OptionalRestArgs } from "convex/server"; 18 | import { api } from "../../convex/_generated/api"; 19 | import { useQuery, useMutation, useAction } from "convex/react"; 20 | 21 | const StoreKey = "ConvexSessionId"; 22 | 23 | const SessionContext = React.createContext | null>(null); 24 | 25 | /** 26 | * Context for a Convex session, creating a server session and providing the id. 27 | * 28 | * @param props - Where you want your session ID to be persisted. Roughly: 29 | * - sessionStorage is saved per-tab 30 | * - localStorage is shared between tabs, but not browser profiles. 31 | * @returns A provider to wrap your React nodes which provides the session ID. 32 | * To be used with useSessionQuery and useSessionMutation. 33 | */ 34 | export const SessionProvider: React.FC<{ 35 | storageLocation?: "localStorage" | "sessionStorage"; 36 | children?: React.ReactNode; 37 | }> = ({ storageLocation, children }) => { 38 | const store = 39 | // If it's rendering in SSR or such. 40 | typeof window === "undefined" 41 | ? null 42 | : window[storageLocation ?? "sessionStorage"]; 43 | const [sessionId, setSession] = useState | null>(() => { 44 | const stored = store?.getItem(StoreKey); 45 | if (stored) { 46 | return stored as Id<"sessions">; 47 | } 48 | return null; 49 | }); 50 | const createSession = useMutation(api.users.createSession); 51 | 52 | // Get or set the ID from our desired storage location, whenever it changes. 53 | useEffect(() => { 54 | if (sessionId) { 55 | store?.setItem(StoreKey, sessionId); 56 | } else { 57 | void (async () => { 58 | setSession(await createSession()); 59 | })(); 60 | } 61 | }, [sessionId, createSession, store]); 62 | 63 | return React.createElement( 64 | SessionContext.Provider, 65 | { value: sessionId }, 66 | children 67 | ); 68 | }; 69 | 70 | type SessionFunction = FunctionReference< 71 | "query" | "mutation" | "action", 72 | "public", 73 | { sessionId: Id<"sessions"> | null } & Args, 74 | any 75 | >; 76 | 77 | type SessionFunctionArgsArray> = 78 | keyof Fn["_args"] extends "sessionId" 79 | ? [] 80 | : [BetterOmit]; 81 | 82 | type SessionFunctionArgs> = 83 | keyof Fn["_args"] extends "sessionId" 84 | ? EmptyObject 85 | : BetterOmit; 86 | 87 | // Like useQuery, but for a Query that takes a session ID. 88 | export function useSessionQuery< 89 | Query extends FunctionReference< 90 | "query", 91 | "public", 92 | { sessionId: Id<"sessions"> | null }, 93 | any 94 | > 95 | >( 96 | query: Query, 97 | ...args: SessionFunctionArgsArray 98 | ): Query["_returnType"] | undefined { 99 | const sessionId = useContext(SessionContext); 100 | const originalArgs = args[0] ?? {}; 101 | 102 | const newArgs = { ...originalArgs, sessionId }; 103 | 104 | return useQuery(query, ...([newArgs] as OptionalRestArgs)); 105 | } 106 | 107 | // Like useMutation, but for a Mutation that takes a session ID. 108 | export const useSessionMutation = < 109 | Mutation extends FunctionReference< 110 | "mutation", 111 | "public", 112 | { sessionId: string }, 113 | any 114 | > 115 | >( 116 | name: Mutation 117 | ) => { 118 | const sessionId = useContext(SessionContext); 119 | const originalMutation = useMutation(name); 120 | 121 | return ( 122 | ...args: SessionFunctionArgsArray 123 | ): Promise => { 124 | const newArgs = { ...(args[0] ?? {}), sessionId } as Mutation["_args"]; 125 | 126 | return originalMutation(...([newArgs] as OptionalRestArgs)); 127 | }; 128 | }; 129 | 130 | // Like useAction, but for an Action that takes a session ID. 131 | export const useSessionAction = < 132 | Action extends FunctionReference< 133 | "action", 134 | "public", 135 | { sessionId: string }, 136 | any 137 | > 138 | >( 139 | name: Action 140 | ) => { 141 | const sessionId = useContext(SessionContext); 142 | const originalAction = useAction(name); 143 | 144 | return ( 145 | ...args: SessionFunctionArgsArray 146 | ): Promise => { 147 | const newArgs = { ...(args[0] ?? {}), sessionId } as Action["_args"]; 148 | 149 | return originalAction(...([newArgs] as OptionalRestArgs)); 150 | }; 151 | }; 152 | 153 | // Type utils: 154 | type EmptyObject = Record; 155 | 156 | /** 157 | * An `Omit<>` type that: 158 | * 1. Applies to each element of a union. 159 | * 2. Preserves the index signature of the underlying type. 160 | */ 161 | declare type BetterOmit = { 162 | [Property in keyof T as Property extends K ? never : Property]: T[Property]; 163 | }; 164 | 165 | /** 166 | * TESTS 167 | */ 168 | 169 | /** 170 | * Tests if two types are exactly the same. 171 | * Taken from https://github.com/Microsoft/TypeScript/issues/27024#issuecomment-421529650 172 | * (Apache Version 2.0, January 2004) 173 | */ 174 | export type Equals = (() => T extends X ? 1 : 2) extends < 175 | T 176 | >() => T extends Y ? 1 : 2 177 | ? true 178 | : false; 179 | 180 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 181 | export function assert() { 182 | // no need to do anything! we're just asserting at compile time that the type 183 | // parameter is true. 184 | } 185 | 186 | assert< 187 | Equals< 188 | SessionFunctionArgs< 189 | FunctionReference< 190 | "query", 191 | "public", 192 | { arg: string; sessionId: Id<"sessions"> | null }, 193 | any 194 | > 195 | >, 196 | { arg: string } 197 | > 198 | >(); 199 | assert< 200 | Equals< 201 | SessionFunctionArgs< 202 | FunctionReference< 203 | "query", 204 | "public", 205 | { sessionId: Id<"sessions"> | null }, 206 | any 207 | > 208 | >, 209 | EmptyObject 210 | > 211 | >(); 212 | -------------------------------------------------------------------------------- /convex/game.ts: -------------------------------------------------------------------------------- 1 | import { api } from "./_generated/api"; 2 | import { calculateScoreDeltas, newRound, startRound } from "./round"; 3 | import { ClientGameState, MaxPlayers } from "./shared"; 4 | import { getUserById } from "./users"; 5 | import { Doc, Id } from "./_generated/dataModel"; 6 | import { randomSlug } from "./lib/randomSlug"; 7 | import { v } from "convex/values"; 8 | import { asyncMap, pruneNull } from "convex-helpers"; 9 | import { getAll } from "convex-helpers/server/relationships"; 10 | import { sessionMutation, sessionQuery } from "./lib/myFunctions"; 11 | 12 | const GenerateDurationMs = 120000; 13 | 14 | export const create = sessionMutation({ 15 | args: {}, 16 | handler: async (ctx) => { 17 | const gameId = await ctx.db.insert("games", { 18 | hostId: ctx.session.userId, 19 | playerIds: [ctx.session.userId], 20 | roundIds: [], 21 | slug: randomSlug(), 22 | state: { stage: "lobby" }, 23 | }); 24 | ctx.session.gameIds.push(gameId); 25 | await ctx.db.patch(ctx.session._id, { gameIds: ctx.session.gameIds }); 26 | return gameId; 27 | }, 28 | }); 29 | 30 | export const playAgain = sessionMutation({ 31 | args: { oldGameId: v.id("games") }, 32 | handler: async (ctx, { oldGameId }) => { 33 | const oldGame = await ctx.db.get(oldGameId); 34 | if (!oldGame) throw new Error("Old game doesn't exist"); 35 | if (!oldGame.playerIds.find((id) => id === ctx.session.userId)) { 36 | throw new Error("You weren't part of that game"); 37 | } 38 | const gameId = await ctx.db.insert("games", { 39 | hostId: ctx.session.userId, 40 | playerIds: oldGame.playerIds, 41 | roundIds: [], 42 | slug: oldGame.slug, 43 | state: { stage: "lobby" }, 44 | }); 45 | await ctx.db.patch(oldGame._id, { nextGameId: gameId }); 46 | ctx.session.gameIds.push(gameId); 47 | await ctx.db.patch(ctx.session._id, { gameIds: ctx.session.gameIds }); 48 | return gameId; 49 | }, 50 | }); 51 | 52 | export const get = sessionQuery({ 53 | args: { gameId: v.id("games") }, 54 | handler: async (ctx, { gameId }): Promise => { 55 | // Grab the most recent game with this code. 56 | const game = await ctx.db.get(gameId); 57 | if (!game) throw new Error("Game not found"); 58 | const rounds = pruneNull(await getAll(ctx.db, game.roundIds)); 59 | const playerLikes: Record, number> = {}; 60 | const playerScore: Record, number> = {}; 61 | for (const round of rounds) { 62 | if (round.stage === "reveal") { 63 | for (const option of round.options) { 64 | playerLikes[option.authorId] = 65 | option.likes.length + (playerLikes[option.authorId] ?? 0); 66 | for (const { userId, score: delta } of calculateScoreDeltas( 67 | option.authorId === round.authorId, 68 | option 69 | )) { 70 | playerScore[userId] = delta + (playerScore[userId] ?? 0); 71 | } 72 | } 73 | } 74 | } 75 | const roundPlayerIds = rounds.map((round) => round.authorId); 76 | const players = await asyncMap(game.playerIds, async (playerId) => { 77 | const player = (await getUserById(ctx.db, playerId))!; 78 | const { name, pictureUrl } = player; 79 | return { 80 | me: player._id === ctx.session?.userId, 81 | name, 82 | pictureUrl, 83 | score: playerScore[player._id] ?? 0, 84 | likes: playerLikes[player._id] ?? 0, 85 | submitted: !!roundPlayerIds.find((id) => id === playerId), 86 | }; 87 | }); 88 | return { 89 | gameCode: game.slug, 90 | hosting: game.hostId === ctx.session?.userId, 91 | playing: !!game.playerIds.find((id) => id === ctx.session?.userId), 92 | players, 93 | state: game.state, 94 | nextGameId: game.nextGameId ?? null, 95 | }; 96 | }, 97 | }); 98 | 99 | export const join = sessionMutation({ 100 | args: { gameCode: v.string() }, 101 | handler: async (ctx, { gameCode }) => { 102 | // Grab the most recent game with this gameCode, if it exists 103 | const game = await ctx.db 104 | .query("games") 105 | .withIndex("s", (q) => q.eq("slug", gameCode)) 106 | .order("desc") 107 | .first(); 108 | if (!game) throw new Error("Game not found"); 109 | if (game.playerIds.length >= MaxPlayers) throw new Error("Game is full"); 110 | if (game.state.stage !== "lobby") throw new Error("Game has started"); 111 | // keep session up to date, so we know what game this session's in. 112 | ctx.session.gameIds.push(game._id); 113 | await ctx.db.patch(ctx.session._id, { gameIds: ctx.session.gameIds }); 114 | // Already in game 115 | if (game.playerIds.find((id) => id === ctx.session.userId) !== undefined) { 116 | console.warn("User joining game they're already in"); 117 | } else { 118 | const playerIds = game.playerIds; 119 | playerIds.push(ctx.session.userId); 120 | await ctx.db.patch(game._id, { playerIds }); 121 | } 122 | 123 | return game._id; 124 | }, 125 | }); 126 | 127 | export const submit = sessionMutation({ 128 | args: { submissionId: v.id("submissions"), gameId: v.id("games") }, 129 | handler: async (ctx, { submissionId, gameId }) => { 130 | const game = await ctx.db.get(gameId); 131 | if (!game) throw new Error("Game not found"); 132 | const submission = await ctx.db.get(submissionId); 133 | if (submission?.result.status !== "saved") { 134 | throw new Error(`Can't add ${submission?.result.status} submissions.`); 135 | } 136 | if (submission.authorId !== ctx.session.userId) { 137 | throw new Error("This is not your submission."); 138 | } 139 | const { authorId, prompt, result } = submission; 140 | for (const roundId of game.roundIds) { 141 | const round = (await ctx.db.get(roundId))!; 142 | if (round.authorId === authorId) { 143 | throw new Error("You already submitted."); 144 | } 145 | } 146 | const roundIds = game.roundIds; 147 | roundIds.push( 148 | await ctx.db.insert( 149 | "rounds", 150 | newRound(authorId, result.imageStorageId, prompt) 151 | ) 152 | ); 153 | await ctx.db.patch(game._id, { roundIds }); 154 | // Start the game, everyone's submitted. 155 | if (roundIds.length === game.playerIds.length) { 156 | await ctx.db.patch(game._id, { 157 | state: { stage: "rounds", roundId: game.roundIds[0] }, 158 | }); 159 | await startRound(ctx.db, game.roundIds[0]); 160 | } 161 | }, 162 | }); 163 | 164 | export const progress = sessionMutation({ 165 | args: { 166 | gameId: v.id("games"), 167 | fromStage: v.union( 168 | v.literal("lobby"), 169 | v.literal("generate"), 170 | v.literal("label"), 171 | v.literal("guess"), 172 | v.literal("reveal"), 173 | v.literal("rounds"), 174 | v.literal("votes"), 175 | v.literal("recap") 176 | ), 177 | }, 178 | handler: async (ctx, { gameId, fromStage }) => { 179 | const game = await ctx.db.get(gameId); 180 | if (!game) throw new Error("Game not found"); 181 | if (game.hostId !== ctx.session.userId) 182 | throw new Error("You are not the host"); 183 | const state = nextState(game.state, game.roundIds); 184 | if (game.state.stage !== fromStage) { 185 | // Just ignore requests that have already been applied. 186 | if (fromStage === state.stage) return; 187 | throw new Error( 188 | `Game ${gameId}(${game.state.stage}) is no longer in stage ${fromStage}` 189 | ); 190 | } 191 | if (state.stage === "rounds") { 192 | await startRound(ctx.db, state.roundId); 193 | } 194 | game.state = state; 195 | await ctx.db.replace(game._id, game); 196 | if (state.stage === "lobby") { 197 | await ctx.scheduler.runAfter(GenerateDurationMs, api.game.progress, { 198 | sessionId: ctx.session._id, 199 | gameId, 200 | fromStage: state.stage, 201 | }); 202 | } 203 | }, 204 | }); 205 | 206 | const nextState = ( 207 | fromState: Doc<"games">["state"], 208 | roundIds: Id<"rounds">[] 209 | ): Doc<"games">["state"] => { 210 | let state = { ...fromState }; 211 | switch (state.stage) { 212 | case "lobby": 213 | state.stage = "generate"; 214 | break; 215 | case "generate": 216 | if (roundIds.length === 0) throw new Error("Game has no rounds"); 217 | state = { 218 | stage: "rounds", 219 | roundId: roundIds[0], 220 | }; 221 | break; 222 | case "rounds": 223 | if (state.roundId === roundIds[roundIds.length - 1]) { 224 | // If it was the last round, go to recap. 225 | state = { stage: "recap" }; 226 | } else { 227 | // Otherwise go to the next round. 228 | const lastRoundId = state.roundId; 229 | const prevIndex = roundIds.findIndex( 230 | (roundId) => roundId === lastRoundId 231 | ); 232 | if (prevIndex === -1) throw new Error("Previous round doesn't exist"); 233 | state.roundId = roundIds[prevIndex + 1]; 234 | } 235 | break; 236 | } 237 | return state; 238 | }; 239 | -------------------------------------------------------------------------------- /convex/round.ts: -------------------------------------------------------------------------------- 1 | import { api, internal } from "./_generated/api"; 2 | import { WithoutSystemFields } from "convex/server"; 3 | import { Doc, Id } from "./_generated/dataModel"; 4 | import { DatabaseWriter } from "./_generated/server"; 5 | import { GuessState, LabelState, MaxPlayers, RevealState } from "./shared"; 6 | import { v } from "convex/values"; 7 | import { asyncMap } from "convex-helpers"; 8 | import { 9 | internalSessionMutation, 10 | myMutation, 11 | sessionMutation, 12 | sessionQuery, 13 | } from "./lib/myFunctions"; 14 | 15 | const LabelDurationMs = 30000; 16 | const GuessDurationMs = 30000; 17 | const RevealDurationMs = 30000; 18 | 19 | export const newRound = ( 20 | authorId: Id<"users">, 21 | imageStorageId: string, 22 | prompt: string 23 | ): WithoutSystemFields> => ({ 24 | authorId, 25 | imageStorageId, 26 | stage: "label", 27 | stageStart: Date.now(), 28 | stageEnd: Date.now() + LabelDurationMs, 29 | options: [{ prompt, authorId, votes: [], likes: [] }], 30 | }); 31 | 32 | export const startRound = async (db: DatabaseWriter, roundId: Id<"rounds">) => { 33 | await db.patch(roundId, { 34 | stageStart: Date.now(), 35 | stageEnd: Date.now() + LabelDurationMs, 36 | }); 37 | }; 38 | 39 | export const getRound = sessionQuery({ 40 | args: { roundId: v.id("rounds") }, 41 | handler: async ( 42 | ctx, 43 | { roundId } 44 | ): Promise => { 45 | const round = await ctx.db.get(roundId); 46 | if (!round) throw new Error("Round not found"); 47 | const { stage, stageStart, stageEnd } = round; 48 | const imageUrl = await ctx.storage.getUrl(round.imageStorageId); 49 | if (!imageUrl) throw new Error("Image not found"); 50 | 51 | const userInfo = async (userId: Id<"users">) => { 52 | const user = (await ctx.db.get(userId))!; 53 | return { 54 | me: user._id === ctx.session?.userId, 55 | name: user.name, 56 | pictureUrl: user.pictureUrl, 57 | }; 58 | }; 59 | 60 | switch (stage) { 61 | case "label": 62 | const labelState: LabelState = { 63 | stage, 64 | mine: round.authorId === ctx.session?.userId, 65 | imageUrl, 66 | stageStart, 67 | stageEnd, 68 | submitted: await asyncMap(round.options, (option) => 69 | userInfo(option.authorId) 70 | ), 71 | }; 72 | return labelState; 73 | case "guess": 74 | const allGuesses = round.options.reduce( 75 | (all, { votes }) => all.concat(votes), 76 | [] as Id<"users">[] 77 | ); 78 | const myGuess = round.options.find( 79 | (o) => !!o.votes.find((voteId) => voteId === ctx.session?.userId) 80 | )?.prompt; 81 | const myPrompt = round.options.find( 82 | (o) => o.authorId === ctx.session?.userId 83 | )?.prompt; 84 | const guessState: GuessState = { 85 | options: round.options.map((option) => option.prompt), 86 | stage, 87 | mine: round.authorId === ctx.session?.userId, 88 | imageUrl, 89 | stageStart, 90 | stageEnd, 91 | myPrompt, 92 | myGuess, 93 | submitted: await asyncMap(allGuesses, userInfo), 94 | }; 95 | return guessState; 96 | case "reveal": 97 | const allUsers = new Set( 98 | round.options.map((option) => option.authorId) 99 | ); 100 | 101 | round.options.forEach((option) => { 102 | for (const id of option.votes) { 103 | allUsers.add(id); 104 | } 105 | for (const id of option.likes) { 106 | allUsers.add(id); 107 | } 108 | }); 109 | const revealState: RevealState = { 110 | results: round.options.map((option) => ({ 111 | authorId: option.authorId, 112 | prompt: option.prompt, 113 | votes: option.votes.map((uId) => uId), 114 | likes: option.likes.map((uId) => uId), 115 | scoreDeltas: calculateScoreDeltas( 116 | option.authorId === round.authorId, 117 | option 118 | ), 119 | })), 120 | stage, 121 | me: ctx.session!.userId, 122 | authorId: round.authorId, 123 | imageUrl, 124 | stageStart, 125 | stageEnd, 126 | users: await asyncMap(allUsers.keys(), async (userId) => ({ 127 | userId, 128 | ...(await userInfo(userId)), 129 | })), 130 | }; 131 | return revealState; 132 | } 133 | }, 134 | }); 135 | 136 | const CorrectAuthorScore = 1000; 137 | const AlternateAuthorScore = 500; 138 | const CorrectGuesserScore = 200; 139 | 140 | export function calculateScoreDeltas( 141 | isCorrect: boolean, 142 | option: Doc<"rounds">["options"][0] 143 | ) { 144 | const scoreDeltas = [ 145 | { 146 | userId: option.authorId, 147 | score: 148 | option.votes.length * 149 | (isCorrect ? CorrectAuthorScore : AlternateAuthorScore), 150 | }, 151 | ]; 152 | if (isCorrect) { 153 | for (const userId of option.votes) { 154 | scoreDeltas.push({ userId, score: CorrectGuesserScore }); 155 | } 156 | } 157 | return scoreDeltas; 158 | } 159 | 160 | // Courtesy of chat-gpt 161 | function levenshteinDistance(a: string, b: string) { 162 | if (a.length === 0) return b.length; 163 | if (b.length === 0) return a.length; 164 | 165 | const matrix = []; 166 | 167 | // Initialize the first row and column of the matrix 168 | for (let i = 0; i <= b.length; i++) { 169 | matrix[i] = [i]; 170 | } 171 | 172 | for (let j = 0; j <= a.length; j++) { 173 | matrix[0][j] = j; 174 | } 175 | 176 | // Calculate the matrix 177 | for (let i = 1; i <= b.length; i++) { 178 | for (let j = 1; j <= a.length; j++) { 179 | if (b.charAt(i - 1) === a.charAt(j - 1)) { 180 | matrix[i][j] = matrix[i - 1][j - 1]; 181 | } else { 182 | matrix[i][j] = Math.min( 183 | matrix[i - 1][j - 1] + 1, // substitution 184 | matrix[i][j - 1] + 1, // insertion 185 | matrix[i - 1][j] + 1 // deletion 186 | ); 187 | } 188 | } 189 | } 190 | 191 | return matrix[b.length][a.length]; 192 | } 193 | 194 | export type OptionResult = 195 | | { success: true } 196 | | { success: false; retry?: boolean; reason: string }; 197 | 198 | export const addOption = internalSessionMutation({ 199 | args: { 200 | gameId: v.optional(v.id("games")), 201 | roundId: v.id("rounds"), 202 | prompt: v.string(), 203 | }, 204 | handler: async (ctx, { gameId, roundId, prompt }): Promise => { 205 | const round = await ctx.db.get(roundId); 206 | if (!round) throw new Error("Round not found"); 207 | if (round.stage !== "label") { 208 | return { success: false, reason: "Too late to add a prompt." }; 209 | } 210 | if (round.authorId === ctx.session.userId) { 211 | throw new Error("You can't submit a prompt for your own image."); 212 | } 213 | if ( 214 | round.options.findIndex( 215 | (option) => option.authorId === ctx.session.userId 216 | ) !== -1 217 | ) { 218 | return { success: false, reason: "You already added a prompt." }; 219 | } 220 | if (round.options.length === MaxPlayers) { 221 | return { success: false, reason: "This round is full." }; 222 | } 223 | if ( 224 | round.options.findIndex( 225 | (option) => 226 | levenshteinDistance( 227 | option.prompt.toLocaleLowerCase(), 228 | prompt.toLocaleLowerCase() 229 | ) < 230 | prompt.length / 2 231 | ) !== -1 232 | ) { 233 | return { 234 | success: false, 235 | retry: true, 236 | reason: "This prompt is too similar to existing prompt(s).", 237 | }; 238 | } 239 | 240 | round.options.push({ 241 | authorId: ctx.session.userId, 242 | prompt, 243 | votes: [], 244 | likes: [], 245 | }); 246 | await ctx.db.patch(round._id, { options: round.options }); 247 | const game = gameId && (await ctx.db.get(gameId)); 248 | if (round.options.length === game?.playerIds.length) { 249 | // All players have added options 250 | await ctx.db.patch(round._id, beginGuessPatch(round)); 251 | await ctx.scheduler.runAfter(GuessDurationMs, api.round.progress, { 252 | roundId: round._id, 253 | fromStage: "guess", 254 | }); 255 | } 256 | return { success: true }; 257 | }, 258 | }); 259 | 260 | export const progress = myMutation({ 261 | args: { 262 | roundId: v.id("rounds"), 263 | fromStage: v.union( 264 | v.literal("label"), 265 | v.literal("guess"), 266 | v.literal("reveal") 267 | ), 268 | }, 269 | handler: async (ctx, { roundId, fromStage }) => { 270 | const round = await ctx.db.get(roundId); 271 | if (!round) throw new Error("Round not found: " + roundId); 272 | if (round.stage === fromStage) { 273 | const stage = fromStage === "label" ? "guess" : "reveal"; 274 | await ctx.db.patch(round._id, { stage }); 275 | } 276 | }, 277 | }); 278 | 279 | // from https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array 280 | function shuffle(array: T[]): T[] { 281 | let currentIndex = array.length, 282 | randomIndex; 283 | 284 | // While there remain elements to shuffle. 285 | while (currentIndex != 0) { 286 | // Pick a remaining element. 287 | randomIndex = Math.floor(Math.random() * currentIndex); 288 | currentIndex--; 289 | 290 | // And swap it with the current element. 291 | [array[currentIndex], array[randomIndex]] = [ 292 | array[randomIndex], 293 | array[currentIndex], 294 | ]; 295 | } 296 | 297 | return array; 298 | } 299 | 300 | // Modifies parameter to progress to guessing 301 | const beginGuessPatch = (round: Doc<"rounds">): Partial> => ({ 302 | options: shuffle(round.options), 303 | stage: "guess", 304 | stageStart: Date.now(), 305 | stageEnd: Date.now() + GuessDurationMs, 306 | }); 307 | 308 | export const guess = sessionMutation({ 309 | args: { 310 | roundId: v.id("rounds"), 311 | prompt: v.string(), 312 | gameId: v.optional(v.id("games")), 313 | }, 314 | handler: async (ctx, { roundId, prompt, gameId }) => { 315 | const round = await ctx.db.get(roundId); 316 | if (!round) throw new Error("Round not found"); 317 | if (round.stage !== "guess") { 318 | return { success: false, reason: "Too late to vote." }; 319 | } 320 | const optionVotedFor = round.options.find( 321 | (option) => option.prompt === prompt 322 | ); 323 | if (!optionVotedFor) { 324 | return { 325 | success: false, 326 | retry: true, 327 | reason: "This prompt does not exist.", 328 | }; 329 | } 330 | if (optionVotedFor.authorId === ctx.session.userId) { 331 | return { 332 | success: false, 333 | retry: true, 334 | reason: "You can't vote for your own prompt.", 335 | }; 336 | } 337 | const existingVote = round.options.find( 338 | (option) => 339 | option.votes.findIndex((vote) => vote === ctx.session.userId) !== -1 340 | ); 341 | if (prompt === existingVote?.prompt) { 342 | return { 343 | success: false, 344 | retry: true, 345 | reason: "You already voted for this option.", 346 | }; 347 | } 348 | if (existingVote) { 349 | // Remove existing vote 350 | const voteIndex = existingVote.votes.indexOf(ctx.session.userId); 351 | existingVote.votes = existingVote.votes 352 | .slice(0, voteIndex) 353 | .concat(...existingVote.votes.slice(voteIndex + 1)); 354 | } 355 | optionVotedFor.votes.push(ctx.session.userId); 356 | await ctx.db.patch(round._id, { options: round.options }); 357 | 358 | if (gameId) { 359 | const game = (await ctx.db.get(gameId))!; 360 | const noGuess = new Set(game.playerIds.map((id) => id.toString())); 361 | noGuess.delete(round.authorId.toString()); 362 | for (const option of round.options) { 363 | for (const vote of option.votes) { 364 | noGuess.delete(vote.toString()); 365 | } 366 | } 367 | if (noGuess.size === 0) { 368 | await ctx.db.patch(round._id, revealPatch(round)); 369 | } 370 | } 371 | return { success: true, retry: true }; 372 | }, 373 | }); 374 | 375 | export const like = sessionMutation({ 376 | args: { 377 | roundId: v.id("rounds"), 378 | prompt: v.string(), 379 | gameId: v.optional(v.id("games")), 380 | }, 381 | handler: async (ctx, { roundId, prompt, gameId }) => { 382 | const round = await ctx.db.get(roundId); 383 | if (!round) throw new Error("Round not found"); 384 | if (round.stage !== "guess") { 385 | return { success: false, reason: "Too late to like." }; 386 | } 387 | const optionVotedFor = round.options.find( 388 | (option) => option.prompt === prompt 389 | ); 390 | if (!optionVotedFor) { 391 | return { 392 | success: false, 393 | retry: true, 394 | reason: "This prompt does not exist.", 395 | }; 396 | } 397 | if (optionVotedFor.authorId === ctx.session.userId) { 398 | return { 399 | success: false, 400 | retry: true, 401 | reason: "You can't like your own prompt.", 402 | }; 403 | } 404 | const existingLike = round.options.find( 405 | (option) => 406 | option.likes.findIndex((like) => like === ctx.session.userId) !== -1 407 | ); 408 | if (prompt === existingLike?.prompt) { 409 | return { 410 | success: false, 411 | retry: true, 412 | reason: "You already voted for this option.", 413 | }; 414 | } 415 | optionVotedFor.likes.push(ctx.session.userId); 416 | await ctx.db.patch(round._id, { options: round.options }); 417 | []; 418 | }, 419 | }); 420 | 421 | // Modifies parameter to progress to guessing 422 | const revealPatch = (round: Doc<"rounds">) => ({ 423 | stage: "reveal" as const, 424 | stageStart: Date.now(), 425 | stageEnd: Date.now() + RevealDurationMs, 426 | }); 427 | 428 | // Return the server's current time so clients can calculate timestamp offsets. 429 | export const serverNow = myMutation(() => Date.now()); 430 | -------------------------------------------------------------------------------- /public/faces.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | --------------------------------------------------------------------------------