feat: retro mode — arcade combo round with gamepad overlays

Every fight now includes one Retro Mode round where bots submit
gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic
(always shown), standard (partially revealed), super (must discover),
and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage.

Includes pixel-art gamepad overlays (P1/P2) with animated button
presses, retro-specific narrations, and mock bot combo responses
scaled by ELO.

Also adds loops/plan.md with 11-phase production hardening roadmap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 14:13:47 +00:00
co-authored by Claude Opus 4.6
parent 0d6ba7d5a7
commit 1f8e8569ef
7 changed files with 807 additions and 2 deletions
+1
View File
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from 'crypto'
import { db, schema, sqlite } from '../db/index.js'
import { randomArena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { generateMockRetroResponse } from './retro-moves.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { eq, sql } from 'drizzle-orm'
+8 -2
View File
@@ -3,6 +3,7 @@ import { db, schema, sqlite } from '../db/index.js'
import { eq, sql } from 'drizzle-orm'
import { randomArena, type Arena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { generateRetroChallenge } from './retro-moves.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
@@ -326,8 +327,13 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
let winnerId: string | null = null
const usedTypes = new Set<string>()
// Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late)
const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4))
for (let round = 1; round <= MAX_ROUNDS; round++) {
const challenge = pickChallenge(usedTypes, arena.modifier)
const challenge = round === retroRound
? generateRetroChallenge()
: pickChallenge(usedTypes, arena.modifier)
usedTypes.add(challenge.type)
emit(fightId, 'round_start', {
@@ -378,7 +384,7 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
fightId,
roundNumber: round,
challengeType: challenge.type,
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring }),
challengeData: JSON.stringify({ prompt: challenge.prompt, scoring: challenge.scoring, retroKnown: challenge.type === 'retro_mode' ? challenge.answers : undefined }),
botAResponse: responseA.answer,
botATimeMs: responseA.timeMs,
botAScore: result.botAScore,
+169
View File
@@ -0,0 +1,169 @@
// Retro Mode — arcade fighting game combo engine
import type { Challenge } from './challenges.js'
export interface RetroMove {
input: string
name: string
damage: number
tier: 'basic' | 'standard' | 'super' | 'ultra'
}
export const RETRO_MOVES: RetroMove[] = [
// Basic — always revealed
{ input: 'A', name: 'Jab', damage: 5, tier: 'basic' },
{ input: 'B', name: 'Kick', damage: 6, tier: 'basic' },
{ input: '→+A', name: 'Hook', damage: 8, tier: 'basic' },
{ input: '←+B', name: 'Low Kick', damage: 7, tier: 'basic' },
// Standard — some revealed each fight
{ input: '↓→+A', name: 'Fireball', damage: 12, tier: 'standard' },
{ input: '↓←+B', name: 'Spin Kick', damage: 14, tier: 'standard' },
{ input: '→→+A', name: 'Dash Punch', damage: 15, tier: 'standard' },
{ input: '↑↓+A', name: 'Uppercut', damage: 16, tier: 'standard' },
{ input: '←→+B', name: 'Slide Kick', damage: 13, tier: 'standard' },
{ input: '↑+A', name: 'Rising Fist', damage: 11, tier: 'standard' },
{ input: '↓+B+A', name: 'Leg Sweep', damage: 10, tier: 'standard' },
{ input: '→+B+A', name: 'Elbow Strike', damage: 12, tier: 'standard' },
// Super — never revealed, must discover
{ input: '↓→↓→+A', name: 'Hadouken', damage: 22, tier: 'super' },
{ input: '←↓→+B', name: 'Dragon Kick', damage: 25, tier: 'super' },
{ input: '↑↑↓↓+A', name: 'Power Surge', damage: 28, tier: 'super' },
{ input: '→←→+A+B', name: 'Tiger Knee', damage: 24, tier: 'super' },
{ input: '↓↓↑+B+A', name: 'Shoryuken', damage: 26, tier: 'super' },
{ input: '←←→→+A', name: 'Sonic Boom', damage: 23, tier: 'super' },
{ input: '↑→↓←+A+B', name: 'Cyclone', damage: 30, tier: 'super' },
// Ultra — the ultimate secret
{ input: '↑↑↓↓←→←→+B+A', name: 'KONAMI CODE', damage: 50, tier: 'ultra' },
]
// Build canonical form: arrows joined, then +buttons
function canonicalize(raw: string): string {
let s = raw
s = s.replace(/\bup\b/gi, '↑')
s = s.replace(/\bdown\b/gi, '↓')
s = s.replace(/\bleft\b/gi, '←')
s = s.replace(/\bright\b/gi, '→')
const dirs: string[] = []
const buttons: string[] = []
for (const c of s) {
if ('↑↓←→'.includes(c)) dirs.push(c)
else if ('Aa'.includes(c)) buttons.push('A')
else if ('Bb'.includes(c)) buttons.push('B')
}
if (dirs.length === 0 && buttons.length === 0) return ''
let result = dirs.join('')
if (buttons.length > 0) {
if (result.length > 0) result += '+'
result += buttons.join('+')
}
return result
}
// Precompute canonical lookup
const MOVE_LOOKUP = new Map<string, RetroMove>()
for (const move of RETRO_MOVES) {
MOVE_LOOKUP.set(canonicalize(move.input), move)
}
export function lookupMove(raw: string): RetroMove | null {
return MOVE_LOOKUP.get(canonicalize(raw)) || null
}
export interface RetroMoveResult {
input: string
name: string | null
damage: number
discovered: boolean
}
export interface RetroScoreResult {
totalDamage: number
moves: RetroMoveResult[]
score: number
}
export function scoreRetroResponse(answer: string | null, knownInputs: string[]): RetroScoreResult {
if (!answer) return { totalDamage: 0, moves: [], score: 0 }
const knownSet = new Set(knownInputs.map(canonicalize))
const parts = answer.split('|').map(s => s.trim()).filter(Boolean).slice(0, 3)
const moves: RetroMoveResult[] = []
let totalDamage = 0
for (const raw of parts) {
const move = lookupMove(raw)
if (move) {
const isDiscovered = !knownSet.has(canonicalize(move.input))
const dmg = isDiscovered ? Math.round(move.damage * 1.5) : move.damage
totalDamage += dmg
moves.push({ input: raw, name: move.name, damage: dmg, discovered: isDiscovered })
} else {
moves.push({ input: raw, name: null, damage: 0, discovered: false })
}
}
return { totalDamage, moves, score: Math.min(10, totalDamage / 5) }
}
export function generateRetroChallenge(): Challenge {
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
// Reveal 3-5 random standard moves
const shuffled = [...standards].sort(() => Math.random() - 0.5)
const revealCount = 3 + Math.floor(Math.random() * 3)
const revealed = shuffled.slice(0, revealCount)
const knownMoves = [...basics, ...revealed]
const knownInputs = knownMoves.map(m => m.input)
const moveList = knownMoves.map(m => ` ${m.input} = ${m.name} (${m.damage} dmg)`).join('\n')
const prompt = `RETRO MODE — ARCADE FIGHT!\n\nEnter 3 gamepad combos separated by |\nButtons: ↑ ↓ ← → A B\n\nKNOWN MOVES:\n${moveList}\n\nSECRET COMBOS exist! Longer button chains = more damage. Experiment!\n\nFormat: combo1 | combo2 | combo3\nExample: ↓→+A | B | →→+A`
return {
type: 'retro_mode',
label: 'Retro Mode',
prompt,
answers: knownInputs,
timeout_ms: 12000,
scoring: 'factual',
baseDamage: 22,
}
}
// Generate mock retro response based on bot ELO
export function generateMockRetroResponse(elo: number): string {
const basics = RETRO_MOVES.filter(m => m.tier === 'basic')
const standards = RETRO_MOVES.filter(m => m.tier === 'standard')
const supers = RETRO_MOVES.filter(m => m.tier === 'super')
const moves: string[] = []
for (let i = 0; i < 3; i++) {
const roll = Math.random()
const superChance = Math.max(0, (elo - 1400) / 1500)
const standardChance = Math.max(0.3, (elo - 900) / 1200)
const whiffChance = Math.max(0, (1300 - elo) / 2000)
if (roll < whiffChance) {
const gibberish = ['↓↓↓+A', '→←+A+A', '↑+B+B+A', '←↓↑→+A', '→↓←+B+B'][Math.floor(Math.random() * 5)]
moves.push(gibberish)
} else if (roll < whiffChance + superChance) {
moves.push(supers[Math.floor(Math.random() * supers.length)].input)
} else if (roll < whiffChance + superChance + standardChance) {
moves.push(standards[Math.floor(Math.random() * standards.length)].input)
} else {
moves.push(basics[Math.floor(Math.random() * basics.length)].input)
}
}
return moves.join(' | ')
}
// Get all known inputs as flat strings for reference
export function getRetroMoveInputs(): string[] {
return RETRO_MOVES.map(m => m.input)
}
+129
View File
@@ -1,5 +1,6 @@
import type { Challenge } from './challenges.js'
import { checkAnswer } from './answers.js'
import { scoreRetroResponse, type RetroScoreResult } from './retro-moves.js'
export interface RoundResult {
botAScore: number
@@ -29,6 +30,11 @@ export function scoreRound(
comboA: number,
comboB: number,
): RoundResult {
// Retro mode has its own scoring
if (challenge.type === 'retro_mode') {
return scoreRetroRound(challenge, botA, botB, responseA, responseB, arenaModifier, comboA, comboB)
}
// Handle timeouts/errors -- instant loss for the failing bot
if (responseA.timedOut && responseB.timedOut) {
return {
@@ -189,6 +195,7 @@ const ARENA_MODIFIER_TYPES: Record<string, string[]> = {
nature_2x: ['nature_clash', 'animal_kingdom'],
hack_2x: ['hack_battle'],
sports_2x: ['sports_showdown'],
retro_2x: ['retro_mode'],
}
function applyModifiers(
@@ -367,6 +374,15 @@ function generateNarration(
`${critPrefix}${winner} played the market perfectly. ${loser} belongs on WallStreetBets.`,
`${critPrefix}${loser}'s token strategy was worse than buying NFTs in 2022. ${winner} PROFITS!`,
],
retro_mode: [
`${critPrefix}${winner}'s combo game is UNREAL! ${loser} should stick to button mashing!`,
`${critPrefix}PERFECT INPUT from ${winner}! ${loser}'s controller might be broken!`,
`${critPrefix}${winner} reads the frame data perfectly! ${loser} gets downloaded and DESTROYED!`,
`${critPrefix}QUARTER CIRCLE FORWARD INTO PAIN! ${winner}'s arcade skills are LEGENDARY! ${loser} needs more quarters!`,
`${critPrefix}${winner} plays like they wrote the strategy guide! ${loser} plays like they're using a dance pad!`,
`${critPrefix}INSERT COIN TO CONTINUE? ${loser} is OUT of quarters! ${winner} DOMINATES the cabinet!`,
`${critPrefix}${winner} chains combos like a speedrunner! ${loser} can't even find the start button!`,
],
food_fight: [
`${critPrefix}${winner} serves up a five-star beating! ${loser} got ROASTED and TOASTED!`,
`${critPrefix}${loser} just got served. Literally. ${winner} is the head chef of PAIN!`,
@@ -387,6 +403,119 @@ function generateNarration(
return options[Math.floor(Math.random() * options.length)]
}
function scoreRetroRound(
challenge: Challenge,
botA: { id: string; name: string },
botB: { id: string; name: string },
responseA: BotResponse,
responseB: BotResponse,
arenaModifier: string | null,
comboA: number,
comboB: number,
): RoundResult {
// Handle timeouts
if (responseA.timedOut && responseB.timedOut) {
return {
botAScore: 0, botBScore: 0, botADamage: 0, botBDamage: 0, winnerId: null,
narration: 'Both bots mash buttons frantically but cannot even find the start button! DOUBLE TIMEOUT!',
isCritical: false,
}
}
if (responseA.timedOut || responseA.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB)
return {
botAScore: 0, botBScore: 10, botADamage: 0, botBDamage: dmg, winnerId: botB.id,
narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`,
isCritical: false,
}
}
if (responseB.timedOut || responseB.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA)
return {
botAScore: 10, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id,
narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`,
isCritical: false,
}
}
const knownInputs = challenge.answers || []
const resultA = scoreRetroResponse(responseA.answer, knownInputs)
const resultB = scoreRetroResponse(responseB.answer, knownInputs)
// Speed bonus: up to 20% more for faster responses
let scoreA = resultA.score
let scoreB = resultB.score
const maxTime = challenge.timeout_ms
if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * 0.2
if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * 0.2
const margin = Math.abs(scoreA - scoreB)
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
const isCritical = margin > 4
let winnerDamage = challenge.baseDamage + margin * 2
if (isCritical) winnerDamage *= 1.5
const winnerCombo = winnerId === botA.id ? comboA : comboB
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin)
const winnerResult = winnerId === botA.id ? resultA : resultB
const loserResult = winnerId === botA.id ? resultB : resultA
const moveSummary = (r: RetroScoreResult) =>
r.moves.map(m => m.name ? (m.discovered ? '★' + m.name + '★' : m.name) : 'WHIFF').join(', ')
let narration: string
if (!winnerId) {
const draws = [
'MIRROR MATCH! Both bots combo for equal damage! Frame-perfect tie!',
'EQUAL POWER! The arcade cabinet shakes from perfectly matched inputs!',
`DOUBLE K.O.! ${botA.name} and ${botB.name} hit identical damage totals! Insert another quarter!`,
]
narration = draws[Math.floor(Math.random() * draws.length)]
} else {
const hasDiscovery = winnerResult.moves.some(m => m.discovered)
const discoveryNames = winnerResult.moves.filter(m => m.discovered).map(m => m.name)
const loserWhiffs = loserResult.moves.filter(m => !m.name).length
const narrations: string[] = []
if (hasDiscovery) {
narrations.push(
`SECRET COMBO UNLOCKED! ${winnerName} discovers ${discoveryNames.join(' + ')}! ${loserName} never saw it coming!`,
`HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`,
)
}
if (loserWhiffs >= 2) {
narrations.push(
`${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`,
)
}
narrations.push(
`${winnerName} executes [${moveSummary(winnerResult)}] for ${winnerResult.totalDamage} total damage! ${loserName} can't keep up!`,
`COMBO BREAKER! ${winnerName}'s inputs are FLAWLESS! ${loserName} gets bodied!`,
`${winnerName} reads the frame data perfectly! ${loserName} gets downloaded and DESTROYED!`,
`PERFECT INPUT! ${winnerName} chains ${winnerResult.moves.filter(m => m.name).length} moves! ${loserName}'s controller might be broken!`,
`${winnerName} plays like they have the strategy guide! ${loserName} plays like they're using a steering wheel!`,
)
const prefix = isCritical ? 'CRITICAL COMBO! ' : ''
narration = prefix + narrations[Math.floor(Math.random() * narrations.length)]
}
return {
botAScore: Math.round(scoreA * 10) / 10,
botBScore: Math.round(scoreB * 10) / 10,
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
winnerId,
narration,
isCritical,
}
}
// Elo calculation
export function calculateElo(
winnerElo: number,