# Project export: Scribble Fighters

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: A Multiplayer Real-Time Battle Scribble-Simulator
- Devpost: https://devpost.com/software/mribs
- GitHub: https://github.com/potatoeggy/mribs
- Demo: https://mribs-frontend.vercel.app/
- Team: 4 GitHub contributor(s) — roselyn (32 commits), Daniel Chen (11 commits), IsaacYu (6 commits), emmaashi (2 commits)

## Devpost submission (written by the team)

### Inspiration

Some of our favorite childhood memories came from drawing silly and magical creatures and imagining them coming to life. For TreeHacks, we wanted to bring back this sense of nostalgia, amazement and feeling of pure imaginative joy. That's why we created Scribble Fighters, a multiplayer game that transforms doodles into playable fighters using AI.

### What it does

Scribble Fighters is a 1v1 multiplayer battle game where players battle each other with their creativity. Players draw creatures of their choices - giant turtles, snakes, to tanks - the sky is the limit! GPT then interprets these drawings, giving each creation a unique name, stats (health, attack damage), and abilities (melee attacks, projectiles). The game is balanced through an ink economy where bigger / detailed drawings are rewarded with stronger stats, but consume ink players need to summon more drawings into the battle. Battles play out in real-time with physics-based environments, with a chat your friends can use to while an AI avatar provides live commentary. Last player with drawings standing wins!

### How we built it

Frontend: Next.js, React, Tailwind CSS, Phaser (js game framework), Backend: Colyseus (multiplayer server and live chat), Matter.js (server side physics), Express AI Pipeline: GPT: Analyzes drawings, generates a structured fighter config with abilities and stats, and provides battle commentary HeyGen LiveAvatar SDK: AI video commentator

### Challenges we ran into

This was our first time vibe coding a project. While we found that getting ideas into code was significantly, debugging became difficult since we had less intimate knowledge of what was actually happening. As such, we spent lots of time tracing through a codebase we were unfamiliar with. This tradeoff taught us that AI-assisted coding excels at rapid prototyping but requires deliberate effort and code reviews to maintain code comprehension.

### Accomplishments we're proud of

Overall, we are proud of building a procedural content pipeline where any drawing becomes a unique playable entity with different abilities. We also explored game design fundamentals by creating an ink economy that forces meaningful strategic choices, and polished the battle feel with projectile trails, knockback physics, and death animations that brought the hand-drawn sprites to life.

### What we learned

One of things we learned was how to get reliable AI output for game mechanics. Getting GPT to consistently return valid, balanced fighter configs meant iterating on structured prompts, defining clear rules for stat scaling based on ink investment, and building fallback configs to ensure creative drawings never crash the game.

### What's next

More abilities: some examples could include shield, healing, teleportation, summons Team battles: 2v2 or free-for-all (e.g. battle royale style) Arena variety: walls, platforms, hazards like spikes or lava that can damage your drawings

## README (from the GitHub repository)

# Scribble Fighters

Draw your champion, bring it to life, and fight!

A web-based game where players draw creatures on a canvas, AI (GPT-4o Vision) analyzes the drawings to determine combat abilities, and the scribbles come to life to battle each other in real-time.

## Quick Start

### Prerequisites
- Node.js 18+
- OpenAI API key (optional for development - uses fallback configs without it)

### 1. Install dependencies

```bash
# Install client dependencies
npm install

# Install server dependencies
cd server && npm install && cd ..
```

### 2. Configure environment

```bash
# Copy and edit the env file
cp .env.local.example .env.local
# Add your OPENAI_API_KEY to .env.local
```

**Production:** Set `OPENAI_API_KEY` in your deployment env. Without it, all fighters become "Scribble Warrior" (fallback). The analyze phase has a 25s timeout; cold starts and latency can cause timeouts if the API is slow.

### 3. Start the game server

```bash
cd server
npm run dev
```

### 4. Start the web app (in another terminal)

```bash
npm run dev
```

### 5. Play!

1. Open http://localhost:3000
2. Click "Create Room"
3. Share the room code with a friend (or open another browser tab)
4. Both players ready up
5. Draw your champion! Add "+fire", "+fly", arrows, etc.
6. AI analyzes your drawing and assigns combat abilities
7. Battle using trackpad/mouse gestures!

## How to Play

### Drawing Phase
- Draw your creature on the canvas
- Add attack shapes (fireballs, swords, etc.) near your creature  
- Write text annotations: "+fire", "+fly", "+shield"
- Draw arrows from attacks to your creature
- Ink is limited - be strategic!

### Battle Phase (Gesture Controls)
- **Click & drag**: Move your creature
- **Tap**: Fire projectile
- **Swipe left/right**: Melee attack
- **Draw circle**: Activate shield
- **Swipe up**: Fly / jump

## Live Commentator (LiveAvatar)

During battles, a live AI commentator speaks play-by-play using HeyGen's LiveAvatar. To enable it:

1. Get an API key from [LiveAvatar](https://app.liveavatar.com) (or use your HeyGen key)
2. Add to `.env.local`:
   ```
   LIVEAVATAR_API_KEY=your-key
   # or HEYGEN_API_KEY=your-heygen-key (fallback)
   ```

The commentator auto-picks an avatar and voice. To customize (e.g. Toronto accent), pass `avatarId` and `voiceId` to `LiveCommentator`—fetch options from `/api/liveavatar/avatars` and `/api/liveavatar/voices`.

## Tech Stack

- **Next.js 14** - Web framework
- **Phaser 3** - Battle rendering
- **Colyseus** - Real-time multiplayer
- **Matter.js** - Physics simulation
- **OpenAI GPT-4o** - Drawing analysis
- **Tailwind CSS** - Styling


## Detected evidence (automated analysis)

Indexed codebase: 45 recognized source files, 329 KB.
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- Next.js (technology) — detected in the code
- OpenAI (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (53 of 53)

```
.gitignore
.npmrc
AUTOATTACK_SYSTEM.md
DYNAMIC_ANIMATIONS.md
eslint.config.mjs
LATEST_IMPROVEMENTS.md
MONOCHROME_INK_SYSTEM.md
next.config.ts
package.json
plan.md
postcss.config.mjs
public/sounds/README.md
README.md
server/package.json
server/src/game/BalanceConfig.ts
server/src/game/BattleSimulation.ts
server/src/index.ts
server/src/rooms/GameRoom.ts
server/src/schema/GameState.ts
server/tsconfig.json
shared/types.ts
src/app/api/analyze/route.ts
src/app/api/analyzeSummon/route.ts
src/app/api/commentary/route.ts
src/app/api/liveavatar/avatars/route.ts
src/app/api/liveavatar/token/route.ts
src/app/api/liveavatar/voices/route.ts
src/app/globals.css
src/app/layout.tsx
src/app/page.tsx
src/app/room/[code]/page.tsx
src/app/room/[code]/spectator/page.tsx
src/components/AbilityHUD.tsx
src/components/BattleChat.tsx
src/components/BattleWrapper.tsx
src/components/DrawingCanvas.tsx
src/components/FloatingDoodles.tsx
src/components/InkBar.tsx
src/components/InkMeter.tsx
src/components/LiveCommentator.tsx
src/components/Lobby.tsx
src/components/OpponentCanvas.tsx
src/components/ResultScreen.tsx
src/components/RevealScreen.tsx
src/components/SummonDrawingModal.tsx
src/game/config.ts
src/game/scenes/BattleScene.ts
src/lib/ai.ts
src/lib/colyseus.ts
src/lib/ink.ts
src/lib/sprites.ts
tsconfig.json
UPDATED_FEATURES.md
```

### Dependencies

- package.json: @heygen/liveavatar-web-sdk@^0.0.10, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, colyseus.js@^0.16.22, eslint@^9, eslint-config-next@16.1.6, livekit-client@^2.17.1, matter-js@^0.20.0, next@16.1.6, openai@^6.22.0, phaser@^3.90.0, react@19.2.3, react-dom@19.2.3, tailwindcss@^4, typescript@^5, uuid@^13.0.0
- server/package.json: @colyseus/monitor@^0.16.0, @colyseus/ws-transport@^0.16.0, @types/cors@^2.8.17, @types/express@^5.0.0, @types/matter-js@^0.19.7, colyseus@^0.16.0, cors@^2.8.5, dotenv@^16.4.0, express@^4.21.0, matter-js@^0.20.0, tsx@^4.19.0, typescript@^5.7.0

### Recent commits (newest first)

- hp
- Merge pull request #8 from potatoeggy/bug-fixes
- fix colour of ink bar in drawing
- fix spectatorrrr
- fix spectator viewing drawings
- fun little background
- beautified
- summon from history cooldown
- fix spectator old data bug
- maybe fix crash in gameroom.resettolobby
- HYPOTHETICAL openai fix
- Revert "feat: blam"
- Merge pull request #7 from potatoeggy/spectators-and-chat
- Merge branch 'main' into spectators-and-chat
- bigger commentator yuhyuh
- CHAT IS BEAUTIFUL
- feat: blam
- chat work but ugly
- fix ts build
- Merge pull request #6 from potatoeggy/battle-tweaks

## Key source files (fetched from GitHub, selected and truncated for size)

### AUTOATTACK_SYSTEM.md

```markdown
# Autoattack System Implementation

## Overview
Converted the combat system from manual gesture-based attacks to an automatic attack system similar to League of Legends or Teamfight Tactics. Each entity now automatically attacks enemies with cool AI-designed animations.

## Key Changes

### 1. Server-Side Autoattack Logic (`server/src/game/BattleSimulation.ts`)

**New Fields:**
- `autoAttackCooldown`: Time remaining until the fighter can autoattack again
- `primaryAttackType`: The fighter's primary attack type (melee or fireProjectile)

**Autoattack System:**
- Automatically triggers attacks when:
  - Enemy is in range
  - Attack cooldown is ready
  - Fighter has enough ink
- Each fighter uses their first offensive ability (melee or projectile) as their primary attack
- Melee fighters automatically move toward enemies when out of range
- Attack cooldown of 0.3s between autoattacks for smooth gameplay

**AI Movement:**
- Melee fighters automatically chase opponents when out of range
- Stop moving when within attack range
- Ranged fighters can attack from any distance

### 2. Enhanced Visual Effects (`src/game/scenes/BattleScene.ts`)

**New Animation Methods:**

#### `playAutoMeleeEffect(attackerId, targetId)`
- Quick lunge animation toward target
- Energy burst impact effect with particles
- 8-particle radial burst on hit
- Target shake effect
- Smooth return to position

#### `playAutoProjectileEffect(attackerId)`
- Charge-up visual at spawn point
- Glowing energy ring expansion
- Blue energy particles

**Enhanced Projectile Visuals:**
- Glowing projectiles with white stroke outline
- 5-particle trail system following each projectile
- Smooth interpolated trail movement
- Pulsing scale animation on main projectile
- Increased size and visibility (8px radius, was 6px)

### 3. UI Updates (`src/components/BattleWrapper.tsx`)

**Removed:**
- Manual gesture controls (tap/swipe/draw)
- Gesture control overlay
- Attack cooldown UI for manual attacks

**Added:**
- "⚔️ Autoattacking" indicator during battle
- Automatic triggering of visual effects on battleEvents
- Enhanced event handlers for meleeHit and projectileSpawn events

**Event Handling:**
- `meleeHit` events now trigger `playAutoMeleeEffect()` with smooth animations
- `projectileSpawn` events trigger `playAutoProjectileEffect()` with charge visuals
- Commentary system updated to work with autoattacks

### 4. Battle Events

**Events Generated:**
- `meleeHit`: When a melee autoattack hits
  - Includes: playerId, targetId, amount
- `projectileSpawn`: When a projectile is fired
  - Includes: playerId, x, y
- `damage`: When projectile hits target
  - Includes: playerId, targetId, amount, x, y

## Visual Design

### Melee Attack Animation
1. **Lunge Phase** (100ms): Fighter dashes toward target
2. **Impact**: Red energy burst with 12px radius expanding to 2.5x
3. **Particles**: 8 particles explode outward in all directions
4. **Shake**: Target shakes horizontally (±8px, 3 times)
5. **Return** (180ms):
[truncated — 1272 more characters]
```

### UPDATED_FEATURES.md

```markdown
# Updated Features

## 1. Increased Health Pools (5x Multiplier)

To make fights longer and more strategic, all health values have been increased by approximately 5x:

### Changes Made:

**AI System Prompt (`src/lib/ai.ts`):**
- Offensive builds: 250-400 HP (was 50-80)
- Defensive builds: 400-600 HP (was 80-120)
- Balanced builds: 400-500 HP (was 80-100)

**Validation:**
- Min HP: 250 (was 50)
- Max HP: 750 (was 150)

**Fallback Configs:**
- Default HP: 500 (was 100)

### Impact:
- Battles now last 5x longer
- More time to see autoattack animations
- More strategic gameplay with extended fights
- Players have more time to summon additional fighters

---

## 2. Mid-Battle Character Summoning System

Players can now create and summon new fighters during battle!

### Features:

#### Summon Button
- Located in bottom-right corner during battle
- Shows ink cost (50 ink)
- Disabled when player doesn't have enough ink
- Purple themed with sparkle icon ✨

#### Drawing Modal
- Full-screen modal for drawing new fighters
- 600x400 canvas with crosshair cursor
- Real-time ink display showing progress toward summon cost
- Clear button to restart drawing
- Visual feedback when insufficient ink

#### AI Analysis
- New API endpoint: `/api/analyzeSummon`
- Uses same GPT-4o Vision analysis as initial fighters
- Generates full FighterConfig with abilities and stats
- Extracts sprite from drawing automatically

#### Battle Integration
- Summoned fighters spawn near player's side
- Each summoned fighter gets fixed 50 ink pool
- Inherits battle ink regeneration rate
- Magical spawn animation with purple circle and sparkles
- Commentary announces new fighter arrival

### Technical Implementation:

**New Files:**
- `/src/components/SummonDrawingModal.tsx` - Drawing UI component
- `/src/app/api/analyzeSummon/route.ts` - AI analysis endpoint

**Server Changes (`server/src/rooms/GameRoom.ts`):**
- New message handler: `summonFighter`
- Validates ink cost (50 ink)
- Deducts ink from player
- Adds fighter to battle simulation
- Broadcasts `fighterSummoned` event

**Client Changes (`src/components/BattleWrapper.tsx`):**
- Summon modal state management
- Ink tracking from room state
- `handleSummonSubmit()` - processes drawing and sends to server
- `extractSprite()` - extracts sprite from drawing bounds
- Event listener for `fighterSummoned`

**Battle Scene (`src/game/scenes/BattleScene.ts`):**
- New `showSummonEffect()` method
- Purple magic circle expansion
- 12 sparkle particles radiating outward
- White flash effect on spawn
- Loads fighter sprite dynamically

### Gameplay:

**Ink Economy:**
- Summon cost: 50 ink
- Players regenerate ink during battle
- Strategic decision: save ink for summons vs abilities
- Summoned fighters get 50 ink pool each

**Spawn Mechanics:**
- Fighters spawn at fixed positions (200px from edges)
- Face toward opponent automatically
- Start with full HP
- Immediately begin autoattacking

**Strategic Depth:**
- Create reinforcements when losing
- C
[truncated — 2312 more characters]
```

### package.json

```
{
  "name": "mribs",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "dev:server": "cd server && npm run dev",
    "dev:all": "npm run dev:server & npm run dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@heygen/liveavatar-web-sdk": "^0.0.10",
    "colyseus.js": "^0.16.22",
    "livekit-client": "^2.17.1",
    "matter-js": "^0.20.0",
    "next": "16.1.6",
    "openai": "^6.22.0",
    "phaser": "^3.90.0",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "uuid": "^13.0.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### server/package.json

```
{
  "name": "scribble-fighters-server",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "colyseus": "^0.16.0",
    "@colyseus/ws-transport": "^0.16.0",
    "@colyseus/monitor": "^0.16.0",
    "matter-js": "^0.20.0",
    "express": "^4.21.0",
    "cors": "^2.8.5",
    "dotenv": "^16.4.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/cors": "^2.8.17",
    "@types/matter-js": "^0.19.7",
    "tsx": "^4.19.0",
    "typescript": "^5.7.0"
  }
}

```

### src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";
import FloatingDoodles from "@/components/FloatingDoodles";

export const metadata: Metadata = {
  title: "Scribble Fighters",
  description: "Draw your champion, bring it to life, and fight!",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="font-hand antialiased min-h-screen relative">
        <FloatingDoodles />
        {children}
      </body>
    </html>
  );
}

```

### server/src/index.ts

```typescript
import { Encoder } from "@colyseus/schema";
import { Server, matchMaker } from "colyseus";
import { WebSocketTransport } from "@colyseus/ws-transport";

Encoder.BUFFER_SIZE = 64 * 1024;
import { monitor } from "@colyseus/monitor";
import express from "express";
import cors from "cors";
import http from "http";
import dotenv from "dotenv";
import { GameRoom } from "./rooms/GameRoom";

dotenv.config();

const port = parseInt(process.env.PORT || "2567");
const app = express();

console.log("env", {
  PORT: process.env.PORT,
  NODE_ENV: process.env.NODE_ENV,
  COLYSEUS_URL: process.env.COLYSEUS_URL,
  OPENAI_API_KEY: process.env.OPENAI_API_KEY,
  HEYGEN_API_KEY: process.env.HEYGEN_API_KEY,
  LIVEAVATAR_API_KEY: process.env.LIVEAVATAR_API_KEY,
})

app.use(cors());
app.use(express.json());

// Health check
app.get("/health", (_req, res) => {
  res.json({ status: "ok" });
});

app.get("/rooms/find/:code", async (req, res) => {
  const code = req.params.code.toUpperCase();
  const rooms = await matchMaker.query({ name: "game", private: false });
  const match = rooms.find((r) => r.metadata?.roomCode === code);
  if (match) {
    res.json({ roomId: match.roomId });
  } else {
    res.status(404).json({ error: "room not found" });
  }
});

// Colyseus monitor (dev only)
if (process.env.NODE_ENV !== "production") {
  app.use("/colyseus", monitor());
}

const server = http.createServer(app);

const gameServer = new Server({
  transport: new WebSocketTransport({
    server,
    maxPayload: 2000000,
  }),
});

// Register room types
gameServer.define("game", GameRoom);

gameServer.listen(port).then(() => {
  console.log(`Scribble Fighters server listening on port ${port}`);
  console.log(`   Monitor: http://localhost:${port}/colyseus`);
});

```

### src/app/page.tsx

```typescript
"use client";

import React, { useState } from "react";
import { useRouter } from "next/navigation";

export default function Home() {
  const router = useRouter();
  const [joinCode, setJoinCode] = useState("");
  const [isCreating, setIsCreating] = useState(false);

  const handleCreate = () => {
    setIsCreating(true);
    router.push("/room/new");
  };

  const handleJoin = () => {
    if (!joinCode.trim()) return;
    router.push(`/room/${joinCode.trim().toUpperCase()}`);
  };

  return (
    <main className="relative flex flex-col items-center justify-center min-h-screen gap-12 p-8 overflow-hidden">
      {/* Floating decorative scribbles - background */}
      <div className="pointer-events-none absolute inset-0 overflow-hidden">
        <svg
          className="absolute top-20 left-[10%] w-16 h-16 text-gray-300/40 animate-float"
          viewBox="0 0 40 40"
          fill="none"
        >
          <path
            d="M5,20 Q15,5 30,20 Q20,35 5,20"
            stroke="currentColor"
            strokeWidth="1.5"
            fill="none"
          />
        </svg>
        <svg
          className="absolute top-40 right-[15%] w-12 h-12 text-amber-300/30 animate-float"
          style={{ animationDelay: "0.5s" }}
          viewBox="0 0 40 40"
          fill="none"
        >
          <circle
            cx="20"
            cy="20"
            r="8"
            stroke="currentColor"
            strokeWidth="1.5"
            fill="none"
          />
        </svg>
        <svg
          className="absolute bottom-32 left-[20%] w-14 h-14 text-blue-300/20 animate-float"
          style={{ animationDelay: "1s" }}
          viewBox="0 0 40 40"
          fill="none"
        >
          <path
            d="M10,30 L20,10 L30,30 L20,25 Z"
            stroke="currentColor"
            strokeWidth="1"
            fill="none"
          />
        </svg>
        <svg
          className="absolute bottom-48 right-[12%] w-20 h-20 text-gray-300/30 animate-float"
          style={{ animationDelay: "1.5s" }}
          viewBox="0 0 200 20"
          fill="none"
        >
          <path
            d="M0,10 Q50,2 100,10 Q150,18 200,10"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
          />
        </svg>
      </div>

      {/* Title - staggered entrance */}
      <div className="flex flex-col items-center gap-4 relative z-10">
        <h1
          className="font-hand text-7xl font-bold text-gray-800 animate-fade-in-up opacity-0"
          style={{
            animationDelay: "0ms",
            animationFillMode: "forwards",
            opacity: 0,
          }}
        >
          <span className="inline-block wobble">Scribble Fighters</span>
        </h1>
        <p
          className="font-hand text-2xl text-gray-500 max-w-md text-center animate-fade-in-up opacity-0"
          style={{ animationDelay: "150ms", animationFillMode: "forwards" }}
        >
          Draw your champion. Bring it to life. Fight!
        </p>
      </div>

      {/* Decorative scribble divider - animated */}
      <svg
        width="200"
        height="20"
        viewBox="0 0 200 20"
        className="opacity-40 animate-fade-in-up opacity-0 relative z-10"
        style={{ animationDelay: "250ms", animationFillMode: "forwards" }}
      >
        <path
          d="M0,10 Q25,2 50,10 Q75,18 100,10 Q125,2 150,10 Q175,18 200,10"
          fill="none"
          stroke="#1a1a1a"
          strokeWidth="2"
          strokeLinecap="round"
        />
      </svg>

      {/* Actions - staggered */}
      <div
        className="flex flex-col items-center gap-6 w-full max-w-sm relative z-10 animate-fade-in-up opacity-0"
        style={{ animationDelay: "350ms", animationFillMode: "forwards" }}
      >
        <button
          onClick={handleCreate}
          disabled={isCreating}
          className="sketchy-button w-full bg-yellow-300 text-gray-800 text-3xl py-5 font-bold hover:bg-yellow-400 hover:shadow-[4px_4px_0_#1a1a1a] transition-all duration-200 hover:-translate-y-0.5"
        >
          {isCreating ? "Creating..." : "Create Room"}
        </button>

        <div className="flex items-center gap-4 w-full">
          <div className="flex-1 h-0.5 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
          <span className="text-xl text-gray-400 font-hand">or</span>
          <div className="flex-1 h-0.5 bg-gradient-to-r from-transparent via-gray-300 to-transparent" />
        </div>

        <div className="flex gap-3 w-full">
          <input
            type="text"
            placeholder="Room code..."
            value={joinCode}
            onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
            onKeyDown={(e) => e.key === "Enter" && handleJoin()}
            maxLength={6}
            className="flex-1 text-center text-2xl tracking-[0.2em] uppercase transition-shadow focus:shadow-[2px_2px_0_#1a1a1a]"
          />
          <button
            onClick={handleJoin}
            disabled={!joinCode.trim()}
            className="sketchy-button bg-blue-400 text-white text-xl px-6 py-3 font-bold disabled:opacity-40 hover:bg-blue-500 hover:shadow-[4px_4px_0_#1a1a1a] transition-all duration-200 enabled:hover:-translate-y-0.5"
          >
            Join
          </button>
        </div>
      </div>

      {/* How to play - fade in */}
      <div
        className="flex flex-col items-center gap-3 mt-8 opacity-80 max-w-lg relative z-10 animate-fade-in-up opacity-0"
        style={{ animationDelay: "500ms", animationFillMode: "forwards" }}
      >
        <h2 className="font-hand text-2xl font-bold text-gray-700">
          How to Play
        </h2>
        <div className="flex flex-col gap-3 text-lg text-gray-500 text-center">
          <p className="font-hand">1. Create or join a room with a friend</p>
          <p className="font-hand">
            2. Draw your creature—the AI brings it to life with unique abilities
          </p>
 
[truncated — 670 more characters]
```

### src/app/api/analyzeSummon/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { analyzeDrawing } from "@/lib/ai";

/**
 * API endpoint to analyze a summoned fighter drawing
 */
export async function POST(request: NextRequest) {
  try {
    const { imageData, inkSpent } = await request.json();

    if (!imageData) {
      return NextResponse.json({ error: "Missing imageData" }, { status: 400 });
    }

    // Analyze the drawing using AI
    // Use provided inkSpent or default to 80 for summoned fighters
    const config = await analyzeDrawing(imageData, inkSpent ?? 80);

    return NextResponse.json({ config });
  } catch (error) {
    console.error("Error analyzing summon:", error);
    return NextResponse.json(
      { error: "Failed to analyze drawing" },
      { status: 500 }
    );
  }
}

```

### src/app/api/analyze/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import { analyzeDrawing, fallbackFighterConfig } from "@/lib/ai";

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { imageData, inkSpent } = body;

    if (!imageData || typeof imageData !== "string") {
      return NextResponse.json(
        { error: "Missing or invalid imageData" },
        { status: 400 }
      );
    }

    if (!process.env.OPENAI_API_KEY) {
      console.warn(
        "[analyze] No OPENAI_API_KEY - using fallback. Set OPENAI_API_KEY in production!"
      );
      return NextResponse.json(fallbackFighterConfig());
    }

    const config = await analyzeDrawing(imageData, inkSpent || 100);
    return NextResponse.json(config);
  } catch (error) {
    console.error("[analyze] AI analysis failed:", error);
    return NextResponse.json(fallbackFighterConfig());
  }
}

```

### src/app/api/commentary/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";

const openai = process.env.OPENAI_API_KEY
  ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
  : null;

export async function POST(request: NextRequest) {
  if (!openai) {
    return NextResponse.json({ line: null }, { status: 200 });
  }

  try {
    const body = await request.json().catch(() => ({}));
    const { eventType, attackerName, targetName, action, amount } = body as {
      eventType: string;
      attackerName?: string;
      targetName?: string;
      action?: string;
      amount?: number;
    };

    const context =
      eventType === "attack"
        ? `"${attackerName}" (attacker) just hit "${targetName}" (target) with ${action}`
        : eventType === "damage"
          ? attackerName
            ? `"${attackerName}" (attacker) just hit "${targetName}" (target) - ranged/projectile`
            : `"${targetName}" (target) just took a hit`
          : eventType === "death"
            ? `"${targetName}" was just knocked out`
            : eventType === "battleStart"
              ? "battle is starting"
              : "";

    const completion = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      max_tokens: 40,
      messages: [
        {
          role: "system",
          content: `You're a CHAOTIC Gen-Z battle commentator for a silly drawing battle game. Characters have creative names (e.g. "Ferocious Feline", "Ink Demon", "Scribble Dragon").

Rules:
- ONE short line only (max 14 words). NO quotes around your response.
- NEVER mention damage numbers (no "20 damage", "15 hp", etc.). Describe WHAT happened in a creative way.
- Use the CHARACTER NAMES—they're fun! Make the commentary fit the character. If "Ferocious Feline" attacks, say something about scratching/pouncing/claws. If "Ink Blob" hits, maybe splat/ooze/smear. Get creative!
- Be unhinged, funny, dramatic. Use slang, hyperbole. Gen-Z energy.
- Examples: "OUCH! Ferocious Feline just landed a huge scratch!", "That Ink Demon really went splat on them", "RIP Scribble Dragon, you will be missed", "Bro the pounce was INSANE".
Make it flavorful and character-relevant.`,
        },
        {
          role: "user",
          content: `Comment on this: ${context}`,
        },
      ],
    });

    const line = completion.choices[0]?.message?.content?.trim();
    if (!line) return NextResponse.json({ line: null }, { status: 200 });

    return NextResponse.json({ line });
  } catch (err) {
    console.warn("Commentary API error:", err);
    return NextResponse.json({ line: null }, { status: 200 });
  }
}

```

[35 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]