2026.05.31
어두운 아레나에서 손전등으로 앞을 비추며 페인트볼로 눈싸움하는, 최대 3인용 웹 FPS를 0에서 끝까지 만든 기록. 과정을 잊지 않고자 기록하는, 남이 읽기엔 불친절한 기록물.
한 번에 다 만들지 못했다. 그럴 수밖에 없었다. 처음엔 어떻게 만드는지를 모르는 상태에서 한 단계씩 빌드업했고, 그게 어느 정도 돌아가게 된 뒤에야 돌아가는 코드를 진짜 게임답게 다듬을 수 있었다.
두 단계는 사고방식이 다르다. 앞은 "이걸 어떻게 만들지?"의 연속이었고, 뒤는 "이걸 어떻게 더 견고하게 만들지?"의 연속이었다. 그래서 문서도 나눴다.
이제 시작.
서버 골격 → R3F 클라 → 네트워킹(예측 없이) → 보간 → 예측 → Rapier → 사격 → 게임 룰 → 시각 컨셉
각 단계는 앞 단계가 굴러가는 걸 눈으로 확인한 뒤에야 다음으로 넘어갔다. 게임 네트워킹은 한 번에 다 만들면 어디서 깨졌는지 못 찾는다. 이 "한 단계씩 검증" 원칙이 끝까지 갔다.
EC2에 NestJS 백엔드가 이미 떠 있었다. 처음엔 "Nest 리소스가 있으니 거기서 시작하자"였는데, 한 단계씩 따져보니 결론이 뒤집혔다.
교훈: "재활용할 리소스"가 Nest 코드베이스가 아니라 EC2 인스턴스라는 걸 깨닫자 답이 명확해졌다. 게임 서버는 tight loop라서 Nest의 DI·모듈 추상화가 오히려 방해된다.
애초에 만들고 싶었던 건, 어두운 아레나 + 손전등 + 눈싸움. 그런데 이게 사실 미니 프로젝트에 훨씬 유리한 선택이었다.
교훈: 컨셉이 곧 기술 난이도를 결정한다. 만들고 싶은 그림이 마침 더 단순한 구현으로 이어진 건 운이 좋았다.
빌드업 전에 머리에 넣어둔 표준 모델. Valve의 Source 엔진 모델이 사실상 표준이고, "Source Multiplayer Networking" 문서가 가장 깔끔한 정리.
이 네 가지가 왜 필요한지는 머리로 아는 것과 직접 끊기는 화면을 보는 것이 천지차이였다. 그래서 일부러 예측·보간을 끄고 시작해서 끔찍한 끊김을 먼저 봤다.
ws 패키지만으로 시작. 의존성은 ws + tsx(개발용) + 타입 정도.
Nest로 같은 걸 짜면 모듈·게이트웨이·데코레이터 합쳐 더 길어진다. 진입점이 30줄도 안 됐다.
모든 메시지를 { type: '...' , ... } 형태의 discriminated union으로 정의하고,
라우터에서 switch (msg.type) + default에 const _exhaustive: never = msg를 뒀다.
// 새 메시지 타입 추가 후 핸들러 빼먹으면 → 여기서 컴파일 에러
default: {
const _exhaustive: never = msg
}
교훈: 게임 서버는 메시지 종류가 폭발.. 수준이다(input, snapshot, hello, fire, hit, kill, ...). 문자열 비교로 분기하면 30분 만에 무너진다. never 패턴이 타입 추가 시 핸들러 누락을 컴파일 타임에 잡아주는 안전망이 됐다.
setInterval은 정확한 30Hz를 보장 안 한다.
다른 이벤트에 밀리면 틱이 늦는다. 그래서 밀린 만큼 따라잡는 self-correcting 루프를 썼다.
다만 한 번에 너무 많이 따라잡으면 위험하다. 예를들면,,
노트북을 덮었다 열면 한 시간이 밀려 있는데, 그걸 다 따라잡으려고 이벤트 루프를 막아버린다(death spiral).
MAX_CATCHUP(5틱) 상한을 두고 그 이상은 현재 시점으로 점프하게 했다.
교훈: 게임 루프는 시계가 튀는 상황(슬립/복귀)을 가정해야 한다.
입력을 "지금 누르고 있는 키 상태"(State, A)로 보낼지 "키 이벤트"(Event, B)로 보낼지. FPS는 거의 (A).
seq(시퀀스 번호)로 어디까지 처리했는지 추적 → reconciliation의 기반.함정: 입력 없는 틱은 정지로 처리된다. 그래서 클라이언트는 키를 안 눌러도 매 틱 입력을 보내야 한다.
R3F의 핵심 원칙.
게임은 매 프레임 위치가 바뀌는데, setState로 했다간 매 프레임 re-render → 컴포넌트 트리 전체 reconcile → 재앙.
// 안 됨: 매 프레임 re-render
const [pos, setPos] = useState(...)
useFrame(() => setPos(...))
// 맞음: ref + useFrame로 직접 mutate
const ref = useRef()
useFrame(() => { ref.current.position.x += ... })
교훈: 3D 씬 안의 모든 매 프레임 변화는 ref + useFrame로 직접 mutate. 누가 있냐 같은 드물게 바뀌는 것만 React state.
페이지를 하위 라우트로 만든 뒤 뒤로가기를 누르니 터졌다.
SecurityError: Pointer lock cannot be acquired immediately after the user has exited the lock.
브라우저는 PointerLock에서 빠져나간 직후(약 1.25초) 재잠금을 막는다!
컨트롤이 remount되며 이전 lock 상태를 복원하려다 발생.
치명적이진 않지만 콘솔이 더러워진다.
→ 그 종류의 unhandledrejection만 골라 preventDefault하는 핸들러로 무시.
Next dev의 React Strict Mode가 컴포넌트를 두 번 마운트한다. WebSocket 연결이 두 번 되면서, 본인이 자기 자신을 "남"으로 인식해 자기 박스가 빨갛게 보이는 버그가 났다.
근본 원인은 따로 있었다(이어지는 내용 참고...) 하지만 이 과정에서 배운 건: 연결 시작 전에 게임 store를 초기화하고, cleanup에서 확실히 정리하는 습관.
처음엔 props를 안 넘겨서인 줄 알았다. 진짜 원인은 welcome 도착 전에 첫 스냅샷이 처리되는 race.
myPlayerId는 null.수정: myPlayerId가 아직 없으면(welcome 전이면) 아무도 렌더하지 않는다.
if (!snap || !myId) { if (otherIds.length) setOtherIds([]); return }
교훈: 내 추측("props 문제 아닐까")은 증상은 맞게 짚었지만 원인은 타이밍이었다. 비동기 메시지 순서를 항상 의심하자.
가장 비직관적인 핵심. 스냅샷이 66ms마다 오니까 도착하자마자 그 위치로 점프하면 뚝뚝 끊긴다. 해결은 렌더링을 고의로 100ms 늦춰서 항상 두 스냅샷 사이를 보간.
100ms를 고른 이유: 스냅샷 간격 66ms의 약 1.5배. 너무 크면(500ms) 남의 행동이 반 초 늦게 보인다.
yaw가 −π에서 π로 넘어가는 순간 일반 lerp를 쓰면 반대 방향으로 한 바퀴 돈다. 짧은 경로를 택하는 각도 보간이 필요하다.
교훈: 본인은 보간하지 않는다. 본인까지 100ms 늦추면 자기 입력에 100ms 지연 반응하게 됨. 본인은 예측(다음 단계), 남은 보간.
가장 까다로운 단계. 클라이언트가 입력 즉시 자기 시뮬을 돌리고, 서버 스냅샷이 오면 그 시점 이후 입력들을 재시뮬한다.
예측과 재시뮬이 일치하려면 클라이언트와 서버가 같은 시뮬 코드여야 한다!
stepPlayer를 양쪽에 복사했다. 그리고:
yaw 좌표계 함정 초반에 서버 회전식을 three.js 카메라 yaw와 안 맞게 짰다. 프론트 붙기 전엔 테스트가 yaw=0만 보내서 안 드러났다. 교훈: 좌표계 검증은 yaw=0뿐 아니라 yaw=π/2도 같이 해야 한다.
"예측 ON/OFF"를 P 키로 토글하게 만들었다. 끄면 서버 권위만으로 끊기는 상태(Step 9), 켜면 부드러움. 토글하며 예측과 서버 결과가 진짜 일치하는지 검증했다. 이 토글이 디버깅 과정에서 정말 유용했다.
P키로 예측을 켜보니 마우스 시점은 부드러운데 WASD 이동만 덜컥거렸다. 원인: 시뮬은 30Hz로만 위치를 갱신하는데 화면은 60fps!!
해결: 매 시뮬 틱의 직전 위치를 저장하고,
매 렌더 프레임에 직전→현재 사이를 보간(getRenderPosition). 다음 틱까지 남은 시간 비율로 lerp.
교훈: "렌더 빈도와 시뮬 빈도는 다르고, 그 둘을 잇는 게 보간".
Glenn Fiedler의 "Fix Your Timestep!"이 이 주제의 표준 글.
클라/서버 양쪽에 Rapier를 도입해 충돌·점프·중력을 넣었다. 서브스텝으로 나눠 진행: 서버 도입 → 클라 도입(예측 일치) → 점프/중력 → 박스/닉네임 마무리.
가장 자주 틀린 실수. 박스 위로 점프가 안 올라가져서 한참 봤더니, 시각 박스만 줄이고 물리 콜라이더는 그대로 2m 높이였다.
ColliderDesc.cuboid(1, 1, 1) → 전체 (2, 2, 2)<Box args={[2, 2, 2]}> → 전체 (2, 2, 2)콜라이더 반 크기 N을 정했으면 시각 박스 args는 2N, 위치는 바닥에 붙이려면 중심을 N만큼 올린다.
캐릭터는 kinematicPositionBased body + KinematicCharacterController.
동역학(dynamic) body로 만들면 마찰·미끄러짐·회전이 골치 아프다.
키네마틱은 위치를 코드로 정밀 제어한다.
computeColliderMovement(collider, desiredDelta) → 충돌 고려한 실제 이동 계산(슬라이딩·경사·계단 자동)setNextKinematicTranslation → 다음 world.step()에 반영. setTranslation(즉시)과 헷갈리지 말 것.중력은 자동이 아니다 World에 중력 벡터를 줘도 kinematic body는 중력 영향을 안 받는다. 이건 dynamic만 받는다. vy(수직 속도)를 매 틱 직접 적분해야(계산해줘야..) 한다.
플레이어끼리 부딪히지 않고 통과하게 만들려고 캡슐을 setSensor(true)로 했는데 여전히 막혔다.
캐릭터 컨트롤러는 물리 시뮬과 별개 알고리즘이라 sensor도 기본적으로 장애물로 본다.
computeColliderMovement에 QueryFilterFlags.EXCLUDE_SENSORS를 함께 줘야 풀린다.
controller.computeColliderMovement(collider, delta, RAPIER.QueryFilterFlags.EXCLUDE_SENSORS)
교훈: sensor 플래그와 EXCLUDE_SENSORS 필터는 한 묶음으로 동작한다.
사망 시 setNextKinematicTranslation으로 새 위치를 줬는데 그 자리에 그대로 다시 생겼다.
setNext는 다음 step에 반영되는데, 그 사이 stepPlayer가 옛 위치 기준으로 계산해 거의 안 움직였다.
→ 즉시 반영되는 setTranslation(pos, true)로 교체~!
직선이면 "느린 총알" 느낌. 페인트볼의 본질은 중력 받는 투사체. 이미 중력 시스템이 있으니 vy 적분 코드를 그대로 재활용했다. 초기 속도 30m/s + 중력 −9.8이면 가까운 거리는 거의 직선, 멀수록 떨어져서 조준의 재미가 생긴다.
투사체가 빠르면(30m/s × 1/30s = 1m/틱) 한 틱에 벽을 통과해버린다?(터널링).
그래서 매 틱 직전 위치 → 현재 위치 선분을 raycast로 검사했다.
world.castRay(ray, maxToi=거리).
페인트볼이 자기 캡슐 안에서 출발하니 발사 즉시 자기 사망이 될 수 있다.
raycast 필터로 발사자 콜라이더를 제외했다(filterExcludeCollider 또는 predicate 콜백).
페인트볼 64개를 InstancedMesh(한 draw call)로 그렸다.
그런데 외곽 방향으로 쏘면 본인 화면에서만 안 보였다.
원인: InstancedMesh의 boundingSphere는 원본 geometry 기준(원점의 작은 구)이라, 카메라가 원점을 안 보면 InstancedMesh 전체가 frustum culling되어 통째로 안 그려진다(!) 실제 인스턴스는 눈앞에 있는데도. 이거 찾는데 힘들었따...
→ frustumCulled={false} 한 줄로 해결
교훈: 일반 mesh는 자동으로 잘 되는데 InstancedMesh는 위치가 인스턴스 행렬에 들어있어 부모 bounding이 안 맞는다. 잘 알려진 함정이라는데,, 처음 겪으면 한참 헤맨다.
"어두운 방향으로 쏘면 안 보인다"고 처음 진단했지만, 실은 위 culling이 어두운 외곽과 자주 겹쳐서 그렇게 보였던 것. material을 meshBasicMaterial(빛 무시)로 바꾼 건 손전등 컨셉상 옳은 선택이었지만, 진짜 원인은 culling이었다.
교훈: 증상의 표면(어둠)과 실제 원인(culling)을 혼동하지 말 것. 조건을 하나씩 격리해야 한다.
점수판에서 터졌다. 이건 그냥 프론트쪽 단순한 문제였는데...
React has detected a change in the order of Hooks called by ScoreboardOverlay.
원인: if (!ended) return null 뒤에 조건부 useEffect를 호출함.
교훈: 이런건 프론트로써 기본 아닌가? 휴 침착하자
체력·시간 표시를 매 프레임 갱신하면 re-render 폭주. 250ms throttle(4Hz)로 충분하다. RAF 폴링으로 gameState를 읽어 변환.
씬을 거의 검정으로 낮추고(ambient 0.02~0.05, 약한 fill), 카메라에 SpotLight를 부착했다.
다른 사람 손전은 보이는데 본인 손전등만 안 보였다. 두 가지가 겹친 현상:
→ 손전등 target의 y를 음수로 해서 살짝 아래로 비추게 했다(target.position.set(0, -0.5, -1)).
발 앞 바닥이 환해지며 손전 효과가 명확해졌다. + intensity·distance·angle 강화.
교훈: 빛을 받는 표면이 있어야 보인다. 광원만 켰다고 보이는 게 아니다.
다른 사람 손전이 항상 바닥으로만 향했다. SpotLight target을 group 자식으로 두니 target의 worldMatrix 갱신 타이밍 문제로 회전이 누락됐다.
→ target을 씬에 직접 추가하고, useFrame에서 플레이어 yaw를 forward 벡터로 변환해 씬 절대 좌표로 매 프레임 직접 설정. 회전 누락이 사라졌다.
const forwardX = -Math.sin(yaw) // -Z가 forward
const forwardZ = -Math.cos(yaw)
target.position.set(x + forwardX, headY - 0.3, z + forwardZ)
getRenderPosition).Math.random() 닉네임이 SSR/CSR에서 달라짐 → 게임 페이지를 dynamic + ssr:false로 통째 클라 전용.frustumCulled={false}.EXCLUDE_SENSORS 같이 필요.여기까지가 "혼자 새로고침해서 노는 수준". 이걸 친구와 인터넷으로 노는 수준으로 끌어올린 게 2편으로 이어짐