왜 퐁이었나Why Pong?
그때까지 만든 것은 전부 오프라인 싱글플레이였다. 화면 안에서 일어나는 일은 전부 내 컴퓨터 한 대가 계산했고, 그 결과가 곧 정답이었다. 유저 두 명의 화면이 서로 같은 상태를 보고 있어야 하는 구조는 한 번도 다뤄 본 적이 없었다.
Everything I had built up to this point was offline single-player. Whatever happened on screen was calculated by one machine, and its state was automatically the truth. Having two players' screens agree on the same game state was something I had never touched.
그 기초를 잡으려고 고른 소재가 퐁이다. 패들 두 개와 공 하나. 게임 로직이 이보다 단순할 수 없으니, 문제가 생기면 그건 거의 확실히 네트워크 쪽 문제다. 게임이 복잡했다면 "이게 게임 버그인가 동기화 버그인가"를 가리는 데만 시간을 다 썼을 것이다.
To learn those fundamentals, I picked Pong: two paddles and one ball. Because the game rules could not be any simpler, any bug that showed up was almost guaranteed to be a networking bug. If the game mechanics had been complex, I would have spent half my time figuring out whether a bug was gameplay logic or state desync.
| 항목 | 내용 |
|---|---|
| 엔진 | Unity 2021.3.45f2 |
| 역할 | 1인 개발 (100%) |
| 네트워크 | Photon PUN2 |
| 인증 | Firebase Authentication |
| 결과 | 완성 |
| Item | Details |
|---|---|
| Engine | Unity 2021.3.45f2 |
| Role | Solo development (100%) |
| Networking | Photon PUN2 |
| Authentication | Firebase Authentication |
| Status | Completed |

리소스는 일부러 만들지 않았다. 유니티 기본 스프라이트와 사각형·원 오브젝트로 끝냈다. 이 프로젝트에서 배우려던 것은 화면이 아니라 연결이었다.
I deliberately skipped custom art assets, using only Unity's default sprites, rectangles, and circles. What I wanted to learn here was not rendering—it was synchronization.
1. 게임에 들어가기 전 — Firebase 인증1. Before Entering the Game — Firebase Authentication
먼저 붙인 것은 로그인이다. 이메일과 비밀번호로 계정을 만들고 로그인하면, 그 유저 정보를 로비 UI(PlayerNameText)에 표시한다.
The first thing I wired up was login. Once a user signs in with an email and password, their account info is displayed in the lobby UI (PlayerNameText).
Firebase는 앱 종속성을 먼저 확인하고 초기화해야 한다. 초기화가 끝나기 전에 로그인 버튼을 누를 수 있으면 안 되므로, 준비 상태와 진행 중 상태를 각각 플래그로 들고 있다가 버튼 자체를 잠근다.
Firebase requires checking app dependencies and initializing asynchronously first. Since pressing the sign-in button before initialization finishes would fail, the script tracks readiness and in-progress states with boolean flags and disables the button while a request is in flight.
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");
}
});
}
SceneManager.LoadScene이나 UI 갱신을 하려면 메인 스레드로 되돌려 줘야 한다. ContinueWith 대신 ContinueWithOnMainThread를 쓰는 이유가 그것이다.SceneManager.LoadScene or updating UI elements inside a standard ContinueWith will fail. ContinueWithOnMainThread marshals the callback back onto Unity's main thread.2. 로비 — 방이 있으면 들어가고, 없으면 만든다2. Lobby — Join a Room If One Exists, Create One If Not
매칭 로직은 단순하게 잡았다. 접속 상태면 랜덤 룸에 조인하고, 아직 마스터 서버에 붙지 않았으면 먼저 연결한다. 그리고 들어갈 방이 없어서 실패한 경우를 방 생성 신호로 쓴다.
I kept matchmaking simple: if connected, try joining a random room; if not connected to the Master Server yet, connect first. Then use the random-join failure callback as the trigger to create a new room.
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");
}
"방 목록을 받아서 → 빈 방을 고르고 → 없으면 만든다"가 아니라 실패 콜백을 분기점으로 쓰는 구조다. 방 목록을 클라이언트가 들고 판단하면 그 사이에 다른 유저가 그 방을 채울 수 있다. 조인 시도를 서버에 맡기고 실패했을 때만 만드는 쪽이 경쟁 상태에 덜 취약하다.
Instead of "fetch room list → pick an open room → create one if empty," the failure callback itself acts as the branch. If a client fetches a room list and decides locally, another player might fill that room in the meantime. Letting the server attempt the join and only creating a room on OnJoinRandomFailed avoids that race condition.
씬 이동에 SceneManager.LoadScene이 아니라 PhotonNetwork.LoadLevel을 쓴 것도 이유가 있다. 같은 방에 있는 클라이언트들이 서로 다른 씬에 서 있는 구간을 없애기 위해서다.
Using PhotonNetwork.LoadLevel instead of SceneManager.LoadScene is also deliberate: it keeps clients in the same room from sitting in different scenes at different times.
3. 소유권을 나누는 지점3. Splitting Object Ownership
여기가 이 프로젝트의 핵심이다. 방에 들어오면 각 클라이언트는 자기 플레이어 프리팹만 인스턴스화한다. 그런데 공은 다르다. 공은 어느 한쪽의 것이 아니다.
This is the core of the project. Upon entering the room, each client instantiates only its own player paddle prefab. The ball, however, is different—it does not belong to either player.
그래서 공은 마스터 클라이언트만 스폰하도록 했다.
So only the Master Client spawns the ball.
private void Start()
{
playerScores = new[] { 0, 0 };
SpawnPlayer();
if (PhotonNetwork.IsMasterClient)
{
SpawnBall();
}
}
PhotonView로 상대에게 알린다. 하지만 공은 아무도 조작하지 않는다. 두 클라이언트가 각자 공을 스폰하면 공이 두 개가 되고, 각자 물리를 돌리면 소수점 오차와 프레임 타이밍 차이 때문에 시간이 갈수록 두 화면의 공 위치가 벌어진다. 주인 없는 오브젝트는 주인을 정해 줘야 한다.PhotonView. Nobody directly controls the ball. If both clients spawn a ball, there are two balls; if both run physics independently, floating-point drift and frame timing differences will quickly split the ball into two different positions. Any unowned shared object needs a single designated authority.물리도 마스터가 단독으로 돌린다Only the Master Client Runs Ball Physics
스폰만 마스터가 하는 게 아니라 충돌 검사와 반사 연산 자체를 마스터에서만 수행하게 했다. 마스터가 아닌 클라이언트는 FixedUpdate 첫 줄에서 그냥 돌아간다.
Not only does the Master Client spawn the ball, collision detection and bounce math also run exclusively on the Master Client. Non-master clients simply return on the first line of 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;
}
private void FixedUpdate()
{
// Ignore physics calculation if not the Master Client
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)
{
// Handle scoring
}
// Calculate reflection angle and add slight random bounce jitter
direction = Vector2.Reflect(direction, hit.normal);
direction += Random.insideUnitCircle * randomRefectionIntensity;
}
transform.position = (Vector2)transform.position + direction * distance;
}
이 코드에서 눈여겨볼 지점이 몇 개 있다.
A few details in this snippet are worth noting:
- 콜라이더 충돌이 아니라 Raycast로 미리 본다. 이번 프레임에 이동할 거리(
distance)만큼 진행 방향으로 광선을 쏘고, 거기에 뭔가 걸리면 그때 반사한다. 공이 빠르면 한 프레임에 벽을 뚫고 지나가는 터널링이 생기는데, 이동 거리만큼 미리 검사하면 그 문제를 피할 수 있다. Vector2.Reflect(direction, hit.normal)한 줄이 입사각·반사각 계산 전부다. 맞은 면의 법선 벡터만 있으면 튕겨 나가는 방향이 나온다.Random.insideUnitCircle로 흔들어 준다. 순수한 물리 반사만 쓰면 공이 같은 궤도를 무한히 왕복해서 랠리가 끝나지 않는다. 반사 방향에 작은 무작위 성분을 더해 궤도가 조금씩 달라지게 했다.PlayerList.Length < 2체크. 상대가 아직 안 들어왔는데 공이 굴러가면 혼자 실점한다. 두 명이 찰 때까지 공을 멈춰 둔다.
- Predictive raycasting instead of collider contact. Each physics step casts a ray forward by
distance(the exact distance the ball will travel this frame) and reflects if anything is in the way. Fast-moving balls can tunnel straight through thin walls in a single step; raycasting the travel distance ahead prevents tunneling. Vector2.Reflect(direction, hit.normal)handles the entire angle-of-incidence reflection in one line using the surface normal.- Adding jitter with
Random.insideUnitCircle. With purely deterministic reflections, the ball can get locked into an endless back-and-forth loop. Adding a tiny random offset nudges the trajectory each bounce. - Checking
PlayerList.Length < 2. If the ball starts moving before the second player finishes loading in, the empty side immediately concedes points. The ball stays frozen until both players are present.
4. 점수는 RPC로 뿌린다4. Broadcasting Scores via RPC
득점 판정은 이미 마스터가 물리를 단독으로 돌리고 있으니 자연히 마스터의 몫이 된다. 문제는 그 결과를 양쪽 화면에 똑같이 띄우는 것이다.
Because the Master Client runs the ball physics, it naturally detects goals as well. The next job is displaying the updated score identically on both screens.
점수판은 [PunRPC]로 모든 클라이언트에게 브로드캐스트한다.
Score updates are broadcast to every client using [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를 받아서 표시한다. 마스터와 다른 클라이언트가 화면을 갱신하는 경로가 하나로 통일된다.
Using RpcTarget.All executes the RPC on the sender as well. In other words, even the Master Client does not update its scoreboard UI directly—it updates the UI when it receives its own RPC. Both clients share the exact same UI update path.
여기서 얻은 것Takeaways
정리하면 이 프로젝트에서 실제로 손에 익은 것은 세 가지다.
Three practical things stuck with me from building this project:
| 배운 것 | 구체적으로 |
|---|---|
| 로비 시스템 | 마스터 서버 접속 → 랜덤 조인 → 실패 시 방 생성 → 동기 씬 로드 |
| 인스턴스 소유권 분리 | 플레이어는 각자, 공은 마스터만. 물리 연산 주체를 하나로 고정 |
| RPC 통신 | RpcTarget.All 브로드캐스트로 모든 클라이언트의 표시 경로 통일 |
| Topic | Specifically |
|---|---|
| Lobby Flow | Connect to Master Server → Join random room → Create room on failure → Synced level load |
| Ownership Separation | Each client owns its paddle; Master Client owns the ball and runs its physics |
| RPC Broadcasting | Unifying UI updates across all clients via RpcTarget.All |
그리고 Firebase 인증을 붙여 본 경험이 예상 밖으로 남았다. 로그인한 유저가 누구인지 아는 순간부터 "이 유저의 데이터를 어디에 어떻게 둘 것인가" 라는 질문이 생긴다. 이번에는 로비에 이름을 띄우는 데서 끝났지만, 멀티플레이어를 다루려면 결국 마주쳐야 하는 구조다.
Wiring up Firebase Authentication also left an unexpected impression. As soon as your game knows *who* the logged-in user is, the next question is "Where and how do we store this user's persistent data?" Here I only displayed their email in the lobby, but any real multiplayer game eventually has to answer that question.
네트워크를 처음 다룬다면 소재를 최대한 단순하게 잡는 것을 권한다. 퐁 정도의 게임에서도 "이건 누가 계산하지"라는 질문은 계속 나온다. 그 질문에 답하는 연습이 이 프로젝트의 전부였다.
If you are writing netcode for the first time, pick the simplest game possible. Even in a game as small as Pong, you constantly have to ask, "Which machine is responsible for calculating this?" Practicing how to answer that question was the entire point of this project.