2014 호빗 체험

Hobbit 환경에 WebRTC 게임플레이 추가

Daniel Isaksson
Daniel Isaksson

새로운 호빗 영화 '호빗: 다섯 군대의 전투' 개봉에 맞춰 작년의 Chrome 실험인 중간계 여행을 새로운 콘텐츠로 확장했습니다. 이번 업데이트의 주요 목표는 더 많은 브라우저와 기기에서 콘텐츠를 볼 수 있도록 WebGL의 사용 범위를 확대하고 Chrome 및 Firefox의 WebRTC 기능을 사용하는 것입니다. 올해 실험의 목표는 세 가지였습니다.

  • Android용 Chrome에서 WebRTC 및 WebGL을 사용하는 P2P 게임플레이
  • 터치 입력을 기반으로 하며 플레이하기 쉬운 멀티플레이어 게임 만들기
  • Google Cloud Platform에서 호스팅

게임 정의

게임 로직은 게임 보드에서 병력이 이동하는 그리드 기반 설정에 기반합니다. 그 덕분에 규칙을 정의하는 과정에서도 게임플레이 방식을 종이에 적힌 내용을 쉽게 시험해 볼 수 있었습니다. 또한 그리드 기반 설정을 사용하면 동일하거나 인접한 타일의 객체와의 충돌만 확인하면 되므로 게임에서 충돌 감지를 통해 우수한 성능을 유지하는 데 도움이 됩니다. 처음부터 중간계의 네 가지 주요 세력인 인간, 드워프, 엘프, 오크 간의 전투에 새로운 게임의 초점을 맞추고자 했습니다. 또한 Chrome 실험 내에서 플레이할 수 있을 만큼 캐주얼해야 했으며 학습할 상호작용이 너무 많아서는 안 되었습니다. 먼저 중간계 지도에 여러 플레이어가 피어 투 피어 전투에서 경쟁할 수 있는 게임 룸 역할을 하는 전장 5개를 정의했습니다. 모바일 화면에 방에 있는 여러 플레이어를 표시하고 사용자가 챌린지할 상대를 선택할 수 있도록 하는 것 자체가 어려운 일이었습니다. 상호작용과 장면을 더 쉽게 만들기 위해 챌린지 및 수락 버튼을 하나만 두고 방은 이벤트와 현재 킹 오브 더 힐을 표시하는 데만 사용하기로 했습니다. 이 방향은 매칭 측면에서 몇 가지 문제를 해결하고 전투에 가장 적합한 후보를 매칭할 수 있도록 했습니다. 이전 Chrome 실험인 Cube Slam에서 게임 결과가 지연 시간에 의존하는 경우 멀티플레이어 게임에서 지연 시간을 처리하는 데 많은 작업이 필요하다는 사실을 알게 되었습니다. 상대방의 상태가 어디에 있을지, 상대방이 내가 어디에 있다고 생각하는지를 지속적으로 가정하고 이를 여러 기기의 애니메이션과 동기화해야 합니다. 이 도움말에서는 이러한 문제를 자세히 설명합니다. 게임을 좀 더 쉽게 만들기 위해 이 게임을 턴 기반으로 만들었습니다.

게임 로직은 게임 보드에서 병사가 이동하는 그리드 기반 설정에 기반합니다. 이렇게 하면 규칙을 정의할 때 종이에 게임플레이를 쉽게 시도할 수 있었습니다. 그리드 기반 설정을 사용하면 게임 내 충돌 감지에도 도움이 됩니다. 같은 타일이나 인접한 타일에 있는 객체와의 충돌만 확인하면 되므로 우수한 성능을 유지할 수 있습니다.

게임의 구성 요소

이 멀티플레이어 게임을 만들기 위해 빌드해야 하는 몇 가지 핵심 요소가 있습니다.

  • 서버 측 플레이어 관리 API는 사용자, 매치메이킹, 세션, 게임 통계를 처리합니다.
  • 플레이어 간의 연결을 설정하는 데 도움이 되는 서버입니다.
  • 게임 방의 모든 플레이어와 연결하고 통신하는 데 사용되는 AppEngine 채널 API 신호를 처리하는 API입니다.
  • 두 플레이어/피어 간 상태 동기화와 RTC 메시지를 처리하는 JavaScript 게임 엔진.
  • WebGL 게임 뷰

플레이어 관리

많은 플레이어를 지원하기 위해 배틀그라운드당 여러 개의 병렬 게임 룸을 사용합니다. 게임 룸당 플레이어 수를 제한하는 주된 이유는 신규 플레이어가 적절한 시간 내에 리더보드 상위권에 도달할 수 있도록 하기 위함입니다. 이 한도는 Channel API를 통해 전송되는 게임 룸을 설명하는 JSON 객체의 크기와도 연결되며, 이 객체의 크기는 32KB로 제한됩니다. 게임에 플레이어, 방, 점수, 세션, 관계를 저장해야 합니다. 이를 위해 먼저 항목에 NDB를 사용하고 쿼리 인터페이스를 사용하여 관계를 처리했습니다. NDB는 Google Cloud Datastore에 대한 인터페이스입니다. 처음에는 NDB를 사용하는 것이 좋았지만 곧 사용 방법에 문제가 발생했습니다. 쿼리가 '커밋된' 버전의 데이터베이스에 대해 실행되었으며 (NDB 쓰기는 이 심층 도움말에서 자세히 설명함) 몇 초의 지연이 발생할 수 있습니다. 하지만 항목 자체는 캐시에서 직접 응답하므로 지연이 발생하지 않았습니다. 다음과 같은 예시 코드를 통해 설명하는 것이 더 쉬울 수 있습니다.

// example code to explain our issue with eventual consistency
def join_room(player_id, room_id):
    room = Room.get_by_id(room_id)
    
    player = Player.get_by_id(player_id)
    player.room = room.key
    player.put()
    
    // the player Entity is updated directly in the cache
    // so calling this will return the room key as expected
    player.room // = Key(Room, room_id)

    // Fetch all the players with room set to 'room.key'
    players_in_room = Player.query(Player.room == room.key).fetch()
    // = [] (an empty list of players)
    // even though the saved player above may be expected to be in the
    // list it may not be there because the query api is being run against the 
    // "committed" version and may still be empty for a few seconds

    return {
        room: room,
        players: players_in_room,
    }

단위 테스트를 추가한 후 문제를 명확하게 확인할 수 있었으며, 쿼리 대신 memcache의 쉼표로 구분된 목록에 관계를 유지하도록 변경했습니다. 약간의 해킹처럼 느껴졌지만 작동했으며 AppEngine memcache에는 우수한 '비교 및 설정' 기능을 사용하는 키에 관한 트랜잭션과 같은 시스템이 있으므로 이제 테스트가 다시 통과했습니다.

안타깝게도 memcache는 장밋빛 미래만 있는 것은 아니며 몇 가지 제한사항이 있습니다. 가장 눈에 띄는 제한사항은 1MB 값 크기(배틀그라운드와 관련된 방을 너무 많이 가질 수 없음)와 키 만료입니다. 문서에 설명된 대로 다음과 같습니다.

다른 우수한 키-값 저장소인 Redis를 사용하는 것도 고려했습니다. 하지만 당시 확장 가능한 클러스터를 설정하는 것은 다소 부담스러웠고 서버를 유지보수하는 것보다 환경을 구축하는 데 집중하고자 하여 이 방법을 선택하지 않았습니다. 반면 Google Cloud Platform은 최근에 간단한 클릭 투 디플로이 기능을 출시했으며, 이 기능의 옵션 중 하나가 Redis 클러스터이므로 매우 흥미로운 옵션이 될 수 있습니다.

마지막으로 Google Cloud SQL을 찾아 관계를 MySQL로 옮겼습니다. 많은 작업이었지만 결국 잘 작동했습니다. 이제 업데이트가 완전히 원자적이며 테스트도 계속 통과합니다. 또한 랜덤 대결 및 점수 기록의 신뢰성도 더욱 향상되었습니다.

시간이 지남에 따라 점점 더 많은 데이터가 NDB 및 memcache에서 SQL로 이전되었지만 일반적으로 플레이어, 전장, 방 항목은 여전히 NDB에 저장되고 세션과 그 사이의 관계는 모두 SQL에 저장됩니다.

또한 누가 누구와 대결하는지 추적하고 플레이어의 기술 수준과 경험을 고려한 매칭 메커니즘을 사용하여 플레이어를 서로 짝지어야 했습니다. 매칭은 오픈소스 라이브러리인 Glicko2를 기반으로 합니다.

멀티플레이어 게임이므로 '누가 들어오거나 나갔는지', '누가 이기거나 졌는지', 수락할 챌린지가 있는지와 같은 이벤트를 방에 있는 다른 플레이어에게 알리고자 합니다. 이를 처리하기 위해 Player Management API에 알림을 수신하는 기능을 빌드했습니다.

WebRTC 설정

두 명의 플레이어가 전투를 위해 일치하면 신호 서비스가 사용되어 일치한 두 피어를 서로 연결하고 피어 연결을 시작하는 데 도움이 됩니다.

신호 전달 서비스에 사용할 수 있는 서드 파티 라이브러리가 여러 개 있으며, 이를 사용하면 WebRTC 설정도 간소화됩니다. PeerJS, SimpleWebRTC, PubNub WebRTC SDK 등이 있습니다. PubNub은 호스팅 서버 솔루션을 사용하므로 이 프로젝트를 Google Cloud Platform에서 호스팅하려고 했습니다. 다른 두 라이브러리는 Google Compute Engine에 설치할 수 있는 Node.js 서버를 사용하지만 수천 명의 동시 사용자를 처리할 수 있는지 확인해야 했습니다. 이는 Channel API에서 이미 할 수 있는 작업입니다.

이 경우 Google Cloud Platform을 사용하는 주요 이점 중 하나는 확장입니다. AppEngine 프로젝트에 필요한 리소스를 확장하는 작업은 Google Developers Console을 통해 쉽게 처리할 수 있으며 Channels API를 사용할 때 신호 전달 서비스를 확장하는 데 추가 작업이 필요하지 않습니다.

지연 시간과 Channels API의 안정성에 대한 우려가 있었지만 이전에 CubeSlam 프로젝트에 사용한 적이 있으며 이 프로젝트에서 수백만 명의 사용자에게 작동하는 것으로 입증되었으므로 다시 사용하기 결정했습니다.

WebRTC를 지원하기 위해 서드 파티 라이브러리를 선택하지 않았기 때문에 자체적으로 개발해야 했습니다. 다행히 CubeSlam 프로젝트에서 진행한 작업의 상당 부분을 재사용할 수 있었습니다. 두 플레이어가 모두 세션에 참여하면 세션은 '활성'으로 설정되며, 두 플레이어 모두 해당 활성 세션 ID를 사용하여 Channel API를 통해 P2P 연결을 시작합니다. 그런 다음 두 플레이어 간의 모든 통신은 RTCDataChannel을 통해 처리됩니다.

또한 연결을 설정하고 NAT 및 방화벽을 처리하는 데 도움이 되는 STUN 및 TURN 서버도 필요합니다. HTML5 Rocks 자료인 실제 세상의 WebRTC: STUN, TURN, 신호에서 WebRTC를 설정하는 방법을 자세히 알아보세요.

또한 사용되는 TURN 서버 수는 트래픽에 따라 확장할 수 있어야 합니다. 이를 처리하기 위해 Google Deployment Manager를 테스트했습니다. 이를 통해 Google Compute Engine에 리소스를 동적으로 배포하고 템플릿을 사용하여 TURN 서버를 설치할 수 있습니다. 아직 알파 버전이지만 저희의 목적에는 완벽하게 작동했습니다. TURN 서버의 경우 STUN/TURN을 매우 빠르고 효율적이며 안정적으로 구현하는 coturn을 사용합니다.

Channel API

Channel API는 클라이언트 측의 오락실과 모든 통신을 전송하는 데 사용됩니다. Player Management API는 게임 이벤트에 관한 알림에 Channel API를 사용하고 있습니다.

Channels API를 사용하는 데 몇 가지 문제가 있었습니다. 한 가지 예는 메시지가 정렬되지 않을 수 있으므로 모든 메시지를 객체에 래핑하고 정렬해야 한다는 것입니다. 다음은 작동 방식을 보여주는 코드 예입니다.

var que = [];  // [seq, packet...]
var seq = 0;
var rcv = -1;

function send(message) {
  var packet = JSON.stringify({
    seq: seq++,
    msg: message
  });
  channel.send(packet);
}

function recv(packet) {
  var data = JSON.parse(packet);

  if (data.seq <= rcv) {
    // ignoring message, older or already received
  } else if (data.seq > rcv + 1) {
    // message from the future. queue it up.
    que.push(data.seq, packet);
  } else {
    // message in order! update the rcv index and emit the message
    rcv = data.seq;
    emit('message', data.message);

    // and now that we have updated the `rcv` index we 
    // will check the que for any other we can send
    setTimeout(flush, 10);
  }
}

function flush() {
  for (var i=0; i<que.length; i++) {
    var seq = que[i];
    var packet = que[i+1];
    if (data.seq == rcv + 1) {
      recv(packet);
      return; // wait for next flush
    }
  }
}

또한 사이트의 다양한 API를 모듈화하고 사이트 호스팅과 분리하고자 했으며 GAE에 내장된 모듈을 사용해 시작했습니다. 안타깝게도 개발 환경에서 모두 작동시킨 후 Channel API가 프로덕션의 모듈에서 전혀 작동하지 않음을 알게 되었습니다. 대신 별도의 GAE 인스턴스를 사용하도록 전환했으며, CORS 문제가 발생하여 iframe postMessage 브리지를 사용해야 했습니다.

게임 엔진

게임 엔진을 최대한 동적으로 만들기 위해 엔티티-구성요소-시스템(ECS) 접근 방식을 사용하여 프런트엔드 애플리케이션을 빌드했습니다. 개발을 시작할 때는 와이어프레임과 기능 사양이 설정되지 않았기 때문에 개발이 진행됨에 따라 기능과 로직을 추가할 수 있다는 것이 큰 도움이 되었습니다. 예를 들어 첫 번째 프로토타입은 간단한 캔버스 렌더링 시스템을 사용하여 그리드에 항목을 표시했습니다. 몇 번 반복한 후 충돌 시스템과 AI 제어 플레이어 시스템이 추가되었습니다. 프로젝트 중간에 나머지 코드를 변경하지 않고도 3d-renderer-system으로 전환할 수 있습니다. 네트워킹 부품이 작동 중일 때 AI 시스템을 수정하여 원격 명령을 사용할 수 있었습니다.

따라서 멀티플레이어의 기본 로직은 DataChannels을 통해 다른 피어에 작업 명령 구성을 전송하고 시뮬레이션이 AI 플레이어인 것처럼 작동하도록 하는 것입니다. 그 외에도 현재 턴을 결정하는 로직, 플레이어가 패스/공격 버튼을 누르는 경우, 플레이어가 이전 애니메이션을 보고 있는 동안 명령어가 들어오는 경우 명령어를 큐에 추가하는 로직 등이 있습니다.

두 사용자만 차례를 바꾸는 경우, 두 사용자가 모두 상대방에게 차례를 넘기는 책임을 공유할 수 있지만 여기서는 제3자가 관여합니다. AI 시스템은 거미와 트롤과 같은 적을 추가해야 할 때 테스트뿐만 아니라 다시 유용하게 사용되었습니다. 턴 기반 흐름에 맞게 하려면 양쪽에서 정확히 동일하게 스폰되고 실행되어야 했습니다. 이는 한 피어가 회전 시스템을 제어하고 현재 상태를 원격 피어에 전송하도록 하여 해결되었습니다. 그런 다음 스파이더의 차례가 되면 턴 관리자가 AI 시스템이 원격 사용자에게 전송되는 명령어를 만들도록 허용합니다. 게임 엔진은 명령어와 항목 ID에 대해서만 작동하기 때문에 게임은 양쪽에서 동일하게 시뮬레이션됩니다. 모든 단위에는 간편한 자동 테스트를 지원하는 ai 구성요소도 있을 수 있습니다.

개발 초기에는 게임 로직에 집중하면서 더 간단한 캔버스 렌더러를 사용하는 것이 가장 좋았습니다. 하지만 진정한 재미는 3D 버전이 구현되고 환경과 애니메이션으로 장면을 생생하게 구현하면서 시작되었습니다. three.js를 3D 엔진으로 사용했으며 아키텍처 덕분에 플레이 가능한 상태로 쉽게 전환할 수 있었습니다.

마우스 위치는 원격 사용자에게 더 자주 전송되며 커서의 현재 위치에 대한 3D의 가벼운 힌트가 제공됩니다.