표 중심 개발 — 게임 밸런스를 코드가 아니라 데이터로

언제 — 밸런스를 바꾸려는데 어디를 고쳐야 할지 몰라 매번 코드를 만질 때. 또는 LLM 이 콘텐츠를 대량으로 뽑아줬는데 손 검수가 불가능할 때. 워크드 예제는 무역 항해 게임(항구·교역품·발견물).

01 → 02 → 03 → 04 가 기반, 05 → 06 → 07 이 측정 인프라, 08~11 은 지표라 순서 무관, 12·13 은 언제든. 표는 싸다. 루프는 안 싸다.

진단 역인덱스

증상 의심 레시피
밸런스 고치려니 코드를 만진다 층 분리 실패 01, 02
튜닝 표는 있는데 아무도 안 만진다 측정 수단 없음 06, 11
후반이 지루하다 선택지 폭 붕괴 08, 09
돈이 갑자기 무한대가 된다 차익 사이클 10
표 고칠 때마다 뭔가 깨진다 파생값 중복 저장 03
LLM 데이터 오류를 못 찾는다 검증기 없이 생산 04, 13
시뮬 결과가 매번 다르다 RNG 미주입 05
좋아졌는지 나빠졌는지 모른다 기준선 없음 11
표는 완벽한데 재미가 없다 Shape 문제 12

01. 튜닝 가능 표면 파악

기능 추가를 멈추고 숫자 리터럴을 전수 조사한다.

rg -n --type ts '(?<![\w.])[0-9]+(\.[0-9]+)?(?![\w])' src/ \
  | rg -v 'test|spec|\.d\.ts' | rg -v '\b(0|1|-1|2)\b'

세 층으로 가른다.

판정 질문 처리
Shape 바꾸면 규칙의 형태가 바뀌는가 코드에 남긴다. 단 별도 문서에 명시
Knob 바꾸면 게임의 느낌만 바뀌는가 data/tuning.ts
Content 인스턴스마다 다른 값인가 해당 개념의 표로

경계 사례 판정법 — “이 값을 0이나 무한대로 만들면 게임이 다른 장르가 되는가?” 순풍 배수 1.3 은 0이어도 항해 게임이니 Knob, 최대 함대 선박 수 4 는 1이면 다른 게임이니 Shape.

확인docs/shape.md 에 Shape 목록을 적는다. 이 문서의 존재 자체가 산출물이다. “표로 못 바꾸는 것”이 명시돼 있지 않으면 나중에 잘못된 축을 몇 주간 튜닝한다.

함정 — 애매하면 Knob 으로 넣고 싶어지지만 반대가 싸다. Shape 로 뒀다가 Knob 으로 내리는 건 쉽고, Knob 으로 뺐다가 Shape 였음을 알게 되면 표 전체가 무의미해진 상태로 발견된다.


02. Knob 표 + 리터럴 금지 린트

표를 만들고 린트로 강제한다. 강제 없는 규칙은 LLM 상대로 이틀을 못 간다.

// data/tuning.ts
export const TUNING = {
  baseSpeedPerSail: 0.8, windTailwindMul: 1.3, scurvyOnsetDays: 45,
  priceVolatility: 0.12, priceMeanReversion: 0.05, bulkPriceImpact: 0.008,
} as const
// eslint.config.js — data/ 하위는 예외. 표는 숫자 덩어리인 게 정상이다
'no-magic-numbers': ['error', { ignore: [0, 1, -1], ignoreArrayIndexes: true, enforceConst: true }]

확인 — 아무 함수에 * 1.5 를 넣어보고 CI 가 빨개지는지 실제로 본다.

함정TUNING.foo 를 지역 변수로 복사한 뒤 그 위에서 산술하면 린트가 못 잡는다(const eff = TUNING.baseSpeedPerSail * 1.2). 리뷰에서 봐야 한다.


03. 표 스키마 — 참조는 ID, 해석은 로드 시 1회

인덱스를 박으면 항구 하나 추가할 때 전부 밀리고, 문자열 참조는 오타로 조용히 깨진다. 작성은 문자열 ID 로, 런타임은 인덱스로.

export const PORTS = [
  { id: 'lisboa', name: '리스본', x: 120, y: 340, size: 3, specialty: 'wine' },
  { id: 'sevilla', name: '세비야', x: 138, y: 352, size: 3, specialty: 'olive' },
] as const satisfies readonly PortDef[]

export type PortId = typeof PORTS[number]['id']   // 리터럴 유니온

PortId 로 다른 표를 제약하면 존재하지 않는 항구 참조를 타입 체커가 잡는다 — 참조 무결성 검사의 절반이 공짜다. 인덱스 해석은 초기화에서 한 번:

const portIndex = new Map(PORTS.map((p, i) => [p.id, i]))
const routes = ROUTES.map(r => ({ ...r, from: portIndex.get(r.from)!, to: portIndex.get(r.to)! }))

확인 — 항구 하나를 표 중간에 삽입한다. 아무것도 안 깨지면 성공.

함정파생값을 표에 넣지 않는다. 항구 간 거리는 좌표에서 계산한다. 표에 박으면 좌표를 옮길 때 조용히 어긋난다. 손으로 덮어써야 하면 distanceOverride 처럼 의도를 이름에 드러낸다.


04. 검증기 — 표 확장 속도의 상한

표를 늘리는 속도의 상한은 검증기가 결정한다. 표마다 불변식을 코드로 적는다.

// src/validate.ts — 실패하면 exit 1
const dup = PORTS.map(p => p.id).filter((v, i, a) => a.indexOf(v) !== i)   // 1. ID 유일성
// 2. 좌표 충돌  3. 해로 연결성(시작 항구에서 BFS 도달)  4. 발견물 도달 가능성
// 5. 범위 검사(basePrice > 0)  6. 진행 가능성(초기 자금으로 흑자 경로가 최소 1개)
const reach = bfs(START_PORT, routes)
for (const p of PORTS) if (!reach.has(p.id)) fail('unreachable-port', p.id)

확인 — 일부러 항구 하나를 육지 안쪽으로 옮기고 unreachable-port 가 뜨는지 본다. 안 뜨면 검증기가 거짓말을 하고 있다. 모든 규칙은 최소 한 번 실패시켜 본 적이 있어야 한다.

함정 — 새 표를 추가하면서 불변식을 안 쓰는 것. 표 추가 PR 에는 반드시 validate 규칙이 함께 온다.


05. 시드 고정 RNG

재현 불가능한 시뮬레이션은 디버깅이 불가능하다. Math.random() 을 제거하고 주입 가능한 PRNG 로 바꾼 뒤 게임 상태가 rng 를 들고 다니게 한다 — 전역 싱글턴은 병렬 시행에서 깨진다.

확인rg -n 'Math\.random' src/ 가 0건, assert(hash(run(42)) === hash(run(42))).

함정 — 시스템별로 스트림을 나누면(항해용/시세용/이벤트용) 한쪽을 고쳐도 다른 쪽 난수열이 안 밀려 비교가 깔끔해진다. 나중에 하면 아프다.


06. 헤드리스 시뮬레이터

UI 없이 게임 루프를 N턴 돌리는 진입점. 창발적 문제는 코드를 읽어서 안 나온다 — 수십 년치를 돌려야 나오는 종류가 있다.

for (let t = 0; t < turns && !state.over; t++) {
  const actions = legalActions(state)
  const chosen = bot(state, actions, rng)
  state = advanceTurn(applyAction(state, chosen, rng), rng)
  trace.push({ turn: t, gold: state.gold, action: chosen.kind, port: state.currentPort,
               choiceCount: actions.filter(a => isMeaningful(state, a)).length })
}

확인 — 루프 한 사이클(표 수정 → validate → sim → 지표 diff)이 몇 분 안에 끝나야 한다. 이 시간이 개발 속도의 실질 상한이다.

함정legalActionsapplyAction 을 UI 에서 분리해두지 않으면 시뮬레이터를 못 만든다. Shape 설계 단계에서 이 둘을 순수 함수로 잡아둔다.


07. 봇 3종 — 상한과 하한을 같이 잰다

단일 봇 결과는 해석이 불가능하다.

구현 판정
Random 합법 행동 중 균등 추출 여기서 안 죽으면 긴장감이 없다
Greedy 1스텝 기대이익 최대 여기서 이기면 전략 깊이가 없다
Optimizer 빔서치 / 짧은 룩어헤드 지배 전략 탐색용

확인 — 셋의 결과가 뚜렷이 갈려야 한다. Random ≈ Greedy 면 플레이어 선택이 결과에 영향을 못 주고, Greedy ≈ Optimizer 면 앞을 내다볼 이유가 없다. 둘 다 게임이 죽어 있다는 뜻이다.

함정 — Optimizer 를 너무 강하게 만들면 게임이 아니라 봇을 튜닝하게 된다. 룩어헤드 3~5턴이면 충분하다.


08. 지표 — 선택지 폭 (샌드박스의 사망 지표)

매 턴 열려 있는 유의미한 행동 수. 최선 대비 70% 이상 나오는 행동의 개수를 센다.

const isMeaningful = (s, a) =>
  estimateGain(s, a) > Math.max(...legalActions(s).map(x => estimateGain(s, x))) * TUNING.meaningfulRatio

전 구간에서 이 값이 3 아래로 내려가면 그 시점부터 플레이어는 관객이다.

확인 — 3개 시드에서 곡선이 비슷하면 구조적 문제, 제각각이면 초기 조건 문제.

함정estimateGain 이 부정확하면 지표 전체가 거짓말이 된다. Greedy 봇과 공유하므로 봇이 멍청하면 지표도 멍청하다.


09. 지표 — 지배 전략 고착 시점

“결국 리스본↔세비야 왕복만 하게 된다.” Greedy 봇의 action:port 분포 엔트로피를 100턴 슬라이딩 윈도로 재고 H < 1.0 bit 로 떨어진 턴을 찾는다.

확인 — 총 턴 수의 20% 지점에서 나오면 게임의 80%가 반복 작업이다.

함정 — 고착 자체가 나쁜 게 아니다. 문제는 고착 이후의 콘텐츠 소진율이다. 발견물 60% 본 상태에서 고착되면 나머지 40%는 아무도 안 본다. 두 지표를 같이 본다.


10. 지표 — 무한 차익 검출

그래프의 양의 비율 사이클 탐지. 간선 가중치를 -log(왕복 수익률) 로 두면 음수 사이클 문제가 되고 Bellman-Ford 로 푼다. bestTradeRatio 에 항해 일수·보급 소모·위험도를 전부 반영하지 않으면 실제로는 손해인 루트가 차익으로 잡힌다.

확인 — validate 에 넣는다. 항구·교역품을 추가할 때마다 자동으로 걸린다. 이것이 콘텐츠 대량 생산의 안전장치다.

함정 — 완전 제거가 목표가 아니다. 어느 정도의 우위 루트는 플레이어가 찾아내는 재미다. 임계를 둔다(예: 왕복 수익률 1.5배 초과만 실패).


11. 회귀 기준선 — 지표 diff

코드가 아니라 결과를 버전 관리한다. 20개 시드 중앙값으로 metrics/baseline.json 을 커밋하고 비교한다.

⚠ lockInTurn: 1840 → 620 (-66.3%) ← 의도한 변경인가? 처럼 15% 넘는 변화만 표시한다.

확인 — 튜닝 값 하나를 명백히 나쁜 방향으로 바꿔보고 diff 에 잡히는지 본다.

함정 — 단일 시드로 기준선을 잡으면 분산이 변화로 오인된다. 그리고 baseline 갱신은 의도적으로만 — 자동 갱신하면 서서히 나빠지는 걸 못 본다.


12. Shape 스파이크

표는 다 채웠는데 재미가 없고 튜닝으로 안 되는 것 — 가장 비싼 실패 모드다. Shape 는 표로 못 고치므로 콘텐츠를 채우기 전에 확인한다.

규격: 항구 2개 / 교역품 3종 / 발견물 5개 / UI 없음, sim 만 / 24시간 안에 폐기 가능할 것. 여기서 볼 것은 재미가 아니라 곡선의 모양이다 — 선택지 폭이 0으로 수렴하지 않는가, 자금 곡선이 지수가 아닌가, 세 봇이 갈리는가.

확인 — 항구 2개로 지루하면 40개여도 지루하다. 통과 못 하면 콘텐츠를 채우지 않는다.

함정 — 스파이크를 본 프로젝트에 병합하고 싶어진다. 버릴 수 없는 프로토타입은 프로토타입이 아니라 레거시다.


13. 콘텐츠 대량 생산

완료 조건을 검증기 통과로 건다.

❌ "항구 40개 추가해줘"
✅ "항구 40개 추가하고 npm run validate 를 통과시켜. 실패하면 고쳐서 다시 돌려."

작업 단위도 바꾼다 — 기능 단위가 아니라 “표 하나 + 불변식 + 지표 하나”. 기능 단위로 쪼개면 LLM 은 코드를 만들고, 데이터 단위로 쪼개면 표를 만든다. CLAUDE.md 에는 숫자 리터럴 금지·validate 통과 의무·파생값 저장 금지·docs/shape.md 항목은 먼저 물어보기를 넣는다.

함정그냥 두면 숫자를 코드에 박는다. 이게 가장 흔하고 조용한 붕괴 경로다 — 산출물은 늘어나는데 조정 가능한 상태로 남는 게 없고, “진도가 안 나가는” 느낌의 실체가 대개 이것이다. 레시피 02의 린트가 유일하게 믿을 만한 방어선이다.


#585raw