← 블로그 목록
2026-05-19
Node "Listen Server" 패턴: 60Hz 시뮬레이션 루프
Quake에는 두 가지 서버 모드가 있었습니다: dedicated server (그래픽 없이 시뮬만)와 listen server (한 클라가 호스트 겸 플레이어). Dogkov Arena는 listen server 패턴을 따라 — 한 동료가 자기 PC에서 npm start로 서버를 띄우면 곧바로 다른 동료들이 접속할 수 있습니다. 그 내부 시뮬레이션 루프를 정리합니다.
틱 주기: 60Hz
초기엔 30Hz로 시작했는데, 다른 플레이어 움직임이 끊겨 보였습니다. 60Hz로 올리니 부드러워졌습니다. 60Hz면 매 16.67ms마다 시뮬레이션 한 번 + 스냅샷 전송. 8인 매치 정도면 LAN에서 트래픽 부담은 무시 가능.
const TICK_HZ = 60;
const TICK_MS = 1000 / TICK_HZ;
room.tickHandle = setInterval(() => {
const now = Date.now();
room.sim.tick(now);
const snap = room.sim.snapshot(now);
io.to('room:' + room.id).emit('snap', snap);
if (room.sim.ended) {
room.scoreboardFinal = room.sim.scoreboard();
io.to('room:' + room.id).emit('matchEnd', { scoreboard: room.scoreboardFinal });
clearInterval(room.tickHandle);
}
}, TICK_MS);
한 틱 안에서 일어나는 일
Sim.tick(now)는 다음 순서로 모든 플레이어와 발사체를 갱신:
- 봇 입력 갱신 — 봇은 클라이언트가 없으므로 매 tick AI가 직접 input.{w,a,s,d,fire,angle}를 설정
- 이동 — 입력에 따라 속도 → 위치, 벽 충돌, 플레이어 간 push
- 사격 — fire 입력 있고 rate cooldown 지났으면 _tryFire
- 빔 비주얼 패스 — 라이트닝건 사용 중인 플레이어의 beam endpoint를 매 tick 재계산 (시각 가시성을 위해)
- 발사체 step — 위치 갱신, 벽/플레이어 충돌, 폭발, 튕김(grenade)
- 픽업 체크 — 플레이어가 픽업 반경에 들면 획득 + 리스폰 타이머 설정
- 파워업 만료 — Quad/Haste 타이머 체크
스냅샷 구조
매 tick 모든 클라에게 전송되는 스냅샷:
{
t: 1716073200000, // 서버 timestamp
ends: 1716073500000, // 매치 종료 시각
ended: false,
players: [
{ id, name, color, x, y, angle, hp, armor,
cw: 'rocket', ammo: 5,
weapons: { gauntlet: 0, machinegun: 100, ... },
hasWeapon: { gauntlet: true, machinegun: true, rocket: true, ... },
beam: { x, y } | null,
powerups: { quad: 12000, haste: 0 },
frags, deaths, dead, respIn,
},
],
projectiles: [{ id, w, x, y, vx, vy }],
pickups: [{ id, av, in }], // available, respawn-in ms
events: [...], // 이번 tick에 일어난 일들 (tracer, explosion, kill...)
}
플레이어 좌표는 +x.toFixed(1)로 소수점 1자리만. JSON 페이로드 크기 절약. 8인 매치에서 한 스냅샷은 ~1-3KB.
이벤트: 일회성 vs 지속
두 종류로 나뉩니다:
- 지속 상태 — 플레이어 위치, HP, 빔 endpoint 등. 매 스냅샷에 들어감. 클라에서 보간으로 부드럽게 처리.
- 일회성 이벤트 — 탄착, 폭발, 킬, 픽업, 빔 tick 사운드.
events배열에 한 번만 들어감. 클라가 sound와 시각 파티클로 promote.
이벤트가 누락되면(패킷 드롭) 잠깐 사운드 안 들리는 정도. 지속 상태는 다음 스냅샷에 다시 들어오니 자동 복구.
스폰 포인트 선택
매번 가장 멀리 떨어진 스폰 포인트를 선택해서 스폰캠프를 최소화:
_pickSpawn() {
const living = [...players.values()].filter(p => !p.dead);
if (!living.length) return SPAWN_POINTS[next++ % SPAWN_POINTS.length];
let best = SPAWN_POINTS[0], bestD = -1;
for (const sp of SPAWN_POINTS) {
const minD = Math.min(...living.map(p => Math.hypot(sp.x - p.x, sp.y - p.y)));
if (minD > bestD) { bestD = minD; best = sp; }
}
return best;
}
왜 setInterval이 OK인가
16ms마다 호출하는 정밀 타이머가 필요하면 보통 process.hrtime + recursive setTimeout 패턴이 권장됩니다. 하지만 Dogkov Arena는 ±2ms 드리프트가 게임플레이에 영향이 없습니다 (모든 데미지가 now로 게이트되므로). setInterval로 충분.