AI Agent Arena: the code
These are the files that do most of the work. The comments explain why each part works the way it does. If anything is unclear, or you would do it differently, tell me. I will also send you the full code for free, with nothing to sign up for. Ask for the full code
engine/congkak.ts
Every rule of the game. The same file plays the games and re-checks them in your browser. 209 lines
// Every Congkak rule, as pure functions. The viewer runs this same file to re-check replays.
// Cells: 0-6 A's houses, 7 A's store, 8-14 B's houses, 15 B's store. Seeds wrap 15 -> 0,
// skip the opponent's store, and house i faces house 14 - i.
export type Seat = "A" | "B";
export const RULES_VERSION = "congkak-1";
export const HOUSES_PER_SIDE = 7;
export const SEEDS_PER_HOUSE = 7;
export const TOTAL_SEEDS = 2 * HOUSES_PER_SIDE * SEEDS_PER_HOUSE; // 98
export const CELLS = 16;
export const DECISION_CAP = 500;
export interface State {
board: number[];
/** Once the game is over, the player who could not move. */
toMove: Seat;
/** An extra turn counts as a new decision. */
decisions: number;
}
/** What happened during a move, in order, for the viewer to animate. */
export type Step =
| { kind: "pickup"; cell: number; seeds: number }
| { kind: "sow"; cell: number }
| { kind: "capture"; from: number; facing: number; seeds: number }
| { kind: "extraTurn" }
| { kind: "bank"; seat: Seat; seeds: number };
export interface MoveOutcome {
state: State;
steps: Step[];
/** The mover's store when the move ends, before any extra turn or end-of-game banking. */
storeAfter: number;
sown: number;
extraTurn: boolean;
}
export interface Result {
over: boolean;
/** "capped": the 500-decision safety cap ended it. Never seen in practice. */
reason: "finished" | "capped" | null;
winner: Seat | null;
score: { A: number; B: number };
}
export function other(seat: Seat): Seat {
return seat === "A" ? "B" : "A";
}
export function storeOf(seat: Seat): number {
return seat === "A" ? 7 : 15;
}
export function housesOf(seat: Seat): number[] {
const first = seat === "A" ? 0 : 8;
return Array.from({ length: HOUSES_PER_SIDE }, (_, i) => first + i);
}
export function ownsHouse(seat: Seat, cell: number): boolean {
return seat === "A" ? cell >= 0 && cell <= 6 : cell >= 8 && cell <= 14;
}
export function facing(cell: number): number {
return 14 - cell;
}
function seedsInHouses(board: number[], seat: Seat): number {
return housesOf(seat).reduce((sum, cell) => sum + board[cell], 0);
}
export function initialState(_seed?: number): State {
// Accepted for a uniform signature; the start is fixed.
const board = new Array<number>(CELLS).fill(SEEDS_PER_HOUSE);
board[storeOf("A")] = 0;
board[storeOf("B")] = 0;
return { board, toMove: "A", decisions: 0 };
}
export function legalMoves(state: State): number[] {
if (isOver(state)) return [];
return housesOf(state.toMove).filter((cell) => state.board[cell] > 0);
}
export function isOver(state: State): boolean {
return state.decisions >= DECISION_CAP || seedsInHouses(state.board, state.toMove) === 0;
}
export function result(state: State): Result {
const score = { A: state.board[storeOf("A")], B: state.board[storeOf("B")] };
if (!isOver(state)) return { over: false, reason: null, winner: null, score };
const finished = seedsInHouses(state.board, state.toMove) === 0;
const winner = score.A > score.B ? "A" : score.B > score.A ? "B" : null;
return { over: true, reason: finished ? "finished" : "capped", winner, score };
}
/** Throws on an illegal move. */
export function playMove(state: State, move: number): MoveOutcome {
if (!legalMoves(state).includes(move)) {
throw new Error(`Illegal move ${move} for ${state.toMove}`);
}
const board = state.board.slice();
const mover = state.toMove;
const ownStore = storeOf(mover);
const theirStore = storeOf(other(mover));
const steps: Step[] = [];
let sown = 0;
let pos = move;
let inHand = board[pos];
board[pos] = 0;
steps.push({ kind: "pickup", cell: pos, seeds: inHand });
let extraTurn = false;
for (;;) {
while (inHand > 0) {
pos = (pos + 1) % CELLS;
if (pos === theirStore) continue;
board[pos] += 1;
inHand -= 1;
sown += 1;
steps.push({ kind: "sow", cell: pos });
}
if (pos === ownStore) {
// Own store: extra turn.
extraTurn = seedsInHouses(board, mover) > 0;
break;
}
if (board[pos] > 1) {
// Relay: an occupied house on either side is picked up and sown on.
inHand = board[pos];
board[pos] = 0;
steps.push({ kind: "pickup", cell: pos, seeds: inHand });
continue;
}
if (ownsHouse(mover, pos)) {
const opposite = facing(pos);
if (board[opposite] > 0) {
// Capture takes the facing house plus the landing seed.
const seeds = board[opposite] + board[pos];
board[ownStore] += seeds;
board[opposite] = 0;
board[pos] = 0;
steps.push({ kind: "capture", from: pos, facing: opposite, seeds });
}
}
break;
}
const storeAfter = board[ownStore];
let toMove: Seat = extraTurn ? mover : other(mover);
if (extraTurn) steps.push({ kind: "extraTurn" });
// If the player to move has no seeds, the other player banks the rest of theirs.
if (seedsInHouses(board, toMove) === 0) {
const banker = other(toMove);
let banked = 0;
for (const cell of housesOf(banker)) {
banked += board[cell];
board[cell] = 0;
}
board[storeOf(banker)] += banked;
if (banked > 0) steps.push({ kind: "bank", seat: banker, seeds: banked });
}
return {
state: { board, toMove, decisions: state.decisions + 1 },
steps,
storeAfter,
sown,
extraTurn,
};
}
export function applyMove(state: State, move: number): State {
return playMove(state, move).state;
}
// House numbers are seat-relative (own houses 1-7, theirs 8-14) and only appear in the
// model's prompt and reply; the engine and replays use cells.
export function cellForHouse(seat: Seat, house: number): number {
if (!Number.isInteger(house) || house < 1 || house > 14) throw new Error(`No house ${house}`);
const ownFirst = seat === "A" ? 0 : 8;
const theirFirst = seat === "A" ? 8 : 0;
return house <= 7 ? ownFirst + house - 1 : theirFirst + house - 8;
}
export function houseForCell(seat: Seat, cell: number): number {
if (cell === 7 || cell === 15 || cell < 0 || cell > 15) throw new Error(`Cell ${cell} is not a house`);
return ownsHouse(seat, cell) ? (cell % 8) + 1 : (cell % 8) + 8;
}
function pad(n: number, width: number): string {
return String(n).padStart(width, " ");
}
/** The board as a model sees it, from its own seat. */
export function toText(state: State, forSeat: Seat): string {
const them = other(forSeat);
const row = (first: number) =>
Array.from({ length: 7 }, (_, i) => pad(first + i, 2) + ":" + pad(state.board[cellForHouse(forSeat, first + i)], 2)).join(" ");
return [
`Your houses: ${row(1)} your store: ${state.board[storeOf(forSeat)]}`,
`Their houses: ${row(8)} their store: ${state.board[storeOf(them)]}`,
`Facing pairs: 1-14 2-13 3-12 4-11 5-10 6-9 7-8`,
].join("\n");
}
players/parse.ts
The referee's reader. It refuses any reply that is not exactly a move, a store prediction and a reason. 43 lines
// The referee: a reply is exactly the JSON asked for with a legal move, or it is refused.
import type { Attempt } from "./types.ts";
export const ATTEMPTS_PER_TURN = 3;
export type Reading = Pick<Attempt, "verdict" | "error" | "parsed">;
const FIELDS = ["move", "reason", "store_after"];
export function readReply(text: string, legal: number[], stopReason = "end_turn"): Reading {
if (stopReason === "refusal") return malformed("The reply was declined by the model (refusal).");
if (stopReason === "max_tokens") return malformed("The reply was cut off at the length limit.");
let value: unknown;
try {
value = JSON.parse(text.trim());
} catch {
return malformed("That reply was not valid JSON.");
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return malformed("That reply was not a JSON object.");
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
if (keys.join(",") !== FIELDS.join(",")) {
return malformed(`That reply must have exactly the fields "move", "store_after" and "reason" (it had: ${keys.join(", ") || "none"}).`);
}
const { move, store_after, reason } = obj;
if (!Number.isInteger(move)) return malformed(`"move" must be a whole number.`);
if (!Number.isInteger(store_after) || (store_after as number) < 0) return malformed(`"store_after" must be a whole number, 0 or more.`);
if (typeof reason !== "string" || reason.trim() === "") return malformed(`"reason" must be a sentence.`);
const parsed = { move: move as number, storeAfter: store_after as number, reason: reason.trim() };
if (!legal.includes(parsed.move)) {
return { verdict: "illegal", error: `House ${parsed.move} is not one of your legal moves (${legal.join(", ")}).`, parsed };
}
return { verdict: "ok", error: null, parsed };
}
function malformed(error: string): Reading {
return { verdict: "malformed", error, parsed: null };
}
runner/budget.ts
The spend guard. A model run cannot start without a budget, and each call's worst-case cost is checked before it is sent. 123 lines
// The spend guard. Before each call it charges the worst case (one token per input byte plus
// the full reply ceiling); a timeout is also charged the worst case, since it may be billed.
import { callCost, settingsFor } from "../adapters/prices.ts";
import { type Adapter, type Completion, type CompletionRequest, NetworkError } from "../adapters/types.ts";
/** Raising this is meant to be a deliberate edit. */
export const MAX_BUDGET_USD = 10;
/** 25 decisions plus 20% retries. */
export const CALLS_PER_GAME = 30;
/** Generous on purpose. */
const REQUEST_OVERHEAD_TOKENS = 1000;
const PER_MESSAGE_OVERHEAD_TOKENS = 20;
export class BudgetExhausted extends Error {
constructor(message: string) {
super(message);
this.name = "BudgetExhausted";
}
}
export class Budget {
readonly limitUsd: number;
spentUsd = 0;
calls = 0;
constructor(limitUsd: number) {
if (!(limitUsd >= 0) || !Number.isFinite(limitUsd)) throw new Error(`Bad budget ${limitUsd}`);
this.limitUsd = limitUsd;
}
canAfford(usd: number): boolean {
return this.spentUsd + usd <= this.limitUsd;
}
charge(usd: number): void {
this.spentUsd += usd;
}
}
export function inputTokenBound(req: CompletionRequest): number {
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
let tokens = REQUEST_OVERHEAD_TOKENS + bytes(req.system);
for (const m of req.messages) tokens += PER_MESSAGE_OVERHEAD_TOKENS + bytes(m.content) + (m.tokensHint ?? 0);
return tokens;
}
export function worstCaseCost(req: CompletionRequest): number {
return callCost(req.model, inputTokenBound(req), req.maxTokens);
}
export interface BudgetedAdapter extends Adapter {
readonly budget: Budget;
readonly guarded: true;
}
export function budgeted(inner: Adapter, budget: Budget): BudgetedAdapter {
return {
provider: inner.provider,
budget,
guarded: true,
async complete(req: CompletionRequest): Promise<Completion> {
settingsFor(req.model);
const worst = worstCaseCost(req);
if (!budget.canAfford(worst)) {
throw new BudgetExhausted(
`next call could cost up to $${worst.toFixed(4)}; spent $${budget.spentUsd.toFixed(4)} of $${budget.limitUsd.toFixed(2)}`,
);
}
budget.calls += 1;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new NetworkError(`timed out after ${req.timeoutMs} ms`, true)), req.timeoutMs);
});
try {
const out = await Promise.race([inner.complete(req), timeout]);
budget.charge(callCost(req.model, out.inputTokens, out.outputTokens));
return out;
} catch (err) {
if (err instanceof NetworkError && err.mayHaveBilled) {
budget.charge(worst);
err.chargedUsd = worst;
}
throw err;
} finally {
clearTimeout(timer);
}
},
};
}
export function estimateCost(modelGames: { model: string; games: number }[]): number {
let total = 0;
for (const { model, games } of modelGames) {
const t = settingsFor(model).typical;
total += games * CALLS_PER_GAME * callCost(model, t.inputTokens, t.outputTokens);
}
return total;
}
/** Shown to the user, not enforced. */
export function ceilingCost(modelGames: { model: string; games: number }[]): number {
let total = 0;
for (const { model, games } of modelGames) {
const s = settingsFor(model);
total += games * CALLS_PER_GAME * callCost(model, s.typical.inputTokens * 2, s.maxTokens);
}
return total;
}
/** Why a model run may not start, or null. */
export function refusalBeforeStart(budgetUsd: number | undefined, estimateUsd: number): string | null {
if (budgetUsd === undefined) {
return "this run includes a model player, so it needs --budget <USD>. The default budget is zero.";
}
if (!(budgetUsd > 0) || !Number.isFinite(budgetUsd)) return `--budget must be a positive number of US dollars (got ${budgetUsd})`;
if (budgetUsd > MAX_BUDGET_USD) {
return `--budget ${budgetUsd} is over the $${MAX_BUDGET_USD} ceiling in runner/budget.ts. Raise MAX_BUDGET_USD there only if you mean to.`;
}
if (estimateUsd > budgetUsd) {
return `the estimated cost $${estimateUsd.toFixed(2)} is over the budget $${budgetUsd.toFixed(2)}. Fewer games, or a bigger budget.`;
}
return null;
}