Socket.io LAN 리슨 서버 + 룸 시스템
처음엔 WebRTC P2P를 검토했습니다 — 진짜 UDP에 가까운 DataChannel, 시그널링 서버 부담 적음. 하지만 "회사 와이파이에서 5분 한 판"이라는 요구사항을 보면 P2P의 NAT 통과 복잡성은 오버킬이었습니다. 결국 평범한 Socket.io + Node 리슨 서버로 갔습니다.
구조: 호스트가 곧 서버
누군가 자기 노트북에서 npm start를 띄우면 그 PC가 바로 게임 서버가 됩니다. 다른 동료들은 그 LAN IP로 브라우저 접속:
┌──────────────────┐ ┌─────────┐
│ Host PC │ <-WS--> │ Player1 │
│ Node + Sim 60Hz │ <-WS--> │ Player2 │
│ (Express + │ <-WS--> │ Bot1 │ (server-side)
│ Socket.io) │ <-WS--> │ Bot2 │
└──────────────────┘
봇은 클라이언트가 아니라 서버 내부 상태입니다. isBot:true 플래그가 붙은 플레이어로 sim에 추가됩니다.
이벤트 카탈로그
클라이언트 → 서버:
createRoom { name, password, nick }→ cb로 룸 IDjoinRoom { id, password, nick }leaveRoomaddBot/removeBot {id}(호스트만)startGame(호스트만)input { w,a,s,d, shift, fire, angle, switchTo }returnToLobby
서버 → 클라이언트:
lobbyUpdate룸 상태 (참가자 추가/퇴장 시)gameStart { staticMap }매치 시작 + 정적 맵 데이터snap매 tick (60Hz) 게임 스냅샷matchEnd { scoreboard }roomClosed { reason }호스트 퇴장 등
룸 채널
Socket.io의 room 기능을 그대로 활용:
socket.on('createRoom', ({ name, password, nick }, cb) => {
const room = new Room({ name, password, hostSocketId: socket.id });
rooms.set(room.id, room);
socket.join('room:' + room.id); // 채널 가입
room.addPlayer(socket.id, nick);
cb({ ok: true, id: room.id });
});
// 매 tick 호스트(노드)는:
io.to('room:' + room.id).emit('snap', snapshot);
io.to(channel).emit는 그 채널에 속한 모든 socket에만 broadcasting. 다른 룸의 트래픽과 격리됩니다.
호스트 권한 모델
클라이언트는 입력({w,a,s,d, fire, angle})만 보냅니다. 서버가 위치·HP·데미지를 모두 계산. 클라이언트는 받은 스냅샷을 보간해서 렌더만 합니다. 치트 방어와 동기화 단순화를 동시에 얻는 표준 패턴입니다.
매치 종료 후 룸 재사용
5분이 지나면 sim이 ended:true를 띄우고 matchEnd 이벤트로 스코어보드 전송. 호스트가 "대기실로" 버튼 누르면 룸 상태가 lobby로 돌아가고, 모든 참가자가 다시 startGame을 기다립니다. 새 매치 = 새 Sim 인스턴스.
호스트 끊김 처리
호스트의 socket이 disconnect 되면 룸이 dissolve됩니다 (마이그레이션 없음). 다른 참가자들은 roomClosed 이벤트를 받고 로비로 돌아갑니다. v1은 단순성을 우선.
LAN IP 자동 표시
서버 부트 시 os.networkInterfaces()로 비-내부 IPv4를 찾아 콘솔에 박스로 출력합니다 — 호스트가 동료들에게 URL 공유할 때 보기 좋게:
┌────────────────────────────────────┐
│ DOGKOV ARENA — LAN listen server │
│ LAN: http://192.168.50.52:3000 │
│ LAN: http://192.168.50.203:3000 │
└────────────────────────────────────┘