← 블로그 목록 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)는 다음 순서로 모든 플레이어와 발사체를 갱신:

  1. 봇 입력 갱신 — 봇은 클라이언트가 없으므로 매 tick AI가 직접 input.{w,a,s,d,fire,angle}를 설정
  2. 이동 — 입력에 따라 속도 → 위치, 벽 충돌, 플레이어 간 push
  3. 사격 — fire 입력 있고 rate cooldown 지났으면 _tryFire
  4. 빔 비주얼 패스 — 라이트닝건 사용 중인 플레이어의 beam endpoint를 매 tick 재계산 (시각 가시성을 위해)
  5. 발사체 step — 위치 갱신, 벽/플레이어 충돌, 폭발, 튕김(grenade)
  6. 픽업 체크 — 플레이어가 픽업 반경에 들면 획득 + 리스폰 타이머 설정
  7. 파워업 만료 — 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 지속

두 종류로 나뉩니다:

이벤트가 누락되면(패킷 드롭) 잠깐 사운드 안 들리는 정도. 지속 상태는 다음 스냅샷에 다시 들어오니 자동 복구.

스폰 포인트 선택

매번 가장 멀리 떨어진 스폰 포인트를 선택해서 스폰캠프를 최소화:

_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로 충분.

← Socket.io 룸 시스템 FOV 시야 안개 →