25강결정론적 시뮬레이션
같은 입력이면 같은 결과 — 리플레이와 롤백의 뿌리.
- 결정론이 무엇이고 왜 깨지는지 안다.
- 입력만 기록해 리플레이를 구현한다.
- 리플레이와 롤백 넷코드가 왜 같은 뿌리인지 이해한다.
결정론이란#
같은 초기 상태에서 같은 입력을 넣으면, 언제 어디서 몇 번을 돌려도 반드시 같은 결과가 나온다.
이게 보장되면 놀라운 일들이 가능해집니다.
결정론이 주는 것
| 기능 | 어떻게 가능해지나 |
|---|---|
| 리플레이 | 입력만 저장하면 전체 경기를 재생 |
| 롤백 넷코드 | 과거로 되감아 다시 계산 가능 |
| 입력 동기화 방식 | 위치 대신 입력만 보내면 됨 (RTS가 이 방식) |
| 버그 재현 | "이 입력 시퀀스에서 터짐"으로 정확히 재현 |
| 치팅 탐지 | 서버가 같은 입력으로 돌려 결과 비교 |
같은 재료, 같은 순서, 같은 시간으로 요리하면 같은 음식이 나옵니다. 그렇다면 음식 사진을 찍어 보낼 필요 없이 레시피만 보내면 됩니다.
RTS 게임에서 유닛 200기의 위치를 전부 보내는 대신 "플레이어가 이 유닛들에게 저기로 가라고 명령했다"만 보내는 이유입니다.
결정론이 깨지는 이유#
아무 생각 없이 만든 게임은 거의 확실히 비결정적입니다.
1. 부동소수점 (가장 악명 높은 원인)#
var a := 0.1 + 0.2
print(a == 0.3) # false!부동소수점은 근삿값입니다. 그리고 더 나쁜 건, CPU와 컴파일러에 따라 결과가 미세하게 다를 수 있다는 점입니다.
- x86과 ARM의 초월함수(
sin,cos,sqrt) 구현이 다를 수 있음 - 컴파일러 최적화 수준에 따라 중간 계산 정밀도가 달라짐
- 웹(WASM)과 네이티브가 다를 수 있음
0.0000001의 차이가 100프레임 뒤에는 캐릭터가 다른 방에 있게 만듭니다. 이걸 나비 효과(divergence) 라고 부릅니다.
해결책 — 고정소수점
정수로 소수를 흉내 내는 방식입니다. 1을 65536으로 표현하면 소수점 아래 16비트를 씁니다.
# 아주 단순화한 예 (실제로는 훨씬 정교해야 함)
const SCALE := 65536
func fx_mul(a: int, b: int) -> int:
return (a * b) / SCALE
func fx_from_float(v: float) -> int:
return int(v * SCALE)정수 연산은 모든 CPU에서 완전히 동일합니다. 격투 게임의 롤백 넷코드, RTS의 입력 동기화가 이 방식을 씁니다.
단점 — 모든 계산을 직접 만들어야 하고(물리 엔진도!), 코드가 읽기 어려워집니다. 그래서 정말 필요한 게임에서만 씁니다.
2. 난수#
randf() # 매번 다름 → 비결정적난수 생성기에 시드(seed) 를 주면 같은 순서의 "무작위" 값이 나옵니다.
var rng := RandomNumberGenerator.new()
func start_match(seed_value: int) -> void:
rng.seed = seed_value # 모두가 같은 시드를 공유
rng.state = 0
func roll() -> int:
return rng.randi_range(1, 6)중요 — 호출 순서와 횟수까지 같아야 합니다.
한쪽에서만 연출용으로 rng.randf() 를 한 번 더 부르면 그 뒤가 전부 어긋납니다.
해결 — 게임 로직용 rng와 연출용 rng를 완전히 분리하세요.
3. 딕셔너리 순회 순서#
for id in enemies: # 순서가 보장되나?
enemies[id].update()Godot의 Dictionary는 삽입 순서를 유지하지만, 삽입 순서 자체가
네트워크 패킷 도착 순서에 따라 달라질 수 있습니다.
해결 — 정렬된 배열로 순회하세요.
var ids := enemies.keys()
ids.sort()
for id in ids:
enemies[id].update()4. delta 값#
position += velocity * delta # delta가 매번 다르면 결과도 다름해결 — 결정론이 필요한 계산은 반드시 _physics_process 에서.
delta가 고정이므로(8강) 안전합니다.
5. 물리 엔진#
Godot의 물리 엔진(Jolt/Godot Physics)은 결정론을 보장하지 않습니다. 멀티스레딩과 부동소수점 때문입니다.
완전한 결정론이 필요하면 물리를 직접 만들어야 합니다. 대부분의 게임은 여기까지 안 갑니다.
결정론 등급#
등급 0 — 필요 없음 싱글 게임, 협동 게임. 서버 권위만으로 충분.
등급 1 — 느슨한 결정론 같은 기기·같은 빌드에서만 재현되면 됨. → 리플레이 저장에 충분.
등급 2 — 크로스 플랫폼 결정론 PC와 모바일이 같은 결과를 내야 함. → 고정소수점 필수. 롤백 넷코드, RTS 입력 동기화.
대부분의 게임은 등급 1이면 충분합니다. 등급 2는 비용이 매우 큽니다.
실습 — 리플레이 만들기#
입력만 저장해서 플레이를 그대로 재생합니다.
# res://autoload/replay.gd
extends Node
enum Mode { OFF, RECORD, PLAYBACK }
var mode: Mode = Mode.OFF
var frame := 0
var seed_value := 0
var _frames: Array = [] # [{f: 프레임, dir: Vector2, act: 비트}]
var _play_index := 0
# ── 기록 시작 ─────────────────
func start_record() -> void:
mode = Mode.RECORD
frame = 0
_frames.clear()
seed_value = randi()
_apply_seed()
# ── 매 물리 프레임 호출 ────────────
func capture(dir: Vector2, actions: int) -> void:
if mode != Mode.RECORD:
return
# 값이 바뀔 때만 기록한다 (용량 절약)
if _frames.is_empty() \
or _frames[-1].dir != dir \
or _frames[-1].act != actions:
_frames.append({
"f": frame, "dir": dir, "act": actions,
})
# ── 재생 시 이번 프레임의 입력 ─────────
func get_input() -> Dictionary:
if mode != Mode.PLAYBACK:
return {}
while _play_index + 1 < _frames.size() \
and _frames[_play_index + 1].f <= frame:
_play_index += 1
if _frames.is_empty():
return {"dir": Vector2.ZERO, "act": 0}
return _frames[_play_index]
func tick() -> void:
frame += 1
# ── 저장 / 불러오기 ──────────────
func save(path := "user://replay.json") -> void:
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string(JSON.stringify({
"version": 1,
"seed": seed_value,
"length": frame,
"frames": _frames.map(func(x): return {
"f": x.f,
"dx": x.dir.x, "dy": x.dir.y,
"a": x.act,
}),
}))
func load_replay(path := "user://replay.json") -> bool:
if not FileAccess.file_exists(path):
return false
var f := FileAccess.open(path, FileAccess.READ)
var d = JSON.parse_string(f.get_as_text())
if typeof(d) != TYPE_DICTIONARY:
return false
seed_value = int(d.seed)
_frames = (d.frames as Array).map(func(x): return {
"f": int(x.f),
"dir": Vector2(x.dx, x.dy),
"act": int(x.a),
})
_play_index = 0
frame = 0
mode = Mode.PLAYBACK
_apply_seed()
return true
func _apply_seed() -> void:
Game.rng.seed = seed_value
Game.rng.state = 0플레이어 쪽에서는 입력을 한 곳에서만 읽게 만듭니다.
extends CharacterBody2D
const ACT_ATTACK := 1
const ACT_JUMP := 2
func _physics_process(_delta: float) -> void:
var dir: Vector2
var act: int
if Replay.mode == Replay.Mode.PLAYBACK:
var r := Replay.get_input()
dir = r.get("dir", Vector2.ZERO)
act = r.get("act", 0)
else:
dir = Input.get_vector("move_left", "move_right",
"move_up", "move_down")
act = 0
if Input.is_action_pressed("attack"):
act |= ACT_ATTACK
if Input.is_action_pressed("jump"):
act |= ACT_JUMP
Replay.capture(dir, act)
_simulate(dir, act)
Replay.tick()
# 입력만 받아서 상태를 바꾸는 순수한 함수
func _simulate(dir: Vector2, act: int) -> void:
velocity = dir * 300.0
move_and_slide()
if act & ACT_ATTACK:
_attack()입력을 읽는 지점이 딱 한 군데여야 합니다.
코드 여기저기서 Input.is_action_pressed() 를 부르면 리플레이가 불가능합니다.
"입력 수집 → 시뮬레이션" 두 단계로 나누는 것이 전부입니다.
그리고 이 구조를 만들어두면 24강의 클라이언트 예측도 그대로 됩니다.
_simulate() 를 다시 부르기만 하면 되니까요.
- 30초쯤 플레이하고 리플레이를 저장하세요.
- 게임을 재시작하고 리플레이를 재생하세요. 똑같이 재생됩니까?
- 적을 배치하고 다시 해보세요. 적의 움직임까지 같습니까?
(적이
randf()를 쓰면 어긋납니다 → 시드 고정 rng로 바꾸세요) - 일부러
_simulate안에randf()를 하나 넣고 다시 재생해 보세요. 몇 초 만에 어긋나는지 관찰하세요. - 리플레이 파일 크기를 확인하세요. 30초 플레이가 몇 KB입니까? (위치를 매 프레임 저장했다면 몇 백 KB, 입력만 저장하면 몇 KB입니다)
리플레이에서 롤백까지#
격투 게임에서 쓰는 최고 수준의 넷코드입니다. 원리는 리플레이와 똑같습니다.
- 상대 입력이 아직 안 왔다 → "직전과 같겠지" 라고 가정하고 진행 (예측)
- 실제 입력이 도착했다 → 내 가정과 비교
- 다르면 → 그 시점으로 상태를 되감고, 올바른 입력으로 다시 계산 (롤백)
- 이 재계산은 한 프레임 안에 끝난다 (7~10프레임을 순식간에)
필요한 것 세 가지
- 결정론적 시뮬레이션 (같은 입력 = 같은 결과)
- 상태를 저장하고 복원하는 기능 (스냅샷)
- 빠른 시뮬레이션 (한 프레임에 10번 돌려도 될 만큼)
24강의 "예측 + 재시뮬레이션"과 구조가 같습니다. 차이는 되감는 대상이 나인가 상대인가 뿐입니다.
왜 격투 게임은 롤백을 쓰는가
격투 게임은 1프레임(16ms) 차이가 승패를 가릅니다. 지연 보상(24강)으로 "과거를 되감아 판정"하면 반응이 느려집니다.
롤백은 입력 지연을 0으로 유지하면서, 어긋나면 조용히 고칩니다. 플레이어는 가끔 상대 캐릭터가 순간이동하는 것만 봅니다. 내 조작은 절대 안 느려집니다.
이 방식이 확산된 뒤 온라인 격투 게임의 체감이 완전히 달라졌습니다. GGPO라는 라이브러리가 이 기법을 대중화했습니다.
스냅샷 — 상태 저장과 복원#
롤백을 하려면 상태를 통째로 저장할 수 있어야 합니다.
func snapshot() -> Dictionary:
return {
"pos": global_position,
"vel": velocity,
"hp": hp,
"state": state, # 16강의 상태 머신
"state_time": _state_time,
"cooldown": _cooldown,
}
func restore(s: Dictionary) -> void:
global_position = s.pos
velocity = s.vel
hp = s.hp
state = s.state
_state_time = s.state_time
_cooldown = s.cooldown- 애니메이션 재생 위치
- 타이머의 남은 시간
- 파티클 상태
- 난수 생성기의 state ← 가장 많이 빠뜨립니다
하나라도 빠지면 되감기 후 결과가 달라집니다. 그래서 실무에서는 "게임 상태"를 하나의 구조체에 모아두고 통째로 복사합니다. 객체 여기저기 흩어져 있으면 관리가 불가능합니다.
- 개발자 — 결정론은 나중에 추가할 수 없습니다. 입력 수집과 시뮬레이션을 분리하는 구조는 처음부터 잡아야 합니다. 다만 고정소수점까지 갈지는 게임이 정합니다.
- 기획자 — 리플레이 기능은 생각보다 가치가 큽니다. 버그 재현, 밸런스 분석, 플레이 영상 공유, 관전 기능이 전부 여기서 나옵니다. "리플레이를 넣을 것인가"는 초기에 결정해야 하는 항목입니다.
- 디자이너 — 연출용 난수(파티클 방향, 피격 이펙트 위치)는 게임 로직 난수와 반드시 분리되어야 합니다. 이걸 섞으면 이펙트 하나 추가했다가 온라인 게임이 어긋납니다.
"리플레이가 처음엔 맞다가 점점 어긋난다"
→ 나비 효과입니다. 어딘가에 비결정적 요소가 있습니다.
randf(), Time.get_ticks_msec(), _process의 delta를 의심하세요.
"리플레이가 아예 안 맞는다" → 초기 상태가 다릅니다. 시드와 시작 위치를 확인하세요.
"입력을 매 프레임 저장했더니 파일이 너무 크다" → 바뀔 때만 저장하세요. 사람의 입력은 대부분 여러 프레임 동안 유지됩니다.
"물리 엔진 때문에 결정론이 안 된다" → 맞습니다. 완전한 결정론이 필요하면 물리를 직접 만들어야 합니다. 대부분의 게임은 등급 1(같은 기기 재현)로 충분합니다.
- 결정론이 무엇인지 설명할 수 있다
- 결정론을 깨는 5가지 원인을 안다
- 입력만 저장하는 리플레이를 만들었다
- 입력 수집과 시뮬레이션이 분리되어 있다
- 롤백이 리플레이와 같은 원리임을 안다
P6 정리 — 온라인 게임이 왜 어려운지, 그리고 그 어려움을 어떻게 감추는지 봤습니다.
다음 강의 — 마지막입니다. 만든 게임을 링크 하나로 공유합니다.