멀티플레이어 아키텍처
En Parlant~의 멀티플레이어는 WebSocket 릴레이 서버를 사용하여 두 플레이어를 실시간으로 연결합니다. P2P 방식은 사용하지 않으며, 모든 통신은 릴레이를 통해 이루어집니다. 이를 통해 네트워킹을 단순하게 유지하고 방화벽과 NAT 환경에서도 안정적으로 동작합니다.
아키텍처 개요
섹션 제목: “아키텍처 개요”Player A (host) Relay Server Player B (joiner) | | | |--- create_game(name) ----------->| | |<-- game_created(code) -----------| | | |<--- join_game(code, name) -----| |<-- game_joined(name) ------------|--- game_joined(name) -------->| | | | |--- game_move(uci, times) ------->|--- game_move(uci, times) --->| |<-- game_move(uci, times) --------|<-- game_move(uci, times) ----| | | | |--- heartbeat ------------------>|--- peer_heartbeat ----------->|릴레이는 얇은 전달 계층입니다. 메모리에 방을 보관하고, 각 방의 두 플레이어 간에 이벤트를 라우팅하며, 정리 작업을 처리합니다. 서버에는 게임 로직이나 수 검증이 없으며, 클라이언트가 권한을 가집니다.
기술 스택
섹션 제목: “기술 스택”- 프론트엔드 (클라이언트): socket.io-client — 표준 Socket.IO JavaScript 클라이언트
- 백엔드 (릴레이): socketioxide — Axum 기반의 Rust Socket.IO 서버
- 프로토콜: WebSocket 위의 Socket.IO (필요시 HTTP 롱 폴링으로 자동 폴백)
Socket.IO는 자동 재연결, 방/네임스페이스 관리, 구조화된 이벤트 처리를 기본적으로 제공하기 때문에 원시 WebSocket 대신 선택되었습니다.
게임 흐름
섹션 제목: “게임 흐름”1. 게임 생성
섹션 제목: “1. 게임 생성”호스트가 Multiplayer를 클릭하고 표시 이름을 입력합니다. 클라이언트는 이름과 함께 create_game 이벤트를 발생시킵니다. 서버는:
- 고유한 6자리 방 코드를 생성합니다
- 방을 만들고 호스트를 첫 번째 플레이어로 추가합니다
game_created(code)로 응답하여 호스트가 코드를 공유할 수 있게 합니다
2. 게임 참가
섹션 제목: “2. 게임 참가”참가자가 방 코드와 표시 이름을 입력합니다. 클라이언트는 join_game(code, name)을 발생시킵니다. 서버는:
- 코드로 방을 조회합니다
- 참가자를 두 번째 플레이어로 추가합니다
- 양쪽 플레이어에게
game_joined(name)을 전송합니다 — 각자 상대방의 표시 이름을 받게 됩니다
3. 플레이
섹션 제목: “3. 플레이”두 플레이어가 모두 방에 입장하면:
- 호스트가 항상 백을, 참가자가 항상 흑을 맡습니다. 이는 협상이 아닌 참가 순서에 의해 결정됩니다.
- 수는 UCI 형식(예:
e2e4)으로 양쪽 플레이어의 시계 시간과 함께 전송됩니다. - 릴레이는 각
game_move이벤트를 상대방에게 전달합니다. 두 플레이어 모두 확인 용도로 자신의 수도 다시 받게 됩니다.
4. 게임 종료 이벤트
섹션 제목: “4. 게임 종료 이벤트”게임 종료 시나리오를 처리하는 여러 이벤트가 있습니다:
- 기권 — 상대방에게 전달되어 기권 대화 상자를 표시합니다
- 무승부 제안 — 상대방에게 전달되며, 상대방은 수락하거나 무시할 수 있습니다
- 무승부 수락 — 양쪽 플레이어에게 전달되어 게임을 무승부로 종료합니다
5. 재대국
섹션 제목: “5. 재대국”게임이 끝난 후, 어느 플레이어든 ready 이벤트를 전송하여 재대국 의사를 표시할 수 있습니다. 두 플레이어 모두 ready를 전송하면, 클라이언트가 보드를 초기화하고 색상을 교환합니다(또는 유지합니다 — 클라이언트 측에서 결정됩니다).
방 코드
섹션 제목: “방 코드”방 코드는 6자리이며, 음성으로 공유할 때 가독성을 위해 XX-XX-XX 형식으로 표시됩니다. 시각적으로 혼동되기 쉬운 문자는 제외됩니다:
0과O없음 (숫자 0과 영문자 O)1,I,L없음 (숫자 1과 대문자 I, 대문자 L)
이를 통해 누군가 음성 채팅으로 코드를 읽어주거나 친구의 메시지를 보고 입력할 때 “그게 숫자 0이야, 영문자 O야?” 같은 문제를 방지합니다.
하트비트 시스템
섹션 제목: “하트비트 시스템”멀티플레이어 연결에서는 플레이어의 연결 끊김을 감지해야 합니다 — 네트워크 단절, 앱 종료, 또는 노트북 절전 모드 진입 등의 경우입니다. En Parlant~는 이를 위해 하트비트 시스템을 사용합니다:
- 각 클라이언트는 5초마다 서버에
heartbeat이벤트를 전송합니다 - 서버는 하트비트를 확인하고 상대방에게
peer_heartbeat로 전달합니다 - 클라이언트는 상대방으로부터 마지막
peer_heartbeat를 받은 시간을 추적합니다 isPeerAlive(timeoutMs)함수가 상대방의 마지막 하트비트가 허용 임계값 내에 있는지 확인합니다
이를 통해 UI의 연결 상태 표시기가 동작합니다. 하트비트가 도착하지 않으면, 플레이어는 상대방이 연결 해제되었을 수 있음을 확인하고 기다리거나 게임을 떠날 수 있습니다.
방 정리
섹션 제목: “방 정리”릴레이 서버는 메모리 누수를 방지하기 위해 유휴 방을 자동으로 제거합니다:
- 방은 30분 동안 활동이 없으면 유휴 상태로 간주됩니다
- 정리 작업이 60초마다 실행되어 유휴 임계값을 초과한 방을 정리합니다
- 방이 정리되면 남아 있는 연결은 모두 끊어집니다
영구 저장소는 없습니다. 서버가 재시작되면 모든 방이 사라집니다. 이는 의도된 동작입니다 — 릴레이는 상태를 유지하지 않는 임시 구조입니다. 진행 중인 게임은 다시 시작해야 하지만, 실제로는 이런 상황이 드뭅니다.
기본 릴레이는 Fly.io에서 실행되며, 자동 TLS와 함께 저지연 WebSocket 연결을 제공합니다. 자체 릴레이를 운영하려면 멀티플레이어 서버 설정 가이드를 참조하십시오.
로컬 테스트
섹션 제목: “로컬 테스트”개발 중 멀티플레이어를 테스트하려면:
-
릴레이를 클론하고 실행합니다:
Terminal window git clone https://github.com/DarrellThomas/en-parlant-relay.gitcd en-parlant-relaycargo run서버는 포트 3210에서 시작됩니다.
-
En Parlant~에서 릴레이 서버 URL을
ws://localhost:3210으로 변경합니다. -
앱 인스턴스를 두 개 열거나(또는 하나는 앱, 하나는 개발 모드로) 양쪽 플레이어를 시뮬레이션합니다.
수, 하트비트 및 모든 이벤트는 프로덕션 릴레이와 동일하게 동작합니다 — 유일한 차이점은 연결 URL뿐입니다.