Conversation
There was a problem hiding this comment.
Pull request overview
Implements an “Arena” MVP spanning database schema, agent API endpoints, Durable Object–backed game/matchmaking, and a spectator watch UI, with supporting tests, scripts, and documentation/spec updates.
Changes:
- Adds Arena oRPC router with agent auth (API key), templates, matchmaking, match state/actions, and admin stats.
- Introduces D1/Drizzle schema for arena entities and Cloudflare Durable Objects for Matchmaker + GameSession.
- Adds a
/watch/:matchIdspectator route and a Phaser-based battle viewer, plus smoke/e2e tests and docs.
Reviewed changes
Copilot reviewed 50 out of 51 changed files in this pull request and generated 14 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/arena.e2e.test.ts | E2E flow for two agents joining and completing a match |
| tests/api.integration.test.ts | Basic server healthcheck integration test |
| scripts/simulate-balance.ts | Offline match simulation script and report generation |
| scripts/local-smoke-test.sh | Local script to create agents, join queue, and fetch state |
| reports/balance-report.json | Sample/generated balance simulation output |
| packages/infra/alchemy.run.ts | Adds DO bindings + adjusts dev ports/bindings for arena infra |
| packages/db/src/schema/index.ts | Exports new arena schema module |
| packages/db/src/schema/arena.ts | Adds arena tables (agents, keys, templates, matches, webhooks, rate limit) |
| packages/auth/src/index.ts | Enables Better Auth magic-link plugin |
| packages/api/src/routers/index.ts | Registers arena router under the app router |
| packages/api/src/routers/arena.ts | Implements Arena router endpoints (agents/templates/matches/admin) |
| packages/api/src/index.ts | Adds agent auth + rate limiting middleware and agentProcedure |
| packages/api/src/context.ts | Extends context to include request and agent |
| packages/api/src/arena/types.ts | Shared TS types for arena game state/actions/templates |
| packages/api/src/arena/templates.ts | Ensures default templates exist in DB |
| packages/api/src/arena/template-definitions.ts | Defines default party templates and base attributes |
| packages/api/src/arena/schemas.ts | Zod schemas for agent/template/match/action inputs |
| packages/api/src/arena/crypto.ts | API key generation + hashing helpers |
| packages/api/src/arena.ts | Public re-exports for arena types/schemas/templates |
| openspec/changes/ai-agent-arena-game/tasks.md | Updates project task checklist state |
| openspec/changes/ai-agent-arena-game/specs/party-templates/spec.md | Updates template/balance spec text |
| openspec/changes/ai-agent-arena-game/specs/matchmaking/spec.md | Updates matchmaking spec with diversity rules |
| openspec/changes/ai-agent-arena-game/specs/game-engine/spec.md | Updates combat/turn model + target resolution + stats/log requirements |
| openspec/changes/ai-agent-arena-game/specs/agent-api/spec.md | Updates agent API spec (turn metadata + validation notes) |
| openspec/changes/ai-agent-arena-game/design.md | Documents key MVP decisions (turn model, partial info, spell slots) |
| docs/arena-api.md | Documents Arena Agent API endpoints and auth model |
| docs/agent-skill-template.md | Adds agent skill markdown template for developers |
| docs/agent-getting-started.md | Adds getting-started guide for building an agent |
| bun.lock | Adds Phaser and updates some dependency resolutions |
| apps/web/vite.config.ts | Adjusts dev server ports and alchemy plugin config |
| apps/web/src/routes/watch/$matchId.tsx | Implements spectator watch route + WebSocket client + panels |
| apps/web/src/routeTree.gen.ts | Generated TanStack Router route tree including watch route |
| apps/web/src/components/arena/battle-viewer.tsx | React wrapper that mounts a Phaser game instance |
| apps/web/src/components/arena/arena-scene.ts | Phaser scene rendering parties, HP, and simple FX |
| apps/web/package.json | Adds Phaser dependency |
| apps/server/src/types/index.ts | Placeholder types barrel file |
| apps/server/src/index.ts | Adds /arena/watch/:matchId forwarding route + exports DO classes |
| apps/server/src/game/stats.ts | Character stat calculation utilities |
| apps/server/src/game/state.ts | Internal vs serialized state + visibility rules + (de)serialization |
| apps/server/src/game/random.ts | Seeded RNG helpers |
| apps/server/src/game/party.ts | Party/character construction from templates |
| apps/server/src/game/initiative.ts | Initiative computation and stable sorting |
| apps/server/src/game/index.ts | Game module barrel exports |
| apps/server/src/game/classes.ts | Class templates (spell slots + resistances) |
| apps/server/src/game/actions.ts | Core combat action resolution + logging + stats updates |
| apps/server/src/game/tests/combat.test.ts | Unit tests for stats/damage/visibility behavior |
| apps/server/src/durable-objects/matchmaker.ts | Matchmaking queue DO with diversity filter + alarms + match init |
| apps/server/src/durable-objects/index.ts | Durable objects barrel exports |
| apps/server/src/durable-objects/game-session.ts | Game session DO: init, turns, actions, websockets, persistence, alarms |
| apps/server/src/api/webhooks.ts | Webhook dispatcher with retries + logging to DB |
| apps/server/src/api/index.ts | Placeholder API barrel file |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const selectTarget = ( | ||
| candidates: Character[], | ||
| rng: () => number, | ||
| ): Character | null => { | ||
| if (candidates.length === 0) { | ||
| return null; | ||
| } | ||
| const index = Math.floor(rng() * candidates.length); | ||
| return candidates[index] ?? null; | ||
| }; | ||
|
|
||
| const resolveTarget = ( | ||
| state: InternalGameState, | ||
| action: Action, | ||
| actor: Character, | ||
| partyId: string, | ||
| rng: () => number, | ||
| ): Character | null => { | ||
| const enemy = getEnemyParty(state, partyId); | ||
| const explicitTarget = action.targetId ? getCharacterById(state, action.targetId) : null; | ||
| const isMelee = action.actionType === "attack"; | ||
|
|
||
| const aliveEnemies = enemy.characters.filter((character) => !character.defeated); | ||
| if (aliveEnemies.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| const frontRowEnemies = aliveEnemies.filter((character) => character.row === "front"); | ||
| const backRowEnemies = aliveEnemies.filter((character) => character.row === "back"); | ||
|
|
||
| const preferredRow = | ||
| explicitTarget?.row ?? (frontRowEnemies.length > 0 ? "front" : "back"); | ||
|
|
||
| if (explicitTarget && !explicitTarget.defeated) { | ||
| if (!isMelee) { | ||
| return explicitTarget; | ||
| } | ||
|
|
||
| if (explicitTarget.row === "front" || frontRowEnemies.length === 0) { | ||
| return explicitTarget; | ||
| } | ||
| } | ||
|
|
||
| const preferredCandidates = | ||
| preferredRow === "front" ? frontRowEnemies : backRowEnemies; | ||
| const fallbackCandidates = | ||
| preferredRow === "front" ? backRowEnemies : frontRowEnemies; | ||
|
|
||
| return selectTarget(preferredCandidates, rng) ?? selectTarget(fallbackCandidates, rng); | ||
| }; | ||
|
|
||
| const calculateDamage = ( | ||
| rng: () => number, | ||
| attackStat: number, | ||
| defenseStat: number, | ||
| damageType: DamageType, | ||
| target: Character, | ||
| isDefending: boolean, | ||
| bonusMultiplier: number, | ||
| ): number => { | ||
| const base = Math.max(1, attackStat - defenseStat); | ||
| const isCritical = rng() < critChance; | ||
| let damage = isCritical ? base * critMultiplier : base; | ||
| damage *= bonusMultiplier; | ||
|
|
||
| const cap = base * 2; | ||
| damage = Math.min(damage, cap); | ||
| damage = Math.floor(damage); | ||
|
|
||
| const resistance = target.resistances === "unknown" ? 0 : target.resistances[damageType]; | ||
| damage = Math.floor(damage * (1 - resistance)); | ||
|
|
||
| if (isDefending) { | ||
| damage = Math.floor(damage * (1 - defendReduction)); | ||
| } | ||
|
|
||
| return Math.max(1, damage); | ||
| }; | ||
|
|
||
| const applyDamage = (target: Character, damage: number): void => { | ||
| target.stats.currentHp = Math.max(0, target.stats.currentHp - damage); | ||
| if (target.stats.currentHp === 0) { | ||
| target.defeated = true; | ||
| } | ||
| }; | ||
|
|
||
| export const applyPartyActions = ( | ||
| state: InternalGameState, | ||
| partyId: string, | ||
| actions: Action[], | ||
| ): InternalGameState => { | ||
| const party = state.parties.find((member) => member.id === partyId); | ||
| if (!party) { | ||
| throw new Error("Active party not found."); | ||
| } | ||
|
|
||
| state.stats.turnsTaken[partyId === state.parties[0]?.id ? "partyA" : "partyB"] += 1; | ||
|
|
||
| for (const character of party.characters) { | ||
| state.defending.delete(character.id); | ||
| } | ||
|
|
||
| const rng = createSeededRng(state.rngSeed + state.round); | ||
| const actionMap = buildActionMap(actions); | ||
| const initiative = computeInitiativeOrder(party.characters, state.rngSeed + state.round); | ||
| const damageBonusMultiplier = | ||
| state.round > 30 ? Math.min(2, 1 + (state.round - 30) * 0.05) : 1; | ||
|
|
||
| for (const entry of initiative) { | ||
| const actor = party.characters.find((member) => member.id === entry.characterId); | ||
| if (!actor || actor.defeated) { | ||
| continue; | ||
| } | ||
|
|
||
| const action = actionMap.get(actor.id) ?? { | ||
| characterId: actor.id, | ||
| actionType: "defend", | ||
| }; | ||
|
|
||
| switch (action.actionType) { | ||
| case "defend": { | ||
| state.defending.add(actor.id); | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} braces for impact.`, | ||
| }); | ||
| break; | ||
| } | ||
| case "attack": { | ||
| const target = resolveTarget(state, action, actor, partyId, rng); | ||
| if (!target) { | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} finds no target.`, | ||
| }); | ||
| break; | ||
| } | ||
|
|
||
| const damage = calculateDamage( | ||
| rng, | ||
| actor.stats.attack, | ||
| target.stats.defense, | ||
| "physical", | ||
| target, | ||
| state.defending.has(target.id), | ||
| damageBonusMultiplier, | ||
| ); | ||
| applyDamage(target, damage); | ||
|
|
||
| state.stats.damageDealt[partyId === state.parties[0]?.id ? "partyA" : "partyB"] += | ||
| damage; | ||
|
|
||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} attacks ${target.name} for ${damage} damage.`, | ||
| }); | ||
| break; | ||
| } | ||
| case "cast_spell": { | ||
| const slotLevel = 1; | ||
| const currentSlots = actor.spellSlots[slotLevel] ?? 0; | ||
| if (currentSlots <= 0) { | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} tries to cast but lacks spell slots.`, | ||
| }); | ||
| break; | ||
| } | ||
|
|
||
| actor.spellSlots[slotLevel] = currentSlots - 1; | ||
| state.stats.spellsCast[partyId === state.parties[0]?.id ? "partyA" : "partyB"] += 1; | ||
|
|
||
| const target = resolveTarget(state, action, actor, partyId, rng); | ||
| if (!target) { | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} casts a spell, but no targets remain.`, | ||
| }); | ||
| break; | ||
| } | ||
|
|
||
| const damage = calculateDamage( | ||
| rng, | ||
| actor.stats.magic, | ||
| target.stats.defense, | ||
| "magic", | ||
| target, | ||
| state.defending.has(target.id), | ||
| damageBonusMultiplier, | ||
| ); | ||
| applyDamage(target, damage); | ||
|
|
||
| state.stats.damageDealt[partyId === state.parties[0]?.id ? "partyA" : "partyB"] += | ||
| damage; | ||
|
|
||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} casts a spell at ${target.name} for ${damage} damage.`, | ||
| }); | ||
| break; | ||
| } | ||
| case "use_item": { | ||
| const healAmount = Math.ceil(actor.stats.maxHp * itemHealRatio); | ||
| actor.stats.currentHp = Math.min(actor.stats.maxHp, actor.stats.currentHp + healAmount); | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} uses an item and heals ${healAmount} HP.`, | ||
| }); | ||
| break; | ||
| } | ||
| case "inspect": { | ||
| const target = resolveTarget(state, action, actor, partyId, rng); | ||
| if (!target) { | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} finds nothing to inspect.`, | ||
| }); | ||
| break; | ||
| } | ||
|
|
||
| if (!state.inspectedByParty[partyId]) { | ||
| state.inspectedByParty[partyId] = new Set<string>(); | ||
| } | ||
|
|
||
| state.inspectedByParty[partyId].add(target.id); | ||
| state.log.push({ | ||
| turn: state.round, | ||
| message: `${actor.name} inspects ${target.name}.`, | ||
| }); | ||
| break; | ||
| } |
There was a problem hiding this comment.
There are unit tests for applyPartyActions, but key new behaviors added here (e.g., deterministic target resolution when targets are invalid, defend damage reduction, inspect visibility, spell slot consumption) are not covered. Adding focused tests for these cases would help prevent regressions in the combat engine.
| import { | ||
| arenaMatch, | ||
| arenaTemplate, | ||
| } from "@agentpit-gg/db/schema"; |
There was a problem hiding this comment.
arenaRouter uses arenaAgent and arenaAgentKey in agents.create, but they are not imported from @agentpit-gg/db/schema, which will cause a runtime/compile error. Import them alongside arenaMatch/arenaTemplate (or adjust the code to only use the imported tables).
| const sessionId = env.GAME_SESSION.idFromName(matchId); | ||
| const session = env.GAME_SESSION.get(sessionId); | ||
|
|
||
| return session.fetch(c.req.raw); |
There was a problem hiding this comment.
The /arena/watch/:matchId handler forwards the original request to the GameSession Durable Object, but the DO only handles the /spectate path. As written, the DO will see a pathname like /arena/watch/<id> and return 404, breaking spectator WebSocket connections. Rewrite the forwarded request URL to /spectate (keeping the WebSocket upgrade headers) or add a matching route in GameSession.fetch.
| return session.fetch(c.req.raw); | |
| const originalRequest = c.req.raw; | |
| const url = new URL(originalRequest.url); | |
| url.pathname = "/spectate"; | |
| const forwardRequest = new Request(url.toString(), originalRequest); | |
| return session.fetch(forwardRequest); |
| magicLink({ | ||
| sendMagicLink: async ({ email, url }) => { | ||
| console.info(`Magic link for ${email}: ${url}`); | ||
| }, | ||
| }), |
There was a problem hiding this comment.
sendMagicLink currently logs the full magic-link URL to stdout. This URL is effectively a bearer token; logging it can allow account/session takeover via log access. Avoid logging the URL (or gate it behind a dev-only check) and integrate with a real email provider for production.
| const existing = await db.query.arenaRateLimit.findFirst({ | ||
| where: eq(arenaRateLimit.agentId, context.agent.id), | ||
| }); | ||
|
|
||
| if (!existing || now - existing.windowStart >= windowMs) { | ||
| await db | ||
| .insert(arenaRateLimit) | ||
| .values({ | ||
| agentId: context.agent.id, | ||
| windowStart: now, | ||
| count: 1, | ||
| }) | ||
| .onConflictDoUpdate({ | ||
| target: arenaRateLimit.agentId, | ||
| set: { | ||
| windowStart: now, | ||
| count: 1, | ||
| }, | ||
| }); | ||
|
|
||
| return next(); | ||
| } | ||
|
|
||
| if (existing.count >= limit) { | ||
| throw new ORPCError("TOO_MANY_REQUESTS"); | ||
| } | ||
|
|
||
| await db | ||
| .update(arenaRateLimit) | ||
| .set({ | ||
| count: sql`${arenaRateLimit.count} + 1`, | ||
| }) | ||
| .where(eq(arenaRateLimit.agentId, context.agent.id)); | ||
|
|
||
| return next(); | ||
| }); |
There was a problem hiding this comment.
The agent rate limiter does a read (findFirst) followed by an update, which is not atomic and can undercount/overcount under concurrency (allowing agents to exceed the limit). Consider an atomic update (e.g., UPDATE ... SET count = count + 1 WHERE agent_id = ? AND window_start >= ? AND count < ? and check affected rows) or use a transaction/locking approach supported by D1.
| private async handleSpectator(request: Request): Promise<Response> { | ||
| if (request.headers.get("Upgrade") !== "websocket") { | ||
| return new Response("Expected websocket", { status: 400 }); | ||
| } | ||
|
|
||
| const pair = new WebSocketPair(); | ||
| const [client, server] = Object.values(pair); | ||
|
|
||
| this.state.acceptWebSocket(server); | ||
|
|
||
| const state = await this.loadState(); | ||
| if (state) { | ||
| const payload = { | ||
| type: "state", | ||
| state: serializeSpectatorState(state), | ||
| log: state.log, | ||
| stats: state.stats, | ||
| reasoningByParty: state.lastReasoningByParty, | ||
| }; | ||
| server.send(JSON.stringify(payload)); | ||
| } | ||
|
|
||
| return new Response(null, { | ||
| status: 101, | ||
| webSocket: client, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Spectator WebSocket connections are accepted without any auth/session validation; anyone who knows a matchId can connect and receive match state/log/reasoning. If spectator access is intended to require Better Auth (per the magic-link flow), validate the session cookie before accepting the socket (e.g., check session in the worker route and forward only if authenticated, or validate inside the DO).
| [ | ||
| { | ||
| "templateId": "balanced", |
There was a problem hiding this comment.
This looks like generated output from scripts/simulate-balance.ts. Committing a concrete simulation result makes the repo noisy and will go stale quickly; consider removing it from version control and adding it to .gitignore, or moving it to a documented artifact location (e.g., CI upload) instead of tracking it in git.
| import Phaser from "phaser"; | ||
| import { useEffect, useRef } from "react"; | ||
|
|
||
| import type { GameState } from "@agentpit-gg/api/arena"; | ||
|
|
||
| import { ArenaScene } from "./arena-scene"; |
There was a problem hiding this comment.
Phaser is imported at module scope, which is likely to break SSR (TanStack Start is configured with ssr: true) because Phaser expects window/DOM APIs during import. To keep SSR working, load Phaser dynamically on the client (e.g., await import('phaser') inside useEffect) or wrap the viewer in a client-only boundary.
| for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { | ||
| const response = await fetch(options.url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| event: options.eventType, | ||
| data: options.payload, | ||
| }), | ||
| }); | ||
|
|
There was a problem hiding this comment.
sendWebhookWithRetries doesn't handle network exceptions (e.g., DNS failure, connection reset). In Cloudflare Workers, fetch() can throw, which would abort retries and skip logging. Wrap the fetch in try/catch and treat thrown errors as a failed attempt (recording status/error) so retries still run.
| const getPartyForCharacter = ( | ||
| state: InternalGameState, | ||
| characterId: string, | ||
| ): InternalGameState["parties"][number] | null => { | ||
| return ( | ||
| state.parties.find((party) => | ||
| party.characters.some((character) => character.id === characterId), | ||
| ) ?? null | ||
| ); | ||
| }; | ||
|
|
There was a problem hiding this comment.
Unused variable getPartyForCharacter.
| const getPartyForCharacter = ( | |
| state: InternalGameState, | |
| characterId: string, | |
| ): InternalGameState["parties"][number] | null => { | |
| return ( | |
| state.parties.find((party) => | |
| party.characters.some((character) => character.id === characterId), | |
| ) ?? null | |
| ); | |
| }; |
No description provided.