├── .gitignore ├── README.md ├── bun.lockb ├── eslint.config.js ├── index.html ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── apple-touch-icon.png ├── codenames-icon-1024-transparent.png ├── codenames-icon-1024.png ├── codenames-screenshot.png ├── favicon-96x96.png ├── favicon.ico ├── favicon.svg ├── og-codenames-banner.png ├── site.webmanifest ├── web-app-manifest-192x192.png └── web-app-manifest-512x512.png ├── src ├── App.tsx ├── assets │ ├── assassin-animated.gif │ ├── card-front-edited.png │ ├── card-front.png │ ├── logos │ │ ├── anthropic.svg │ │ ├── deepseek.svg │ │ ├── gemini.svg │ │ ├── meta.svg │ │ ├── openai.svg │ │ └── xai.svg │ └── wordlist-eng.txt ├── components │ ├── Card.tsx │ ├── Chat.tsx │ ├── ModelPill.tsx │ └── Scoreboard.tsx ├── index.css ├── main.tsx ├── prompts │ ├── baseSysPrompt.ts │ ├── opSysPrompt.ts │ ├── spySysPrompt.ts │ └── userPrompt.ts ├── utils │ ├── colors.tsx │ ├── game.ts │ ├── llm.ts │ └── models.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # For development 2 | /ignore 3 | .env 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pnpm-debug.log* 12 | lerna-debug.log* 13 | 14 | node_modules 15 | dist 16 | dist-ssr 17 | *.local 18 | 19 | # Editor directories and files 20 | .vscode/* 21 | !.vscode/extensions.json 22 | .idea 23 | .DS_Store 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | # Local Netlify folder 31 | .netlify 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LLM Codenames 2 | 3 | ![LLM Codenames Screenshot](/public/codenames-screenshot.png) 4 | 5 | An implementation of the board game [Codenames](), with all four players replaced with LLM agents. 6 | 7 | ## Features 8 | 9 | - Uses Cloudflare Workers as a proxy to make LLM calls 10 | - Uses OpenRouter to enable hot-swapping of LLM models from different providers 11 | 12 | ## Future Work 13 | 14 | - Add leaderboard to track ELO 15 | - Add context on prior moves in prompt 16 | - Stream responses from backend 17 | 18 | ## Technologies Used 19 | 20 | - HTML 21 | - CSS 22 | - TS 23 | - React 24 | - Vite 25 | - Cloudflare Workers 26 | - OpenRouter 27 | - Tailwind 28 | 29 | ## Installation 30 | 31 | - `bun install` 32 | - dev server: `bun run dev` 33 | - for dev, add `VITE_CLOUDFLARE_WORKER_URL=` to `.env` and set it to the URL of the proxy worker that will relay the LLM API calls 34 | - prod: `bun run build` 35 | - for prod, provide an `VITE_CLOUDFLARE_WORKER_URL` as a build variable 36 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/bun.lockb -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | LLM Codenames 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | LLM Codenames 41 | 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llm-codenames", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "bunx --bun vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "dotenv": "^16.4.5", 14 | "jsonrepair": "^3.11.0", 15 | "lucide-react": "^0.456.0", 16 | "openai": "^4.73.1", 17 | "react": "^18.3.1", 18 | "react-dom": "^18.3.1" 19 | }, 20 | "devDependencies": { 21 | "@eslint/js": "^9.15.0", 22 | "@types/react": "^18.3.12", 23 | "@types/react-dom": "^18.3.1", 24 | "@vitejs/plugin-react-swc": "^3.7.2", 25 | "autoprefixer": "^10.4.20", 26 | "eslint": "^9.15.0", 27 | "eslint-plugin-react-hooks": "^5.0.0", 28 | "eslint-plugin-react-refresh": "^0.4.14", 29 | "globals": "^15.12.0", 30 | "postcss": "^8.4.49", 31 | "prettier-plugin-tailwindcss": "^0.6.9", 32 | "tailwindcss": "^3.4.15", 33 | "typescript": "~5.6.3", 34 | "typescript-eslint": "^8.16.0", 35 | "vite": "^5.4.11" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: ['prettier-plugin-tailwindcss'], 3 | singleQuote: true, 4 | jsxSingleQuote: true, 5 | printWidth: 100, 6 | experimentalTernaries: true, 7 | }; 8 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/codenames-icon-1024-transparent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/codenames-icon-1024-transparent.png -------------------------------------------------------------------------------- /public/codenames-icon-1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/codenames-icon-1024.png -------------------------------------------------------------------------------- /public/codenames-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/codenames-screenshot.png -------------------------------------------------------------------------------- /public/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/favicon-96x96.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/favicon.ico -------------------------------------------------------------------------------- /public/og-codenames-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/og-codenames-banner.png -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MyWebSite", 3 | "short_name": "MySite", 4 | "icons": [ 5 | { 6 | "src": "/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /public/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /public/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/public/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2, Pause, Play } from 'lucide-react'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | import Card from './components/Card'; 4 | import { Chat } from './components/Chat'; 5 | import { Scoreboard } from './components/Scoreboard'; 6 | import { 7 | GameState, 8 | initializeGameState, 9 | OperativeMove, 10 | SpymasterMove, 11 | updateGameStateFromOperativeMove, 12 | updateGameStateFromSpymasterMove, 13 | } from './utils/game'; 14 | import { createMessagesFromGameState, fetchLLMResponse } from './utils/llm'; 15 | 16 | type AppState = 'game_start' | 'ready_for_turn' | 'waiting_for_response' | 'error' | 'game_over'; 17 | 18 | export default function App() { 19 | const [gameState, setGameState] = useState(initializeGameState()); 20 | const [appState, setAppState] = useState('game_start'); 21 | const [isGamePaused, setIsGamePaused] = useState(true); 22 | const chatContainerRef = useRef(null); 23 | 24 | useEffect(() => { 25 | const fetchResponse = async () => { 26 | try { 27 | const data = await fetchLLMResponse({ 28 | messages: createMessagesFromGameState(gameState), 29 | modelName: 30 | gameState.agents[gameState.currentTeam][gameState.currentRole].openrouter_model_id, 31 | referer: 'https://llmcodenames.com', 32 | title: 'LLM Codenames', 33 | }); 34 | if (gameState.currentRole === 'spymaster') { 35 | setGameState(updateGameStateFromSpymasterMove(gameState, data as SpymasterMove)); 36 | } else { 37 | setGameState(updateGameStateFromOperativeMove(gameState, data as OperativeMove)); 38 | } 39 | setAppState('ready_for_turn'); 40 | } catch (error) { 41 | console.error('Error in fetchResponse:', error); 42 | setAppState('error'); 43 | setIsGamePaused(true); 44 | } 45 | }; 46 | if (isGamePaused) { 47 | return; 48 | } 49 | if (gameState.gameWinner) { 50 | setIsGamePaused(true); 51 | setAppState('game_over'); 52 | } else if (appState === 'ready_for_turn') { 53 | setAppState('waiting_for_response'); 54 | fetchResponse(); 55 | } else if (appState === 'game_start') { 56 | setAppState('ready_for_turn'); 57 | } 58 | }, [appState, gameState, isGamePaused]); 59 | 60 | // Handle scrolling to the bottom of the chat history as chats stream in 61 | useEffect(() => { 62 | if (chatContainerRef.current) { 63 | chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; 64 | } 65 | }, [gameState, appState]); 66 | 67 | return ( 68 |
69 | {/* Left panel: Scoreboard + Game Board + Game Controls */} 70 |
71 | {appState === 'error' && ( 72 |
73 | An error occurred. Please reload the game. 74 |
75 | )} 76 | 77 | {/* Scoreboard */} 78 | 79 | 80 | {/* Game board */} 81 |
82 |
83 | {gameState.cards.map((card, index) => ( 84 | 92 | ))} 93 |
94 |
95 | 96 | {/* Start/Pause game button */} 97 | 120 |
121 | 122 | {/* Right panel: Chat history */} 123 |
127 | {gameState.chatHistory.map((message, index) => ( 128 | 129 | ))} 130 | {appState === 'game_over' && ( 131 |
132 |
135 | {gameState.gameWinner === 'red' ? 'Red' : 'Blue'} team wins. 136 |
137 |
138 | )} 139 | {/* Spinner & Pause indicator */} 140 | {appState === 'waiting_for_response' && ( 141 |
142 | 143 |
144 | )} 145 | {isGamePaused && appState === 'ready_for_turn' && ( 146 |
147 | 148 |
149 | )} 150 |
151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /src/assets/assassin-animated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/src/assets/assassin-animated.gif -------------------------------------------------------------------------------- /src/assets/card-front-edited.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/src/assets/card-front-edited.png -------------------------------------------------------------------------------- /src/assets/card-front.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ilya-aby/llm-codenames/7fc6152c89e3ff9228c24c55e4cc2ca4cee1d343/src/assets/card-front.png -------------------------------------------------------------------------------- /src/assets/logos/anthropic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Anthropic 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/logos/deepseek.svg: -------------------------------------------------------------------------------- 1 | DeepSeek -------------------------------------------------------------------------------- /src/assets/logos/gemini.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/logos/meta.svg: -------------------------------------------------------------------------------- 1 | facebook-meta -------------------------------------------------------------------------------- /src/assets/logos/openai.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/logos/xai.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/wordlist-eng.txt: -------------------------------------------------------------------------------- 1 | AFRICA 2 | AGENT 3 | AIR 4 | ALIEN 5 | ALPS 6 | AMAZON 7 | AMBULANCE 8 | AMERICA 9 | ANGEL 10 | ANTARCTICA 11 | APPLE 12 | ARM 13 | ATLANTIS 14 | AUSTRALIA 15 | AZTEC 16 | BACK 17 | BALL 18 | BAND 19 | BANK 20 | BAR 21 | BARK 22 | BAT 23 | BATTERY 24 | BEACH 25 | BEAR 26 | BEAT 27 | BED 28 | BEIJING 29 | BELL 30 | BELT 31 | BERLIN 32 | BERMUDA 33 | BERRY 34 | BILL 35 | BLOCK 36 | BOARD 37 | BOLT 38 | BOMB 39 | BOND 40 | BOOM 41 | BOOT 42 | BOTTLE 43 | BOW 44 | BOX 45 | BRIDGE 46 | BRUSH 47 | BUCK 48 | BUFFALO 49 | BUG 50 | BUGLE 51 | BUTTON 52 | CALF 53 | CANADA 54 | CAP 55 | CAPITAL 56 | CAR 57 | CARD 58 | CARROT 59 | CASINO 60 | CAST 61 | CAT 62 | CELL 63 | CENTAUR 64 | CENTER 65 | CHAIR 66 | CHANGE 67 | CHARGE 68 | CHECK 69 | CHEST 70 | CHICK 71 | CHINA 72 | CHOCOLATE 73 | CHURCH 74 | CIRCLE 75 | CLIFF 76 | CLOAK 77 | CLUB 78 | CODE 79 | COLD 80 | COMIC 81 | COMPOUND 82 | CONCERT 83 | CONDUCTOR 84 | CONTRACT 85 | COOK 86 | COPPER 87 | COTTON 88 | COURT 89 | COVER 90 | CRANE 91 | CRASH 92 | CRICKET 93 | CROSS 94 | CROWN 95 | CYCLE 96 | CZECH 97 | DANCE 98 | DATE 99 | DAY 100 | DEATH 101 | DECK 102 | DEGREE 103 | DIAMOND 104 | DICE 105 | DINOSAUR 106 | DISEASE 107 | DOCTOR 108 | DOG 109 | DRAFT 110 | DRAGON 111 | DRESS 112 | DRILL 113 | DROP 114 | DUCK 115 | DWARF 116 | EAGLE 117 | EGYPT 118 | EMBASSY 119 | ENGINE 120 | ENGLAND 121 | EUROPE 122 | EYE 123 | FACE 124 | FAIR 125 | FALL 126 | FAN 127 | FENCE 128 | FIELD 129 | FIGHTER 130 | FIGURE 131 | FILE 132 | FILM 133 | FIRE 134 | FISH 135 | FLUTE 136 | FLY 137 | FOOT 138 | FORCE 139 | FOREST 140 | FORK 141 | FRANCE 142 | GAME 143 | GAS 144 | GENIUS 145 | GERMANY 146 | GHOST 147 | GIANT 148 | GLASS 149 | GLOVE 150 | GOLD 151 | GRACE 152 | GRASS 153 | GREECE 154 | GREEN 155 | GROUND 156 | HAM 157 | HAND 158 | HAWK 159 | HEAD 160 | HEART 161 | HELICOPTER 162 | HIMALAYAS 163 | HOLE 164 | HOLLYWOOD 165 | HONEY 166 | HOOD 167 | HOOK 168 | HORN 169 | HORSE 170 | HORSESHOE 171 | HOSPITAL 172 | HOTEL 173 | ICE 174 | ICE CREAM 175 | INDIA 176 | IRON 177 | IVORY 178 | JACK 179 | JAM 180 | JET 181 | JUPITER 182 | KANGAROO 183 | KETCHUP 184 | KEY 185 | KID 186 | KING 187 | KIWI 188 | KNIFE 189 | KNIGHT 190 | LAB 191 | LAP 192 | LASER 193 | LAWYER 194 | LEAD 195 | LEMON 196 | LEPRECHAUN 197 | LIFE 198 | LIGHT 199 | LIMOUSINE 200 | LINE 201 | LINK 202 | LION 203 | LITTER 204 | LOCH NESS 205 | LOCK 206 | LOG 207 | LONDON 208 | LUCK 209 | MAIL 210 | MAMMOTH 211 | MAPLE 212 | MARBLE 213 | MARCH 214 | MASS 215 | MATCH 216 | MERCURY 217 | MEXICO 218 | MICROSCOPE 219 | MILLIONAIRE 220 | MINE 221 | MINT 222 | MISSILE 223 | MODEL 224 | MOLE 225 | MOON 226 | MOSCOW 227 | MOUNT 228 | MOUSE 229 | MOUTH 230 | MUG 231 | NAIL 232 | NEEDLE 233 | NET 234 | NEW YORK 235 | NIGHT 236 | NINJA 237 | NOTE 238 | NOVEL 239 | NURSE 240 | NUT 241 | OCTOPUS 242 | OIL 243 | OLIVE 244 | OLYMPUS 245 | OPERA 246 | ORANGE 247 | ORGAN 248 | PALM 249 | PAN 250 | PANTS 251 | PAPER 252 | PARACHUTE 253 | PARK 254 | PART 255 | PASS 256 | PASTE 257 | PENGUIN 258 | PHOENIX 259 | PIANO 260 | PIE 261 | PILOT 262 | PIN 263 | PIPE 264 | PIRATE 265 | PISTOL 266 | PIT 267 | PITCH 268 | PLANE 269 | PLASTIC 270 | PLATE 271 | PLATYPUS 272 | PLAY 273 | PLOT 274 | POINT 275 | POISON 276 | POLE 277 | POLICE 278 | POOL 279 | PORT 280 | POST 281 | POUND 282 | PRESS 283 | PRINCESS 284 | PUMPKIN 285 | PUPIL 286 | PYRAMID 287 | QUEEN 288 | RABBIT 289 | RACKET 290 | RAY 291 | REVOLUTION 292 | RING 293 | ROBIN 294 | ROBOT 295 | ROCK 296 | ROME 297 | ROOT 298 | ROSE 299 | ROULETTE 300 | ROUND 301 | ROW 302 | RULER 303 | SATELLITE 304 | SATURN 305 | SCALE 306 | SCHOOL 307 | SCIENTIST 308 | SCORPION 309 | SCREEN 310 | SCUBA DIVER 311 | SEAL 312 | SERVER 313 | SHADOW 314 | SHAKESPEARE 315 | SHARK 316 | SHIP 317 | SHOE 318 | SHOP 319 | SHOT 320 | SINK 321 | SKYSCRAPER 322 | SLIP 323 | SLUG 324 | SMUGGLER 325 | SNOW 326 | SNOWMAN 327 | SOCK 328 | SOLDIER 329 | SOUL 330 | SOUND 331 | SPACE 332 | SPELL 333 | SPIDER 334 | SPIKE 335 | SPINE 336 | SPOT 337 | SPRING 338 | SPY 339 | SQUARE 340 | STADIUM 341 | STAFF 342 | STAR 343 | STATE 344 | STICK 345 | STOCK 346 | STRAW 347 | STREAM 348 | STRIKE 349 | STRING 350 | SUB 351 | SUIT 352 | SUPERHERO 353 | SWING 354 | SWITCH 355 | TABLE 356 | TABLET 357 | TAG 358 | TAIL 359 | TAP 360 | TEACHER 361 | TELESCOPE 362 | TEMPLE 363 | THEATER 364 | THIEF 365 | THUMB 366 | TICK 367 | TIE 368 | TIME 369 | TOKYO 370 | TOOTH 371 | TORCH 372 | TOWER 373 | TRACK 374 | TRAIN 375 | TRIANGLE 376 | TRIP 377 | TRUNK 378 | TUBE 379 | TURKEY 380 | UNDERTAKER 381 | UNICORN 382 | VACUUM 383 | VAN 384 | VET 385 | WAKE 386 | WALL 387 | WAR 388 | WASHER 389 | WASHINGTON 390 | WATCH 391 | WATER 392 | WAVE 393 | WEB 394 | WELL 395 | WHALE 396 | WHIP 397 | WIND 398 | WITCH 399 | WORM 400 | YARD 401 | -------------------------------------------------------------------------------- /src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import assassinGif from '../assets/assassin-animated.gif'; 2 | import cardFrontImage from '../assets/card-front.png'; 3 | import { bgColorMap } from '../utils/colors.tsx'; 4 | import { CardType } from '../utils/game.ts'; 5 | 6 | type CardProps = CardType & { 7 | isSpymasterView: boolean; 8 | }; 9 | 10 | export default function Card({ 11 | word, 12 | color, 13 | isRevealed, 14 | isSpymasterView, 15 | wasRecentlyRevealed, 16 | }: CardProps) { 17 | return ( 18 |
23 | Card background 24 | {/* Color overlay hint to reveal card color */} 25 | {isSpymasterView && color !== 'neutral' && !isRevealed && ( 26 |
29 | )} 30 | {/* Full-color mask for revealed cards */} 31 | {isRevealed && ( 32 |
35 | )} 36 | {/* Special animated assassin reveal */} 37 | {isRevealed && color === 'black' && ( 38 | Assassin 43 | )} 44 | {/* Word overlay */} 45 |
46 | = 9 ? 'text-[0.6rem] sm:text-base' : 'text-[0.7rem] sm:text-base'}`} 50 | > 51 | {word} 52 | 53 |
54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { colorizeMessage } from '../utils/colors'; 2 | import { CardType, TeamColor } from '../utils/game'; 3 | import { LLMModel } from '../utils/models'; 4 | 5 | export type ChatMessage = { 6 | model: LLMModel; 7 | message: string; 8 | team: TeamColor; 9 | cards?: CardType[]; 10 | }; 11 | 12 | export function Chat({ message, team, model, cards }: ChatMessage) { 13 | return ( 14 |
15 | {/* Model chat heading */} 16 |
17 | {/* Avatar logo */} 18 |
19 | {model.short_name} 26 |
27 | {/* Model name */} 28 | 31 | {model.short_name} 32 | 33 |
34 | 35 | {/* Chat message */} 36 |
37 | {/* Colorize the words in the message based on game cards */} 38 |

43 | {cards ? colorizeMessage(message, cards) : message} 44 |

45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/ModelPill.tsx: -------------------------------------------------------------------------------- 1 | import { TeamColor } from '../utils/game'; 2 | import { LLMModel } from '../utils/models'; 3 | 4 | export function ModelPill({ 5 | model, 6 | teamColor, 7 | isActive = false, 8 | }: { 9 | model: LLMModel; 10 | teamColor: TeamColor; 11 | isActive?: boolean; 12 | }) { 13 | return ( 14 | 19 | {/* Avatar logo */} 20 | {model.short_name} 27 | {/* Model name */} 28 | {model.short_name} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/Scoreboard.tsx: -------------------------------------------------------------------------------- 1 | import { colorizeMessage } from '../utils/colors'; 2 | import { GameState } from '../utils/game'; 3 | import { ModelPill } from './ModelPill'; 4 | 5 | export function Scoreboard({ gameState }: { gameState: GameState }) { 6 | return ( 7 |
8 |
9 | {/* Red Team */} 10 |
11 | 16 | 21 |
22 | 23 | {/* Score */} 24 |
25 |
30 | {9 - gameState.remainingRed} 31 |
32 | {gameState.gameWinner === 'red' && ( 33 |
34 | )} 35 |
40 | {8 - gameState.remainingBlue} 41 |
42 | {gameState.gameWinner === 'blue' && ( 43 |
44 | )} 45 | {/* "Final" label */} 46 | {gameState.gameWinner && ( 47 |
48 | FINAL 49 |
50 | )} 51 |
52 | 53 | {/* Blue Team */} 54 |
55 | 60 | 65 |
66 |
67 | {/* Status Message */} 68 | {gameState.currentClue && ( 69 |
70 | 71 | {gameState.currentClue.clueText}, {gameState.currentClue.number} 72 | 73 | {gameState.currentGuesses && ( 74 | 75 | {colorizeMessage(gameState.currentGuesses.join(' '), gameState.cards)} 76 | 77 | )} 78 |
79 | )} 80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App.tsx'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /src/prompts/baseSysPrompt.ts: -------------------------------------------------------------------------------- 1 | export const baseSysPrompt: string = ` 2 | You're a highly-skilled player playing the game Codenames. 3 | 4 | You are given a set of rules and strategies for playing the game. You'll then receive the current 5 | game state and be asked to play a turn for your team. 6 | 7 | ### Game Rules 8 | - Four players are split into two teams of two players each: Red and Blue 9 | - Each team has one player acting as the spymaster, who gives clues, and one player acting as a 10 | field operative, who makes guesses based on his partner's spymaster's clue 11 | - 25 cards are randomly selected at the start of the game. Each one has a word and a color: 12 | red, blue, neutral, or black 13 | - There are always 9 red cards, 8 blue cards, 1 black card, and 7 neutral cards 14 | - The black card is known as the assassin and is not associated with any team 15 | - The spymasters on both teams always see the colors & words on all cards 16 | - The field operatives see the words on all cards but do not initially know the colors of any of 17 | the cards 18 | - The objective of the game is to guess all of your team's cards before the opposing team does 19 | 20 | ### Example Turn 21 | - Teams take turns as follows: 22 | - Suppose Red goes first 23 | - The Red spymaster looks at all the words to try to find a logical grouping of Red words they 24 | can get their partner to guess 25 | - The Red spymaster sees that there are a number of potentially baseball-related Red cards in the 26 | grid: 'Run', 'Strike', and 'Boring' 27 | - The Red spymaster thus gives a clue to their Red field operative teammate: "Baseball, 3" 28 | - The clue always consists of a single word and a number. The number represents how many words are 29 | related to the spymaster's clue 30 | - The clue hints to the operative which cards to guess and the number is how many guesses they 31 | should make 32 | - Based on the clue, the Red field operative guesses which cards might have words that are 33 | associated with the clue 34 | - If the Red operative gueses a card and it is Red, it is revealed and they can keep guessing 35 | - If the Red operative guesses a card and it is Blue or Neutral, it is revealed but their turn 36 | ends 37 | - If the Red operative guesses a card and it is the Assassin, the Red team loses the game 38 | immediately 39 | - Let's suppose the Red operative correctly guessed 'Strike' - that card is turned over and Red is 40 | one card closer to winning 41 | - Since the number was 3, the Red operative can make 2 more guesses 42 | - They incorrectly choose 'Sphere' next, but that card was neutral so the turn ends and the Blue 43 | spymaster starts their turn 44 | - The Blue spymaster starts looking at all the words to try to find a logical grouping of Blue 45 | words they can get their partner to guess 46 | - The game continues until one team guesses all of their cards or someone mistakenly guesses the 47 | Assassin 48 | 49 | ### Clue Format 50 | - The spymaster must give a clue that consists of a single word and a number. The number is a 51 | positive integer that represents how many words are related to the spymaster's clue 52 | - It's VERY IMPORTANT that the clue **cannot** contain any words in the grid or be a 53 | substing/superset of any words in the grid 54 | - For example, if the word OCEAN is on a blue card, the clue **cannot** be OCEAN or any other word 55 | that contains OCEAN as a substring, like OCEANIC 56 | - Your clue must be about the meaning of the words. You can't use your clue to talk about the 57 | letters in a word. For example, Gland is not a valid clue for ENGLAND 58 | - You can't tie BUG, BED, and BOW together with a clue like b: 3 nor with a clue like three: 3 59 | - You must play in English. A foreign word is allowed only if the players in your group would use it 60 | in an English sentence. For example, you can't use Apfel as a clue for APPLE and BERLIN, but you can 61 | use strudel 62 | - You can't say any form of a visible word on the table. Until BREAK is covered up by a card, you 63 | can't say break, broken, breakage, or breakdown 64 | - You can't say part of a compound word on the table. Until HORSESHOE is covered up, you can't say 65 | horse, shoe, unhorsed, or snowshoe 66 | - You can use ISLAND as a valid clue for ENGLAND because it's about the meaning of the words 67 | - Proper nouns are allowed as long as they follow the above rules. For example, you can use 68 | GEORGE WASHINGTON, JUSTIN BIEBER, or SOUTH DAKOTA as valid clues 69 | 70 | ### Spymaster Clue-Giving Strategy 71 | - It's smart to consider risk vs. reward when giving clues 72 | - If you give clues with low numbers, you might not reveal enough of your team's cards to win the 73 | game 74 | - If you give clues with high numbers but the associations are weak, the field operative might guess 75 | the wrong cards and the turn will end 76 | - It's smart to always take extra care not to give clues that the field operative could think relate 77 | to the other team's cards or the assassin 78 | - If you only have a few cards left to guess and are well ahead, you can play more conservatively 79 | and give lower numbers 80 | - If you're behind and need to catch up, you can take more risks and give higher numbers 81 | - If one of your team's cards is thematically similar to the assassin or the other team's cards, 82 | you should think extra carefully when clueing to avoid your operative teammate accidentally guessing 83 | the assassin or the other team's cards. For example, if you're the blue spymaster and FLUTE is a 84 | blue card while OPERA is an assassin black card, "MUSIC, 2" is a really bad clue because your 85 | teammate will likely get FLUTE, but then they'll also likely guess OPERA next and instantly lose 86 | the game. 87 | 88 | ### Field Operative Guessing Strategy 89 | - At the most fundamental level, think about which words on the board are most related to the clue 90 | you've been given 91 | - Codenames is a game of associations, so think about which words on the board are most strongly 92 | associated with the clue 93 | - Sometimes the association is very obvious, but other times it's more subtle and you'll need to 94 | think laterally 95 | - Consider risk vs. reward when guessing. If your team is well ahead, you may want to take fewer 96 | risks and vice versa 97 | - Consider your confidence in how strongly the clue is associated with the words you're guessing 98 | - Consider the context of prior turns if some have already been played. For example, if you were 99 | given "FRUIT, 3" in a previous turn and got 2 of them correct but the third one wrong, you could 100 | use one of your guesses in a later turn to "pick up" the third one you missed if you now realize 101 | what it was 102 | - Similarly, pay attention to your opponent spymaster's clues and try to pick up on which cards 103 | they might be targeting, because that could help you steer away from those cards, since they're 104 | likely to be the opposite team's color 105 | `; 106 | -------------------------------------------------------------------------------- /src/prompts/opSysPrompt.ts: -------------------------------------------------------------------------------- 1 | import { baseSysPrompt } from './baseSysPrompt'; 2 | 3 | export const opSysPrompt = ` 4 | ${baseSysPrompt} 5 | 6 | You are playing the role of the field operative. 7 | 8 | ### Output Format 9 | Based on the clue and number given by your Spymaster, you should return a list of words from the 10 | board that you want to guess. 11 | You do not have to guess all of the words that your Spymaster gave you a clue for. 12 | Only guess words that have not already been revealed. 13 | Return the list of words in the order you want to guess them, separated by commas in the array as 14 | shown below. 15 | Order them by how confident you are that they are the correct words to guess. 16 | For example, if you're given the clue "SEASON, 4", you might guess ["WINTER", "SPRING", "PEPPER"] 17 | because you're confident that WINTER and SPRING are correct but PEPPER might not be. And you only 18 | want to guess 3 words because you couldn't find a fourth word that was obviously related to the clue 19 | and didn't want to risk guessing a word that was wrong. 20 | 21 | Before you return your final guess list, you should start by thinking step by step and writing a 22 | reasoning string that explains your thought process. 23 | 24 | Reason about how you make sense of the clue and number with respect to the board, and any other 25 | considerations you took into account. This string should be plaintext, not markdown. 26 | Give your reasoning in a friendly and conversational tone and in the present tense. For example, 27 | given the clue "ARCHITECTURE, 3": "I see a couple of architecture-related words. I'm very confident 28 | in BRIDGE and SPAN. I'm less sure about what the third could be. EMBASSY is a bit of a reach because 29 | embassies have fancy architecture. But we're behind and I'll take the risk. So I'll guess 30 | BRIDGE, SPAN, EMBASSY." Keep your reasoning concise. Do not write more than 100 words. There's 31 | no need to list all the words on the board. Just mention the most relevant ones you're considering. 32 | 33 | Return a valid JSON object with the following structure: 34 | { 35 | "reasoning": "string", 36 | "guesses": ["string"] 37 | } 38 | 39 | Your response will be parsed as JSON, so make sure you ONLY return a JSON object and nothing else. 40 | `; 41 | -------------------------------------------------------------------------------- /src/prompts/spySysPrompt.ts: -------------------------------------------------------------------------------- 1 | import { baseSysPrompt } from './baseSysPrompt'; 2 | 3 | export const spySysPrompt = ` 4 | ${baseSysPrompt} 5 | 6 | You are playing the role of the spymaster. 7 | 8 | ### Output Format 9 | You will provide your final clue and number as described above. Remember to follow the clue format 10 | rules described above. 11 | Most importantly, the clue cannot contain any words in the grid or be a substing/superset of any 12 | words in the grid. 13 | And it must be a SINGLE WORD unless it's a proper noun, like someone's name or the name of a place 14 | or piece of media, etc. 15 | Your clue CANNOT be a word that is one of the words on the board - this is an invalid clue and will 16 | end the turn without any guesses. 17 | 18 | Before returning your final clue and number, you should start by thinking step by step and writing 19 | a reasoning string that explains your thought process. 20 | 21 | Reason about how you make sense of the board, what associations you see among your team's words, 22 | any other considerations you're taking into account, and what cards you're hoping your field 23 | operative will guess based on your clue. 24 | 25 | This string should be plaintext, not markdown. Your thought process will not be shown to the field 26 | operative but will help you improve your strategy. 27 | 28 | Give your reasoning in a friendly and conversational tone and in the present tense. For example, 29 | "Ok, I see some blue words that all relate to sports, like NET and BALL. Normally, I'd go with a 30 | sports clue, but I'm concerned that my partner might guess SPIKE, which is the assassin, so I'll 31 | try a movie reference instead and try for a smaller number." Keep your reasoning concise. Do not 32 | write more than 100 words. There's no need to list all the words on the board. Just mention 33 | the most relevant ones that you hope to clue into & to avoid. 34 | 35 | Return a valid JSON object with the following structure: 36 | { 37 | "reasoning": "string", 38 | "clue": "string", 39 | "number": "number" 40 | } 41 | 42 | Your response will be parsed as JSON, so make sure you ONLY return a JSON object and nothing else. 43 | `; 44 | -------------------------------------------------------------------------------- /src/prompts/userPrompt.ts: -------------------------------------------------------------------------------- 1 | import { GameState } from '../utils/game'; 2 | 3 | export const createUserPrompt = (gameState: GameState): string => ` 4 | ### Current Game State 5 | Your Team: ${gameState.currentTeam} 6 | Your Role: ${gameState.currentRole} 7 | Red Cards Left to Guess: ${gameState.remainingRed} 8 | Blue Cards Left to Guess: ${gameState.remainingBlue} 9 | 10 | Board: ${JSON.stringify( 11 | gameState.currentRole === 'spymaster' ? 12 | gameState.cards 13 | : gameState.cards.map((card) => ({ 14 | word: card.word, 15 | isRevealed: card.isRevealed, 16 | color: card.isRevealed ? card.color : undefined, 17 | })), 18 | )} 19 | 20 | ${ 21 | gameState.currentRole === 'operative' && gameState.currentClue ? 22 | ` 23 | Your Clue: ${gameState.currentClue.clueText} 24 | Number: ${gameState.currentClue.number} 25 | ` 26 | : '' 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /src/utils/colors.tsx: -------------------------------------------------------------------------------- 1 | import { CardType } from './game'; 2 | 3 | export const bgColorMap = { 4 | blue: 'bg-sky-700', 5 | red: 'bg-rose-600', 6 | black: 'bg-black', 7 | neutral: 'bg-slate-700', 8 | }; 9 | 10 | export const borderColorMap = { 11 | blue: 'border-sky-600', 12 | red: 'border-rose-500', 13 | black: 'border-black', 14 | neutral: 'border-slate-700', 15 | }; 16 | 17 | // Helper function to colorize words in the message based on the cards 18 | export const colorizeMessage = (text: string, cards: CardType[]) => { 19 | return text.split(/(\s+)/).map((word, i) => { 20 | // If it's whitespace or a boundary, return it unchanged 21 | if (!word.trim()) return word; 22 | const cleanWord = word.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); 23 | const card = cards.find((c) => c.word.toUpperCase() === cleanWord); 24 | 25 | if (!card) return word + ' '; 26 | 27 | const colorClasses = { 28 | red: 'text-rose-50 bg-rose-600/70', 29 | blue: 'text-sky-50 bg-sky-700/70', 30 | black: 'text-slate-50 bg-slate-800/90', 31 | neutral: 'text-slate-600 bg-orange-200/70', 32 | }; 33 | 34 | return ( 35 | 36 | {cleanWord} 37 | 38 | ); 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/utils/game.ts: -------------------------------------------------------------------------------- 1 | import wordlist from '../assets/wordlist-eng.txt?raw'; 2 | import { ChatMessage } from '../components/Chat'; 3 | import { agents, LLMModel } from './models'; 4 | // Core types 5 | export type TeamColor = 'red' | 'blue'; 6 | export type CardColor = 'red' | 'blue' | 'black' | 'neutral'; 7 | export type Role = 'spymaster' | 'operative'; 8 | 9 | export type CardType = { 10 | word: string; 11 | color: CardColor; 12 | isRevealed: boolean; 13 | wasRecentlyRevealed: boolean; 14 | }; 15 | 16 | export type SpymasterMove = { 17 | clue: string; 18 | number: number; 19 | reasoning: string; 20 | }; 21 | 22 | export type OperativeMove = { 23 | guesses: string[]; 24 | reasoning: string; 25 | }; 26 | 27 | // Nested object type for team agents to track which LLM model is being used for each role 28 | export type TeamAgents = { 29 | [key in Role]: LLMModel; 30 | }; 31 | 32 | export type GameAgents = { 33 | [key in TeamColor]: TeamAgents; 34 | }; 35 | 36 | // Game state 37 | export type GameState = { 38 | agents: GameAgents; 39 | cards: CardType[]; 40 | chatHistory: ChatMessage[]; 41 | currentTeam: TeamColor; 42 | currentRole: Role; 43 | previousTeam?: TeamColor; 44 | previousRole?: Role; 45 | remainingRed: number; 46 | remainingBlue: number; 47 | currentClue?: { 48 | clueText: string; 49 | number: number; 50 | }; 51 | currentGuesses?: string[]; 52 | gameWinner?: TeamColor; 53 | }; 54 | 55 | // Initialize new game state 56 | export const initializeGameState = (): GameState => { 57 | return { 58 | cards: drawNewCards(), 59 | agents: selectRandomAgents(), 60 | currentTeam: 'red', 61 | currentRole: 'spymaster', 62 | remainingRed: 9, 63 | remainingBlue: 8, 64 | chatHistory: [], 65 | }; 66 | }; 67 | 68 | const drawNewCards = (): CardType[] => { 69 | const allWords = wordlist.split('\n').filter((word) => word.trim() !== ''); 70 | const gameCards: CardType[] = []; 71 | 72 | // Randomly select 25 words 73 | const selectedWords = []; 74 | const tempWords = [...allWords]; 75 | for (let i = 0; i < 25; i++) { 76 | const randomIndex = Math.floor(Math.random() * tempWords.length); 77 | selectedWords.push(tempWords[randomIndex]); 78 | tempWords.splice(randomIndex, 1); 79 | } 80 | 81 | // Team assignment counts for randomization 82 | const teams: CardColor[] = [ 83 | ...Array(9).fill('red'), 84 | ...Array(8).fill('blue'), 85 | ...Array(1).fill('black'), 86 | ...Array(7).fill('neutral'), 87 | ]; 88 | 89 | // Randomly assign teams to words 90 | selectedWords.forEach((word) => { 91 | const randomIndex = Math.floor(Math.random() * teams.length); 92 | gameCards.push({ 93 | word, 94 | color: teams[randomIndex], 95 | isRevealed: false, 96 | wasRecentlyRevealed: false, 97 | }); 98 | teams.splice(randomIndex, 1); 99 | }); 100 | 101 | return gameCards; 102 | }; 103 | 104 | // Select four random agents to form the two teams 105 | // More agents can be added by editing the `agents` array in `constants/models.ts` 106 | const selectRandomAgents = (): GameAgents => { 107 | const availableAgents = [...agents]; 108 | 109 | const pickRandomAgent = () => { 110 | const randomIndex = Math.floor(Math.random() * availableAgents.length); 111 | return availableAgents.splice(randomIndex, 1)[0]; 112 | }; 113 | 114 | return { 115 | red: { 116 | spymaster: pickRandomAgent(), 117 | operative: pickRandomAgent(), 118 | }, 119 | blue: { 120 | spymaster: pickRandomAgent(), 121 | operative: pickRandomAgent(), 122 | }, 123 | } satisfies GameAgents; 124 | }; 125 | 126 | const resetAnimations = (cards: CardType[]) => { 127 | cards.forEach((card) => { 128 | card.wasRecentlyRevealed = false; 129 | }); 130 | }; 131 | 132 | // Set the guess properties and switch to operative role 133 | export function updateGameStateFromSpymasterMove( 134 | currentState: GameState, 135 | move: SpymasterMove, 136 | ): GameState { 137 | const newState = { ...currentState }; 138 | newState.currentClue = { 139 | clueText: move.clue.toUpperCase(), 140 | number: move.number, 141 | }; 142 | newState.chatHistory.push({ 143 | message: move.reasoning + '\n\nClue: ' + move.clue.toUpperCase() + ', ' + move.number, 144 | model: currentState.agents[currentState.currentTeam].spymaster, 145 | team: currentState.currentTeam, 146 | cards: currentState.cards, 147 | }); 148 | newState.currentRole = 'operative'; 149 | newState.currentGuesses = undefined; 150 | newState.previousRole = currentState.currentRole; 151 | newState.previousTeam = currentState.currentTeam; 152 | return newState; 153 | } 154 | 155 | // Make guesses and switch to spymaster role 156 | export function updateGameStateFromOperativeMove( 157 | currentState: GameState, 158 | move: OperativeMove, 159 | ): GameState { 160 | const newState = { ...currentState }; 161 | newState.chatHistory.push({ 162 | message: move.reasoning + '\n\nGuesses: ' + move.guesses.join(', '), 163 | model: currentState.agents[currentState.currentTeam].operative, 164 | team: currentState.currentTeam, 165 | cards: currentState.cards, 166 | }); 167 | 168 | // Reset recently revealed cards 169 | resetAnimations(newState.cards); 170 | 171 | newState.currentGuesses = move.guesses; 172 | 173 | for (const guess of move.guesses) { 174 | const card = newState.cards.find((card) => card.word.toUpperCase() === guess.toUpperCase()); 175 | 176 | // If card not found or already revealed, it's an invalid guess 177 | if (!card || card.isRevealed) { 178 | console.error(`INVALID GUESS: ${guess}`); 179 | continue; 180 | } 181 | 182 | card.isRevealed = true; 183 | card.wasRecentlyRevealed = true; 184 | 185 | newState.previousRole = currentState.currentRole; 186 | newState.previousTeam = currentState.currentTeam; 187 | 188 | // Assassin card instantly loses the game 189 | if (card.color === 'black') { 190 | newState.gameWinner = currentState.currentTeam === 'red' ? 'blue' : 'red'; 191 | resetAnimations(newState.cards); 192 | return newState; 193 | } 194 | 195 | // Decrement the count of remaining cards for the team 196 | if (card.color === 'red') { 197 | newState.remainingRed--; 198 | } else if (card.color === 'blue') { 199 | newState.remainingBlue--; 200 | } 201 | 202 | // If no more cards remain for the team, they win 203 | if (newState.remainingRed === 0) { 204 | newState.gameWinner = 'red'; 205 | return newState; 206 | } else if (newState.remainingBlue === 0) { 207 | newState.gameWinner = 'blue'; 208 | resetAnimations(newState.cards); 209 | return newState; 210 | } 211 | 212 | // If we guessed a card that isn't our team's color, we're done 213 | if (card.color !== currentState.currentTeam) { 214 | break; 215 | } 216 | } 217 | 218 | // Switch to the other team's spymaster once we're done guessing 219 | newState.currentRole = 'spymaster'; 220 | newState.currentTeam = currentState.currentTeam === 'red' ? 'blue' : 'red'; 221 | // newState.currentClue = undefined; 222 | 223 | return newState; 224 | } 225 | -------------------------------------------------------------------------------- /src/utils/llm.ts: -------------------------------------------------------------------------------- 1 | import { jsonrepair } from 'jsonrepair'; 2 | import { opSysPrompt } from '../prompts/opSysPrompt'; 3 | import { spySysPrompt } from '../prompts/spySysPrompt'; 4 | import { createUserPrompt } from '../prompts/userPrompt'; 5 | import { GameState, OperativeMove, SpymasterMove } from './game'; 6 | 7 | type Message = { 8 | role: 'system' | 'user' | 'assistant'; 9 | content: string; 10 | }; 11 | 12 | type LLMRequest = { 13 | messages: Message[]; // Array of messages in the conversation 14 | modelName: string; // OpenRouter model string (e.g. "openai/gpt-4o") 15 | stream?: boolean; // Whether to stream the response 16 | referer?: string; // Optional referer URL for OpenRouter identification (e.g. "https://mysite.com") 17 | title?: string; // Optional title header for OpenRouter identification (e.g. "My AI App") 18 | }; 19 | 20 | const REFERRER = 'https://llmcodenames.com'; 21 | const TITLE = 'LLM Codenames'; 22 | 23 | export function createMessagesFromGameState(gameState: GameState): Message[] { 24 | const messages: Message[] = []; 25 | messages.push({ 26 | role: 'system', 27 | content: gameState.currentRole === 'spymaster' ? spySysPrompt : opSysPrompt, 28 | }); 29 | 30 | messages.push({ 31 | role: 'user', 32 | content: createUserPrompt(gameState), 33 | }); 34 | 35 | return messages; 36 | } 37 | 38 | export async function fetchLLMResponse( 39 | request: LLMRequest, 40 | ): Promise { 41 | const CLOUDFLARE_WORKER_URL = import.meta.env.VITE_CLOUDFLARE_WORKER_URL || ''; 42 | if (!CLOUDFLARE_WORKER_URL) { 43 | throw new Error( 44 | 'Cloudflare Worker URL is not configured. Add VITE_CLOUDFLARE_WORKER_URL to .env', 45 | ); 46 | } 47 | try { 48 | console.log('Messages:', request.messages); 49 | 50 | // 1 second delay to make the game less hectic 51 | await new Promise((resolve) => setTimeout(resolve, 1000)); 52 | 53 | const response = await fetch(CLOUDFLARE_WORKER_URL, { 54 | method: 'POST', 55 | headers: { 56 | 'Content-Type': 'application/json', 57 | }, 58 | body: JSON.stringify({ 59 | messages: request.messages, 60 | modelName: request.modelName, 61 | stream: false, 62 | referer: request.referer || REFERRER, 63 | title: request.title || TITLE, 64 | }), 65 | }); 66 | 67 | if (!response.ok) { 68 | throw new Error(`API returned ${response.status}: ${response.statusText}`); 69 | } 70 | 71 | const data = await response.json(); 72 | console.log('Response:', data); 73 | 74 | if (data.error) { 75 | throw new Error(data.error); 76 | } 77 | 78 | // Extract the actual content from the OpenRouter response format 79 | const rawContent = data.choices[0].message.content; 80 | 81 | // Clean and repair JSON (keeping this from the old proxy) 82 | const cleanContent = rawContent.substring( 83 | rawContent.indexOf('{'), 84 | rawContent.lastIndexOf('}') + 1, 85 | ); 86 | const repairedContent = jsonrepair(cleanContent); 87 | return JSON.parse(repairedContent); 88 | } catch (error) { 89 | console.error('Error fetching LLM response:', error); 90 | throw error; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/utils/models.ts: -------------------------------------------------------------------------------- 1 | // Import logos 2 | import anthropicLogo from '../assets/logos/anthropic.svg'; 3 | import deepseekLogo from '../assets/logos/deepseek.svg'; 4 | import geminiLogo from '../assets/logos/gemini.svg'; 5 | import openaiLogo from '../assets/logos/openai.svg'; 6 | import xaiLogo from '../assets/logos/xai.svg'; 7 | 8 | export type LLMModel = { 9 | openrouter_model_id: string; 10 | model_name: string; 11 | short_name: string; 12 | logo: string; 13 | }; 14 | 15 | export const agents: LLMModel[] = [ 16 | { 17 | openrouter_model_id: 'openai/gpt-4o', 18 | model_name: 'GPT-4o', 19 | short_name: '4o', 20 | logo: openaiLogo, 21 | }, 22 | { 23 | openrouter_model_id: 'openai/gpt-4o-mini', 24 | model_name: 'GPT-4o mini', 25 | short_name: '4o mini', 26 | logo: openaiLogo, 27 | }, 28 | { 29 | openrouter_model_id: 'openai/o3-mini', 30 | model_name: 'OpenAI o3-mini', 31 | short_name: 'o3 mini', 32 | logo: openaiLogo, 33 | }, 34 | { 35 | openrouter_model_id: 'google/gemini-pro-1.5', 36 | model_name: 'Gemini Pro 1.5', 37 | short_name: 'Pro 1.5', 38 | logo: geminiLogo, 39 | }, 40 | { 41 | openrouter_model_id: 'google/gemini-2.0-flash-001', 42 | model_name: 'Gemini Flash 2.0', 43 | short_name: 'Flash 2.0', 44 | logo: geminiLogo, 45 | }, 46 | { 47 | openrouter_model_id: 'anthropic/claude-3.7-sonnet', 48 | model_name: 'Claude 3.7 Sonnet', 49 | short_name: 'Sonnet 3.7', 50 | logo: anthropicLogo, 51 | }, 52 | { 53 | openrouter_model_id: 'anthropic/claude-3-5-haiku', 54 | model_name: 'Claude 3.5 Haiku', 55 | short_name: 'Haiku 3.5', 56 | logo: anthropicLogo, 57 | }, 58 | // Disabled for slow & unreliable performance 59 | // { 60 | // openrouter_model_id: 'meta-llama/llama-3.2-90b-vision-instruct', 61 | // model_name: 'Llama 3.2 90B', 62 | // short_name: '3.2 90B', 63 | // logo: metaLogo, 64 | // }, 65 | { 66 | openrouter_model_id: 'x-ai/grok-2-1212', 67 | model_name: 'Grok 2 1212', 68 | short_name: 'Grok 2', 69 | logo: xaiLogo, 70 | }, 71 | { 72 | openrouter_model_id: 'deepseek/deepseek-r1', 73 | model_name: 'DeepSeek R1', 74 | short_name: 'DeepSeek R1', 75 | logo: deepseekLogo, 76 | }, 77 | ]; 78 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | fontFamily: { 7 | sans: ['Inter', 'system-ui', 'sans-serif'], 8 | }, 9 | }, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "Bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true 25 | }, 26 | "include": ["src"] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "Bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------