Building a NBA Connections Web App with Curosr, V0, Claude & ChatGPT
Ever find yourself playing the NYT Connections game and thinking “I could build this, but for [insert passion here]”? That’s exactly what happened to me with NBA Connections. As a daily NYT Connections player and NBA enthusiast, I saw an opportunity to combine these worlds. The catch? I had never built a full fledged web app before.
Feel free to take the GitHub Repo for a spin.
Tech Stack
For this project, I thought this would be a perfect opportunity to use AI and Cursor to see if the hype was real. Oh boy was it real. My tech stack looked like this:
- Cursor (for AI-assisted development)
- Supabase (for database)
- ChatGPT (for requirements and coding assistance)
- Vercel + V0 (for deployment)
A special shoutout to McKay Wrigley’s Cursor course — it was invaluable for getting started with AI-assisted development. If you’re new to this space, I highly recommend watching his videos and following Riley Brown for additional insights.
Starting with ChatGPT for Initial Requirements
The first step in any project should be clearly defining what you want to build. Being a hobbyist Python developer but a web development novice, I knew I needed help structuring my requirements properly. Enter ChatGPT.
I started by recording myself playing a round of NYT Connections on my phone.
This visual reference would help ensure I captured all the key gameplay elements. I then crafted a detailed prompt for ChatGPT, essentially asking it to act as a product manager and convert my gameplay video into technical requirements.
Here’s a snippet of the prompt I used:
You are a world class product manager and engineer who is planning to build a Web App that is the New York Times Connections game. Here is a video walk through of a successful game play. Please convert this into a requirements prompt that I can pass into an AI web app vercel tool. A brief overview of the game: Connections is a word game that challenges players to find themes between words. Players are given 16 words and find groups of four items that share something in common. For example: FISH: Bass, Flounder, Salmon, Trout Things that start with FIRE: Ant, Drill, Island, Opal Each group is assigned a color (Yellow, Green, Blue, or Purple), with Yellow being the easiest category and Purple being the trickiest. Each puzzle has exactly one solution and is meant to be tricky by having words that could fit into multiple categories. Words and categories are curated by editors using the Oxford Dictionary. You can find more details about the game here: https://help.nytimes.com/hc/en-us/articles/28525912587924-Connections My goal is to create a web app that I can input in the 16 words and groupings into a separate file.
The key was uploading a video so it could see everything frame by frame. You can see the conversation here: https://chatgpt.com/share/66e63bda-93f0-8011-a6a3-430cb51ae143
Through my conversation with ChatGPT, we established these core requirements:
- Display a 4x4 grid of NBA player names (16 total)
- Allow players to select/deselect names
- Enable group submission when 4 names are selected
- Provide feedback on correct/incorrect groupings
- Maintain color-coding for difficulty (Yellow → Purple)
- Track remaining guesses and game completion
When working with AI tools for requirements gathering, be as specific as possible about edge cases and user interactions. The more detailed your prompt, the more useful the output.
Vercel’s V0: From Zero to Scaffold
Here’s where things get interesting — imagine having zero JavaScript experience and being able to generate a working web app prototype in minutes. That’s exactly what happened when I took my ChatGPT requirements to Vercel’s V0.
First Contact with V0
Let me be upfront: before this project, my JavaScript experience was essentially nonexistent. My comfort zone was Python, and the closest I’d gotten to web development was “monkeying around with Streamlit” (as one does). But V0 made the transition surprisingly painless.
I literally copied and pasted the ChatGPT output directly into V0, and the results were… surprisingly decent!
The Back-and-Forth Dance
Like any good AI tool, V0 works best when you engage in a dialogue. My conversation with V0 was like teaching someone to build a puzzle game — it got better with each iteration. Let me walk you through the evolution:
Basic Game Structure
Adding Player Selection Logic
- Disabled buttons until exactly 4 players were selected
- Added visual feedback for selected states
Implementing Feedback Systems
- Toast notifications for incorrect guesses
- Celebration animations for correct groups
Polishing the Experience
- Repositioned notifications for better visibility
- Added smooth transitions for board updates
- Implemented dynamic color coding for difficulty levels
Each iteration brought us closer to that satisfying NYT Connections feel, even though I was basically speaking JavaScript as a second language through V0’s interface. The key was being specific about what wasn’t working and letting V0 suggest improvements.
What V0 Got Right
Even in its first attempt, V0 nailed several key aspects:
- Responsive grid layout
- Basic player selection functionalit
- Clean, modern UI component
- Proper React component structure (which I wouldn’t have known how to create from scratch)
Areas Needing Refinement
Of course, not everything was perfect out of the box. Some areas needed work:
- Game logic for validating groups
- State management for selected players
- Animation and transition effects
- Error handling and edge cases
Pro Tips for V0 Users
If you’re new to V0 like I was, here are some lessons learned:
- Be Specific: The more detailed your initial prompt, the better the output
- Iterate Gradually: Don’t try to fix everything at once. Make small, focused improvements
- Save Versions: Keep track of different iterations — sometimes earlier versions have useful code you’ll want to reference
- Learn from the Output: Even if you don’t understand all the code initially, V0’s output is a great learning tool
Getting Ready for Cursor
Once I had a decent scaffold from V0, I felt ready to move into Cursor for more detailed development. The great thing about V0 is that it gave me working React components I could build upon, even without deep JavaScript knowledge.
This output served as my foundation for the rest of the project. While it wasn’t perfect, it provided enough structure that I could start thinking about the real implementation details rather than getting stuck on basic setup.
Setting Up a GitHub Repo
Step-by-Step Setup
Create a Local Project Folder
mkdir nba-connections
cd nba-connections
Launch GitHub Desktop
- Open the GitHub Desktop app
- Click File > New Repository in the menu bar
Configure Your New Repo
- Name: nba-connections
- Description: “NBA-themed word association game built with React”
- Local Path: Select your project folder
- ✅ Initialize with README
- ✅ Git ignore: Choose Node
- Create Repository
Adding the Node .gitignore template right from the start will save you from accidentally committing your node_modules folder — trust me, you don’t want that in your repo!
Now we’ve got a clean slate to work with, and more importantly, we’re ready to track all our changes as we build. In the next section, we’ll set up our virtual environment to keep our dependencies organized.
You can go ahead and publish your repository.
Creating a Virtual Environment
Let’s set up a clean virtual environment for our project. Even though we’re building a JavaScript app, using a Python virtual environment helps manage any Python scripts we might need (like data processing or API calls) and keeps our development environment isolated.
Setting Up Venv
From your project’s root directory (I like to call mine nba-connections), run these commands in your terminal:
# Create a new virtual environment
python3 -m venv venv
# Activate the environment
source venv/bin/activate # On Mac/Linux
You’ll know it’s working when you see (venv) appear at the beginning of your terminal prompt.
Pro Tip: If you ever forget if your virtual environment is activated, just look for that (venv) prefix in your terminal!
Initial Requirements
While we’re here, let’s create a requirements.txt for any Python packages we might need later:
# From your activated virtual environment
pip freeze > requirements.txt
If someone else wants to recreate your environment, they can simply run:
pip install -r requirements.txt
Environment Variables
Create a .env file in your root directory to store any sensitive information:
touch .env
Remember: Never commit your .env file to GitHub! That’s why we added it to .gitignore earlier.
Directory Structure
After setting up your virtual environment, your project structure should look something like this:
nba-connections/
├── .git/
├── venv/
├── .gitignore
├── .env
├── README.md
└── requirements.txt
Don’t worry if it looks a bit empty — we’ll be adding plenty of files as we build out our app!
Cursor Composer for File & Folder Setup
If you’ve been coding in VS Code, get ready for a serious upgrade. Cursor is like VS Code with an AI co-pilot, and proper setup makes all the difference. Let’s walk through getting Cursor configured for our NBA Connections project.
Installing Cursor
- Head to cursor.com and download the latest version
- Open Cursor and select File > Open Folder
- Navigate to your nba-connections project folder
Pro Tip: If you’re migrating from VS Code, Cursor will feel familiar — it’s built on the same foundation but supercharged with AI capabilities.
Go ahead and open your new folder in Cursor.
Configuring AI Settings
This is where the magic happens. Click on the Settings gear icon and navigate to “Rules for AI”. I’m using settings based on McKay Wrigley’s course (highly recommended!):
Project Structure
With Cursor open and configured, let’s create our initial project structure. You can do this manually, but here’s a neat trick — ask Cursor to help generate it:
Lets open the composer and ask it to input the following:
Create a Next.js project with the following structure:
/
├── .next/ # Next.js build output
├── .env # Environment variables
├── .env.local # Local environment variables
├── .gitignore # Git ignore file
├── .venv/
├── backend/ # Backend code
├── node_modules/ # Node dependencies
├── src/ # Source code
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── nba-connections-game.tsx
│ ├── components/
│ │ └── ui/ # UI components
│ │ ├── BasketballIcon.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── toast.tsx
│ │ ├── toaster.tsx
│ │ └── use-toast.tsx
│ ├── lib/ # Utility functions
│ └── styles/
│ └── globals.css # Global styles
└── venv/ # Python virtual environment
In addition to the folder structure, I am also going to copy the code from V0 to give it more context so it knows that this is what the bare bones app will look like.
Cursor Chat for Getting the Front End Right
While I definitely had some back and forth with V0 at the start, the core of my coding took place in Cursor, using their native AI coding capabilities. If you’re coming from a Python background like me, React and TypeScript might feel intimidating — but Cursor makes it surprisingly approachable.
Prepare to have A LOT of back and forth within Cursor. I must have used claude-sonnet-3.5 in the right rail for over 1000 messages.
The beauty of Cursor is that you can “Apply” and it picks out the code that needs to be changed. THIS IS SUCH AN UPGRADE FROM COPY PASTING FROM CLAUDE/CHATGPT. There I either had to use my brain to find the specific places OR ask the model to output ALL of the code and not to get lazy. This takes a while as the file gets bigger and bigger.
Core Game Components
At a high level we’d be using the following:
- Next.js for the framework
- React for UI components
- Tailwind CSS for styling
- Supabase for database management
- Framer Motion for animations
The game includes:
- State Management: Uses React’s useState for game state (tiles, mistakes, completed groups, etc.) Dynamic Animations: Leverages Framer Motion for smooth tile movements, shaking on incorrect guesses, and celebratory animations
- Database Integration: Pulls daily puzzles from Supabase, similar to Wordle’s daily puzzle format
- Responsive Design: Adapts to different screen sizes with tailored text sizing and grid layouts
- Social Features: Includes result sharing functionality that generates emoji-based summaries of player performance
The general user flow is as follows:
- Players see a welcome screen with “Tip Off” button
- During gameplay, they can: Select/deselect players, Shuffle the board, Substitute (deselect) all selections, Submit guesses
- After completion, players see their performance stats and can share results
The game maintains engagement through immediate feedback (toasts), visual cues for correct/incorrect guesses, and satisfying animations throughout the experience. Some additional features I added:
- Confetti celebration for wins
- Adaptive text sizing for longer player names
- Mistake counter displayed as “timeouts”
Game State Management
The game uses several key state variables to track the game’s progress:
const [gamePhase, setGamePhase] = useState<'loading' | 'ready' | 'playing'>('loading');
const [tiles, setTiles] = useState<WordTile[]>([]);
const [mistakes, setMistakes] = useState(4);
const [completedGroups, setCompletedGroups] = useState<Group[]>([]);
Data Structure
Our puzzle data is structured using TypeScript interfaces:
interface Group {
words: string[];
theme: string;
color: string;
emoji: string;
}
interface Puzzle {
id: number;
puzzle_id: number;
date: string;
groups: Group[];
author: string;
}
Key Features Implementations
Player Selection System
The selection system allows players to pick up to four names at a time. Here’s the core selection logic:
const handleTileClick = (index: number) => {
const selectedCount = tiles.filter(tile => tile.isSelected).length;
if (selectedCount >= 4 && !tiles[index].isSelected) return;
const newTiles = [...tiles];
newTiles[index].isSelected = !newTiles[index].isSelected;
setTiles(newTiles);
}
Group Validation
When players submit their selections, we validate against known groups:
const matchingGroup = puzzle?.groups.find(group =>
group.words.every(word => selectedWords.includes(word))
);
if (matchingGroup) {
// Handle correct guess
rearrangeTilesForCorrectGuess(matchingGroup);
setCompletedGroups([...completedGroups, matchingGroup]);
} else {
// Handle incorrect guess
setMistakes(mistakes - 1);
// Show feedback toast
}
UX Design
Responsive Grid Layout
The game uses a responsive grid layout that adapts to different screen sizes:
<motion.div layout className="grid grid-cols-4 gap-1 sm:gap-2 mb-4 sm:mb-6 w-full">
{/* Tiles */}
</motion.div>
Mobile First Design
We implement responsive text sizing and touch-friendly controls:
<span className={`
${tile.word.length > 12 ? 'text-[8px] sm:text-xs md:text-sm' :
tile.word.length > 8 ? 'text-[10px] sm:text-sm md:text-base' :
'text-xs sm:text-base md:text-lg'}
`}>
Shuffle Functionality
Players can shuffle remaining tiles to get a fresh perspective:
const handleShuffle = () => {
const uncompletedTiles = tiles.filter(tile => !tile.group);
const completedTiles = tiles.filter(tile => tile.group);
setTiles([...completedTiles, ...shuffleArray(uncompletedTiles)]);
}
Color Coded Difficulty Levels
Each difficulty level has its own color:
- Yellow: Straightforward connections
- Green: Moderate difficulty
- Blue: Challenging connections
- Purple: Tricky associations
Game Flow Management
Daily Puzzle System
Puzzles are fetched from Supabase based on the current date:
const fetchPuzzleOfTheDay = async () => {
const today = new Date().toISOString().split('T')[0];
const { data, error } = await supabase
.from('puzzles')
.select('*')
.eq('date', today)
.single();
// Handle response
}
Game State Persistence
Completed groups are maintained and displayed in order:
const renderCompletedGroups = () => {
return completedGroups.map((group, groupIndex) => (
<motion.div
key={groupIndex}
className={`mb-1 sm:mb-2 p-2 rounded-md ${getGroupColor(group.color)}`}
>
{/* Group content */}
</motion.div>
));
}
Results Sharing
Players can share their results with a custom-formatted string:
const resultsText = `NBA Connections
Puzzle #${puzzle?.puzzle_id}
${guesses.map(guess =>
guess.selectedWords.map(word => {
const correctGroup = puzzle?.groups.find(group => group.words.includes(word));
return correctGroup?.emoji || '⬜';
}).join('')
).join('\n')}`;
Playing with a Local Build
After setting up our project structure, it’s time to get our hands dirty with local development. This is where the real magic happens — seeing your changes in real-time and debugging as you go.
Getting Started with npm
First, let’s fire up our development server. In Cursor’s integrated terminal (a lifesaver for those of us who hate switching windows), run:
npm run dev
You’ll see something like this:
> nba-connections@0.1.0 dev
> next dev
✓ Ready in 2.1s
○ Compiled /app in 300ms (398 modules)
Pro Tip: Keep this terminal visible in Cursor while you work. It’s like having a development companion that alerts you to problems immediately.
Hot Reload Workflow
One of the best things about modern web development is hot reloading. Make a change, save the file, and watch your app update instantly. Here’s my typical workflow:
- Make changes in Cursor
- Save (
Cmd/Ctrl + S
) - Watch the terminal for any errors
- Check browser at
localhost:3000
Using Cursor’s Terminal
The integrated terminal in Cursor is particularly helpful for:
Error Tracking
Error: Cannot find module ‘@/components/ui/button’
When you see errors like this, Cursor often suggests fixes right in the editor.
Build Status
[Fast Refresh] building /app/page.tsx
[Fast Refresh] done in 148ms
Watch for these messages to know when your changes are ready to view.
Package Management
npm install react-confetti
Install new packages without leaving your editor.
Claude Back and Forth for Animation
When I had the barebones of my front end app, it was finally time to make it dance with some Framer animation. I have never done this before so first thing I did was turn to Claude to help me with the right vocabulary for prompting V0.
I then went back to V0 and had a long conversation to get the Framer motion basics right.
Here are some highlights from the convo.
Basic Setup
First, import Framer Motion:
import { motion, AnimatePresence } from ‘framer-motion’
Animated Grid Layout
The game uses a responsive grid with automatic layout animations:
- layout prop enables automatic animations when elements move
- AnimatePresence handles enter/exit animations
- Each tile gets its own transition timing
<motion.div layout className=”grid grid-cols-4 gap-1 sm:gap-2 mb-4 sm:mb-6 w-full”>
<AnimatePresence>
{uncompletedTiles.map((tile) => (
<motion.button
key={tile.word}
layout
transition={{ duration: 0.5, ease: “easeInOut” }}
// … other props
>
{/* tile content */}
</motion.button>
))}
</AnimatePresence>
</motion.div>
Tile Selection Animation
When tiles are selected, they scale up briefly:
<motion.button
animate={tile.isAnimating ? { scale: [1, 1.1, 1] } : {}}
whileTap={{ scale: 0.95 }}
>
{/* tile content */}
</motion.button>
Incorrect Guess Shake Animation
When a guess is wrong, tiles shake with this animation:
<motion.span
animate={tile.isShaking ? { x: [-5, 5, -5, 5, 0] } : {}}
transition={{ duration: 0.5 }}
>
{tile.word}
</motion.span>
Group Completion Animation
When a group is completed, it scales up briefly:
<motion.div
initial={{ scale: 1 }}
animate={justEnded ? { scale: [1, 1.2, 1] } : {}}
transition={{ delay: 0.2, duration: 0.5 }}
className={`mb-1 sm:mb-2 p-2 rounded-md ${getGroupColor(group.color)}`}
>
{/* group content */}
</motion.div>
Managing Animation State
The game tracks animation states in the tile objects:
interface WordTile {
word: string
isSelected: boolean
isAnimating?: boolean
isShaking?: boolean
}
Trigger animations by updating these flags:
setTiles(prevTiles => {
const newTiles = […prevTiles];
newTiles[tileIndex] = { …newTiles[tileIndex], isAnimating: true };
return newTiles;
});
// Reset animation after delay
setTimeout(() => {
setTiles(prevTiles => prevTiles.map(tile => ({
…tile,
isAnimating: false
})));
}, 500);
Tips for Smooth Animations
- Use layout for automatic grid reorganization
- Keep animations short (0.2–0.5s)
- Use AnimatePresence for enter/exit animations
- Add micro-interactions like whileTap for better feel
- Chain animations with setTimeout for sequences
- Use array syntax [1, 1.1, 1] for multi-step animations
- This setup creates a polished, responsive game feel while keeping the code maintainable.
Additional reading sources: Framer Motion Documentation
Creating & Exporting SVG Icons from Figma to React Components
SVG icons are essential UI elements that can enhance your application’s visual appeal while maintaining crisp quality at any size. In this tutorial, we’ll walk through the process of creating a custom basketball icon in Figma and implementing it as a reusable React component. A free Figma account will suffice for what we need!
Designing the Icon in Figma
First, create a new design file in Figma and set up your artboard:
- Create a new frame (F) sized 66x66px for our icon
- Use basic shapes (rectangles and paths) to create the basketball design
- Ensure all shapes are properly aligned and grouped
- Set fill colors (#E87503 for the orange basketball color)
- Add a 3px black stroke around the entire icon
The basketball icon consists of:
- 9 segments making up the basketball pattern
- Rounded corners (7.5px radius) for a modern look
- Clean 1px black strokes between segments
- Consistent orange fill color throughout
Exporting from Figma
To export your icon as an SVG:
- Select your entire icon frame
- Right-click and choose “Export”
- Select SVG format
- Enable “Include ‘id’ attribute”
- Click “Export”
Converting to a React Component
Create a new file called BasketballIcon.tsx and transform the SVG into a React component:
import React from 'react';
const BasketballIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg width="66" height="66" viewBox="0 0 66 66" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.5 43.5H22.5V62.5H9C5.96243 62.5 3.5 60.0376 3.5 57V43.5Z" fill="#E87503" stroke="black"/>
<path d="M43.5 43.5H62.5V57C62.5 60.0376 60.0376 62.5 57 62.5H43.5V43.5Z" fill="#E87503" stroke="black"/>
<rect x="23.5" y="43.5" width="19" height="19" fill="#E87503" stroke="black"/>
<rect x="3.5" y="23.5" width="19" height="19" fill="#E87503" stroke="black"/>
<rect x="43.5" y="23.5" width="19" height="19" fill="#E87503" stroke="black"/>
<rect x="23.5" y="23.5" width="19" height="19" fill="#E87503" stroke="black"/>
<path d="M9 3.5H22.5V22.5H3.5V9C3.5 5.96243 5.96243 3.5 9 3.5Z" fill="#E87503" stroke="black"/>
<path d="M43.5 3.5H57C60.0376 3.5 62.5 5.96243 62.5 9V22.5H43.5V3.5Z" fill="#E87503" stroke="black"/>
<rect x="23.5" y="3.5" width="19" height="19" fill="#E87503" stroke="black"/>
<rect x="1.5" y="1.5" width="63" height="63" rx="7.5" stroke="black" strokeWidth="3"/>
</svg>
);
export default BasketballIcon;
Using the Icon Component
Import and use the icon in your React components:
import BasketballIcon from ‘@/components/ui/BasketballIcon’
// In your component JSX
<BasketballIcon
className="w-20 h-20 text-gray-700 mx-auto mb-4"
/>
You can customize the icon using Tailwind classes:
w-20 h-20
: Sets width and heighttext-gray-700
: Controls the colormx-auto
: Centers horizontallymb-4
: Adds bottom margin
Creating custom SVG icons in Figma and implementing them in React is a powerful way to maintain unique, scalable visual elements in your application. By following these steps and best practices, you can create professional-looking icons that enhance your user interface while maintaining good performance and accessibility.
Connecting to Supabase
Supabase is an open-source alternative to Firebase that provides a powerful PostgreSQL database, authentication, and real-time subscriptions. In this tutorial, we’ll walk through setting up Supabase for a Python application, focusing on creating a puzzles database for a game.
Setting Up Your Supabase Account
- Visit Supabase and click “Start Your Project”
- Create a new organization if you haven’t already
- Create a new project:
- Choose a name
- Set a secure database password
- Select your region (closest to your users)
- Wait for your database to be provisioned
After creation, you’ll need two key pieces of information from your project dashboard:
- Project URL
- Project API Keys (anon public and service_role key)
Environment Configuration
⚠️ Important: Add .env
to your .gitignore
file to keep your keys secure!
SUPABASE_URL=”your-project-url”
SUPABASE_SERVICE_KEY="your-service-role-key"
SUPABASE_ANON_KEY="your-anon-public-key"
Creating the Database Schema
Let’s create a puzzles table to store our game data. In the Supabase SQL editor, run:
CREATE TABLE public.puzzles(
id bigint GENERATED BY DEFAULT AS IDENTITY NOT NULL,
puzzle_id integer NOT NULL,
date date NOT NULL,
groups jsonb NOT NULL,
author text NOT NULL,
CONSTRAINT puzzles_pkey PRIMARY KEY (id),
CONSTRAINT puzzles_date_key UNIQUE (date)
);
This creates a table with:
- Auto-incrementing primary key (
id
) - Sequential puzzle number (
puzzle_id
) - Scheduled date (
date
) - JSON array of puzzle groups (
groups
) - Puzzle creator (
author
)
The groups column uses JSONB to store structured data like:
[{
“color”: “bg-yellow-200”,
“emoji”: “🟨”,
“theme”: “Theme description”,
“words”: [“PLAYER 1”, “PLAYER 2”, “PLAYER 3”, “PLAYER 4”]
}]
Setting Up the Supabase Client
Create a new file called supabase_client.py
:
from supabase import create_client
from dotenv import load_dotenv
import os
# Load environment variables
load_dotenv()
# Initialize the Supabase client
supabase = create_client(
os.getenv('SUPABASE_URL'),
os.getenv('SUPABASE_SERVICE_KEY')
)
Basic Database Operations
Here are some common operations you’ll perform with your Supabase client:
Inserting a New Puzzle
def create_puzzle(puzzle_data):
try:
response = supabase.table(‘puzzles’).insert({
‘puzzle_id’: puzzle_data[‘puzzle_id’],
‘date’: puzzle_data[‘date’],
‘groups’: puzzle_data[‘groups’],
‘author’: puzzle_data[‘author’]
}).execute()
return response.data
except Exception as e:
print(f”Error creating puzzle: {e}”)
return None
Fetching the Puzzle of the Day
from datetime import date
def get_todays_puzzle():
try:
response = supabase.table('puzzles')\
.select('*')\
.eq('date', date.today().isoformat())\
.execute()
return response.data[0] if response.data else None
except Exception as e:
print(f"Error fetching puzzle: {e}")
return None
Updating a Puzzle
def update_puzzle(puzzle_id, updated_data):
try:
response = supabase.table(‘puzzles’)\
.update(updated_data)\
.eq(‘puzzle_id’, puzzle_id)\
.execute()
return response.data
except Exception as e:
print(f”Error updating puzzle: {e}”)
return None
Security Considerations
Row Level Security (RLS)
Enable RLS to control access to your data:
ALTER TABLE public.puzzles ENABLE ROW LEVEL SECURITY;
Access Policies
Create policies to control read/write access:
— Allow anyone to read puzzles
CREATE POLICY “Public read access”
ON public.puzzles FOR SELECT
TO public
USING (true);
- Only allow authenticated users to insert
CREATE POLICY "Authenticated insert access"
ON public.puzzles FOR INSERT
TO authenticated
WITH CHECK (true);
Themes Table
Later we will use OpenAI to generate the themes but first we need to make the Supabase table. Here’s the SQL schema for the themes table:
CREATE TABLE
public.themes (
theme_id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
theme TEXT NOT NULL,
color TEXT NOT NULL,
emoji TEXT NOT NULL,
words jsonb NOT NULL,
used_in_puzzle BOOLEAN DEFAULT false,
validated_state BOOLEAN DEFAULT false,
CONSTRAINT themes_pkey PRIMARY KEY (theme_id)
) TABLESPACE pg_default;
- Add index for commonly queried columns
CREATE INDEX themes_color_idx ON public.themes(color);
CREATE INDEX themes_used_puzzle_idx ON public.themes(used_in_puzzle);
Key points:
- Words uses jsonb to store the array of player names
- Added default false for both boolean fields
- Added indexes for color and used_in_puzzle since they’ll likely be common query filters
- Using TEXT for color/emoji/theme since they’re string values
- Primary key auto-increments
Building the Theme Generator with OpenAI
I had tried some one shot prompting to generate the puzzles entirely but actually found it to be more effective breaking it down into discrete tasks. Here we will use OpenAI API to first generate the themes. Keep in mind that the model is likely to hallucinate but we will validate the themes separately.
Here’s an overview of the theme_generator.py
script
Core Architecture
The theme generator has three main responsibilities:
- Generate themed groups of NBA players using GPT-4
- Validate and structure the data
- Store themes for later use in puzzles
Let’s break down each component.
Environment Setup
First, we need to set up our development environment with the necessary dependencies:
import openai
from dotenv import load_dotenv
from pydantic import BaseModel
from supabase_client import supabase
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
We’re using:
- OpenAI’s API for GPT-4 access
- python-dotenv for secure API key management
- Pydantic for data validation
- Supabase for database storage
Data Modeling
To ensure consistency, we define a clear structure for our themes using Pydantic:
class ThemeGroup(BaseModel):
color: str
emoji: str
theme: str
words: List[str]
Each theme includes:
- color: Difficulty indicator (e.g., “bg-yellow-200”)
- emoji: Visual representation (🟨, 🟩, 🟦, 🟪)
- theme: Description of the connection
- words: List of 4 NBA player names
Difficulty Levels
Like the original Connections game, we use four color-coded difficulty levels:
colors = [
{
“name”: “Yellow”,
“description”: “the easiest category”,
“color_code”: “bg-yellow-200”,
“emoji”: “🟨”,
},
# … other colors …
]
The progression goes:
- Yellow (Easiest) — Think “Current Lakers Starters”
- Green (Moderate) — Like “2020 All-NBA First Team”
- Blue (Hard) — Example: “Players with 70+ Point Games”
- Purple (Trickiest) — Such as “Changed Their Names During Career”
Theme Generation with GPT-4
The heart of our generator is the generate_themes_for_color()
function:
def generate_themes_for_color(n, color):
prompt = PROMPT_TEMPLATE.format(
n=n,
color_name=color[“name”],
color_description=color[“description”],
color_code=color[“color_code”],
emoji=color[“emoji”],
)
response = client.chat.completions.create(
model=MODEL,
messages=[{“role”: “user”, “content”: prompt}],
)
The function:
- Takes a difficulty color and number of themes needed
- Formats a detailed prompt for GPT-4
- Processes the response into structured ThemeGroup objects
We provide GPT-4 with specific context about NBA Connections and requirements for good themes through our prompt template:
PROMPT_TEMPLATE = """<context>
NBA Connections is a word game that challenges users to find themes between NBA players…
</context>
<requirements>
Your task is to come up with {n} unique themes for the {color_name} category…
</requirements>
"""
Persistence Layer
Generated themes are saved in two places for redundancy and different access patterns:
Local JSON file for development and backup:
def save_themes_to_file(themes, filename=”nba_themes.json”):
themes_data = [theme.dict() for theme in themes]
with open(filename, “w”) as file:
json.dump(themes_data, file, indent=4)
Supabase database for production access:
def save_themes_to_supabase(themes):
for theme in themes:
data = {
“theme”: theme.theme,
“words”: theme.words,
“color”: theme.color,
“emoji”: theme.emoji,
}
supabase.table(“themes”).insert(data).execute()
Using the Generator
To generate new themes:
- Set up your environment variables in .env:
OPENAI_API_KEY=your-key-here
2. Run the script:
python theme_generator.py
3. Enter the number of themes you want per difficulty level
4. Check the output in nba_themes.json
and your Supabase dashboard
Example Output
A generated theme might look like:
{
“color”: “bg-yellow-200”,
“emoji”: “🟨”,
“theme”: “2023 NBA Champions Denver Nuggets Starters”,
“words”: [
“Nikola Jokic”,
“Jamal Murray”,
“Michael Porter Jr.”,
“Aaron Gordon”
]
}
By combining GPT-4’s NBA knowledge with structured data validation and storage, we’ve created a robust system for generating themed player groups. This forms the foundation of our NBA Connections game, providing a steady stream of engaging content for players.
Validating NBA Themes with LangChain and OpenAI
After generating themed groups of NBA players, we need to ensure they’re accurate. Is Allen Iverson really a former scoring champion? Did Karl Malone actually play in the Western Conference Finals? Let’s build an intelligent validator using LangChain, OpenAI, and web search capabilities.
The Validation Challenge
Validating NBA themes presents several challenges:
- Historical data spans decades
- Player stats and achievements change over time
- Sources can be unreliable or outdated
- Manual verification is time-consuming
Our solution: Create an automated validator that cross-references trusted sources and uses GPT-4 for analysis.
Building the Validator
Specialized NBA Search Tool
First, we create a targeted search tool that only pulls from reliable NBA sources:
def create_nba_search_tool():
search = SearchApiAPIWrapper(
searchapi_api_key=os.getenv(“SEARCHAPI_API_KEY”),
engine=”google”,
)
def search_nba_stats(query: str) -> str:
refined_query = (
f"(site:basketball-reference.com OR "
f"site:nba.com/stats OR "
f"site:statmuse.com/nba OR "
f"site:espn.com/nba/stats) "
f"{query}"
)
return search.run(refined_query)[:1000]
return Tool(
name="NBA_Search",
description="Search for NBA player statistics, awards, and historical facts",
func=search_nba_stats,
)
This ensures we’re only getting data from authoritative sources like:
- basketball-reference.com
- nba.com/stats
- statmuse.com
- espn.com/nba/stats
Theme Validation Logic
The core validation happens in the validate_player_theme function:
def validate_player_theme(player, theme):
prompt = (
f”Using the search tool, verify if the NBA player ‘{player}’ “
f”fits the theme ‘{theme}’. Provide evidence from the search results.”
)
result = agent_executor.invoke({“input”: prompt})
response = result.get(“output”, “”).strip()
if response.lower().startswith(“yes”):
return “Yes — “ + response[3:]
elif response.lower().startswith(“no”):
return “No — “ + response[2:]
The function:
- Takes a player name and theme
- Searches for relevant statistics and facts
- Uses GPT-4 to analyze the evidence
- Returns a clear Yes/No with explanation
Handling Rate Limits and Errors
API calls can fail, so we implement robust retry logic:
max_retries = 5
retry_delay = 5
for attempt in range(max_retries):
try:
# Validation logic here
except Exception as e:
if "rate limit" in str(e).lower():
print(f"⏳ Rate limited. Waiting {retry_delay} seconds…")
sleep(retry_delay)
retry_delay *= 2
This exponential backoff helps us gracefully handle API limits while ensuring validation completes.
Database Integration
We use Supabase to track validation status:
def validate_themes():
# Fetch unvalidated themes
response = supabase.table(“themes”) \
.select(“theme_id, theme, words, validated_state”) \
.eq(“validated_state”, False) \
.execute()
for theme in themes:
is_valid = True
for player in theme[“words”]:
result = validate_player_theme(player, theme[“theme”])
if not result.startswith(“yes”):
is_valid = False
break
# Update validation status
supabase.table(“themes”) \
.update({“validated_state”: is_valid}) \
.eq(“theme_id”, theme[“theme_id”]) \
.execute()
Validation in Action
Let’s see the validator analyze a theme “90s Scoring Champions”:
🤔 Analyzing if Michael Jordan fits theme: 90s Scoring Champions
NBA_Search: Searching for Jordan scoring titles 1990s…
Observation: Jordan led the NBA in scoring from 1990–1993 and 1996–1998
🎯 Final Answer: Yes - Michael Jordan won 7 scoring titles in the 90s
🤔 Analyzing if David Robinson fits theme: 90s Scoring Champions
NBA_Search: Searching for Robinson scoring titles 1990s…
Observation: Robinson won the scoring title in 1994 (29.8 ppg)
🎯 Final Answer: Yes - David Robinson won the 1994 scoring title
Theme validation result: ✅ Valid
Assembling NBA Connections Puzzles from Validated Themes
Now that we have a collection of validated themes, it’s time to combine them into complete puzzles. A valid puzzle needs exactly four themes (one per difficulty level) with no overlapping players. Let’s dive into how we build the puzzle generator.
Database Setup
First, we need a Supabase database to store our themes. Create a .env
file with your credentials:
SUPABASE_URL=your_supabase_url
SUPABASE_KEY=your_supabase_key
Your database needs a themes table with these columns:
CREATE TABLE themes (
color text, — bg-yellow-200, bg-green-200, etc.
emoji text, — Theme emoji (🟨, 🟩, 🟦, 🟪)
theme text, — Theme description
words text[], — Array of 4 NBA players
validated_state boolean, — Must be true to use
used_in_puzzle boolean — Tracks usage
);
Loading Validated Themes
We only want to use themes that have passed validation and haven’t been used before:
def load_themes_from_db():
response = (
supabase.table(“themes”)
.select(“*”)
.eq(“validated_state”, True)
.eq(“used_in_puzzle”, False)
.execute()
)
themes = [
ThemeGroup(
color=theme["color"],
emoji=theme["emoji"],
theme=theme["theme"],
words=theme["words"],
)
for theme in response.data
]
logging.info(f"Loaded {len(themes)} unused validated themes")
return themes
Puzzle Assembly Logic
The trickiest part is assembling non-overlapping puzzles. Here’s our strategy:
def assemble_puzzles_from_themes(themes, num_puzzles):
# Organize themes by color
themes_by_color = {color["color_code"]: [] for color in colors}
for theme in themes:
themes_by_color[theme.color].append(theme)
puzzles = []
max_attempts_per_puzzle = 10
while len(puzzles) < num_puzzles:
selected_themes = []
for color in colors:
color_code = color["color_code"]
selected_theme = random.choice(themes_by_color[color_code])
selected_themes.append(selected_theme)
# Check for overlapping players
all_players = []
for theme in selected_themes:
all_players.extend(theme.words)
if len(set(all_players)) == 16:
puzzles.append({"groups": selected_themes})
The key steps are:
- Organize themes by difficulty color
- Try to select one theme from each color
- Check that all 16 players are unique
- Retry up to 10 times if overlaps found
Tracking Used Themes
Once a theme is used in a puzzle, we mark it in the database:
def mark_themes_as_used(themes):
for theme in themes:
supabase.table("themes").update({"used_in_puzzle": True}) \
.eq("theme", theme.theme).execute()
logging.info(f"Marked {len(themes)} themes as used")
Troubleshooting Tips
If you’re having trouble generating puzzles:
- Not Enough Themes: Ensure you have multiple validated themes for each color
- Too Many Overlaps: Add more diverse player selections to your themes
- Database Issues: Check your Supabase connection and permissions
- Validation Status: Verify themes are marked as validated in the database
Building a Puzzle Validator with OpenAI
Let’s create a Python script that validates and uploads puzzle data to a Supabase database. This validator ensures puzzles follow specific rules and maintains data integrity.
Setting Up the Foundation
First, let’s set up our data models using Pydantic:
from pydantic import BaseModel
from typing import List, Dict, Union
class PuzzleGroup(BaseModel):
color: str
emoji: str
theme: str
words: List[str]
class Puzzle(BaseModel):
groups: List[PuzzleGroup]
Core Validation Logic
The heart of our validator checks several rules:
def validate_puzzle_structure(puzzle: Union[Dict, Puzzle]) -> Dict:
errors = []
groups = puzzle.get("groups", puzzle) if isinstance(puzzle, dict) else puzzle.groups
# Rule 1: Must have 4 groups
if len(groups) != 4:
errors.append(f"Puzzle must have exactly 4 themes (found {len(groups)})")
# Rule 2: Each group needs 4 unique words
all_words = []
for group in groups:
words = group.words if isinstance(group, PuzzleGroup) else group.get("words", [])
if len(words) != 4:
errors.append(f"Theme '{group.theme}' must have exactly 4 words (found {len(words)})")
if len(set(words)) != len(words):
errors.append(f"Theme '{group.theme}' contains duplicate words")
all_words.extend(words)
# Rule 3: Total 16 unique words across all groups
if len(set(all_words)) != 16:
errors.append(f"Puzzle must have exactly 16 unique words (found {len(set(all_words))})")
# Rule 4: Specific colors required
colors = [group.color if isinstance(group, PuzzleGroup) else group.get("color", "")
for group in groups]
required_colors = {"bg-yellow-200", "bg-green-200", "bg-blue-200", "bg-purple-200"}
if set(colors) != required_colors:
errors.append(f"Missing or invalid colors. Required: {required_colors}")
return {"valid": len(errors) == 0, "errors": errors}
Database Integration
Set up Supabase connection and date handling:
from datetime import datetime, timezone, timedelta
from supabase_client import supabase
def get_latest_puzzle_date():
try:
response = supabase.table("puzzles").select("date").order("date", desc=True).limit(1).execute()
if response.data:
return datetime.fromisoformat(response.data[0]["date"])
return datetime.now(timezone.utc)
except Exception as e:
print(f"❌ Error fetching latest date: {str(e)}")
return datetime.now(timezone.utc)
def insert_puzzle(puzzle: Dict, puzzle_number: int, start_date: datetime) -> bool:
try:
puzzle_date = start_date + timedelta(days=puzzle_number - 1)
data = {
"puzzle_id": puzzle_number,
"date": puzzle_date.isoformat(),
"groups": puzzle["groups"],
"author": "admin",
}
response = supabase.table("puzzles").insert(data).execute()
return bool(response.data)
except Exception as e:
print(f"❌ Database error: {str(e)}")
return False
Batch Processing
Create a function to handle multiple puzzles:
def validate_puzzles(puzzles: Union[List[Dict], List[Puzzle]]) -> List[Dict]:
results = []
start_date = get_latest_puzzle_date()
for i, puzzle in enumerate(puzzles, 1):
validation = validate_puzzle_structure(puzzle)
if not validation["valid"]:
results.append({
"puzzle_number": i,
"valid": False,
"errors": validation["errors"],
"inserted": False
})
continue
inserted = insert_puzzle(puzzle, i, start_date)
results.append({
"puzzle_number": i,
"valid": True,
"errors": None,
"inserted": inserted
})
return results
Putting it All Together with a Puzzle Manager
We create our flow step by step to get to where we are now, but we’ve been running these discretely. Ideally we want to create one puzzle_manager.py script which takes the pieces of everything, removes the JSON dependencies, and reads/writes to Supabase directly.
To handle this, lets try using Cursors Composer feature:
The Puzzle Manager automates the process of generating, validating, and managing the themes generated by our AI. So now we have AI generating and validating the themes, then putting them together in a complete puzzle and inserting it into our database.
The puzzle manager handles three main tasks:
- Loading validated themes from a Supabase database
- Generating and validating new puzzles
- Inserting valid puzzles and marking used themes in the database
Creating the Puzzle Pipeline
The core functionality lives in the run_puzzle_pipeline() function, which orchestrates the entire puzzle generation process. Let’s break it down into steps:
Step 1: Load Themes
First, we load validated themes from our Supabase database:
validated_themes = load_themes_from_db()
theme_count = len(validated_themes)
logging.info(f"Loaded {theme_count} validated themes from database")
# Verify we have enough themes
if theme_count < puzzles_to_generate * 4:
raise Exception(
f"Not enough themes. Need {puzzles_to_generate * 4}, have {theme_count}"
)
Each puzzle requires 4 themes, so we check that we have enough themes available before proceeding.
Step 2: Generate Puzzles
Next, we generate the puzzles using our loaded themes:
puzzles = assemble_puzzles_from_themes(
validated_themes,
puzzles_to_generate,
max_attempts=50
)
if not puzzles:
raise Exception("Failed to generate any valid puzzles")
Step 3: Validate and Insert Puzzles
For each generated puzzle:
- Validate it meets our requirements
- Insert valid puzzles into the database
- Mark used themes as taken
results = validate_puzzles(puzzles)
start_date = get_latest_puzzle_date() + timedelta(days=1)
for i, result in enumerate(results):
if result.get("valid", False):
puzzle = puzzles[i]
puzzle["author"] = MODEL
success = insert_puzzle(puzzle, i + 1, start_date)
Step 4: Mark Used Themes
After successfully inserting a puzzle, we mark its themes as used:
if success:
for group in puzzle["groups"]:
try:
supabase.table("themes").update(
{"used_in_puzzle": True}
).eq("theme", group["theme"]).execute()
logging.info(f"✓ Marked theme '{group['theme']}' as used")
except Exception as e:
logging.error(f"✗ Failed to mark theme as used: {e}")
Putting it All Together
The main interface provides a simple CLI for generating puzzles:
def main():
print("\n=== NBA Connections Puzzle Manager ===")
print("This script will generate puzzles and validate them.")
try:
puzzles_to_generate = int(input("Enter number of puzzles to generate: "))
if puzzles_to_generate < 1:
raise ValueError("Number must be positive")
success = run_puzzle_pipeline(puzzles_to_generate)
if success:
print("\n✅ Pipeline completed successfully!")
else:
print("\n❌ Pipeline failed. Check logs for details.")
except ValueError as e:
print(f"\n❌ Invalid input: {str(e)}")
except KeyboardInterrupt:
print("\n\n⚠️ Process interrupted by user")
except Exception as e:
print(f"\n❌ Unexpected error: {str(e)}")
The NBA Connections Puzzle Manager provides an automated way to:
- Generate NBA-themed word connection puzzles
- Validate puzzles meet game requirements
- Store puzzles and track used themes in Supabase
- Log the entire process for monitoring
The system is designed to be maintainable and extensible, making it easy to add new validation rules or modify the generation process as needed.
Hosting on Vercel
The best part of Vercel is that I can just import one of my github repositories and whatever was working on my local dev environment will now work on Vercel.
Make sure to install the required dependencies and then head to vercel to create your project. You can chose the appropriate repo and it will handle the build for you. If you run into any build log errors, jus bring those over to Cursor and it will help you debug!
If successful, you’ll see the Green light in your Vercel Project. Feel free to go check out the new vercel hosted URL and play around with it. Now is a good time to send it to some friends to help QA.
Everytime you update your repo, Vercel will automatically update the build.
Buying a Domain with GoDaddy
After building our NBA Connections app, we’ll want to give it a professional domain name instead of using the default Vercel URL.This will improve our chances of going viral.
I ended up landing on nbaconnections.app
Step 1: Add Domain in Vercel
- Go to your project dashboard in Vercel
- Click “Settings” → “Domains”
- Enter your new domain name and click “Add”
- Vercel will provide DNS records you need to configure in GoDaddy
Step 2: Configure DNS Records in GoDaddy
- Log into your GoDaddy account
- Go to “My Products” → Find your domain → “DNS”
- Remove any existing A or CNAME records
- Add the following records provided by Vercel:
Verifying Your Domain Setup
- After configuring DNS records, return to Vercel
- Click “Verify” next to your domain
- Wait for verification (can take up to 24 hours)
- Once verified, your domain will show as “Valid Configuration”
Enable SSL/HTTPS
Vercel automatically provisions SSL certificates for custom domains. Once DNS propagation is complete:
- Visit your domain with https:// prefix
- Verify the padlock icon appears in browser
- Check certificate details if desired
Best Practices
- Monitor Expiration: Set calendar reminders for domain renewal
- Auto-Renewal: Consider enabling to avoid expiration
- Domain Privacy: Keep enabled to prevent spam
- Documentation: Save DNS settings for reference
Troubleshooting Common Issues
If your domain isn’t working:
- DNS Propagation: Wait 24–48 hours for changes to take effect
- Record Verification: Double-check DNS record values
- SSL Issues: Ensure proper HTTPS configuration
- Cache: Clear browser cache or try incognito mode
We are now ready to rock and roll!
Using Claude AI to Write This Blog Post
As an aspiring developer, writing documentation and blog posts can sometimes feel as challenging as writing the code itself. Here’s how I used Claude AI to help write clear, engaging blog posts about my technical projects.
Why Use AI for Technical Writing?
While AI shouldn’t completely replace human writing, it can help:
- Structure your content logically
- Maintain consistent tone and style
- Expand on technical concepts clearly
- Save time on initial drafts
- Suggest improvements to existing content
My Process with Claude
Provide Context and Examples
First, I shared with Claude:
- The code files and project structure
- Example blog posts showing desired style/tone
- Clear instructions about the target audience
- Any specific sections or topics to cover
Break Down by Sections
Rather than trying to generate the entire post at once, I went section by section:
For example:
Here are 10 example technical blog posts that show the writing style I want. Please analyze these and then help me write a post about my NBA Connections Puzzle Manager project that follows a similar format.
Iterative Refinement
For each section, I:
- Reviewed Claude’s initial draft
- Requested specific improvements or changes
- Asked for additional detail where needed
- Had Claude incorporate my feedback
Example refinement prompt:
The setup section is good but could you add more detail about the Supabase configuration steps? Also, make the code snippets more prominent with better formatting.
Technical Accuracy Check
I always verified:
- Code snippets were correct and properly formatted
- Technical explanations were accurate
- Links and references were valid
- Dependencies and versions were current
Final Assembly and Flow
After getting all sections, I had Claude:
- Connect sections smoothly
- Add transitions
- Create a cohesive introduction and conclusion
- Format consistently
Go Forward Plan
For now I’ll rely on AI to generate the puzzles, maybe mixing in a few here and there from friends, but ideally I want to get users submitting puzzles to me so it because crowdsourced going forward.
I hope you enjoyed this blog post!
-Jabe