Skip to main content

Command Palette

Search for a command to run...

How I Built an AI-Powered IPL Fantasy Cricket League for My Friend Group in a Weekend

Updated
7 min read

Every IPL season, our friend group has the same problem: someone creates a WhatsApp poll, half the group forgets to pick their team, and the whole thing dies by match two. This year I decided to fix that properly — a real web app, with an AI that suggests your Best XI, live match scores, and a leaderboard. Here's how I built it in a weekend and what I learned along the way.


The Product

cric.inguva.dev — an IPL 2026 fantasy cricket league built for a small private group.

Features:

  • Register / login (email+password or Google Sign-In)
  • Pick your fantasy XI from the full IPL 2026 squad with budget and role constraints
  • AI-generated Best XI suggestion powered by Claude
  • Post-toss playing 11 entry — AI re-picks only from confirmed players
  • Live match scores
  • Leaderboard with team drill-down
  • Share your AI suggestion to iMessage / clipboard with one tap

The Stack

LayerTechnology
APIHono on Cloudflare Workers
DatabaseCloudflare D1 (SQLite at the edge)
Cache / KVCloudflare Workers KV
FrontendReact + Vite + TypeScript
StylingTailwind CSS
StateTanStack Query + Zustand
AuthPBKDF2 password hashing + JWT, Google OAuth
AIAnthropic Claude (claude-sonnet-4-6)
DeployCloudflare Pages + Workers

Everything runs on Cloudflare's free tier. No servers, no containers, no ops.


Architecture

Browser (React SPA on Cloudflare Pages)
        │
        │  /api/*  (proxied in dev, custom domain in prod)
        ▼
Cloudflare Worker (Hono router)
        │
        ├── D1 (SQLite) — users, players, fantasy teams
        └── KV — AI suggestion cache, toss/playing 11 data
                │
                └── Anthropic API — Best XI generation

The frontend is a single-page React app deployed to Cloudflare Pages. The API is a Hono app running on a Cloudflare Worker. They talk over cric-api.inguva.dev. D1 holds all the relational data; KV is used purely as a cache and ephemeral store for the day's toss data.


The AI Best XI — Making Claude a Fantasy Analyst

This was the most interesting part of the build.

The prompt gives Claude the full eligible player list with IDs, roles, credits, overseas status, and historical points. It also includes today's match schedule (fetched live from the IPL stats feed), pitch/venue context, the confirmed toss result if available, and the full scoring system breakdown.

Claude returns a JSON object with:

  • 11 player IDs
  • Captain and vice-captain
  • Role counts, total credits, overseas count
  • Pitch analysis, strategy, captain reasoning, VC reasoning
  • 2-3 differential picks with reasons
{
  "players": [455, 461, 472],
  "captain_id": 461,
  "vice_captain_id": 472,
  "total_credits": 98.5,
  "overseas_count": 4,
  "role_counts": {"WK": 1, "BAT": 4, "AR": 2, "BOWL": 4},
  "pitch_analysis": "Wankhede is a batting paradise...",
  "strategy": "Load up on MI batters...",
  "captain_reasoning": "Rohit Sharma opens at Wankhede...",
  "differential_picks": [{"id": 502, "name": "Tilak Varma", "reason": "..."}]
}

The hard part: Claude doesn't always follow the rules

Fantasy cricket has strict constraints: exactly 11 players, ≤100 credits, max 4 overseas, minimum role counts. Claude occasionally violates one of these — usually the budget (it picks too many premium players) or overseas count.

My fix was a two-layer validation system on the server:

Layer 1 — Retry with correction prompt. If violations are found, I send Claude the original conversation plus a correction message listing exactly which constraints were broken. This fixes structural violations (wrong role counts, wrong player count) almost every time.

Layer 2 — Algorithmic budget fix. If the team is still over 100 credits after the retry, I run a greedy swap: find the most expensive player whose role has surplus players, swap them with the cheapest available alternative of the same role. Repeat until within budget.

I never cache a violating suggestion, and I bump the KV cache key whenever the validation logic changes — otherwise stale suggestions stick around.

Post-toss: only pick from confirmed players

The real-world use case is: toss happens, playing 11 is announced, then you finalise your fantasy team. After someone enters the playing 11 (two text areas, one name per line), the AI should only consider those 22 players.

The tricky part was name matching. The IPL feed uses formats like "V Kohli" or "Virat Kohli" interchangeably. My fuzzy matcher:

function isInPlaying11(dbName: string, playing11Names: string[]): boolean {
  const norm = normName(dbName);
  const normParts = norm.split(/\s+/);
  for (const n of playing11Names) {
    const normN = normName(n);
    if (normN === norm) return true;
    const shorter = normN.length <= norm.length ? normN : norm;
    const longer  = normN.length <= norm.length ? norm  : normN;
    if (shorter.length >= 5 && longer.includes(shorter)) return true;
    const lastName = normParts[normParts.length - 1];
    if (lastName.length > 4 && normN.includes(lastName)) return true;
  }
  return false;
}

The race condition I didn't see coming

Cloudflare KV is eventually consistent. When the playing 11 is saved, I delete the AI suggestion cache key. But "delete" doesn't propagate globally in under a millisecond. If the frontend immediately fires a GET /suggest/best11 (without ?refresh=1), the Worker might read the old cached value before the delete has propagated — and serve the stale suggestion that includes players not in the playing 11.

The fix: after posting the playing 11, the frontend directly calls GET /suggest/best11?refresh=1, which skips the KV read entirely and forces a fresh generation. It then uses setQueryData to inject the result into TanStack Query's cache, avoiding a second fetch.

onSuccess: async () => {
  queryClient.invalidateQueries({ queryKey: ['toss-status'] });
  const fresh = await api.suggest.best11(true); // ?refresh=1 bypasses KV
  queryClient.setQueryData(['ai-suggestion'], fresh);
},

Hono Sub-Router Gotcha

Hono v4 has a subtle behavior with sub-router root paths. If you do:

app.route('/api/teams', teamsRouter);
teamsRouter.post('/', handler); // does NOT match POST /api/teams/ in production

The root path of a mounted sub-router doesn't match in Cloudflare Workers production (it works fine in local dev, which makes it extra confusing). The fix is to give every route a non-empty path:

teamsRouter.post('/create', handler); // works

This cost me about an hour of debugging a "not found" error that only appeared in production.


Player Credits: The Calibration Problem

I initially set player credits on a 7–13 scale because I thought bigger numbers looked more meaningful. Bad idea. The real fantasy.iplt20.com uses a 7–10.5 scale, which means the 100-credit budget is genuinely tight — you have to make real trade-offs between premium players and value picks. With a 13-credit ceiling you can pack in premium players and the budget constraint becomes trivial.

I re-seeded the entire database with accurate credits. One gotcha: Cloudflare D1 (SQLite) auto-increment IDs never reset on DELETE — they continue from the last highest ID. So re-seeding bumps all player IDs. I bumped the KV cache key to invalidate all stale AI suggestions.


The Share Button

One of the most-used features turned out to be the simplest: a Share button that formats the AI suggestion as text and sends it to the iMessage group.

async function handleShare() {
  const text = buildShareText();
  if (navigator.share) {
    await navigator.share({ title: 'AI Best XI', text });
    return;
  }
  await navigator.clipboard.writeText(text);
  setCopied(true);
  setTimeout(() => setCopied(false), 2500);
}

navigator.share() triggers the native iOS share sheet (perfect for iMessage). Desktop falls back to clipboard copy with a 2.5-second "Copied!" confirmation. Two lines of product code, but the thing people actually use most.


What's Next

  • Score entry UI (admin updates player points after each match)
  • Auto-scoring via IPL stats feed
  • Transfer window — limited swaps after the tournament starts
  • Head-to-head mini-leagues

Final Thoughts

The whole thing took a weekend. Cloudflare's stack (Workers + D1 + KV + Pages) is genuinely excellent for this kind of project — you get a globally distributed backend with zero cold starts, a relational database, a cache, and static hosting, all on a free tier with a single wrangler deploy.

The AI integration was the most fun to build and the most work to get right. Claude is good at fantasy cricket strategy but needs guardrails — the constraint validation + algorithmic fallback pattern is something I'd reuse in any domain where an LLM needs to output structured data that satisfies hard rules.

If you're building a small internal tool for a friend group, skip the traditional backend infra and go straight to Workers + D1. You'll spend your time on product, not ops.


Built with Cloudflare Workers, Hono, React, and Claude. Live at cric.inguva.dev.