---
type: note
kind: Recipe
title: 표 중심 개발 — 게임 밸런스를 코드가 아니라 데이터로
description: 숫자를 Shape/Knob/Content 로 가르고, 검증기·헤드리스 시뮬·지표 기준선으로 튜닝 루프를 닫는 절차.
tags: ['game-dev', 'data-driven', 'simulation', 'llm', 'validation']
status: release
ctime: 2026-09-01
mtime: 2026-09-02
generated: { by: claude/opus-5, at: 2026-09-01T00:00:00Z }
---

**언제** — 밸런스를 바꾸려는데 어디를 고쳐야 할지 몰라 매번 코드를 만질 때. 또는 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. 튜닝 가능 표면 파악

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

```bash
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 상대로 이틀을 못 간다.**

```ts
// 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
```

```js
// 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 로, 런타임은 인덱스로.

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

```ts
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. 검증기 — 표 확장 속도의 상한

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

```ts
// 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턴 돌리는 진입점. **창발적 문제는 코드를 읽어서 안 나온다** — 수십 년치를 돌려야 나오는 종류가 있다.

```ts
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)이 **몇 분 안에** 끝나야 한다. 이 시간이 개발 속도의 실질 상한이다.

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

---

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

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

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

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

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

---

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

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

```ts
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의 린트가 유일하게 믿을 만한 방어선이다.

---
