울면서개발하기
VARENA 개발일지

하루 만에 만든 브라우저 FPS — 서버 권위 넷코드부터 팀전·캐릭터·산탄총까지

· 커밋 111개

VARENA 를 처음 만든 날이다. 아침 8시 반에 규칙 문서와 포트 스크립트만 있는 빈 저장소로 시작했고, 9시에 서버 권위 방식으로 도는 첫 대전 버전이 나왔다. 오전에 공개 준비를 마쳐 베타로 열었고, 그 뒤로는 밤까지 기능을 하나씩 얹었다.

일은 기능마다 브랜치를 따로 따서 만들고, 검토(리뷰)와 테스트를 거쳐 main 에 합치는 방식으로 했다. 그래서 커밋 수가 많다. 오전에는 UI 모양을 다른 AI 도구가 맡아 병렬로 다듬었고, 오후에 그 도구는 테스트와 브라우저 확인만 하는 독립 테스터로 역할을 바꿨다.

한 일

첫 버전: 서버 권위 온라인 FPS

  • 구조는 세 덩어리다. three.js 클라이언트, Node.js + ws 게임 서버, 그리고 둘이 같이 import 하는 공용 코드(통신 규약·상수·이동 계산·맵·광선 판정)
  • 서버가 초당 30번(30Hz) 틱을 돌며 판정하고 스냅샷을 보낸다. 클라이언트는 입력만 보낸다
  • 내 캐릭터는 클라이언트가 초당 60번 입력을 만들면서 먼저 움직이고(예측), 서버 결과가 오면 맞춘다(보정)
  • 다른 플레이어는 100ms 전 상태를 스냅샷 사이에서 보간해 부드럽게 보여 준다
  • 총은 히트스캔이고, 서버가 쏜 사람이 보던 시점으로 상대 위치를 되감아 판정한다
  • 메시지 형식 검사, 메시지 속도 제한, 입장 시간 제한 같은 기본 방어도 첫 버전에 같이 넣었다
  • 테스트 27개로 시작했다

공개

  • WebSocket 연결에 Origin 검사를 넣어 허용한 페이지에서만 게임 서버에 붙을 수 있게 했다
  • 운영 이미지는 Node 서버 하나가 정적 파일과 WebSocket 을 같이 내주는 컨테이너 하나다. 이걸로 varena.victory.sarl 에 베타로 공개했다

화면(UI) 다듬기

입장 화면, HUD, 점수판, 킬 로그(헤드샷 강조 아이콘), 조준점과 명중 표시 구분, 소리 꺼짐 표시, 설정 화면, 무기 표시를 차례로 다듬었다. 겹치고 가려지는 문제가 여럿 나왔다.

  • 접속 상태 문구가 입장 화면에 가려짐, 점수 칸과 킬 로그 겹침, 입장 화면 뒤로 빈 HUD 가 비침, 좁은 화면에서 점수판이 넘침 — 모두 고쳤다
  • 입장 화면을 띄우자마자 연결하던 것을 입장 버튼을 누른 뒤에 연결하도록 바꿨다. 서버의 입장 시간 제한에 걸려 끊기던 문제였다
  • 입장 직후 스폰 방향이 입력의 시선 값 0 으로 덮어써지던 문제도 고쳤다. 스폰 방향을 받기 전에는 입력을 보내지 않는다

효과음과 설정

  • Web Audio 로 사격·명중·피격·발소리를 붙였다. 다른 사람의 총소리는 위치음이다. 소리 파일이 있으면 파일을, 없으면 합성음을 낸다. M 키로 음소거
  • 설정: 마우스 감도, 소리 크기, 시야각, 상하 반전. 브라우저에 저장하고, 마우스 잠금이 풀리면 설정 화면이 뜬다

자동 재접속

끊겨도 화면을 그대로 둔 채 다시 붙고, 점수·체력·위치를 이어받는다.

  • 서버는 입장할 때 재접속 표를 주고, 끊긴 사람의 상태를 30초 동안 보관한다. 표는 한 번만 쓸 수 있다
  • 클라이언트는 1·2·4초… 최대 10초 간격으로 다시 시도한다. 응답이 일정 시간 없으면 끊긴 것으로 본다(탭이 잠들었다 돌아온 경우는 빼고)

무기: 칼, 그리고 산탄총·저격총

  • 무기표를 공용 코드에 두고 서버와 클라이언트가 같이 쓴다. 1·2(나중에 1~4)·Q·휠로 바꾼다
  • 칼: 사거리가 짧은 대신 피해가 크고 들고 있으면 조금 빨리 뛴다
  • 같은 순간에 서로 쏘면 둘 다 죽게 했다(아래 기술 메모)
  • 밤에는 산탄총과 저격총을 추가했다. 산탄총은 낟알 9개가 정해진 무늬로 퍼지고, 저격총은 조준하면 확대되며 조준하지 않고 쏘면 서버가 시선을 흐트러뜨린다
  • 조준에는 대가를 붙였다. 조준 중에는 이동이 느려지고, 조준 직후 잠깐은 덜 정확하며, 공중에서는 흐트러진다

서버 봇과 넓어진 맵

  • 사람이 적으면 서버가 봇을 채운다. 봇도 입력만 만들고 판정은 사람과 같은 경로를 탄다. 방이 차면 봇이 자리를 비켜 준다
  • 봇은 보이는 상대를 반응 시간·회전 속도 제한·조준 오차를 두고 겨눈다. 격자 A* 길찾기와 “마지막으로 본 자리 찾아가기”를 넣었고, 처음 버전이 너무 셌어서 난이도를 낮췄다
  • 맵을 한 변 64m 에서 96m 로 넓히고 모서리 방·통로·ㄱ자 엄폐벽, 스폰 16곳을 만들었다
  • 그래픽: 코드로 만든 텍스처, 셰이더 하늘, ACES 톤 매핑, 사람 모양 캐릭터와 걷기 동작

모바일 터치 조작

  • 가상 조이스틱, 화면 끌어 시점 돌리기, 사격·점프·무기·점수·일시정지 버튼. 사격 버튼을 누른 채 끌면 쏘면서 조준도 된다
  • 마지막으로 누른 입력(손가락·마우스)에 따라 조작 방식을 고른다. 마우스를 붙인 태블릿이나 터치 노트북도 자연스럽게 된다
  • 휴대폰은 그림자·안티에일리어싱을 끄고 해상도를 낮췄다. 조준 보조는 넣지 않았다

게임 규칙: 스폰 보호, 경기, 방, 팀전

  • 스폰 보호: 다시 나온 뒤 2초 무적, 먼저 쏘면 풀린다. 봇은 보호 중인 상대를 노리지 않는다
  • 피격 방향 표시와, 화면 밖에서 나를 쏜 적을 3초 동안 화면 가장자리 화살표로 가리키는 기능
  • 경기: 10분 또는 30킬이면 끝나고 10초 결과 화면 뒤 새 경기. 끊긴 사람도 결과표에 남는다
  • 방 여러 개: 공개 방은 사람이 있고 자리가 남은 가장 붐비는 방으로 모으고, 친구용 비공개 방은 초대 코드가 든 링크로 들어온다
  • 팀전: 편 나누기, 봇으로 편 인원 맞추기, 같은 편 사격 무시, 팀 점수와 승패, 팀 색 표시. 초대 링크에 방식까지 담긴다

캐릭터와 감정표현

  • 다른 게임(KARTORY)과 세계관을 공유하는 캐릭터를 만들었다. 3등신 리그에 총·칼 자세, 맞은 직후에만 짓는 우는 표정, 팀 스카프를 붙였고, 판정 상자에 맞게 치수를 잡았다
  • 그중 하나는 내가 그린 그림을 바탕으로 했고, 저녁에 KARTORY 쪽과 같은 디자인으로 맞췄다
  • 입장 화면에서 캐릭터를 고르고 3D 로 돌려 볼 수 있다
  • 1인칭 손과 무기도 캐릭터에 맞췄다. 뷰모델을 따로 그려 벽에 붙어도 손이 벽에 파묻히지 않는다
  • 감정표현(안녕·여기·GG·고마워): Z X C V 키나 휴대폰 말풍선 버튼, 머리 위 말풍선과 손 흔들기·뛰기 동작

성능 표시와 그래픽 품질

  • FPS·프레임 시간·그리기 호출·삼각형 수를 보여 주는 성능 표시
  • 그래픽 품질 자동·높음·낮음. 자동은 평균 FPS 가 낮으면 한 단계 내린다. 리뷰에서 30fps 고정 기기를 느린 기기로 오판하는 문제, 낮춰도 효과가 없으면 멈추기, 게임 중이 아닐 때 바꾸고 셰이더를 미리 컴파일하기 같은 점을 고쳤다

협업 방식

  • 작업 규칙(언어, 포트 구간, 비밀값, 브랜치, 인수인계 문서)을 첫 커밋에 정했다
  • 인수인계 문서를 시작할 때 읽고 끝날 때 갱신한다. 옛 브랜치에서 읽으면 새 요청이 안 보여서, 최신 main 에서 브랜치를 딴 뒤 읽도록 규칙을 고쳤다
  • 오후에 역할을 바꿨다. UI 를 포함한 제품 코드는 메인 쪽이 맡고, 다른 도구는 테스트 작성과 브라우저 확인만 한다. 버그를 찾으면 고치지 않고 실패하는 테스트와 재현 방법을 남긴다
  • 테스트는 아침 27개에서 오후에 160개를 넘겼다

기술 메모

예측이 어긋나지 않게: 같은 코드, 같은 반올림

클라이언트 예측이 서버 결과와 조금이라도 다르면 보정할 때마다 화면이 튄다. 그래서 이동 계산은 공용 코드 한 곳에 두고, 난수·시계·전역 상태를 쓰지 않는 순수 함수로 만들었다. 입력 하나를 처리할 때마다 위치·속도를 소수 셋째 자리로 반올림한다. 스냅샷에 실려 오는 값과 클라이언트가 계산한 값이 정확히 같아지게 하려는 것이다.

/** 소수 셋째 자리로 맞춘다. stepMove 가 입력마다 부른다(스냅샷으로 받은 값과 정확히 같아지게). */
export function quantize(v: number): number {
  return Math.round(v * 1000) / 1000;
}

틱(30Hz)과 입력(60Hz) 주기를 나눈 것도 같은 이유다. 이동 계산 한 번이 입력 하나에 대응하므로, 서버는 받은 입력 순서대로 같은 계산을 다시 돌리기만 하면 된다.

지연 보정: 되감기에는 한도가 필요하다

서버는 틱마다 플레이어 위치를 기록해 두고, 사격이 오면 쏜 사람이 보던 시점(대략 핑의 반 + 보간 지연)으로 상대를 되감아 판정한다. 되감는 폭은 클라이언트가 보낸 값을 그대로 믿지 않고 서버가 직접 잰 핑으로 한도를 둔다. 그리고 죽어 있던 구간은 기록에 표시해 두어, 되감은 시점에 죽어 있던 상대는 맞지 않게 했다. 칼은 되감은 위치로 맞더라도 지금도 실제로 가까이 있어야 맞는다(되감기로 사거리가 늘어나는 것을 막으려고).

동시 처치: 입력을 돌아가며 처리하고 피해는 바퀴마다

처음에는 플레이어마다 쌓인 입력을 통째로 처리했다. 이러면 처리 순서가 먼저인 사람이 상대를 죽이고, 같은 순간에 쏜 상대의 사격은 이미 죽은 뒤라 무효가 된다. 바꾼 방식은 이렇다.

  1. 플레이어마다 입력을 하나씩 돌아가며 처리한다
  2. 한 바퀴 동안 나온 명중은 모아 두었다가 바퀴 끝에 한꺼번에 적용한다

같은 바퀴에 서로 쏜 두 사람은 둘 다 맞는다. 입력을 모아 두었다가 한꺼번에 보내는 경우에도 늦게 쏜 사격이 먼저 처리되는 일이 없어진다.

방이 사라져도 초대 링크는 살린다

방은 메모리에만 있어서 서버를 다시 배포하면 사라진다. 그래서 비공개 초대 코드로 들어왔는데 그 방이 없으면 같은 코드로 방을 새로 만든다. 반대로 공개 방에 재접속하려는데 방이 없으면 새로 만들지 않고 공개 방 매칭으로 보낸다. 혼자 빈 방에 갇히지 않게 하기 위해서다. 아무도 없는 방은 틱 루프를 멈춰 둔다.

산탄총 낟알은 정해진 무늬로

산탄총 낟알 방향을 난수로 만들면 클라이언트가 그리는 궤적과 서버 판정이 어긋난다. 낟알은 가운데 하나와 고리 위에 고르게 퍼지는 고정 무늬로 만들고, 서버와 클라이언트가 같은 함수를 쓴다. 다른 사람에게는 가운데 낟알 궤적만 보내고 나머지는 클라이언트가 같은 무늬로 그린다. 같은 대상에 맞은 낟알은 모아서 한 번에 피해를 준다. 반대로 조준하지 않은 저격총의 흐트러짐은 서버가 난수로 정한다(원판 위에 고르게 퍼지도록 반지름에 제곱근을 씌웠다).

다음에

  • 물리가 무거워지면 이동·충돌을 WASM 물리 엔진으로 바꿀 수 있게 분리해 두었고, UDP 계열 전송(WebRTC·WebTransport)은 나중에 검토한다