mapSeed 하나에서 Python 서버와 TypeScript 클라이언트가 비트 단위로 같은 초기 격자를
만드는 명세다. docs/netcode.md 의 matchInit.mapSeed 가 이 문서의 유일한 입력이다.
이 문서는 Phase 3의 결정론적 기준선이다. Phase 6에서 지형 종류를 늘릴 수 있지만,
규칙을 바꾸면 MAPGEN_VERSION과 골든 리플레이를 함께 갱신한다.
buildMap(mapSeed: uint32) -> MaterialGrid[960 * 540]
chooseSpawnCells(grid, playerCount: 2..6, spawnSeed: uint32 = 0) -> cellX[]
buildMap은 새 격자를 반환하고 전역MaterialGrid를 수정하지 않는다.- 생성 직후 호출자가 격자를 sim에 적재하고,
seed = mapSeed,step = 0, 전 행 활성으로 정착시킨다. 정착 후 연결성 재검사 루프까지 끝나야 초기 상태가 확정된다. - 스폰은 정착이 끝난 격자에서 선택한다. 생성 전 표면을 기준으로 잡으면 첫 정착 뒤 탱크가 공중에 남거나 서로 가까워질 수 있다.
- float,
Math.random(),random.*, 런타임 노이즈 라이브러리 금지 - 언어별 라이브러리 보간 함수 금지
set/dict순회 순서에 의존하는 배치 금지- 생성 직후 전체 격자를 네트워크로 보내는 정상 경로 금지
난수는 전부 terrain.md §7.1의 hash32를 좌표 해시로 사용한다.
상수:
| 이름 | 값 |
|---|---|
MAPGEN_VERSION |
3 |
NOISE_SHIFT |
7 |
SURFACE_BASE |
262 cells |
SURFACE_AMP |
70 cells |
BEDROCK_Y |
522 |
열 x의 지표면은 128셀 간격 정수 값노이즈로 만든다.
i = x >> 7
f = x & 127
a = hash32(mapSeed, i, 0x4D47, 0) & 0xFFFF
b = hash32(mapSeed, i + 1, 0x4D47, 0) & 0xFFFF
n = a + (((b - a) * f) >> 7)
y0 = 262 + (((n - 32768) * 70) >> 16) - mainPeak - secondaryPeak + valley
y0는 72..342로 clamp한다. 봉우리 단면은
max(0, width - abs(x - center)) * amplitude // width인 정수 삼각형이다.
주봉은 중심 240..720, 반폭 145..272, 높이 148..211이며, 반대쪽에 중심 약 190/770,
반폭 120..183, 높이 76..123의 보조 봉우리를 둔다. 두 중심 사이에는 반폭 110..173,
깊이 32..63의 골짜기를 더한다. 모두 hash32(mapSeed, index, 0x504B, 0)에서 파생한다.
모든 연산은 정수이며 지형 형상은 양 언어가 동일하게 생성한다.
각 열에서 y0부터 [SAND, SOIL, SAND, SCREE, SOIL] 순서로 채우고, 남는 깊이는 ROCK,
그 아래는 BEDROCK 이다. 두께는 열마다 다르다 — 맵을 좌우로 나눈 구역이 정한다.
왜 구역을 나누는가. Phase 3 기준선은 960열 전부가 같은 두께 (
18/28/10/12/40)였다. 그래서 실측으로 갈라놓은 안식각SAND 26.6°·SOIL 40.3°·SCREE 44.2°가 전술적으로 아무 일도 하지 않았다 — 어디를 파도 같은 순서로 같은 것이 나오니 서 있는 자리에 의미가 없었다.CLAUDE.md작업 원칙("무기를 늘리기 전에 지형을 완성한다")이 가리키는 자리다.
| # | 이름 | SAND |
SOIL |
SAND |
SCREE |
SOIL |
기반암 깊이 |
|---|---|---|---|---|---|---|---|
| 0 | 모래 분지 | 34 | 14 | 6 | 10 | 30 | — |
| 1 | 점토 대지 | 0 | 48 | 4 | 8 | 40 | — |
| 2 | 자갈 사면 | 0 | 0 | 0 | 34 | 30 | — |
| 3 | 암반 선반 | 6 | 12 | 0 | 6 | 16 | y0 + 132 |
기반암 깊이가 있는 구역은 BEDROCK_Y 대신 min(BEDROCK_Y, y0 + depth) 를 쓴다.
표면 재질이 구역마다 다르다. 앞 밴드를 0 으로 두어 아래 재질을 노출시킨다.
첫 판에서는 네 구역 모두 SAND 로 시작했는데, 그러면 화면상 전부 모래라 플레이어가
서 있는 땅을 구분할 수 없다 — 지질이 달라도 보이지 않으면 전술이 되지 않는다.
count = 4 + (hash32(mapSeed, 0, 0x9E07, 0) & 1) // 4 또는 5 구역
width = floorDiv(960, count)
index = floorDiv(x, width) // count 를 넘으면 count-1
구역 index 의 프로파일은 순열 provinceOrder(mapSeed)[index % 4] 다.
순열은 정수 Fisher-Yates 이고 hash32(mapSeed, i, 0x9E07, 1) 를 쓴다.
왜 순열인가. 처음에는 구역마다
hash32(...) % 4로 독립 추첨했는데, 그러면 시드에 따라 맵 전체가 한 지질로 덮인다 (실측: 모래 809열 / 960). 다양성이 이 변경의 전부라 우연에 맡기지 않는다. 순열은 한 맵 안에 네 지질이 전부 나오는 것을 보장한다.
구역 경계에서 지층이 수직으로 잘리지 않게 PROVINCE_BLEND = 48 셀에 걸쳐 섞는다.
구역 안 localX < 48 인 열은 이전 구역과 정수 가중 평균한다.
blend(a, b, t) = a + floorDiv((b - a) * t, 48) // t = localX
index == 0 인 첫 구역은 혼합하지 않는다.
v3에서는 각 열의 좌우 6셀 지표와의 최대 고도차 relief를 계산한다.
급경사가 한 지질 구역 전체를 덮으면 그 구역의 표면은 암반이 될 수 있다. 검증은 암반과 최소 두 종류의
느슨한 지표 재질이 함께 나타나는지를 확인하며, 네 지질 프로파일의 배치 순열은 그대로 유지한다.
relief <= 2이면 기존 지층 두께를 유지하고, relief == 3이면 각 두께를 1/4로 줄인다.
relief > 3인 급경사는 퇴적층 대신 파괴 가능한 ROCK을 노출한다.
높은 봉우리를 느슨한 흙으로만 만들면 시작 정착에 예산 대부분을 쓰므로 암반 코어가 형상을 지탱한다.
6개 교차 골든 시드에서 초기 정착은 4~17스텝이다. 자동자·안식각·정착 상한 자체는 바꾸지 않는다.
아래는 v2 지질 구역 도입 당시의 실측이다. 당시에는 지표 아래 두께만 바뀌었으므로
새로 노출되는 경사가 없다. 노출된 SOIL(40.3°)·SCREE(44.2°)도 지표 경사(최대 25°)보다
안식각이 급해서 그대로 선다.
| 초기 정착 | 초기 연결성 변환 | |
|---|---|---|
| Phase 3 기준선 (균일 지층) | 최악 9 스텝 | 0 셀 |
| 지질 구역 | 최악 9 스텝 | 0 셀 |
12 시드 실측이다. 상한은 MAX_SETTLE_STEPS = 12000 이므로 여유가 11,991 스텝 남는다.
createMatch 는 초기 정착이 상한을 넘으면 예외를 던져 매치가 아예 안 만들어지므로
이 여유를 확인하지 않고 지층을 바꾸면 안 된다.
파헤쳤을 때는 확실히 다르다. 같은 반경 30 크레이터의 붕괴 규모:
| 정착 스텝 | 이동 셀 | 붕괴 폭 | |
|---|---|---|---|
| 가장 약한 자리 | 408 | 5,988 | 82 셀 |
| 가장 무른 자리 | 2,076 | 32,244 | 152 셀 |
5배 차이다. 이게 "어디에 서 있느냐"가 의미를 갖는다는 뜻이다.
후반에 맵 전체가 평평해지는 것을 막기 위해 기반암 기둥 4개를 묻는다.
baseX = [160, 320, 640, 800][i] // i = 0..3
jitter = (hash32(mapSeed, i, 0xA11C, 0) & 31) - 15
x = clamp(baseX + jitter, 24, 935)
top = clamp(surface[x] + 96, 220, 500)
x-3 .. x+2, top .. 539를 BEDROCK으로 채운다. 최하단 기반암층과 연결되므로
연결성 검사의 영구 앵커가 된다.
기준점과 지터 범위는 아래 암반 아치의 최대 범위 x=345..615와 겹치지 않게 고정한다.
생성 순서는 지층 → 기반암 앵커 → 암반 아치 → 좌우 봉인이다.
모래 자동자의 구조 붕괴를 첫 맵부터 검증할 수 있도록 암반 아치 하나를 넣는다.
center = 480 + ((hash32(mapSeed, 0, 0xA2C4, 0) & 127) - 63)
left = center - 72
right = center + 72
roofY = max(surface[left..right]) + 24
- 지붕:
x = left..right,y = roofY..roofY+7을ROCK - 빈 공간:
x = left+8..right-8,y = roofY+8..roofY+56을EMPTY - 다리: 좌우 8열을
roofY..legBottom까지ROCK legBottom = max(roofY+64, surface[left]+112, surface[right]+112), 상한 510
다리는 아래 ROCK층에 닿고, 한쪽 다리만 끊으면 버티며 두 다리가 모두 끊기면 지붕이
SCREE로 변환되어 무너진다. BEDROCK으로 만들지 않는 이유는 파괴 가능해야 하기 때문이다.
terrain.md §1.1대로 최외곽 2열을 봉인한다.
- 왼쪽은
x=2, 오른쪽은x=957의 최초 비-EMPTY행을 기준으로 한다. x=0,1,958,959를 그 행부터 바닥까지BEDROCK으로 채운다.- 인덱스 래핑으로 이웃 열을 읽지 않는다.
호출 순서는 고정한다.
grid = buildMap(mapSeed)
terrain.seed = mapSeed
terrain.resetGateCache()
terrain.connectivity()
terrain.markAll()
terrain.step = 0
정착 → 연결성 재검사 → 필요하면 정착 재개
- 자동자 스텝 예산은
MAX_SETTLE_STEPS를 공유한다. - 연결성 재개 상한은
CONNECTIVITY_MAX_ROUNDS다. - 초기 정착이 강제 종료에 걸리면 그 맵은 생성 실패다. 정상 매치에 사용하지 않는다.
플레이어 수 n은 2~6이다. spawnSeed는 정상 매치에서 mapSeed다.
정착된 격자의 20..939열에서 실제 지면이 있고 이미 고른 위치와 36셀 이상 떨어진 후보를 고른다.
양끝(x±6)과 중앙의 높이 차 합이 12셀 이하인 후보만 먼저 검사하고, 없을 때만 경사 제한을 푼다.
서로 겹치지 않는 실제 지면이 부족하면 실패하며 임의의 공중 좌표를 반환하지 않는다.
hash32(spawnSeed, n, 0x5A17, 0) % 3으로 다음 배치 중 하나를 선택한다.
- 근접 군집: 시드로 정한 중심(
80..879)의 반경48 + n*24안을 선호한다. - 장거리 분산:
32 + slot*(960-64)//(n-1)의 ±64셀 안을 선호한다. - 전역 무작위: 위치에 대한 선호 구역이 없다.
구역 바깥 거리 penalty에 대해 점수는
penalty*65536 + (hash32(spawnSeed, x, slot, 0x5A17) & 65535)다.
최소 점수, 동점이면 작은 열을 고른다. 마지막에 시드 기반 Fisher–Yates로 슬롯 순서를 섞는다.
같은 격자와 시드는 같은 결과를 내지만 시드가 바뀌면 간격·순서 모두 달라진다.
스폰의 y는 ballistics.makeTank()가 정착된 지표면에서 계산한다.
v2는 x 오름차순 배치라 슬롯 0 이 왼쪽 끝을 받았다. v3는 첫 매치부터 순서를 섞되,
라운드 간에는 기존 회전 규칙도 유지한다. 지질 구역이 생기기 전에는 고정 배치여도 됐지만(960열이 같은 지층이었다)
이제는 자리마다 발밑이 다르다. 실측한 스폰 발밑 60셀 구성:
| 자리 | 구성 | 뜻 |
|---|---|---|
| 암반 선반 | BEDROCK 52 / SOIL 8 |
사실상 파괴 불가 |
| 모래 분지 | SAND 40 / SOIL 14 |
통째로 쓸려나간다 |
회전이 없으면 슬롯 번호가 5라운드 내내 고정 유불리가 된다. 그래서
match 의 beginRound(players, spawnCells, rotation) 이 rotation = roundNo - 1 로
슬롯과 자리를 어긋나게 돌린다.
player[i].x = spawnCells[(i + rotation mod n) mod n]
4인 · 5라운드에서 슬롯별 "단단한 땅" 누적 격차 실측:
| 최대 격차 | |
|---|---|
| 회전 없음 | 4,580‰ |
| 회전 있음 | 916‰ |
잔여 916‰ 은 5라운드를 4인으로 나누면 한 슬롯이 첫 라운드 자리를 두 번 받기 때문이다.
지질을 평탄화해서 공정하게 만드는 방법도 있다. 그러면 이 변경의 목적이 사라진다. 자리를 돌리는 쪽이 차이를 남기면서 매치 단위로 균등하게 만든다.
test_cross_sim_mapgen은 여러 mapSeed와 플레이어 수에 대해 아래를 대조한다.
- 정착 전 격자 체크섬
- 초기 정착 스텝 수와 정착 후 체크섬
- 질량
- 스폰 열 배열
- 좌우 2열 봉인과 기반암 불변식
초기 맵 골든은 mapSeed만 담고 .grid.gz 사이드카를 사용하지 않는다.