따꿍의 프로젝트

[Precourse] 프리코스 챌린지 - Snake 게임 만들기 본문

우테코

[Precourse] 프리코스 챌린지 - Snake 게임 만들기

공장 주인 따꿍 2025. 11. 22. 01:22

Snake Game with Websocket

배경

예전에 항해 코육대 게임 만들기 대회를 했었는데 당시에는 개발 실력이 좋지 않아서 단순한 행맨 게임을 만들었었다.  

https://github.com/quothraven1122/HangMan

 

GitHub - quothraven1122/HangMan

Contribute to quothraven1122/HangMan development by creating an account on GitHub.

github.com

프런트엔드 초짜였던 시절이라 진짜 순수 html css js으로 만들었었다.

개발 실력도 키웠으니 조금 더 복잡한 게임을 만들어 보고 싶기도 했고, websocket으로 무언가를 만들어보고 싶어서 

Snake 게임을 구상해 보았다.

 

사실 내가 작업하고 있는 대형 프로젝트 snorose에서 

다른 TF 팀에서 일림 기능 (댓글 달렸거나 포인트 받을때 알림이 뜬다)을 websocket 비슷한걸로 만들었는데

나도 한번 싸보고 싶은 욕심이 생겨서 만들어봤다. 

 

목적은 다음과 같다:    
- React에 TS를 쓰는 방법을 배워서 TS를 사용해보고 싶다.
- websocket을 써보고 싶다.

 

개발 프레임워크

- TS로 React

- JS로 Node

- 양방향에 Websocket 사용

 

개요

두 사람이 랜덤으로 매칭되어 뱀을 조종한다.    
뱀은 맵 밖으로 나가거나, 머리가 상대의 뱀에 부딪히면 죽는다.    
뱀 크기는 7칸이다.    

게임의 목적은 의도적으로 상대의 머리가 내 뱀의 몸통에 부딪히도록 유도하는 것이다

뱀은 방향키로 조종한다. 

 

배포된 사이트 링크

https://277c98ed.snake-4vt.pages.dev/

 

snake

 

277c98ed.snake-4vt.pages.dev

(다시 블로그 볼 나를 위한 편의성 배포 링크)
https://dash.cloudflare.com/1b84baad3a4230b0d9468bf4aa137065/pages/view/snake

https://dashboard.render.com/web/srv-d4eomvngi27c73cqun2g/events

 

Cloud Application Hosting for Developers | Render

Render is a unified cloud to build and run all your apps and websites with free SSL, global CDN, private networks and automatic deploys from Git.

dashboard.render.com

프런트는 cloudflare에 배포했고 백엔드는 dashboard에 배포했다. 

 

(다시 블로그 볼 나를 위한 편의성 깃허브 링크)

프런트: https://github.com/quothraven1122/javascript-snake-game

 

GitHub - quothraven1122/javascript-snake-game

Contribute to quothraven1122/javascript-snake-game development by creating an account on GitHub.

github.com

백엔드: https://github.com/quothraven1122/javascript-snake-game-server

 

GitHub - quothraven1122/javascript-snake-game-server

Contribute to quothraven1122/javascript-snake-game-server development by creating an account on GitHub.

github.com


개발 과정

처음에는 프런트엔드 측에서 useEffect와 useCallback을 사용해서 

Unity의 무한 루프로 돌아가는 게임 코드 공간을 모방했다. 

근데 나중에 알고 보니 2p 게임이 sync하게 돌아가려면 게임 로직은 서버에 있어야 했다. 

Front End responsibility:

  • Send key presses to server
  • Render the board sent by the server

Server responsibility:

  • Update snake positions
  • Detect collisions
  • Keep both players in sync
  • Send board updates 10 times/sec

그래서 원래 프런트에 존재했던 게임 로직을 백엔드 서버로 옮겼다. 

 

또한 원래는 3판 2선제로, 그걸 기록하기 위해서 scoreboard를 만들었었는데
그것보다는 두 플레이어가 websocket room에 잘 들어왔는지,

매칭이 잘 선사 됐는지 확인해주는 UI가 더 필요하다고 보아서

Scoreboard를 ReadyBox로 교체해줬다. 

 

그 외에는 채팅 기능을 만들었다. 

 

코드 로직

1. 클라이언트가 사이트 접속 → WebSocket 연결됨

2. 서버가 이 클라이언트를 waitingPlayer로 저장함

3. 두 번째 클라이언트도 접속하면 → 방(room) 생성

4. 두 플레이어에게 matched 메시지를 보내서 p1, p2 역할 부여

5. 서버가 게임 루프 시작

  • 두 뱀 이동
  • 충돌 체크
  • 게임 상태(gameState)를 매 프레임마다 두 플레이어에게 전송

6. 클라이언트는 gameState를 받아서 보드를 업데이트함

7. 클라이언트가 방향키 누르면 서버에 dir 메시지 전송

8. 서버가 방향 업데이트하여 다음 틱에 반영

9. 충돌 시 서버가 gameOver 전송

10. 한쪽이 WebSocket 연결 종료하면 → 방 제거 + 다른 플레이어는 다시 waiting 상태로 복귀


배운 점

1. Hook을 언제 만들어줘야하는지 좀 이해가 되기 시작한 것 같다. 

비슷한 기능에 포함되는 함수야 그냥 js파일에 따로 묶어서 다 export하면 되지 않나 싶었는데

그러면 useState나 useEffect, useRef같은 React 기능을 사용할 수가 없다. 

그래서 비슷한 기능에 포함되는 함수들은 

Hook에다가 묶어서 export하는게 낫다. 

 

2. Hook 안에  useState, useEffect와 useCallback이 모두 사용 가능했다.

Hook안에 useEffect를 쓰면

const { playerId, readyState, chatMessages, sendMessage, board, result } = useWebSocket();

이렇게 다른 컴포넌트에서 hook을 이렇게 mount하기만 해도
안에 있는 모든 useEffect가 돌아가는게 신기했다.
Hook이랑 useEffect랑 같이 잘 안사용해봤어서 처음 알았다

 

또한, Hook에 다른 컴포넌트의 ref를 사용하고 싶으면,

//useWebSocket
export function useWebSocket(ref){}

//MainScreen
const { playerId, readyState, chatMessages, sendMessage, board, result } = useWebSocket(ref);

이런식으로 가져오는게 아니라,

1) 일단 Hook에서 useRef를 사용해 ref 변수를 만든다

2) Hook에서 ref 변수를 리턴한다.

3) 다른 컴포넌트에서 Hook을 mount해서 ref 변수를 꺼내낸다.

4) 그 다른 컴포넌트에 ref를 빼내고 싶은 애에 ref={refVar} 이렇게 달아준다. 

//대충 이런식
const { containerRef, autoScroll, scrollToBottom } = useAutoScroll<HTMLDivElement>();

<div ref={containerRef} className={styles.chatContainer}>

 

3. websocket은 context에 저장해서 사용하는 것이 좋았다. 

아무래도 모든 파일에 사용할 예정이니

어디든 사용할 수 있게 context에 저장하는 것이 유리했다. 

 

4. 의외로 websocket 사용은 간단했다

겁낼게 없었다. 

1) 일단 useRef안에 new WebSocket("서버링크")를 만들고 context로 모든 파일에서 사용 가능하게 만든다

2) useEffect가 [wsRef]을 신경쓰게 하고 (websocket 방이 죽었는지 확인해야함 - 죽으면 새로운 방에서 listen해야기 떄문)

3) listen method들은 다음과 같았다

.onopen: 연결과 동시에 실행

.onclose: 연결 끊길시 실행

.onerror: 문제 생길시 실행

.onmessage: 메세지 주고 받기 위해 필수 (가장 중요!)

- 메세지 받기

//프런트 쪽 예시
wsRef.current.onmessage = (event) => {
      const data = JSON.parse(event.data);
      if (data.type === "waiting") {
        setPlayerId("p1");
        setReadyState({ p1: true, p2: false });
      }
      if (data.type === "matched") {
        setPlayerId(data.playerId);
        setReadyState({ p1: true, p2: true });
      }
      if (data.type === "chat") {
        setChatMessages((prev) => [
          ...prev,
          { playerId: data.playerId, text: data.message },
        ]);
      }
      if (data.type === "gameState") {
        const snakes = {
          p1: data.p1.body,
          p2: data.p2.body,
        };
        updateBoard(snakes);
      }
      if (data.type === "gameOver") {
        setResult(data.result);
      }
    };
    
    //백엔드 쪽 예시
ws.on("message", (msg) => {
      const data = JSON.parse(msg.toString());

      // Handle chat separately (even if not matched yet)
      if (data.type === "chat") {
        if (ws.roomId && rooms[ws.roomId]) {
          rooms[ws.roomId].players.forEach((player) =>
            player.send(
              JSON.stringify({
                type: "chat",
                playerId: ws.playerId,
                message: data.message,
              })
            )
          );
        }
        return;
      }

      const room = rooms[ws.roomId];
      if (!room) return;

      if (data.type === "dir") {
        const snake = room.snakes[ws.playerId];
        if (snake && !snake.isDead) snake.setDirection(data.direction);
      }
    });

- 메세지 전달 (send키워드 사용)

//프런트쪽 예시
useEffect(() => {
    const handleKeyPress = (e: KeyboardEvent) => {
      let dir: "UP" | "DOWN" | "LEFT" | "RIGHT" | null = null;

      if (e.key === "ArrowUp") dir = "UP";
      if (e.key === "ArrowDown") dir = "DOWN";
      if (e.key === "ArrowLeft") dir = "LEFT";
      if (e.key === "ArrowRight") dir = "RIGHT";
      if (!dir) return;

      wsRef.current?.send(
        JSON.stringify({
          type: "dir",
          direction: dir,
        })
      );
    };

//백엔드쪽 예시
if (ws.roomId && rooms[ws.roomId]) {
          rooms[ws.roomId].players.forEach((player) =>
            player.send(
              JSON.stringify({
                type: "chat",
                playerId: ws.playerId,
                message: data.message,
              })
            )
          );
        }

ws.onmessage=()=>{} 를 하든, ws.on("message",()=>{})를 하든 상관 없다

 

5. 우테코에서 class 사용하는 거 배웠는데 써먹어봤다!

함수 형태로 계속 React를 사용해온 나로서는 JS에서 class를 만들어서 사용하는건 생소했다

애초에 내가 초보 개발자라서 잘 안써왔는 것일 수도 있다.

그래도 배웠는데 써먹어봐야지 싶어서 Snake을 클래스로 생성했는데

길이, body위치, 어느 플레이어것인지, 가는 방향, 죽었는지 여부 등을 깔끔하게 저장할 수 있어서 좋았던 것 같다. 

 

적용하는데 꽤 애를 먹었지만 뭐, 다음번에는 조금 더 자연스럽게 다가오지 않겠는가


웃긴 일화

왜 2p 게임을 기획했는지 하나도 모르겠다.

multiplayer 게임을 워낙 좋아해서 무의식적으로 그런 게임을 만들어보고 싶었나보다. 

 

두 플레이어 모두에게 잘 돌아가는지 확인하려면 두번째 플레이어가 필요했는데

나는 거지라서 다른 컴퓨터가 없다.

그래서 이거 테스트하기 위해서 서버배포하고, 피방까지 가서 VSCode의 Port 기능 (그걸로 Forwarded Address 생성 가능)으로 주소 만들고, 피방 컴퓨터로 그거 열어서

혼자서 왼손으로 p1 오른손으로 p2 플레이하고 있었다.

 

두 역할을 한 사람이 수행해나가는 것은 머리깨지는 일이었다.

p1 이기는 시나리오, p2 이기는 시나리오, 동점인 시나리오 모두 테스트해야했는데

따로 노는 손으로 승리를 조작하는건 여간 어려운 일이 아니었다.

'우테코' 카테고리의 다른 글

[Precourse] 최종 코딩 테스트 - 행성 로또 후기  (1) 2026.01.10
[Precourse] 로또  (0) 2025.11.01
[Precourse] 자동차 경주  (0) 2025.10.25
[Precourse] 문자열 덧셈 계산기  (0) 2025.10.16