feat: production deployment — Dockerfile, docker-compose, SPA serving, classic bot fights

- Add Dockerfile (multi-stage: build frontend + server, serve from single container)
- Add docker-compose.yml for Portainer stack deployment
- Server serves frontend SPA in production (static assets + SPA fallback)
- Auto-run migrations and seed mock bots on server startup
- DB path configurable via DB_PATH env var
- Add "Fight a Classic Bot" button for instant mock bot matches
- FIGHT button queues for real AI opponents

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 10:42:18 +00:00
co-authored by Claude Opus 4.6
parent 120250c01a
commit 6bf9fe27b3
17 changed files with 2321 additions and 298 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
dist
.git
.claude
*.db
*.db-wal
*.db-shm
.DS_Store
.env
.env.local
loop
botfights.db
server/data
frontend/dist
server/dist
+44 -1
View File
@@ -184,11 +184,54 @@ EXAMPLES:
NEVER answer "42" to everything. Actually read and answer each challenge.
```
## Character Customization
Customize your bot's appearance via the profile page (owner only) or the API:
```bash
curl -X POST https://your-site.com/api/auth/update \
-H "Content-Type: application/json" \
-d '{
"pubkey": "your_nostr_pubkey_hex",
"customization": {
"archetype": "dragon",
"primaryColor": "#ff4400",
"secondaryColor": "#00ccff",
"forceVisor": true,
"forceMohawk": false,
"forceHorns": true
}
}'
```
### Customization Options
| Field | Type | Description |
|-------|------|-------------|
| `archetype` | string | Character type (100 options, see below) |
| `primaryColor` | string | Body color as hex `#RRGGBB` or `hsl(h, s%, l%)` |
| `secondaryColor` | string | Accent color as hex `#RRGGBB` or `hsl(h, s%, l%)` |
| `forceVisor` | boolean | Always show visor accessory |
| `forceMohawk` | boolean | Always show mohawk |
| `forceHorns` | boolean | Always show horns |
All values are validated server-side against whitelists. Invalid values are rejected.
### Available Archetypes (100)
`GET /api/bots/meta/archetypes` returns the full list. Categories:
- **Animals:** cat, crocodile, dog, elephant, flamingo, frog, giraffe, hamster, hedgehog, hippo, lion, lobster, monkey, octopus, panda, parrot, penguin, raccoon, shark, sheep, snail, snake, turtle, whale
- **Fantasy:** alien, cyclops, demon, dragon, gargoyle, ghost, golem, griffin, mermaid, minotaur, phoenix, skeleton, unicorn, vampire, werewolf, witch, wizard, zombie
- **Robots:** android, antenna_bot, calculator, circuit, cyberdog, cyborg, drone, led_cube, mech, microwave, robocat, robot, satellite, toaster, tv_head, ufo_bot
- **Warriors:** astronaut, boxer, chef, clown, cowboy, detective, firefighter, gladiator, knight, lumberjack, ninja, nurse, pirate, samurai, scientist, viking, wrestler
- **Silly:** balloon_man, bee, blob, broom_man, cactus, cloud_man, dinosaur, garden_gnome, jack_o_lantern, lamp_post, mushroom, pizza, potato, rock_man, rubber_duck, scarecrow, snowman, sock_puppet, standard, tank, toilet_man, traffic_cone, trash_can
## Testing Your Bot
| Endpoint | Description |
|----------|-------------|
| `POST /api/bots/{name}/test-webhook` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. |
| `POST /api/bots/{name}/test` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. |
| `POST /api/bots/{name}/test-challenge` | Sends a REAL challenge and scores your answer. Shows if you'd be marked correct. |
| `POST /api/queue/join/{botId}` | Join the fight queue. If no opponents available, you fight a mock bot after 3 seconds. |
+43
View File
@@ -0,0 +1,43 @@
# Stage 1: Install dependencies
FROM node:22-slim AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY frontend/package.json frontend/
COPY server/package.json server/
RUN pnpm install --frozen-lockfile
# Stage 2: Build frontend
FROM deps AS build-fe
COPY frontend/ frontend/
RUN pnpm --filter frontend build
# Stage 3: Build server
FROM deps AS build-be
COPY server/ server/
RUN pnpm --filter server build
# Stage 4: Production image
FROM node:22-slim AS production
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY server/package.json server/
RUN pnpm install --filter server --prod --frozen-lockfile
# Copy built server
COPY --from=build-be /app/server/dist server/dist
# Copy built frontend into server's public dir
COPY --from=build-fe /app/frontend/dist server/public
# Data volume for SQLite
RUN mkdir -p /app/server/data
VOLUME /app/server/data
ENV NODE_ENV=production
ENV PORT=9100
EXPOSE 9100
CMD ["node", "server/dist/index.js"]
+15
View File
@@ -0,0 +1,15 @@
services:
botfights:
build: .
container_name: botfights
restart: unless-stopped
ports:
- "9100:9100"
volumes:
- botfights-data:/app/server/data
environment:
- NODE_ENV=production
- PORT=9100
volumes:
botfights-data:
+21 -11
View File
@@ -336,7 +336,7 @@ async function replay() {
sfxDrumRoll()
await sleep(500)
announceFinishHim()
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
await showOverlay('FINISH IT!', '#ff2d2d', 900)
await sleep(150)
if (isPerfect) {
@@ -344,7 +344,6 @@ async function replay() {
announceFlawlessVictory()
} else {
await scene.playKO(winningSide, winnerName)
announceFatality()
}
glitching.value = true
@@ -387,16 +386,27 @@ async function replay() {
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
</div>
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1 leading-relaxed">
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in logItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-2" />
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2">{{ item.text }}</p>
<p v-else-if="item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1">{{ item.text }}</p>
<p v-else-if="item.type === 'responseA'" class="text-neon-cyan text-sm pl-2">{{ item.text }}</p>
<p v-else-if="item.type === 'responseB'" class="text-neon-pink text-sm pl-2">{{ item.text }}</p>
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4">{{ item.text }}</p>
<p v-else-if="item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1">{{ item.text }}</p>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<div v-if="item.type === 'divider'" class="py-1.5">
<div class="border-t border-white/5" />
</div>
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
<div v-else-if="item.type === 'prompt'" class="bg-white/[0.04] border border-white/[0.08] rounded-md px-3 py-2 my-1.5">
<span class="text-neon-purple/60 font-bold text-[10px] uppercase tracking-widest block mb-1">Challenge</span>
<p class="text-text-primary text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4 opacity-60">{{ item.text }}</p>
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
</div>
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { generateHumanSpriteSheet, getBotColors, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_FRAME_SIZE } from '../game/sprites'
const props = defineProps<{
seed: string
archetype?: string
size?: number
winRate?: number
anim?: keyof typeof HUMAN_ANIMATIONS
}>()
const canvasRef = ref<HTMLCanvasElement>()
let img: HTMLImageElement | null = null
let frame = 0
let animHandle: ReturnType<typeof setTimeout> | null = null
function render() {
if (!canvasRef.value || !img?.complete) return
const ctx = canvasRef.value.getContext('2d')!
const displaySize = props.size || 64
canvasRef.value.width = displaySize
canvasRef.value.height = displaySize
ctx.clearRect(0, 0, displaySize, displaySize)
ctx.imageSmoothingEnabled = false
const animName = props.anim || 'idle'
const animCfg = HUMAN_ANIMATIONS[animName]
const f = frame % animCfg.frames
ctx.drawImage(
img,
f * HUMAN_FRAME_SIZE, animCfg.row * HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE, HUMAN_FRAME_SIZE,
0, 0, displaySize, displaySize,
)
frame++
animHandle = setTimeout(() => render(), 180)
}
function loadSprite() {
const colors = getBotColors(props.seed)
const dataUrl = generateHumanSpriteSheet(props.seed, props.archetype || 'standard', colors.primary, colors.secondary, props.winRate ?? 0.5)
img = new Image()
img.onload = () => render()
img.src = dataUrl
}
onMounted(() => loadSprite())
watch(() => [props.seed, props.archetype, props.winRate, props.anim], () => {
if (animHandle) clearTimeout(animHandle)
frame = 0
loadSprite()
})
onUnmounted(() => {
if (animHandle) clearTimeout(animHandle)
})
</script>
<template>
<canvas
ref="canvasRef"
:style="{ width: `${size || 64}px`, height: `${size || 64}px`, imageRendering: 'pixelated' }"
/>
</template>
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -335,11 +335,11 @@ const ROUND_HYPE = [
// Mortal Kombat style dramatic calls
export function announceFinishHim() {
speak('Finish him!', 'announcer', true, true)
speak('Finish it!', 'announcer', true, true)
}
export function announceFatality() {
speak('Fatality!', 'deep', true, true)
export function announceFatality(tagline?: string) {
speak(tagline || 'Fatality!', 'deep', true, true)
}
export function announceFlawlessVictory() {
+866
View File
@@ -0,0 +1,866 @@
// Human sprite — 3x resolution for detailed pixel art of the bot's owner
// Renders at 144x144 internal grid (vs 48x48 for bots) for crisp detail
export const HUMAN_FRAME_SIZE = 288
export const HUMAN_INTERNAL = 144
export const HUMAN_SCALE = HUMAN_FRAME_SIZE / HUMAN_INTERNAL
export const HUMAN_ANIMATIONS = {
idle: { frames: 4, row: 0 },
cheer: { frames: 4, row: 1 },
panic: { frames: 4, row: 2 },
run: { frames: 4, row: 3 },
coach: { frames: 4, row: 4 },
}
export const HUMAN_ROWS = Object.keys(HUMAN_ANIMATIONS).length
export const HUMAN_MAX_FRAMES = 4
// Costume system
type CostumeType = 'animal' | 'armor' | 'hat' | 'tech' | 'monster' | 'food' | 'silly' | 'generic'
interface CostumeConfig {
type: CostumeType
hat?: string
hatShape: 'none' | 'ears' | 'helmet' | 'pointy' | 'wide' | 'tall' | 'horns' | 'antenna' | 'crown' | 'cone'
prop?: string
propShape: 'none' | 'sword' | 'wand' | 'phone' | 'sign' | 'shield' | 'food' | 'remote'
onesie: boolean
facePaint?: string
tail: boolean
}
const COSTUME_MAP: Record<string, Partial<CostumeConfig>> = {
dog: { type: 'animal', hatShape: 'ears', hat: '#aa7744', onesie: true, tail: true },
cat: { type: 'animal', hatShape: 'ears', hat: '#ff8844', onesie: true, tail: true },
frog: { type: 'animal', hatShape: 'ears', hat: '#44aa22', onesie: true },
shark: { type: 'animal', hatShape: 'tall', hat: '#4477aa', onesie: true },
penguin: { type: 'animal', hatShape: 'none', hat: '#222222', onesie: true },
octopus: { type: 'animal', hatShape: 'none', hat: '#cc44aa', onesie: true },
bee: { type: 'animal', hatShape: 'antenna', hat: '#ffcc00', onesie: true },
snail: { type: 'animal', hatShape: 'antenna', hat: '#88aa44', onesie: true },
lobster: { type: 'animal', hatShape: 'antenna', hat: '#cc2222', onesie: true },
sheep: { type: 'animal', hatShape: 'ears', hat: '#eeeeee', onesie: true },
elephant: { type: 'animal', hatShape: 'ears', hat: '#888899', onesie: true },
giraffe: { type: 'animal', hatShape: 'horns', hat: '#ddaa44', onesie: true },
hippo: { type: 'animal', hatShape: 'ears', hat: '#8877aa', onesie: true },
lion: { type: 'animal', hatShape: 'ears', hat: '#cc8822', onesie: true },
monkey: { type: 'animal', hatShape: 'ears', hat: '#884422', onesie: true, tail: true },
parrot: { type: 'animal', hatShape: 'tall', hat: '#ff4422', onesie: true, tail: true },
raccoon: { type: 'animal', hatShape: 'ears', hat: '#666666', onesie: true, tail: true },
snake: { type: 'animal', hatShape: 'none', hat: '#448822', onesie: true, tail: true },
turtle: { type: 'animal', hatShape: 'none', hat: '#44aa44', onesie: true },
whale: { type: 'animal', hatShape: 'none', hat: '#4466bb', onesie: true },
crocodile: { type: 'animal', hatShape: 'none', hat: '#447722', onesie: true, tail: true },
flamingo: { type: 'animal', hatShape: 'tall', hat: '#ff66aa', onesie: true },
hedgehog: { type: 'animal', hatShape: 'tall', hat: '#886644', onesie: true },
panda: { type: 'animal', hatShape: 'ears', hat: '#222222', onesie: true },
hamster: { type: 'animal', hatShape: 'ears', hat: '#ddaa77', onesie: true },
dinosaur: { type: 'animal', hatShape: 'horns', hat: '#44aa44', onesie: true, tail: true },
knight: { type: 'armor', hatShape: 'helmet', hat: '#aaaaaa', propShape: 'sword', prop: '#cccccc' },
gladiator: { type: 'armor', hatShape: 'helmet', hat: '#cc8833', propShape: 'sword', prop: '#dddddd' },
samurai: { type: 'armor', hatShape: 'helmet', hat: '#882222', propShape: 'sword', prop: '#cccccc' },
viking: { type: 'armor', hatShape: 'horns', hat: '#886633', propShape: 'shield', prop: '#aa8844' },
boxer: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#cc2222' },
wrestler: { type: 'armor', hatShape: 'none', propShape: 'none', facePaint: '#ffcc00' },
pirate: { type: 'hat', hatShape: 'wide', hat: '#222222', propShape: 'sword', prop: '#dddddd' },
cowboy: { type: 'hat', hatShape: 'wide', hat: '#aa7744' },
wizard: { type: 'hat', hatShape: 'pointy', hat: '#4422aa', propShape: 'wand', prop: '#ffcc44' },
witch: { type: 'hat', hatShape: 'pointy', hat: '#222222', propShape: 'wand', prop: '#44ff44' },
chef: { type: 'hat', hatShape: 'tall', hat: '#ffffff', propShape: 'food', prop: '#ff8844' },
detective: { type: 'hat', hatShape: 'wide', hat: '#554433' },
nurse: { type: 'hat', hatShape: 'none', hat: '#ffffff' },
firefighter:{ type: 'hat', hatShape: 'helmet', hat: '#ff2222' },
astronaut: { type: 'hat', hatShape: 'helmet', hat: '#eeeeee' },
lumberjack: { type: 'hat', hatShape: 'none', hat: '#cc4422', propShape: 'sword', prop: '#886633' },
scientist: { type: 'hat', hatShape: 'none', propShape: 'remote', prop: '#44cc44' },
clown: { type: 'hat', hatShape: 'tall', hat: '#ff2222', facePaint: '#ffffff' },
robot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
android: { type: 'tech', hatShape: 'antenna', propShape: 'phone' },
drone_bug: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
toaster: { type: 'tech', hatShape: 'none', propShape: 'remote' },
tv_head: { type: 'tech', hatShape: 'none', propShape: 'phone', facePaint: '#4488ff' },
calculator: { type: 'tech', hatShape: 'none', propShape: 'phone' },
satellite: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
mech: { type: 'tech', hatShape: 'helmet', hat: '#888888', propShape: 'remote' },
led_cube: { type: 'tech', hatShape: 'none', propShape: 'phone' },
circuit: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
antenna_bug:{ type: 'tech', hatShape: 'antenna', propShape: 'phone' },
microwave: { type: 'tech', hatShape: 'none', propShape: 'remote' },
cyberdog: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
robocat: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
ufo_bot: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
cyborg: { type: 'tech', hatShape: 'antenna', propShape: 'remote' },
vampire: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' },
werewolf: { type: 'monster', hatShape: 'ears', hat: '#664422', facePaint: '#664422' },
zombie: { type: 'monster', hatShape: 'none', facePaint: '#668844' },
demon: { type: 'monster', hatShape: 'horns', hat: '#cc2222', facePaint: '#cc2222' },
skeleton: { type: 'monster', hatShape: 'none', facePaint: '#ffffff' },
ghost: { type: 'monster', hatShape: 'none', onesie: true, hat: '#ffffff' },
alien: { type: 'monster', hatShape: 'antenna', hat: '#44ff44', facePaint: '#44ff44' },
minotaur: { type: 'monster', hatShape: 'horns', hat: '#884422' },
unicorn: { type: 'monster', hatShape: 'horns', hat: '#ff88cc' },
phoenix: { type: 'monster', hatShape: 'tall', hat: '#ff4400', onesie: true },
dragon: { type: 'monster', hatShape: 'horns', hat: '#44aa22', onesie: true, tail: true },
mermaid: { type: 'monster', hatShape: 'none', hat: '#44ccaa', onesie: true },
griffin: { type: 'monster', hatShape: 'ears', hat: '#cc8822', onesie: true },
cyclops: { type: 'monster', hatShape: 'none', facePaint: '#8877aa' },
gargoyle: { type: 'monster', hatShape: 'horns', hat: '#666666', facePaint: '#888888' },
golem: { type: 'monster', hatShape: 'none', facePaint: '#886644' },
pizza: { type: 'food', hatShape: 'cone', hat: '#ffcc44' },
mushroom: { type: 'food', hatShape: 'tall', hat: '#cc2222' },
cactus: { type: 'food', hatShape: 'tall', hat: '#44aa22' },
sock_puppet: { type: 'silly', hatShape: 'none', onesie: true, hat: '#ff8844' },
traffic_cone: { type: 'silly', hatShape: 'cone', hat: '#ff6600' },
toilet_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ffffff' },
potato: { type: 'silly', hatShape: 'none', hat: '#aa8844', onesie: true },
cloud_man: { type: 'silly', hatShape: 'none', hat: '#ffffff', onesie: true },
rock_man: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true },
balloon_man: { type: 'silly', hatShape: 'none', propShape: 'sign', prop: '#ff4488' },
trash_can: { type: 'silly', hatShape: 'none', hat: '#888888', onesie: true },
rubber_duck: { type: 'silly', hatShape: 'none', hat: '#ffcc00', onesie: true },
snowman: { type: 'silly', hatShape: 'tall', hat: '#222222', onesie: true },
scarecrow: { type: 'silly', hatShape: 'wide', hat: '#ddbb66', onesie: true },
jack_o_lantern:{ type: 'silly', hatShape: 'none', hat: '#ff8800', facePaint: '#ff8800' },
garden_gnome: { type: 'silly', hatShape: 'pointy', hat: '#ff0000' },
lamp_post: { type: 'silly', hatShape: 'cone', hat: '#ffee88' },
broom_man: { type: 'silly', hatShape: 'none', propShape: 'sword', prop: '#886633' },
ninja: { type: 'hat', hatShape: 'none', facePaint: '#222222', onesie: true, hat: '#222222' },
}
const SKIN_TONES = [
'#ffdbb4', '#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#6b3a1f',
'#ffe0bd', '#ffcd94', '#eac086', '#deb887', '#d2a679', '#a0785a',
]
const HAIR_COLORS = [
'#2c1b0e', '#4a3728', '#8b6914', '#c4a35a', '#e6c87f',
'#1a1a1a', '#333333', '#cc2222', '#ff6633', '#ff88cc', '#4488ff',
]
type PxFn = (x: number, y: number, color: string, ox: number, oy: number) => void
function seededRng(seed: string) {
let sh = 0
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
sh = (sh * 31337) | 0
return () => { sh = (sh * 16807) % 2147483647; return (sh & 0x7fffffff) / 2147483647 }
}
/**
* Generate human sprite at 3x resolution. winRate (0-1) controls physique:
* 0.0 = fat slobby mess with cheetos, 0.5 = normal, 1.0 = swole superhero with cape
*/
export function generateHumanSpriteSheet(
seed: string,
archetype: string,
primaryColor: string,
secondaryColor: string,
winRate: number = 0.5,
): string {
const canvas = document.createElement('canvas')
canvas.width = HUMAN_FRAME_SIZE * HUMAN_MAX_FRAMES
canvas.height = HUMAN_FRAME_SIZE * HUMAN_ROWS
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const I = HUMAN_INTERNAL
const S = HUMAN_SCALE
const rng = seededRng(seed)
const skinTone = SKIN_TONES[Math.floor(rng() * SKIN_TONES.length)]
const skinMid = darken(skinTone, 12)
const skinDark = darken(skinTone, 30)
const skinLight = lighten(skinTone, 10)
const hairColor = HAIR_COLORS[Math.floor(rng() * HAIR_COLORS.length)]
const hairDark = darken(hairColor, 20)
const shirtColor = primaryColor
const shirtDark = darken(primaryColor, 15)
const pantsColor = secondaryColor
const pantsDark = darken(secondaryColor, 15)
const wr = Math.max(0, Math.min(1, winRate))
const isFat = wr < 0.3 ? true : wr < 0.5 ? rng() > 0.4 : false
const isShort = wr < 0.25
const isSwole = wr > 0.75
const isSuperHero = wr > 0.85
const hasBeard = wr < 0.35 ? true : rng() > 0.6
const hasGlasses = wr < 0.4 ? true : rng() > 0.5
const hasStains = wr < 0.3
const hasCheetos = wr < 0.2
const hasCape = isSuperHero
const hasHeadband = wr > 0.7
const hasSweatDrops = wr < 0.4
const costume = getCostume(archetype)
const out = '#0a0a0a'
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= I || y < 0 || y >= I) return
ctx.fillStyle = color
ctx.fillRect(ox + x * S, oy + y * S, S, S)
}
function fill(x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) {
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, color, ox, oy)
}
function box(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) {
for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) }
for (let i = y; i < y + h; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) }
fill(x, y, w, h, fc, ox, oy)
}
// Rounded box for softer shapes
function roundBox(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) {
fill(x + 1, y, w - 2, h, fc, ox, oy)
fill(x, y + 1, w, h - 2, fc, ox, oy)
// Outline
for (let i = x + 1; i < x + w - 1; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) }
for (let i = y + 1; i < y + h - 1; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) }
px(x, y, out, ox, oy); px(x + w - 1, y, out, ox, oy)
px(x, y + h - 1, out, ox, oy); px(x + w - 1, y + h - 1, out, ox, oy)
}
function drawHuman(fx: number, fy: number, pose: string, frame: number, total: number) {
const ox = fx * HUMAN_FRAME_SIZE
const oy = fy * HUMAN_FRAME_SIZE
const t = frame / Math.max(1, total - 1)
const bounce = Math.round(Math.sin(t * Math.PI * 2) * 2)
const isIdle = pose === 'idle'
const isCheer = pose === 'cheer'
const isPanic = pose === 'panic'
const isRun = pose === 'run'
const isCoach = pose === 'coach'
const cx = 72 // center x
const ground = 126 // ground line
// Body dimensions (3x base, adjusted by physique)
const bodyW = isFat ? 42 : isSwole ? 36 : 27
const bodyH = isShort ? 24 : isSwole ? 33 : 30
const headW = isFat ? 30 : isSwole ? 28 : 27
const headH = isFat ? 27 : 24
const legH = isShort ? 18 : isSwole ? 30 : 27
const legW = isFat ? 14 : isSwole ? 12 : 9
const armW = isSwole ? 9 : 6
const armH = isShort ? 15 : isSwole ? 24 : 21
const neckH = 3
const feetY = ground - 4
const legsTop = feetY - legH
const bodyTop = legsTop - bodyH
const neckTop = bodyTop - neckH
const headTop = neckTop - headH
// Pose offsets
const cheerJump = isCheer ? Math.round(Math.abs(Math.sin(t * Math.PI)) * 18) : 0
const panicShake = isPanic ? Math.round(Math.sin(t * Math.PI * 6) * 4) : 0
const runBob = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 4) : 0
const yOff = -(cheerJump + runBob) + (isIdle ? bounce : 0)
const xOff = panicShake
// Shadow
const shadowW = isFat ? 22 : isSwole ? 18 : 14
for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) {
px(sx, ground, 'rgba(0,0,0,0.15)', ox, oy)
px(sx, ground + 1, 'rgba(0,0,0,0.08)', ox, oy)
}
// ===== CAPE (drawn behind everything) =====
if (hasCape) {
const capeX = cx - Math.floor(bodyW / 2) - 4 + xOff
const capeW2 = bodyW + 8
for (let iy = bodyTop + yOff; iy < feetY + yOff + 12; iy++) {
const flutter = isRun ? Math.round(Math.sin(t * Math.PI * 3 + iy * 0.15) * 4) : Math.round(Math.sin(iy * 0.1) * 1)
for (let ix = capeX - flutter; ix < capeX + capeW2 + flutter; ix++) {
const shade = (iy + ix) % 5 === 0 ? '#881111' : (iy + ix) % 3 === 0 ? '#aa1818' : '#cc2222'
px(ix, iy, shade, ox, oy)
}
}
// Cape clasp at neck
fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy)
}
// ===== LEGS =====
const legGap = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 8) : isPanic ? 6 : 3
const ll = cx - legGap - Math.floor(legW / 2) + xOff
const rl = cx + legGap - Math.floor(legW / 2) + xOff
const legColor = costume.onesie ? (costume.hat || shirtColor) : pantsColor
const legColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
// Left leg
roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy)
fill(ll + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy) // shading
// Knee highlight
px(ll + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy)
// Right leg
roundBox(rl, legsTop + yOff, legW, legH, legColor, ox, oy)
fill(rl + legW - 2, legsTop + yOff + 2, 2, legH - 4, legColorDk, ox, oy)
px(rl + 2, legsTop + Math.floor(legH / 2) + yOff, lighten(legColor, 10), ox, oy)
// Socks visible (between pants and shoes)
if (!costume.onesie) {
fill(ll, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy)
fill(rl, feetY + yOff - 3, legW, 3, '#ffffff', ox, oy)
}
// Shoes (chunky)
roundBox(ll - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy)
px(ll + legW + 2, feetY + yOff + 1, '#555555', ox, oy) // shoe highlight
fill(ll - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy) // sole
roundBox(rl - 2, feetY + yOff, legW + 5, 5, '#333333', ox, oy)
px(rl + legW + 2, feetY + yOff + 1, '#555555', ox, oy)
fill(rl - 1, feetY + yOff + 4, legW + 4, 1, '#222222', ox, oy)
// Superhero boots
if (isSuperHero) {
fill(ll - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy)
fill(rl - 2, feetY + yOff - 6, legW + 5, 6, '#cc2222', ox, oy)
fill(ll, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy) // gold trim
fill(rl, feetY + yOff - 7, legW + 1, 1, '#ffd700', ox, oy)
}
// ===== BODY =====
const bx = cx - Math.floor(bodyW / 2) + xOff
const by = bodyTop + yOff
const torsoColor = costume.onesie ? (costume.hat || shirtColor) : shirtColor
const torsoDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : shirtDark
roundBox(bx, by, bodyW, bodyH, torsoColor, ox, oy)
// Body shading (right side darker)
for (let iy = by + 2; iy < by + bodyH - 2; iy++) {
px(bx + bodyW - 2, iy, torsoDk, ox, oy)
px(bx + bodyW - 3, iy, torsoDk, ox, oy)
px(bx + 1, iy, lighten(torsoColor, 8), ox, oy) // left highlight
}
// Zipper for onesies
if (costume.onesie) {
for (let iy = by + 2; iy < by + bodyH - 2; iy++) {
px(cx + xOff, iy, darken(torsoColor, 25), ox, oy)
px(cx + 1 + xOff, iy, darken(torsoColor, 20), ox, oy)
}
} else {
// Collar
fill(bx + 3, by, bodyW - 6, 3, darken(shirtColor, 10), ox, oy)
fill(bx + Math.floor(bodyW / 2) - 2, by, 4, 4, skinTone, ox, oy) // V-neck
// Shirt pattern (bigger at 3x)
const patCx = cx + xOff
const patCy = by + Math.floor(bodyH / 2) + 2
// Draw a big emblem
fill(patCx - 4, patCy - 4, 8, 8, darken(torsoColor, 8), ox, oy)
fill(patCx - 3, patCy - 3, 6, 6, secondaryColor, ox, oy)
fill(patCx - 2, patCy - 2, 4, 4, lighten(secondaryColor, 15), ox, oy)
px(patCx, patCy, '#ffffff', ox, oy)
}
// Belt
fill(bx, by + bodyH - 3, bodyW, 3, '#443322', ox, oy)
fill(cx - 2 + xOff, by + bodyH - 3, 5, 3, '#ffcc00', ox, oy) // buckle
px(cx + xOff, by + bodyH - 2, '#ffffff', ox, oy) // buckle highlight
// Food stains (losers)
if (hasStains) {
fill(bx + 4, by + 6, 3, 2, '#cc8822', ox, oy)
fill(bx + bodyW - 8, by + 10, 2, 3, '#cc2222', ox, oy)
fill(bx + 10, by + 4, 2, 2, '#ff8844', ox, oy)
px(bx + bodyW - 5, by + 8, '#884400', ox, oy)
}
// Belly hanging over belt
if (isFat && wr < 0.25) {
fill(bx + 4, by + bodyH, bodyW - 8, 4, skinTone, ox, oy)
fill(bx + 5, by + bodyH + 3, bodyW - 10, 2, skinDark, ox, oy)
}
// ===== NECK =====
fill(cx - 4 + xOff, neckTop + yOff, 8, neckH, skinTone, ox, oy)
px(cx + 3 + xOff, neckTop + yOff + 1, skinDark, ox, oy)
// ===== ARMS =====
const armAttach = by + 3
const armLx = bx - armW - 2
const armRx = bx + bodyW + 2
const armSkin = costume.onesie ? (costume.hat || shirtColor) : skinTone
const armSkinDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : skinDark
if (isCheer) {
const armUpH = Math.round(Math.abs(Math.sin(t * Math.PI)) * 12) + 6
// Arms reaching up
roundBox(armLx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy)
roundBox(armRx, armAttach - armUpH, armW, armUpH, armSkin, ox, oy)
// Hands (open, fingers spread)
fill(armLx - 2, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy)
fill(armRx - 1, armAttach - armUpH - 5, armW + 3, 4, skinTone, ox, oy)
// Individual fingers
for (let f = 0; f < 4; f++) {
px(armLx - 2 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy)
px(armRx - 1 + f * 2, armAttach - armUpH - 6, skinTone, ox, oy)
}
} else if (isPanic) {
const flapL = Math.round(Math.sin(t * Math.PI * 4) * 12)
const flapR = Math.round(Math.cos(t * Math.PI * 4) * 12)
roundBox(armLx + flapL, armAttach - 8, armW, armH, armSkin, ox, oy)
roundBox(armRx - flapR, armAttach - 10, armW, armH, armSkin, ox, oy)
// Blurred hands (motion)
fill(armLx + flapL - 1, armAttach - 10, armW + 2, 3, skinTone, ox, oy)
fill(armRx - flapR - 1, armAttach - 12, armW + 2, 3, skinTone, ox, oy)
} else if (isCoach) {
const pointExt = Math.round(Math.sin(t * Math.PI) * 8)
// Pointing arm
roundBox(armRx, armAttach, armW + 8 + pointExt, armW, armSkin, ox, oy)
// Pointing finger
fill(armRx + armW + 8 + pointExt, armAttach, 4, armW - 2, skinTone, ox, oy)
px(armRx + armW + 12 + pointExt, armAttach + 1, skinTone, ox, oy)
// Other arm on hip
roundBox(armLx, armAttach + 4, armW, 8, armSkin, ox, oy)
fill(armLx + 2, armAttach + 11, armW - 2, 3, skinTone, ox, oy) // hand on hip
} else if (isRun) {
const pump = Math.round(Math.sin(t * Math.PI * 2) * 8)
roundBox(armLx, armAttach + pump, armW, armH - 6, armSkin, ox, oy)
roundBox(armRx, armAttach - pump, armW, armH - 6, armSkin, ox, oy)
// Fists
fill(armLx, armAttach + pump + armH - 8, armW + 1, 3, skinTone, ox, oy)
fill(armRx, armAttach - pump + armH - 8, armW + 1, 3, skinTone, ox, oy)
} else {
// Idle: arms hanging
roundBox(armLx, armAttach, armW, armH, armSkin, ox, oy)
roundBox(armRx, armAttach, armW, armH, armSkin, ox, oy)
// Arm shading
for (let iy = armAttach + 2; iy < armAttach + armH - 2; iy++) {
px(armLx + armW - 1, iy, armSkinDk, ox, oy)
px(armRx + armW - 1, iy, armSkinDk, ox, oy)
}
// Hands
fill(armLx, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy)
fill(armRx - 1, armAttach + armH - 4, armW + 1, 4, skinTone, ox, oy)
// Fingers (3 visible)
for (let f = 0; f < 3; f++) {
px(armLx + f * 2, armAttach + armH, skinTone, ox, oy)
px(armRx - 1 + f * 2, armAttach + armH, skinTone, ox, oy)
}
// Phone or cheetos in right hand
if (hasCheetos) {
roundBox(armRx - 1, armAttach + armH + 1, 8, 12, '#ff8800', ox, oy)
fill(armRx, armAttach + armH + 2, 6, 3, '#ffcc00', ox, oy) // label
fill(armRx + 1, armAttach + armH + 6, 4, 2, '#ffaa00', ox, oy) // crumbs
// Cheese dust on fingers
px(armRx, armAttach + armH - 1, '#ff8844', ox, oy)
px(armRx + 2, armAttach + armH - 1, '#ff8844', ox, oy)
} else {
// Gamepad controller
const gpX = armRx - 3
const gpY = armAttach + armH - 2
roundBox(gpX, gpY, 14, 8, '#333344', ox, oy) // body
fill(gpX + 1, gpY + 1, 12, 6, '#2a2a3a', ox, oy) // inset
// Grips
roundBox(gpX - 2, gpY + 2, 3, 6, '#333344', ox, oy)
roundBox(gpX + 13, gpY + 2, 3, 6, '#333344', ox, oy)
// D-pad (left side)
fill(gpX + 2, gpY + 3, 3, 1, '#555566', ox, oy)
fill(gpX + 3, gpY + 2, 1, 3, '#555566', ox, oy)
// Buttons (right side)
px(gpX + 9, gpY + 2, '#ff4444', ox, oy)
px(gpX + 11, gpY + 2, '#4488ff', ox, oy)
px(gpX + 10, gpY + 1, '#ffcc00', ox, oy)
px(gpX + 10, gpY + 3, '#44cc44', ox, oy)
// Wire coming out the top
const wireX = gpX + 7
for (let w = 0; w < 14; w++) {
const wobble = Math.round(Math.sin(w * 0.6 + t * Math.PI * 2) * 2)
px(wireX + wobble, gpY - 1 - w, '#444444', ox, oy)
}
}
}
// ===== PROP (costume-specific, bigger) =====
if (costume.propShape !== 'none' && costume.prop && !isCheer && !isPanic) {
const propX = armRx + armW + 3
const propY = armAttach + 3
if (costume.propShape === 'sword') {
// Blade
for (let i = 0; i < 22; i++) { px(propX + i, propY - i, costume.prop, ox, oy); px(propX + i, propY - i - 1, lighten(costume.prop, 15), ox, oy) }
// Guard
fill(propX - 1, propY + 1, 5, 2, '#886633', ox, oy)
fill(propX - 2, propY + 3, 2, 6, '#664422', ox, oy) // handle
px(propX - 1, propY + 8, '#ffcc44', ox, oy) // pommel
} else if (costume.propShape === 'wand') {
for (let i = 0; i < 16; i++) px(propX, propY - i, '#886633', ox, oy)
// Sparkle at tip
const sparkle = ['#ffcc44', '#ffffff', '#ffee88']
for (let dx = -2; dx <= 2; dx++) for (let dy = -2; dy <= 2; dy++) {
if (Math.abs(dx) + Math.abs(dy) <= 2) px(propX + dx, propY - 17 + dy, sparkle[(dx + dy + 4) % 3], ox, oy)
}
} else if (costume.propShape === 'shield') {
roundBox(propX, propY - 6, 12, 15, costume.prop, ox, oy)
fill(propX + 3, propY - 3, 6, 9, lighten(costume.prop, 15), ox, oy)
fill(propX + 5, propY - 1, 2, 5, '#ffffff', ox, oy) // emblem
} else if (costume.propShape === 'sign') {
for (let i = 0; i < 20; i++) px(propX + 2, propY - i, '#886633', ox, oy) // pole
roundBox(propX - 6, propY - 30, 18, 12, costume.prop, ox, oy) // sign board
} else if (costume.propShape === 'phone' || costume.propShape === 'remote') {
roundBox(propX, propY, 5, 8, '#333333', ox, oy)
fill(propX + 1, propY + 1, 3, 5, '#44ff44', ox, oy)
px(propX + 2, propY + 2, '#88ff88', ox, oy)
} else if (costume.propShape === 'food') {
roundBox(propX, propY - 2, 8, 6, costume.prop, ox, oy)
fill(propX + 1, propY - 4, 6, 2, '#44aa22', ox, oy) // lettuce
px(propX + 3, propY - 5, '#ff4444', ox, oy) // cherry on top
}
}
// ===== HEAD =====
const hx = cx - Math.floor(headW / 2) + xOff
const hy = headTop + yOff
const faceColor = costume.facePaint || skinTone
const faceDark = costume.facePaint ? darken(costume.facePaint, 15) : skinDark
const faceLight = costume.facePaint ? lighten(costume.facePaint, 10) : skinLight
roundBox(hx, hy, headW, headH, faceColor, ox, oy)
// Head shading
for (let iy = hy + 2; iy < hy + headH - 2; iy++) {
px(hx + headW - 2, iy, faceDark, ox, oy)
px(hx + headW - 3, iy, faceDark, ox, oy)
px(hx + 1, iy, faceLight, ox, oy)
}
// Ears
if (!costume.facePaint) {
fill(hx - 2, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy)
px(hx - 2, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy) // inner ear
fill(hx + headW, hy + Math.floor(headH * 0.3), 2, 5, skinTone, ox, oy)
px(hx + headW + 1, hy + Math.floor(headH * 0.3) + 1, skinDark, ox, oy)
}
// Hair
if (!costume.onesie || costume.type !== 'animal') {
// Top hair (fluffy)
for (let ix = hx; ix < hx + headW; ix++) {
px(ix, hy, hairColor, ox, oy)
px(ix, hy + 1, hairColor, ox, oy)
px(ix, hy + 2, hairColor, ox, oy)
}
for (let ix = hx + 1; ix < hx + headW - 1; ix++) {
px(ix, hy - 1, hairColor, ox, oy)
}
// Hair volume on sides
fill(hx - 1, hy, 2, 5, hairColor, ox, oy)
fill(hx + headW - 1, hy, 2, 5, hairColor, ox, oy)
// Hair highlights
px(hx + 3, hy, lighten(hairColor, 15), ox, oy)
px(hx + 4, hy, lighten(hairColor, 15), ox, oy)
px(hx + 5, hy - 1, lighten(hairColor, 10), ox, oy)
// Messy hair for losers
if (wr < 0.3) {
px(hx + 2, hy - 2, hairColor, ox, oy)
px(hx + headW - 4, hy - 3, hairColor, ox, oy)
px(hx - 1, hy - 1, hairColor, ox, oy)
}
}
// ===== EYES =====
const eyeY = hy + Math.floor(headH * 0.35)
const leX = hx + 5
const reX = hx + headW - 11
if (isPanic) {
// Wide terrified eyes
fill(leX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy)
fill(reX - 1, eyeY - 2, 8, 7, '#ffffff', ox, oy)
// Tiny pupils (fear)
fill(leX + 2, eyeY + 1, 2, 2, '#000000', ox, oy)
fill(reX + 2, eyeY + 1, 2, 2, '#000000', ox, oy)
// Eyebrow (raised)
fill(leX - 1, eyeY - 4, 7, 2, hairColor, ox, oy)
fill(reX - 1, eyeY - 4, 7, 2, hairColor, ox, oy)
} else if (isCheer) {
// Happy squinted eyes
fill(leX, eyeY, 6, 2, '#000000', ox, oy)
fill(reX, eyeY, 6, 2, '#000000', ox, oy)
fill(leX, eyeY - 1, 6, 1, '#ffffff', ox, oy)
fill(reX, eyeY - 1, 6, 1, '#ffffff', ox, oy)
// Raised cheeks
fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy)
fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy)
} else {
// Normal eyes with iris detail
fill(leX, eyeY, 6, 5, '#ffffff', ox, oy)
fill(reX, eyeY, 6, 5, '#ffffff', ox, oy)
// Iris
fill(leX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy)
fill(reX + 2, eyeY + 1, 3, 3, '#4466aa', ox, oy)
// Pupil
fill(leX + 3, eyeY + 2, 2, 2, '#000000', ox, oy)
fill(reX + 3, eyeY + 2, 2, 2, '#000000', ox, oy)
// Eye highlight
px(leX + 2, eyeY + 1, '#ffffff', ox, oy)
px(reX + 2, eyeY + 1, '#ffffff', ox, oy)
// Eyebrows
fill(leX, eyeY - 2, 6, 2, hairColor, ox, oy)
fill(reX, eyeY - 2, 6, 2, hairColor, ox, oy)
// Angry eyebrows for coach
if (isCoach) {
px(leX, eyeY - 3, hairColor, ox, oy)
px(reX + 5, eyeY - 3, hairColor, ox, oy)
}
}
// Glasses
if (hasGlasses) {
// Frames
for (let ix = leX - 2; ix <= reX + 7; ix++) px(ix, eyeY - 1, '#444444', ox, oy)
// Left lens
for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(leX - 2, iy, '#444444', ox, oy); px(leX + 6, iy, '#444444', ox, oy) }
px(leX - 2, eyeY + 4, '#444444', ox, oy); px(leX + 6, eyeY + 4, '#444444', ox, oy)
for (let ix = leX - 1; ix <= leX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy)
// Right lens
for (let iy = eyeY - 1; iy <= eyeY + 4; iy++) { px(reX - 2, iy, '#444444', ox, oy); px(reX + 6, iy, '#444444', ox, oy) }
for (let ix = reX - 1; ix <= reX + 5; ix++) px(ix, eyeY + 4, '#444444', ox, oy)
// Lens reflection
px(leX, eyeY, '#aaccff', ox, oy)
px(reX, eyeY, '#aaccff', ox, oy)
}
// Nose
const noseY = hy + Math.floor(headH * 0.55)
px(cx + xOff, noseY, skinDark, ox, oy)
px(cx + 1 + xOff, noseY, skinDark, ox, oy)
px(cx + xOff, noseY + 1, skinDark, ox, oy)
// Red nose for clown
if (costume.type === 'hat' && archetype === 'clown') {
fill(cx - 1 + xOff, noseY - 1, 4, 4, '#ff0000', ox, oy)
}
// Mouth
const mY = hy + Math.floor(headH * 0.72)
if (isCheer) {
// Big grin with teeth
fill(cx - 5 + xOff, mY, 10, 4, '#000000', ox, oy)
fill(cx - 4 + xOff, mY, 8, 2, '#ffffff', ox, oy) // teeth
fill(cx - 4 + xOff, mY + 2, 8, 2, '#cc4444', ox, oy) // tongue
} else if (isPanic) {
// Screaming
roundBox(cx - 4 + xOff, mY - 2, 8, 8, '#000000', ox, oy)
fill(cx - 3 + xOff, mY + 2, 6, 2, '#cc4444', ox, oy) // tongue
// Teeth
px(cx - 3 + xOff, mY - 1, '#ffffff', ox, oy)
px(cx + 2 + xOff, mY - 1, '#ffffff', ox, oy)
} else if (isCoach) {
// Yelling
fill(cx - 4 + xOff, mY, 8, 5, '#000000', ox, oy)
fill(cx - 3 + xOff, mY, 6, 2, '#ffffff', ox, oy)
} else {
// Neutral / slight smile
fill(cx - 3 + xOff, mY, 6, 2, '#0a0a0a', ox, oy)
px(cx - 4 + xOff, mY + 1, '#0a0a0a', ox, oy)
px(cx + 3 + xOff, mY + 1, '#0a0a0a', ox, oy)
// Drool for very low win rate
if (wr < 0.15) {
px(cx + 3 + xOff, mY + 2, '#88ccff', ox, oy)
px(cx + 3 + xOff, mY + 3, '#88ccff', ox, oy)
px(cx + 3 + xOff, mY + 4, '#66aadd', ox, oy)
}
}
// Sweat drops
if (hasSweatDrops && (isIdle || isPanic || isCoach)) {
fill(hx + headW + 2, eyeY + 2, 2, 4, '#88ccff', ox, oy)
px(hx + headW + 2, eyeY + 6, '#66aadd', ox, oy)
if (wr < 0.2) {
fill(hx - 3, eyeY + 4, 2, 3, '#88ccff', ox, oy)
// Puddle forming
fill(hx + headW + 1, eyeY + 7, 4, 1, '#aaddff', ox, oy)
}
}
// Double chin
if (isFat && wr < 0.25) {
fill(hx + 4, hy + headH, headW - 8, 4, skinTone, ox, oy)
fill(hx + 5, hy + headH + 3, headW - 10, 2, skinDark, ox, oy)
fill(hx + 6, hy + headH + 4, headW - 12, 2, skinDark, ox, oy)
}
// Beard
if (hasBeard && !costume.facePaint) {
// Scrappy neckbeard for losers, cool beard for winners
if (wr < 0.35) {
// Patchy neckbeard
for (let ix = hx + 3; ix < hx + headW - 3; ix++) {
if ((ix + hy) % 3 !== 0) px(ix, hy + headH - 2, hairDark, ox, oy)
if ((ix + hy) % 2 !== 0) px(ix, hy + headH - 1, hairDark, ox, oy)
if ((ix + hy) % 3 === 0) px(ix, hy + headH, hairDark, ox, oy)
}
} else {
// Full beard
for (let ix = hx + 3; ix < hx + headW - 3; ix++) {
fill(ix, hy + headH - 3, 1, 5, hairColor, ox, oy)
}
fill(cx - 2 + xOff, hy + headH + 1, 4, 2, hairDark, ox, oy)
}
}
// Headband (winners)
if (hasHeadband && costume.hatShape === 'none') {
const hbColor = wr > 0.85 ? '#ffd700' : '#ff2222'
fill(hx - 2, hy + 3, headW + 4, 3, hbColor, ox, oy)
// Trailing ribbons
px(hx - 3, hy + 4, hbColor, ox, oy)
px(hx - 4, hy + 5, hbColor, ox, oy)
px(hx - 5, hy + 5 + (isIdle ? bounce : 0), hbColor, ox, oy)
px(hx - 6, hy + 6 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
}
// Winner glow aura
if (isSuperHero && !isPanic) {
for (let a = 0; a < 16; a++) {
const angle = a * Math.PI * 2 / 16 + t * Math.PI * 0.5
const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8
const gx = cx + xOff + Math.round(Math.cos(angle) * r)
const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r)
fill(gx - 1, gy - 1, 3, 3, '#ffcc4430', ox, oy)
px(gx, gy, '#ffcc4460', ox, oy)
}
}
// ===== COSTUME HAT/ACCESSORIES =====
const hatColor = costume.hat || hairColor
const hatDk = darken(hatColor, 15)
const hatLt = lighten(hatColor, 15)
if (costume.hatShape === 'ears') {
// Big floppy costume ears
fill(hx - 1, hy - 5, 5, 6, hatColor, ox, oy)
fill(hx + headW - 4, hy - 5, 5, 6, hatColor, ox, oy)
fill(hx, hy - 4, 3, 4, hatLt, ox, oy) // inner
fill(hx + headW - 3, hy - 4, 3, 4, hatLt, ox, oy)
} else if (costume.hatShape === 'helmet') {
for (let ix = hx - 3; ix < hx + headW + 3; ix++) {
px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy)
px(ix, hy - 1, hatColor, ox, oy); px(ix, hy, hatColor, ox, oy)
}
for (let ix = hx - 1; ix < hx + headW + 1; ix++) {
px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy)
}
fill(hx + 2, hy - 4, 4, 2, hatLt, ox, oy) // highlight
// Visor
fill(hx + 2, hy + 1, headW - 4, 3, '#33333388', ox, oy)
} else if (costume.hatShape === 'pointy') {
for (let h = 0; h < 18; h++) {
const w = Math.max(1, Math.floor((headW + 2) * (1 - h / 18)))
const sx2 = cx + xOff - Math.floor(w / 2)
for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
}
// Stars and moons on the hat
px(cx - 3 + xOff, hy - 8, '#ffcc44', ox, oy)
px(cx + 4 + xOff, hy - 12, '#ffcc44', ox, oy)
px(cx + xOff, hy - 16, '#ffffff', ox, oy) // star tip
fill(cx - 1 + xOff, hy - 18, 3, 3, '#ffcc44', ox, oy)
} else if (costume.hatShape === 'wide') {
for (let ix = hx - 8; ix < hx + headW + 8; ix++) {
px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 2, hatColor, ox, oy)
}
for (let ix = hx - 3; ix < hx + headW + 3; ix++) {
px(ix, hy - 4, hatColor, ox, oy); px(ix, hy - 5, hatColor, ox, oy)
px(ix, hy - 6, hatColor, ox, oy); px(ix, hy - 7, hatColor, ox, oy)
}
// Hat band
fill(hx - 2, hy - 4, headW + 4, 2, hatDk, ox, oy)
} else if (costume.hatShape === 'tall') {
for (let h = 0; h < 16; h++) {
for (let ix = hx + 2; ix < hx + headW - 2; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
}
fill(hx + 3, hy - 14, headW - 8, 2, hatLt, ox, oy) // puff/highlight
} else if (costume.hatShape === 'horns') {
// Chunky horns
for (let h = 0; h < 8; h++) {
px(hx - h, hy - 1 - h, hatColor, ox, oy); px(hx - h - 1, hy - 1 - h, hatColor, ox, oy)
px(hx + headW - 1 + h, hy - 1 - h, hatColor, ox, oy); px(hx + headW + h, hy - 1 - h, hatColor, ox, oy)
}
px(hx - 8, hy - 9, hatLt, ox, oy); px(hx + headW + 8, hy - 9, hatLt, ox, oy) // tips
} else if (costume.hatShape === 'antenna') {
// Bouncy antenna headband
fill(hx + 2, hy - 2, headW - 4, 2, '#888888', ox, oy) // headband
const bobX = isIdle ? Math.round(Math.sin(t * Math.PI * 3) * 2) : 0
// Left antenna
for (let h = 0; h < 10; h++) px(hx + 4 + bobX, hy - 3 - h, '#888888', ox, oy)
fill(hx + 2 + bobX, hy - 14, 5, 4, '#ff2222', ox, oy) // left ball
px(hx + 3 + bobX, hy - 13, '#ff6666', ox, oy) // highlight
// Right antenna
for (let h = 0; h < 10; h++) px(hx + headW - 5 - bobX, hy - 3 - h, '#888888', ox, oy)
fill(hx + headW - 7 - bobX, hy - 14, 5, 4, '#ff2222', ox, oy)
px(hx + headW - 6 - bobX, hy - 13, '#ff6666', ox, oy)
} else if (costume.hatShape === 'cone') {
for (let h = 0; h < 20; h++) {
const w = Math.max(2, 18 - h)
const sx2 = cx + xOff - Math.floor(w / 2)
const stripe = (h % 6 < 3) ? '#ffffff' : hatColor
for (let ix = sx2; ix < sx2 + w; ix++) px(ix, hy - 1 - h, stripe, ox, oy)
}
}
// Tail (costume)
if (costume.tail) {
const tailColor = costume.hat || shirtColor
const tx = bx - 4
for (let i = 0; i < 8; i++) {
px(tx - i, by + bodyH - 4 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy)
px(tx - i, by + bodyH - 3 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2) * 2), tailColor, ox, oy)
}
px(tx - 8, by + bodyH - 4, darken(tailColor, 20), ox, oy) // tail tip
}
}
// Render all frames
const entries = Object.entries(HUMAN_ANIMATIONS) as [string, { frames: number; row: number }][]
for (let row = 0; row < entries.length; row++) {
const [pose, cfg] = entries[row]
for (let f = 0; f < cfg.frames; f++) drawHuman(f, row, pose, f, cfg.frames)
}
return canvas.toDataURL()
}
function getCostume(archetype: string): CostumeConfig {
const raw = COSTUME_MAP[archetype] || {}
return {
type: raw.type || 'generic',
hat: raw.hat,
hatShape: raw.hatShape || 'none',
prop: raw.prop,
propShape: raw.propShape || 'none',
onesie: raw.onesie || false,
facePaint: raw.facePaint,
tail: raw.tail || false,
}
}
function darken(hex: string, amount: number): string {
if (hex.startsWith('hsl')) {
const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.max(0, +m[3] - amount)}%)`
}
if (hex.length < 7) return hex
const r = Math.max(0, parseInt(hex.slice(1, 3), 16) - amount * 2.55)
const g = Math.max(0, parseInt(hex.slice(3, 5), 16) - amount * 2.55)
const b = Math.max(0, parseInt(hex.slice(5, 7), 16) - amount * 2.55)
return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}`
}
function lighten(hex: string, amount: number): string {
if (hex.startsWith('hsl')) {
const m = hex.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
if (m) return `hsl(${m[1]}, ${m[2]}%, ${Math.min(95, +m[3] + amount)}%)`
}
if (hex.length < 7) return hex
const r = Math.min(255, parseInt(hex.slice(1, 3), 16) + amount * 2.55)
const g = Math.min(255, parseInt(hex.slice(3, 5), 16) + amount * 2.55)
const b = Math.min(255, parseInt(hex.slice(5, 7), 16) + amount * 2.55)
return `#${Math.round(r).toString(16).padStart(2, '0')}${Math.round(g).toString(16).padStart(2, '0')}${Math.round(b).toString(16).padStart(2, '0')}`
}
+1
View File
@@ -7,6 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants'
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
export { getBotColors } from './palette'
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
export { generateHumanSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
export { archetypes, rollArchetype } from './archetypes'
export interface SpriteCustomization {
+109 -7
View File
@@ -3,6 +3,7 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
import type { SpriteCustomization } from '../game/sprites'
const route = useRoute()
@@ -278,25 +279,34 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<template v-else>
<!-- Header with large character -->
<!-- Header with human + bot character -->
<div class="text-center mb-5">
<div class="relative inline-block mb-3">
<div class="flex items-end justify-center gap-2 mb-3">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
:size="200"
:win-rate="(stats.winRate || 0) / 100"
anim="idle"
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)]"
/>
<SpritePreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype"
:tier="stats.tier"
:size="160"
class="mx-auto drop-shadow-[0_0_20px_var(--glow)]"
:customization="stats.customization || undefined"
class="drop-shadow-[0_0_20px_var(--glow)]"
:style="{ '--glow': stats.tierColor + '80' } as any"
/>
<div class="absolute -bottom-1 left-1/2 -translate-x-1/2 px-3 py-0.5 border text-[9px] font-display font-black tracking-widest whitespace-nowrap"
:style="{ borderColor: stats.tierColor, color: stats.tierColor, backgroundColor: 'rgba(0,0,0,0.8)' }">
{{ stats.tierName }}
</div>
</div>
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
{{ stats.name }}
</h2>
<div class="inline-block px-3 py-0.5 border text-[9px] font-display font-black tracking-widest mt-1"
:style="{ borderColor: stats.tierColor, color: stats.tierColor, backgroundColor: 'rgba(0,0,0,0.8)' }">
{{ stats.tierName }}
</div>
<p class="font-display text-sm font-bold tracking-widest mt-1"
:style="{ color: stats.tierColor }">
{{ getStatusTitle(stats) }}
@@ -304,6 +314,98 @@ const tierClass = (t: number) => `tier-${t}`
<p class="font-mono text-text-muted text-[10px] mt-0.5">
#{{ stats.rank }} of {{ stats.totalBots }}
</p>
<button
v-if="isOwner"
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
@click="showCustomize = !showCustomize; if (showCustomize) initCustForm()"
>
{{ showCustomize ? 'CLOSE' : 'CUSTOMIZE' }}
</button>
</div>
<!-- Customization panel (owner only) -->
<div v-if="showCustomize && isOwner" class="mb-4 border border-neon-cyan/20 bg-surface-raised/60 p-4">
<p class="font-display text-[10px] font-bold text-neon-cyan tracking-[0.15em] mb-3">
CUSTOMIZE CHARACTER
</p>
<!-- Live preview -->
<div class="flex justify-center mb-3">
<SpritePreview
:seed="stats.avatarSeed || stats.name"
:archetype="custForm.archetype || stats.archetype"
:tier="stats.tier"
:size="120"
:customization="previewCustomization"
class="drop-shadow-[0_0_12px_rgba(0,255,255,0.3)]"
/>
</div>
<!-- Archetype selector -->
<label class="block mb-2">
<span class="font-display text-[9px] text-text-muted tracking-wider">ARCHETYPE</span>
<select
v-model="custForm.archetype"
class="mt-0.5 w-full bg-surface-base border border-border text-text-primary
font-mono text-xs px-2 py-1.5 focus:border-neon-cyan/50 outline-none"
>
<option value="">Default (from seed)</option>
<option v-for="a in ARCHETYPES" :key="a" :value="a">{{ a.replace(/_/g, ' ') }}</option>
</select>
</label>
<!-- Colors -->
<div class="flex gap-3 mb-2">
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">PRIMARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.primaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.primaryColor }}</span>
</div>
</label>
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">SECONDARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.secondaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.secondaryColor }}</span>
</div>
</label>
</div>
<!-- Accessories -->
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">ACCESSORIES</p>
<div class="flex gap-3 mb-3">
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceVisor"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Visor</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceMohawk"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Mohawk</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceHorns"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Horns</span>
</label>
</div>
<p v-if="custError" class="font-mono text-[10px] text-neon-pink mb-2">{{ custError }}</p>
<button
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveCustomization"
>
{{ isSaving ? 'SAVING...' : 'SAVE LOOK' }}
</button>
</div>
<!-- Stats -->
+62 -2
View File
@@ -2,6 +2,8 @@
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from '../components/PixelGlove.vue'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
interface FightResult {
id: string
@@ -12,6 +14,20 @@ interface FightResult {
totalRounds: number
}
// Decorative crowd characters scattered around the page
const crowd = [
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'bot' as const, x: 3, y: 18, size: 56, wr: 0.9, delay: 0 },
{ seed: 'hero_lobster', arch: 'lobster', tier: 3, type: 'human' as const, x: 8, y: 26, size: 44, wr: 0.9, delay: 0.2 },
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'bot' as const, x: 88, y: 15, size: 52, wr: 0.7, delay: 0.5 },
{ seed: 'cool_ninja', arch: 'ninja', tier: 4, type: 'human' as const, x: 92, y: 22, size: 40, wr: 0.7, delay: 0.7 },
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'bot' as const, x: 5, y: 72, size: 48, wr: 0.1, delay: 1.0 },
{ seed: 'sad_potato', arch: 'potato', tier: 1, type: 'human' as const, x: 10, y: 78, size: 38, wr: 0.1, delay: 1.2 },
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'bot' as const, x: 85, y: 68, size: 60, wr: 0.95, delay: 0.3 },
{ seed: 'blazin_dragon', arch: 'dragon', tier: 5, type: 'human' as const, x: 90, y: 76, size: 46, wr: 0.95, delay: 0.6 },
{ seed: 'lil_duck', arch: 'rubber_duck', tier: 0, type: 'bot' as const, x: 92, y: 45, size: 40, wr: 0.3, delay: 1.5 },
{ seed: 'chef_boy', arch: 'chef', tier: 2, type: 'bot' as const, x: 2, y: 48, size: 44, wr: 0.5, delay: 0.8 },
]
const tagline = ref('')
const fullTagline = 'A safe place to hash it out.'
const isTypingDone = ref(false)
@@ -39,8 +55,41 @@ onMounted(async () => {
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-4xl w-full text-center slide-up">
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden relative">
<!-- Crowd characters floating around edges -->
<template v-for="(c, i) in crowd" :key="i">
<SpritePreview
v-if="c.type === 'bot'"
:seed="c.seed"
:archetype="c.arch"
:tier="c.tier"
:size="c.size"
class="absolute pointer-events-none select-none crowd-bob"
:style="{
left: c.x + '%',
top: c.y + '%',
opacity: 0.35,
animationDelay: c.delay + 's',
}"
/>
<HumanPreview
v-else
:seed="c.seed"
:archetype="c.arch"
:size="c.size"
:win-rate="c.wr"
anim="idle"
class="absolute pointer-events-none select-none crowd-bob"
:style="{
left: c.x + '%',
top: c.y + '%',
opacity: 0.3,
animationDelay: c.delay + 's',
}"
/>
</template>
<div class="max-w-4xl w-full text-center slide-up relative z-10">
<!-- BIG NEON TITLE -->
<div class="mb-6">
@@ -130,3 +179,14 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.crowd-bob {
animation: crowdBob 3s ease-in-out infinite;
}
@keyframes crowdBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
</style>
+60 -11
View File
@@ -11,6 +11,7 @@ const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login,
const step = ref<string>('login')
const error = ref('')
const isJoining = ref(false)
const isFightingClassic = ref(false)
const queueCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
@@ -173,6 +174,25 @@ async function fight() {
isJoining.value = false
}
async function fightClassicBot() {
if (!bot.value || isFightingClassic.value) return
isFightingClassic.value = true
error.value = ''
try {
const res = await fetch(`/api/fights/mock/${bot.value.id}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
} else {
const data = await res.json()
error.value = data.error || 'Failed to start fight.'
}
} catch {
error.value = 'Network error.'
}
isFightingClassic.value = false
}
function handleSignOut() {
logout()
step.value = 'login'
@@ -505,17 +525,46 @@ function handleSignOut() {
</p>
</div>
<!-- Big fight button -->
<button
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isJoining"
@click="fight"
>
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<!-- Fight buttons -->
<div class="space-y-3">
<!-- Main fight button real queue -->
<button
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isJoining || isFightingClassic"
@click="fight"
>
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Queue up against a real AI bot
</p>
<!-- Classic bot button instant mock fight -->
<button
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-[0.15em]
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-30 disabled:cursor-not-allowed
flex items-center justify-center gap-3"
:disabled="isJoining || isFightingClassic"
@click="fightClassicBot"
>
<svg class="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="12" rx="2" />
<circle cx="9" cy="10" r="1.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="10" r="1.5" fill="currentColor" stroke="none" />
<line x1="8" y1="20" x2="8" y2="16" />
<line x1="16" y1="20" x2="16" y2="16" />
</svg>
{{ isFightingClassic ? 'STARTING...' : 'FIGHT A CLASSIC BOT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Instant match against a house bot
</p>
</div>
<!-- Quick links -->
<div class="mt-5 flex gap-2">
+55
View File
@@ -7,6 +7,10 @@ import { queueRouter } from './routes/queue.js'
import { authRouter } from './routes/auth.js'
import { docsRouter } from './routes/docs.js'
import { rateLimit } from './middleware/rate-limit.js'
import { existsSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { cleanupOrphanedFights } from './engine/orchestrator.js'
export const app = new Hono()
@@ -30,6 +34,57 @@ app.route('/api/fights', fightsRouter)
app.route('/api/queue', queueRouter)
app.route('/api/docs', docsRouter)
// In production, serve the frontend SPA
const __dirname = dirname(fileURLToPath(import.meta.url))
const publicDir = join(__dirname, '..', 'public')
if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
const MIME: Record<string, string> = {
js: 'application/javascript',
css: 'text/css',
html: 'text/html',
json: 'application/json',
png: 'image/png',
jpg: 'image/jpeg',
svg: 'image/svg+xml',
ico: 'image/x-icon',
woff: 'font/woff',
woff2: 'font/woff2',
webp: 'image/webp',
webmanifest: 'application/manifest+json',
}
function serveFile(c: any, reqPath: string, cacheControl: string) {
const resolved = join(publicDir, reqPath)
// Prevent path traversal
if (!resolved.startsWith(publicDir)) return c.notFound()
if (!existsSync(resolved)) return c.notFound()
const ext = resolved.split('.').pop() || ''
c.header('Content-Type', MIME[ext] || 'application/octet-stream')
c.header('Cache-Control', cacheControl)
return c.body(readFileSync(resolved))
}
// Hashed assets — immutable cache
app.get('/assets/*', (c) => serveFile(c, c.req.path, 'public, max-age=31536000, immutable'))
// Root static files (favicon, manifest, robots, etc.)
app.get('/favicon.ico', (c) => serveFile(c, '/favicon.ico', 'public, max-age=86400'))
app.get('/robots.txt', (c) => serveFile(c, '/robots.txt', 'public, max-age=86400'))
app.get('/manifest.webmanifest', (c) => serveFile(c, '/manifest.webmanifest', 'public, max-age=86400'))
// SPA fallback: all non-API routes serve index.html
app.get('*', (c) => {
if (c.req.path.startsWith('/api/')) return c.notFound()
const indexPath = join(publicDir, 'index.html')
c.header('Content-Type', 'text/html')
c.header('Cache-Control', 'no-cache')
return c.body(readFileSync(indexPath))
})
console.log('[botfights] serving frontend from', publicDir)
}
// Cleanup orphaned fights on startup
cleanupOrphanedFights().then(() => {
console.log('[botfights] orphaned fights cleaned up')
+3 -3
View File
@@ -6,10 +6,10 @@ import { fileURLToPath } from 'url'
import { mkdirSync } from 'fs'
const __dirname = dirname(fileURLToPath(import.meta.url))
const dataDir = join(__dirname, '..', '..', 'data')
mkdirSync(dataDir, { recursive: true })
const dbPath = process.env.DB_PATH || join(__dirname, '..', '..', 'data', 'botfights.db')
mkdirSync(dirname(dbPath), { recursive: true })
const sqlite = new Database(join(dataDir, 'botfights.db'))
const sqlite = new Database(dbPath)
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
+76
View File
@@ -0,0 +1,76 @@
import { sqlite } from './index.js'
export function runMigrations() {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS bots (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
webhook_url TEXT NOT NULL,
avatar_seed TEXT NOT NULL,
archetype TEXT NOT NULL DEFAULT 'standard',
secret_hash TEXT NOT NULL,
public_key TEXT,
profile_pic_url TEXT,
elo_rating REAL NOT NULL DEFAULT 1200,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
win_streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
tier INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
last_fight_at TEXT,
consecutive_errors INTEGER NOT NULL DEFAULT 0,
last_error_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS fights (
id TEXT PRIMARY KEY,
bot_a_id TEXT NOT NULL REFERENCES bots(id),
bot_b_id TEXT NOT NULL REFERENCES bots(id),
arena TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'scheduled',
winner_id TEXT REFERENCES bots(id),
bot_a_hp INTEGER NOT NULL DEFAULT 200,
bot_b_hp INTEGER NOT NULL DEFAULT 200,
total_rounds INTEGER NOT NULL DEFAULT 0,
scheduled_at TEXT,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rounds (
id TEXT PRIMARY KEY,
fight_id TEXT NOT NULL REFERENCES fights(id),
round_number INTEGER NOT NULL,
challenge_type TEXT NOT NULL,
challenge_data TEXT NOT NULL,
bot_a_response TEXT,
bot_a_time_ms INTEGER,
bot_a_score REAL,
bot_b_response TEXT,
bot_b_time_ms INTEGER,
bot_b_score REAL,
winner_id TEXT REFERENCES bots(id),
narration TEXT,
created_at TEXT NOT NULL
);
`)
// Column migrations for existing databases
const migrations = [
"ALTER TABLE bots ADD COLUMN archetype TEXT NOT NULL DEFAULT 'standard'",
"ALTER TABLE bots ADD COLUMN profile_pic_url TEXT",
"ALTER TABLE bots ADD COLUMN last_fight_at TEXT",
"ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN last_error_at TEXT",
"ALTER TABLE bots ADD COLUMN customization TEXT",
]
for (const sql of migrations) {
try { sqlite.exec(sql) } catch { /* column already exists */ }
}
console.log('[botfights] database migrated')
}
+6
View File
@@ -1,5 +1,11 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
import { runMigrations } from './db/startup.js'
import { seedMockBots } from './engine/mock.js'
// Run migrations and seed mock bots before starting the server
runMigrations()
await seedMockBots()
const port = Number(process.env.PORT) || 9100