2014 호빗 체험

호빗 환경에 WebRTC 게임플레이 추가

다니엘 이삭손
다니엘 이삭슨

새 호빗 영화 '호빗: 다섯 군대 전투'에 맞춰 Google에서는 지난해 Chrome 실험실 기능인 중간계를 가로지르는 여정에 새로운 콘텐츠를 추가하는 작업을 진행했습니다. 이번에는 더 많은 브라우저와 기기에서 콘텐츠를 볼 수 있고 Chrome 및 Firefox에서 WebRTC 기능을 사용할 수 있게 됨에 따라 WebGL 사용을 확대하는 데 중점을 두었습니다. 올해 실험의 세 가지 목표는 다음과 같습니다.

  • Android용 Chrome에서 WebRTC 및 WebGL을 사용한 P2P 게임플레이
  • 플레이하기 쉽고 터치 입력을 기반으로 하는 멀티플레이어 게임을 만드세요.
  • Google Cloud Platform에서 호스팅

게임 정의

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

게임 로직은 게임 보드에서 병력을 움직이는 그리드 기반 설정으로 구축됩니다. 덕분에 규칙을 정의할 때 종이에 적혀 있는 게임플레이를 쉽게 시험해 볼 수 있었습니다. 그리드 기반 설정을 사용하면 게임에서 충돌 감지를 통해 우수한 성능을 유지하는 데 도움이 됩니다. 동일하거나 이웃 타일에 있는 객체와의 충돌만 확인하면 되기 때문입니다.

게임의 일부

이 멀티플레이어 게임을 만들기 위해서는 다음과 같은 몇 가지 핵심 요소를 빌드해야 했습니다.

  • 서버 측 플레이어 관리 API는 사용자, 랜덤 대결, 세션 및 게임 통계를 처리합니다.
  • 플레이어 간의 연결 설정을 지원하는 서버
  • 게임룸에 있는 모든 플레이어와 연결하고 통신하는 데 사용되는 App Engine 채널 API 신호를 처리하기 위한 API입니다.
  • 두 플레이어/동료 간의 상태 및 RTC 메시지 동기화를 처리하는 JavaScript 게임 엔진입니다.
  • WebGL 게임 뷰

플레이어 관리

많은 플레이어를 지원하기 위해 전장당 많은 병렬 게임룸을 사용합니다. 게임룸당 플레이어 수를 제한하는 주된 이유는 신규 플레이어가 합리적인 시간 내에 리더보드 상단에 도달할 수 있도록 하기 위해서입니다. 또한 이 제한은 Channel API를 통해 전송되는 32kb의 게임방을 설명하는 json 객체의 크기에도 연결됩니다. 플레이어, 방, 점수, 세션 및 이들의 관계를 게임에 저장해야 합니다. 이를 위해 먼저 항목에 NDB를 사용하고 관계를 처리하기 위해 쿼리 인터페이스를 사용했습니다. NDB는 Google Cloud Datastore에 대한 인터페이스입니다. AOSP는 처음에 잘 사용했지만, 얼마 지나지 않아 이를 사용해야 하는 문제에 직면하게 되었습니다. 쿼리는 데이터베이스의 '커밋된' 버전을 대상으로 실행되었습니다 (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에서 쉼표로 구분된 목록으로 관계를 유지했습니다. 약간 편법처럼 느껴졌지만 App Engine Memcache는 우수한 '비교 및 설정' 기능을 사용하여 키에 대해 트랜잭션과 유사한 시스템을 갖추어 테스트를 다시 통과했습니다.

안타깝게도 Memcache가 무지개나 유니콘으로만 사용되는 것은 아니지만 몇 가지 제한 사항이 있습니다. 가장 눈에 띄는 제한은 1MB 값 크기 (전장과 관련된 방이 너무 많을 수 없음) 및 키 만료입니다. 또는 문서에 설명되어 있습니다.

우리는 또 다른 훌륭한 키-값 저장소인 Redis를 사용하는 것을 고려했습니다. 하지만 그 당시에는 확장 가능한 클러스터를 설정하기가 어려웠고, 서버를 유지보수하는 것보다 환경을 구축하는 데 집중하고 싶었기 때문에 그 방법을 따르지 않았습니다. 반면 Google Cloud Platform에서는 최근 간단한 클릭하여 배포 기능을 출시했는데, 옵션 중 하나는 Redis 클러스터이므로 매우 흥미로운 옵션이 될 것입니다.

마침내 Google Cloud SQL을 찾아 관계를 MySQL로 옮겼습니다. 많은 작업이었지만 결국에는 잘 작동했습니다. 업데이트는 이제 완전히 원자적으로 수행되고 테스트는 여전히 통과합니다. 또한 랜덤 대결과 점수를 더욱 안정적으로 유지할 수 있게 되었습니다.

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

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

이 게임은 멀티플레이어 게임이므로 방에 있는 다른 플레이어에게 '누가 참여하거나 떠났는지', '승자 또는 패자'와 같은 이벤트에 대해 알리고 수락할 챌린지가 있는지 여부를 알려주려고 합니다. 이 문제를 해결하기 위해 Player Management API에 알림을 수신하는 기능을 추가했습니다.

WebRTC 설정

배틀을 위해 2명의 플레이어가 짝을 이룬 경우 신호 서비스를 사용하여 매칭된 두 피어가 서로 대화하도록 하고 피어 연결을 시작합니다.

신호 서비스에 사용할 수 있고 WebRTC 설정을 간소화하는 서드 파티 라이브러리가 여러 개 있습니다. 옵션으로는 PeerJS, SimpleWebRTC, PubNub WebRTC SDK가 있습니다. PubNub은 호스팅된 서버 솔루션을 사용하며 이 프로젝트를 위해 Google Cloud Platform에서 호스팅하고자 했습니다. 다른 두 라이브러리는 Google Compute Engine에 설치할 수 있는 node.js 서버를 사용하지만 수천 명의 동시 사용자를 처리할 수 있어야 합니다. 이는 Channel API가 이미 알고 있었던 기능입니다.

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

지연 시간 및 채널 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 서버에는 coturn을 사용합니다. coturn은 STUN/TURN을 매우 빠르고 효율적이며 안정적으로 구현한 것으로 보입니다.

Channel API

Channel API는 클라이언트 측의 게임룸과 모든 통신을 주고받는 데 사용됩니다. Player Management API는 게임 이벤트 알림에 Channel API를 사용합니다.

Channel 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 인스턴스를 사용하도록 전환한 후 iframe postMessage 브리지를 사용해야 하는 CORS 문제가 발생했습니다.

게임 엔진

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

멀티플레이어의 기본 로직은 DataChannels를 통해 다른 피어에게 작업 명령의 구성을 전송하고 시뮬레이션이 AI 플레이어인 것처럼 작동하도록 하는 것입니다. 그 외에, 어느 턴을 할지 결정하는 로직이 있습니다. 플레이어가 통과/공격 버튼을 누르면 플레이어가 이전 애니메이션을 계속 보고 있을 때 커맨드가 들어오면 큐에 추가하는 등입니다.

턴을 전환하는 두 명의 사용자뿐이라면 두 플레이어 모두 상대에게 턴을 넘길 책임이 있지만, 세 번째 플레이어가 관련됩니다. AI 시스템은 테스트 목적뿐만 아니라 거미와 트롤 같은 적을 추가해야 할 때 다시 편리해졌습니다. 턴 기반 흐름에 맞도록 양측에서 정확히 동일하게 생성되고 실행되어야 했습니다. 이 문제는 한 피어가 턴 시스템을 제어하고 원격 피어에게 현재 상태를 전송하도록 허용하여 해결되었습니다. 스파이더의 차례가 되면 턴 관리자는 AI 시스템이 원격 사용자에게 전송할 명령어를 만들 수 있게 합니다. 게임 엔진은 명령어와 entity-id:s에 대해서만 작동하므로 게임은 양쪽에서 동일하게 시뮬레이션됩니다. 또한 모든 단위에는 간편한 자동 테스트를 지원하는 AI 구성요소가 있을 수 있습니다.

개발 초기에는 게임 로직에 집중하면서 더 단순한 캔버스 렌더기를 사용하는 것이 최적화되었습니다. 그러나 진정한 재미는 3D 버전을 구현하고 환경과 애니메이션으로 장면에 생명을 불어넣기 시작했습니다. 여기서는 three.js를 3D 엔진으로 사용하며, 아키텍처 덕분에 재생 가능한 상태로 쉽게 전환할 수 있었습니다.

마우스 위치가 원격 사용자에게 더 자주 전송되며 현재 커서가 어디에 위치하는지 3D로 미묘한 힌트가 표시됩니다.