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