Skip to content

feat: implement arena MVP#1

Open
memorysaver wants to merge 1 commit intomainfrom
codex/arena-initial
Open

feat: implement arena MVP#1
memorysaver wants to merge 1 commit intomainfrom
codex/arena-initial

Conversation

@memorysaver
Copy link
Copy Markdown
Owner

No description provided.

Copilot AI review requested due to automatic review settings February 10, 2026 05:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/:matchId spectator 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.

Comment on lines +54 to +284
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;
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +9
import {
arenaMatch,
arenaTemplate,
} from "@agentpit-gg/db/schema";
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread apps/server/src/index.ts
const sessionId = env.GAME_SESSION.idFromName(matchId);
const session = env.GAME_SESSION.get(sessionId);

return session.fetch(c.req.raw);
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +53
magicLink({
sendMagicLink: async ({ email, url }) => {
console.info(`Magic link for ${email}: ${url}`);
},
}),
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread packages/api/src/index.ts
Comment on lines +77 to +112
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();
});
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +298 to +324
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,
});
}
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +3
[
{
"templateId": "balanced",
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
import Phaser from "phaser";
import { useEffect, useRef } from "react";

import type { GameState } from "@agentpit-gg/api/arena";

import { ArenaScene } from "./arena-scene";
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +27
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,
}),
});

Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +34
const getPartyForCharacter = (
state: InternalGameState,
characterId: string,
): InternalGameState["parties"][number] | null => {
return (
state.parties.find((party) =>
party.characters.some((character) => character.id === characterId),
) ?? null
);
};

Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable getPartyForCharacter.

Suggested change
const getPartyForCharacter = (
state: InternalGameState,
characterId: string,
): InternalGameState["parties"][number] | null => {
return (
state.parties.find((party) =>
party.characters.some((character) => character.id === characterId),
) ?? null
);
};

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants