├── .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 | joinGame(props)}
11 | >
12 | Join
13 |
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 |
17 | {props.title}
18 |
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 |
15 | {submitted.map((player) => (
16 |
17 | {player.me ? "👉" : "✅"}
18 |
19 |
20 | {player.me ? : player.name}
21 |
22 |
23 | ))}
24 |
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 |
setPickerOpen(true)}
24 | className="hidden group-hover:block absolute inset-0 bg-gray-500 bg-opacity-75 rounded"
25 | >
26 | ✍️
27 |
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 |
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 |
25 | {player.me && "👉"}
26 |
27 | {player.me ? : player.name}
28 |
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 |
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 |
15 | Place
16 |
17 |
21 | Player
22 |
23 |
27 | Score
28 |
29 |
33 | Likes
34 |
35 |
36 |
37 |
38 | {game.players
39 | .slice()
40 | .sort((a, b) => b.score - a.score)
41 | .map((player, index) => (
42 |
43 |
44 | #{index + 1}
45 | , {player.name}
46 |
47 |
48 |
52 | {player.name}
53 |
54 |
55 | {player.score}
56 |
57 |
58 | {player.likes}
59 |
60 |
61 | ))}
62 |
63 |
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 |
32 | {option.prompt}
33 |
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 |
46 | 👍
47 |
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 |
59 |
64 | {users.get(userId)!.name || "(Anonymous)"}
65 |
66 | {scoreDeltas.has(userId) ? (
67 |
68 | +{scoreDeltas.get(userId)}
69 |
70 | ) : null}
71 |
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 | done(null)}
87 | className="h-12 border border-blue-200 bg-blue-200 py-2 px-4 text-neutral-black hover:bg-blue-400"
88 | >
89 | Home
90 |
91 | {
94 | const nextGameId = await playAgain({ oldGameId: gameId });
95 | done(nextGameId);
96 | }}
97 | className="h-12 border border-blue-200 bg-blue-200 py-2 px-4 text-neutral-black hover:bg-blue-400"
98 | >
99 | Play again
100 |
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 |
94 | {submissionId && (
95 | <>
96 |
97 |
onSubmit(submissionId)}
100 | className="h-12 border border-blue-200 bg-blue-200 py-2 px-4 text-neutral-black hover:bg-blue-400"
101 | >
102 | Submit
103 |
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 |
36 |
37 | Whose Prompt is it Anyways?
38 |
39 |
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 |
81 |
82 | or
83 |
84 | {
86 | setGameId(await hostGame());
87 | }}
88 | className="h-12 grow basis-0 bg-blue-200 text-neutral-black hover:bg-blue-400 lg:w-full lg:py-3"
89 | >
90 | Host a game
91 |
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 |
43 | {
45 | setError(undefined);
46 | const result = await submitGuess({
47 | roundId,
48 | gameId,
49 | prompt: option,
50 | });
51 | if (!result.success) setError(result.reason);
52 | }}
53 | disabled={round.mine || option === round.myPrompt}
54 | title={
55 | round.mine
56 | ? "You can't vote on your own image"
57 | : option === round.myPrompt
58 | ? "You can't vote for your own prompt"
59 | : ""
60 | }
61 | className={classNames(
62 | "w-full text-left min-h-12 border border-blue-200 bg-blue-200 py-2 px-4 text-neutral-black hover:bg-blue-400 hover:border-blue-400 disabled:border-neutral-400 disabled:text-neutral-500 disabled:cursor-not-allowed cursor-pointer",
63 | {
64 | "bg-blue-500": option === round.myGuess,
65 | }
66 | )}
67 | aria-invalid={option === round.myGuess && !!error}
68 | >
69 | {option}
70 |
71 |
72 | {
74 | console.log([...likes.keys()]);
75 | console.log({ option });
76 | setLikes((state) => new Set(state.keys()).add(option));
77 | console.log([...likes.keys()]);
78 | await addLike({
79 | roundId,
80 | gameId,
81 | prompt: option,
82 | });
83 | }}
84 | disabled={option === round.myPrompt}
85 | title={
86 | option === round.myPrompt
87 | ? "You can't like your own prompt"
88 | : ""
89 | }
90 | className={classNames(
91 | "w-12 h-12 text-3xl text-neutral-black rounded-full hover:disabled:bg-none disabled:cursor-default cursor-pointer",
92 | {
93 | "bg-blue-200": likes.has(option),
94 | "hover:bg-blue-400 hover:border-blue-400":
95 | option !== round.myPrompt,
96 | }
97 | )}
98 | >
99 | {option !== round.myPrompt && (
100 | <>
101 | 👍 Like {option}.
102 | >
103 | )}
104 |
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 |
--------------------------------------------------------------------------------