목표
브라우저에서 JavaScript 봇을 제출하면 서버가 decide(input) 함수를 호출해 매 턴 행동을 결정합니다. 봇은 그 순간 공개된 정보만 보고, 정해진 형식의 action 객체 하나를 반환하면 됩니다.
공통 제출 형식
모든 종목이 같은 함수 시그니처를 씁니다. 반환 객체의 모양만 종목별로 다릅니다.
export function decide(input) {
// input.game 종목 id
// input.playerId "A" | "B"
// input.turn 현재 턴 번호
// input.state 종목별 공개 상태 (아래 종목 표 참고)
// 매 턴 동기적으로 plain object 하나만 반환합니다.
return { /* 종목별 action */ };
}실행 제한
- decide(input)는 매 턴 2000ms 안에 반환해야 하며, 초과하면 해당 턴 플레이어가 기권 처리됩니다.
- 봇 소스 초기 실행도 2000ms 제한을 받습니다.
- 한 경기는 최대 200턴까지만 진행됩니다.
코드 · 보안 규칙
- decide(input)는 동기 함수여야 합니다. async 함수나 Promise 반환은 유효한 action으로 처리되지 않습니다.
- 반환값은 반드시 plain object여야 합니다. 문자열, 배열, null, undefined는 검증 실패입니다.
- input과 input.state를 수정해도 실제 경기 상태에는 반영되지 않습니다.
- 허용되는 전역은 Math, JSON, structuredClone, console.log 정도입니다.
- fetch, XMLHttpRequest, WebSocket, EventSource, eval, Function, import, require, process, globalThis, window, document, constructor.constructor 사용은 금지됩니다.
알까기 — 입력과 반환
물리 기반 보드 게임
내 알 하나를 골라 방향과 힘을 반환하면 엔진이 충돌과 장외 판정을 계산합니다.
입력 요약
내 알 목록, 상대 알 목록, 전체 알 개수, 현재 턴, 보드 크기와 알 반지름, 각 알의 좌표와 생존 상태가 들어옵니다.
입력 필드
- input.game: "alkkagi"
- input.playerId: "A" | "B"
- input.turn: number
- input.state.turn: number
- input.state.stoneCount: number
- input.state.myStones: Array<{ id: string; owner: "A" | "B"; x: number; y: number; alive: boolean }>
- input.state.opponentStones: Array<{ id: string; owner: "A" | "B"; x: number; y: number; alive: boolean }>
- input.state.board.width: number
- input.state.board.height: number
- input.state.board.stoneRadius: number
- input.state.physics.collisionModel: string
- input.state.physics.shotSpeed: number
- input.state.physics.powerToVelocity: "initialSpeed = power * shotSpeed"
- input.state.physics.maxInitialSpeed: number
- input.state.physics.maxHeadOnImpulse: number
- input.state.physics.restitution: number
- input.state.physics.friction: number
- input.state.physics.stopSpeed: number
- input.state.physics.maxSteps: number
- input.state.physics.subSteps: number
- input.state.legalPowerRange: [number, number]
input.state 예시
input.state = {
turn: 4,
stoneCount: 9,
myStones: [{ id: "A1", owner: "A", x: 30, y: 50, alive: true }],
opponentStones: [{ id: "B1", owner: "B", x: 70, y: 50, alive: true }],
board: { width: 100, height: 100, stoneRadius: 2.3 },
physics: {
collisionModel: "equal-mass-normal-impulse",
shotSpeed: 3.4,
powerToVelocity: "initialSpeed = power * shotSpeed",
maxInitialSpeed: 5.44,
maxHeadOnImpulse: 5.222,
restitution: 0.92,
friction: 0.955,
stopSpeed: 0.035,
maxSteps: 170,
subSteps: 8
},
legalPowerRange: [0, 1.6]
};반환 형식
{ stoneId, angle, power }
- return { stoneId: string, angle: number, power: number }
- stoneId는 input.state.myStones 안에 있는 살아있는 알 id
- angle은 0 이상 360 이하이며 오른쪽이 0도
- power는 0 이상 1.6 이하
주의사항
- angle은 0도 이상 360도 이하 방향이며 360도는 0도와 같은 방향입니다.
- power는 0부터 1.6까지의 값으로 반환합니다.
- 충돌 판정은 보드 좌표계 기준 반지름 2.3 원이며, 두 알의 중심 거리가 4.6보다 작으면 부딪힙니다.
- power는 초기속도 initialSpeed = power * input.state.physics.shotSpeed로 변환됩니다.
- 풀파워 1.6의 초기속도는 input.state.physics.maxInitialSpeed(현재 5.44)이며, 정면 충돌 기준 최대 임펄스는 maxHeadOnImpulse(현재 5.222)입니다.
- 물리는 스핀 없는 같은 질량 원 충돌 모델이며, restitution/friction/stopSpeed/maxSteps 값은 input.state.physics로 확인할 수 있습니다.
- 벽 반사는 없습니다. 알 중심이 보드 바깥으로 반지름 이상 벗어나면 장외 처리됩니다.
- 알 개수는 경기 설정에 따라 1~15개이며 input.state.stoneCount로 확인합니다. 기본 친선전은 9개입니다.
- 초기 알 배치는 seed 기반 랜덤입니다. 같은 seed는 같은 배치, 다른 seed는 다른 배치를 만들며 봇은 input.state.myStones/opponentStones 좌표를 보고 판단해야 합니다.
- 알까기는 한쪽 알이 모두 나가거나 60턴이 지나면 종료됩니다.
- 살아있는 내 알의 id만 stoneId로 사용할 수 있습니다.
샘플 봇
장외 저격수가장자리에 가까운 상대 알을 먼저 고르고, 보드 바깥 방향으로 밀어내는 공격형
export function decide(input) {
const s = input.state;
const me = s.myStones || [];
const foe = s.opponentStones || [];
if (!me.length) return { stoneId: "A1", angle: 0, power: 0 };
if (!foe.length) return { stoneId: me[0].id, angle: 0, power: 0 };
const board = s.board || { width: 100, height: 100 };
const maxPower = s.legalPowerRange ? s.legalPowerRange[1] : 1.6;
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function edgeGap(stone) {
return Math.min(stone.x, stone.y, board.width - stone.x, board.height - stone.y);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function angleTo(from, x, y) {
const angle = Math.atan2(y - from.y, x - from.x) * 180 / Math.PI;
return angle < 0 ? angle + 360 : angle;
}
let bestStone = me[0];
let bestTarget = foe[0];
let bestScore = -1000000;
for (const stone of me) {
for (const target of foe) {
const score = (50 - edgeGap(target)) * 1.6 - distance(stone, target) + edgeGap(stone) * 0.25;
if (score > bestScore) {
bestScore = score;
bestStone = stone;
bestTarget = target;
}
}
}
const pushOut = edgeGap(bestTarget) < 18 ? 0.5 : 0;
const aimX = bestTarget.x + (bestTarget.x - board.width / 2) * pushOut;
const aimY = bestTarget.y + (bestTarget.y - board.height / 2) * pushOut;
const power = clamp(0.64 + distance(bestStone, bestTarget) / 50 + Math.max(0, 18 - edgeGap(bestTarget)) / 42, 0.18, Math.min(1.42, maxPower));
return {
stoneId: bestStone.id,
angle: angleTo(bestStone, aimX, aimY),
power
};
}군집 파쇄자상대 알이 모인 지점을 노려 여러 알을 흔들고, 내 후방 알은 최대한 보존하는 균형형
export function decide(input) {
const s = input.state;
const me = s.myStones || [];
const foe = s.opponentStones || [];
if (!me.length) return { stoneId: "A1", angle: 0, power: 0 };
if (!foe.length) return { stoneId: me[0].id, angle: 0, power: 0 };
const board = s.board || { width: 100, height: 100 };
const maxPower = s.legalPowerRange ? s.legalPowerRange[1] : 1.6;
function distance(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function angleTo(from, x, y) {
const angle = Math.atan2(y - from.y, x - from.x) * 180 / Math.PI;
return angle < 0 ? angle + 360 : angle;
}
function clusterScore(target) {
let score = 0;
for (const other of foe) {
if (other.id !== target.id) score += Math.max(0, 18 - distance(target, other));
}
return score;
}
let bestStone = me[0];
let bestTarget = foe[0];
let bestScore = -1000000;
for (const stone of me) {
for (const target of foe) {
const backline = input.playerId === "A" ? 54 - stone.x : stone.x - 46;
const score = clusterScore(target) * 2 + backline * 0.7 - distance(stone, target) * 0.7;
if (score > bestScore) {
bestScore = score;
bestStone = stone;
bestTarget = target;
}
}
}
let totalX = bestTarget.x;
let totalY = bestTarget.y;
let count = 1;
for (const other of foe) {
if (other.id !== bestTarget.id && distance(bestTarget, other) < 18) {
totalX += other.x;
totalY += other.y;
count += 1;
}
}
const aimX = totalX / count;
const aimY = totalY / count;
const power = clamp(0.82 + distance(bestStone, bestTarget) / 42 + count * 0.05, 0.25, Math.min(1.52, maxPower));
return {
stoneId: bestStone.id,
angle: angleTo(bestStone, aimX, aimY),
power
};
}로컬 시뮬레이터
서버와 같은 입력 계약, 물리 상수, action 검증, 2000ms 실행 제한을 담은 단일 Node.js 파일입니다. 내 봇을 저장해 두고 seed와 알 개수를 바꿔가며 빠르게 반복 테스트하세요.
node bot-brawl-local-simulator.mjs --bot ./my-bot.js --opponent sample --seed 1234 --stone-count 9
node bot-brawl-local-simulator.mjs --bot-a ./mine.js --bot-b ./other.js --json시뮬레이터 내려받기