charades.cyh-lab.com은 서버가 없다. 외부 API 호출도 없다. 게임 진행 중 상태 전부를 클라이언트에서만 관리한다. 처음에는 단순하게 생각했는데, 막상 타이머·팀 점수·현재 라운드·라이어 게임 역할 배정까지 맞물리다 보니 상태 관리를 어떻게 할지 고민이 좀 됐다. 정리한 방식을 기록해둔다.
useState만으로는 한계가 생긴다
게임 하나에 상태가 꽤 많다. 몸으로 말해요를 예로 들면 현재 제시어, 팀별 점수, 남은 시간, 현재 라운드, 게임 진행 단계(설정→게임중→결과) 정도가 있다.
처음엔 각 컴포넌트에 useState를 분산해서 썼는데, 컴포넌트 간에 상태를 전달하는 props가 길어지면서 관리가 어려워졌다. 특히 타이머가 끝났을 때 상위 컴포넌트까지 이벤트를 올려보내는 흐름이 복잡해졌다.
useReducer로 게임 상태를 한 곳에 모았다
게임별로 reducer를 만들어서 상태를 한 곳에서 관리하는 방식으로 바꿨다.
// 몸으로 말해요 상태 타입
type CharadesState = {
phase: 'setup' | 'playing' | 'result'
currentWord: string
timeLeft: number
scores: Record<string, number> // 팀명: 점수
currentTeam: string
round: number
}
type CharadesAction =
| { type: 'START_GAME'; word: string; team: string }
| { type: 'TICK' }
| { type: 'CORRECT'; team: string }
| { type: 'SKIP' }
| { type: 'TIME_UP' }
| { type: 'RESET' }
function charadesReducer(state: CharadesState, action: CharadesAction): CharadesState {
switch (action.type) {
case 'START_GAME':
return { ...state, phase: 'playing', currentWord: action.word, currentTeam: action.team }
case 'TICK':
return { ...state, timeLeft: state.timeLeft - 1 }
case 'CORRECT':
return {
...state,
scores: { ...state.scores, [action.team]: (state.scores[action.team] ?? 0) + 1 },
}
case 'TIME_UP':
return { ...state, phase: 'result' }
case 'RESET':
return initialState
default:
return state
}
}이렇게 하면 상태 변경 로직이 reducer 안에 모여서 추적하기 쉬워진다. 컴포넌트는 dispatch만 호출하면 된다.
타이머는 useEffect로 처리
타이머는 useInterval 커스텀 훅을 만들어서 썼다.
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}게임 중일 때만 타이머를 돌리고, phase가 바뀌면 멈추는 방식으로 연결했다.
useInterval(
() => dispatch({ type: 'TICK' }),
state.phase === 'playing' && state.timeLeft > 0 ? 1000 : null
)
useEffect(() => {
if (state.phase === 'playing' && state.timeLeft === 0) {
dispatch({ type: 'TIME_UP' })
}
}, [state.timeLeft, state.phase])라이어 게임은 역할 배정이 핵심
라이어 게임은 상태보다 초기 설정이 중요했다. 인원 수와 라이어 수를 받아서 랜덤으로 역할을 배정하는 로직을 썼다.
function assignRoles(players: string[], liarCount: number) {
const shuffled = [...players].sort(() => Math.random() - 0.5)
return shuffled.map((player, index) => ({
name: player,
isLiar: index < liarCount,
}))
}역할 배정 결과는 화면에 한 명씩 확인하는 방식으로 구현했다. 옆 사람이 볼 수 없게 확인 버튼 누르고 본인이 봐야 하는 구조다.
서버 없이 충분하다
멀티플레이 실시간 게임이 아니라 한 화면을 같이 보면서 하는 파티게임이라 서버가 필요 없었다. 오히려 서버 없이 정적 배포로 끝나니까 인프라 관리 부담이 없다. Vercel 무료 플랜으로 운영 가능한 이유이기도 하다.