왜 퐁이었나

그때까지 만든 것은 전부 오프라인 싱글플레이였다. 화면 안에서 일어나는 일은 전부 내 컴퓨터 한 대가 계산했고, 그 결과가 곧 정답이었다. 유저 두 명의 화면이 서로 같은 상태를 보고 있어야 하는 구조는 한 번도 다뤄 본 적이 없었다.

그 기초를 잡으려고 고른 소재가 퐁이다. 패들 두 개와 공 하나. 게임 로직이 이보다 단순할 수 없으니, 문제가 생기면 그건 거의 확실히 네트워크 쪽 문제다. 게임이 복잡했다면 "이게 게임 버그인가 동기화 버그인가"를 가리는 데만 시간을 다 썼을 것이다.

항목내용
엔진Unity 2021.3.45f2
역할1인 개발 (100%)
네트워크Photon PUN2
인증Firebase Authentication
결과완성
패들 두 개와 공 하나로 구성된 단순한 도형의 네트워크 퐁 플레이 화면
플레이 화면. 네트워크 로직에 집중하려고 유니티 기본 UI 스프라이트와 단순한 도형 오브젝트만으로 구성했다.

리소스는 일부러 만들지 않았다. 유니티 기본 스프라이트와 사각형·원 오브젝트로 끝냈다. 이 프로젝트에서 배우려던 것은 화면이 아니라 연결이었다.

1. 게임에 들어가기 전 — Firebase 인증

먼저 붙인 것은 로그인이다. 이메일과 비밀번호로 계정을 만들고 로그인하면, 그 유저 정보를 로비 UI(PlayerNameText)에 표시한다.

Firebase는 앱 종속성을 먼저 확인하고 초기화해야 한다. 초기화가 끝나기 전에 로그인 버튼을 누를 수 있으면 안 되므로, 준비 상태와 진행 중 상태를 각각 플래그로 들고 있다가 버튼 자체를 잠근다.

public void SignIn()
{
    if(!IsFirebaseReady || IsSignInOnProgress || User != null) return;

    IsSignInOnProgress = true;
    signInButton.interactable = false;

    firebaseAuth.SignInWithEmailAndPasswordAsync(emailField.text, passwordField.text).ContinueWithOnMainThread(task => { 
        IsSignInOnProgress = false;
        signInButton.interactable = true;

        if (task.IsFaulted) {
            Debug.LogError(task.Exception);
        } else if (!task.IsCanceled) {
            User = task.Result;
            SceneManager.LoadScene("Lobby");
        }
    });
}
ContinueWithOnMainThreadFirebase의 비동기 작업은 완료 콜백이 메인 스레드가 아닌 곳에서 불릴 수 있다. 유니티 API는 메인 스레드에서만 호출해야 하므로, 그 안에서 SceneManager.LoadScene이나 UI 갱신을 하려면 메인 스레드로 되돌려 줘야 한다. ContinueWith 대신 ContinueWithOnMainThread를 쓰는 이유가 그것이다.

2. 로비 — 방이 있으면 들어가고, 없으면 만든다

매칭 로직은 단순하게 잡았다. 접속 상태면 랜덤 룸에 조인하고, 아직 마스터 서버에 붙지 않았으면 먼저 연결한다. 그리고 들어갈 방이 없어서 실패한 경우를 방 생성 신호로 쓴다.

public void Connect()
{
    if (PhotonNetwork.IsConnected) {
        PhotonNetwork.JoinRandomRoom();
    } else {
        PhotonNetwork.ConnectUsingSettings();
    }
}

public override void OnJoinRandomFailed(short returnCode, string message)
{
    PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 });
}

public override void OnJoinedRoom()
{
    PhotonNetwork.LoadLevel("Main");
}

"방 목록을 받아서 → 빈 방을 고르고 → 없으면 만든다"가 아니라 실패 콜백을 분기점으로 쓰는 구조다. 방 목록을 클라이언트가 들고 판단하면 그 사이에 다른 유저가 그 방을 채울 수 있다. 조인 시도를 서버에 맡기고 실패했을 때만 만드는 쪽이 경쟁 상태에 덜 취약하다.

씬 이동에 SceneManager.LoadScene이 아니라 PhotonNetwork.LoadLevel을 쓴 것도 이유가 있다. 같은 방에 있는 클라이언트들이 서로 다른 씬에 서 있는 구간을 없애기 위해서다.

3. 소유권을 나누는 지점

여기가 이 프로젝트의 핵심이다. 방에 들어오면 각 클라이언트는 자기 플레이어 프리팹만 인스턴스화한다. 그런데 공은 다르다. 공은 어느 한쪽의 것이 아니다.

그래서 공은 마스터 클라이언트만 스폰하도록 했다.

private void Start()
{
    playerScores = new[] { 0, 0 };
    SpawnPlayer();

    if (PhotonNetwork.IsMasterClient)
    {
        SpawnBall();
    }
}
왜 공만 다르게 취급하나패들은 입력의 주인이 분명하다. 내 패들은 내가 움직이고, 그 결과를 PhotonView로 상대에게 알린다. 하지만 공은 아무도 조작하지 않는다. 두 클라이언트가 각자 공을 스폰하면 공이 두 개가 되고, 각자 물리를 돌리면 소수점 오차와 프레임 타이밍 차이 때문에 시간이 갈수록 두 화면의 공 위치가 벌어진다. 주인 없는 오브젝트는 주인을 정해 줘야 한다.

물리도 마스터가 단독으로 돌린다

스폰만 마스터가 하는 게 아니라 충돌 검사와 반사 연산 자체를 마스터에서만 수행하게 했다. 마스터가 아닌 클라이언트는 FixedUpdate 첫 줄에서 그냥 돌아간다.

private void FixedUpdate()
{
    // 마스터 클라이언트가 아니면 물리 연산 무시
    if(!IsMasterClientLocal || PhotonNetwork.PlayerList.Length < 2) return;

    var distance = speed * Time.deltaTime;
    var hit = Physics2D.Raycast(transform.position, direction, distance);

    if(hit.collider != null)
    {
        var goalPost = hit.collider.GetComponent<Goalpost>();
        if(goalPost != null)
        {
            // 점수 판정 처리
        }
        // 반사각 계산 및 랜덤 튕김 요소 부여
        direction = Vector2.Reflect(direction, hit.normal);
        direction += Random.insideUnitCircle * randomRefectionIntensity;
    }
    transform.position = (Vector2)transform.position + direction * distance;
}

이 코드에서 눈여겨볼 지점이 몇 개 있다.

4. 점수는 RPC로 뿌린다

득점 판정은 이미 마스터가 물리를 단독으로 돌리고 있으니 자연히 마스터의 몫이 된다. 문제는 그 결과를 양쪽 화면에 똑같이 띄우는 것이다.

점수판은 [PunRPC]로 모든 클라이언트에게 브로드캐스트한다.

public void AddScore(int playerNumber, int score)
{
    photonView.RPC("RPCUpdateScoreText", RpcTarget.All, playerScores[0].ToString(), playerScores[1].ToString());
}
 
[PunRPC]
private void RPCUpdateScoreText(string player1ScoreText, string player2ScoreText)
{
    scoreText.text = $"{player1ScoreText} : {player2ScoreText}";
}

RpcTarget.All이므로 호출한 본인도 포함해서 실행된다. 즉 마스터도 자기 점수를 직접 올려서 표시하는 게 아니라, 자기가 보낸 RPC를 받아서 표시한다. 마스터와 다른 클라이언트가 화면을 갱신하는 경로가 하나로 통일된다.

이 구조가 말해 주는 원칙계산은 한 곳에서만 하고, 나머지는 결과를 받는다. 공의 물리도, 점수 판정도 마스터 하나가 맡는다. 판정 주체가 둘 이상이면 지금은 맞아 보여도 언젠가는 어긋난다. 네트워크 코드에서 반복해서 나오는 형태다.

여기서 얻은 것

정리하면 이 프로젝트에서 실제로 손에 익은 것은 세 가지다.

배운 것구체적으로
로비 시스템마스터 서버 접속 → 랜덤 조인 → 실패 시 방 생성 → 동기 씬 로드
인스턴스 소유권 분리플레이어는 각자, 공은 마스터만. 물리 연산 주체를 하나로 고정
RPC 통신RpcTarget.All 브로드캐스트로 모든 클라이언트의 표시 경로 통일

그리고 Firebase 인증을 붙여 본 경험이 예상 밖으로 남았다. 로그인한 유저가 누구인지 아는 순간부터 "이 유저의 데이터를 어디에 어떻게 둘 것인가" 라는 질문이 생긴다. 이번에는 로비에 이름을 띄우는 데서 끝났지만, 멀티플레이어를 다루려면 결국 마주쳐야 하는 구조다.

네트워크를 처음 다룬다면 소재를 최대한 단순하게 잡는 것을 권한다. 퐁 정도의 게임에서도 "이건 누가 계산하지"라는 질문은 계속 나온다. 그 질문에 답하는 연습이 이 프로젝트의 전부였다.