Fullstack 117

heo4·5일 전

Fullstack

목록 보기
72/74

풀스택

사다리타기를 핀볼로, 룰렛은 공정하게 — 심심오락실 방송 도구 2종 개발기

2026-10-06 · 심심오락실(simsim-arcade) 팀 프로젝트 · 작성: Heo (팀장 / 프론트엔드)

들어가며

심심오락실은 예능식 퀴즈와 추억의 플래시 게임을 웹에서 즐기는 사이트입니다.
팀원 4명이 1인 1게임으로 게임을 하나씩 만들고 있고, 게임과 별개로 인터넷 방송 스트리머가 쓰는 추첨 도구를 모아 둔 "방송 도구" 섹션도 운영하고 있습니다.
지금까지 방송 도구는 시청자 이름 구슬이 서킷을 달리는 구슬 레이스 하나뿐이었습니다.

오늘은 방송 도구 두 개를 새로 만들어 배포했습니다.

도구한 줄 소개PR
🎯 핀볼 사다리사다리타기 대신 쓰는 1:1 짝짓기 추첨#70
🎡 돌림판 룰렛벌칙·미션·후원 룰렛#71

두 도구 모두 서버 없이 브라우저 안에서만 동작합니다. 사용한 기술은 React + TypeScript + Canvas 2D이고, 핀볼 사다리에만 물리 엔진 matter-js를 썼습니다.
main에 머지되면 Cloudflare Pages로 자동 배포됩니다.


1. 무엇을 만들지 정하기

먼저 기존 도구가 어떤 상황을 커버하지 못하는지부터 정리했습니다.

  • 구슬 레이스는 여러 명 중에서 1등이나 꼴등을 뽑는 도구입니다.
  • 방송에서 자주 쓰는 사다리타기는 성격이 다릅니다. "참가자 N명 ↔ 결과 N개"를 1:1로 짝지어 주는 도구입니다.
  • 스트리머들이 가장 많이 쓰는 후원 룰렛처럼 "사람"이 아니라 "벌칙·미션 중 하나"를 뽑는 도구도 비어 있었습니다.

그래서 첫 번째는 사다리타기를 대체할 1:1 짝짓기 도구로 정했습니다.
연출 후보로 핀볼 낙하(Plinko), 캡슐 뽑기, 돌림판, 카드 뒤집기를 놓고 비교한 끝에 핀볼 낙하를 골랐습니다.
물리 엔진으로 매번 다르게 튕기기 때문에 긴장감이 제일 컸습니다.
두 번째는 쓰임새가 가장 넓은데 비어 있던 돌림판 룰렛입니다.


2. 🎯 핀볼 사다리

동작 방식

  1. 참가자와 결과를 입력합니다. 결과는 꽝*4처럼 개수를 쓸 수 있고, 참가자보다 적으면 남는 칸은 「꽝」으로 채웁니다.
  2. 이름 공이 위에서 하나씩 떨어지고, 핀에 튕기며 아래 결과 칸에 들어갑니다.
  3. 공이 들어간 칸은 뚜껑이 닫힙니다. 다음 공은 닫힌 칸 위를 굴러 빈 칸으로 갑니다.
  4. 그래서 사다리처럼 한 칸에 한 명씩 짝이 지어집니다.

결과를 처음부터 보여 줄 수도 있고, "들어가면 공개" 모드에서는 공이 들어갈 때 카드가 뒤집히며 결과가 나옵니다.
진행은 자동 / 한 명씩(스트리머가 버튼으로 떨어뜨리기)을 고를 수 있고, ×1·×2·×4 배속, 효과음, 결과 복사를 지원합니다.

공정성: 물리는 공정하지 않아도 결과는 공정하게

핀볼은 가운데로 공이 몰리는 경향이 있습니다(이항분포).
그래서 "어느 칸에 무엇이 있는지"가 고정되어 있으면, 가운데 칸 결과가 더 잘 나오게 됩니다.

해결책은 단순합니다. 결과를 칸에 배치하는 순서를 매 판 무작위로 섞습니다.
공이 어느 칸으로 잘 가든, 그 칸에 무엇이 있을지는 균등하게 무작위입니다.
그러니 참가자 입장에서는 어떤 결과가 나올 확률이든 똑같습니다.
시드는 crypto.getRandomValues로 매 판 새로 뽑아서 미리 결과를 정할 수 없게 했습니다.

// 결과는 칸에 무작위로 섞어 놓는다 (같은 명단이라도 매 판 배치가 다르다)
this.slots = shuffle(results, this.rng).map((result, i) => ({ index: i, result, ... }));

화면 없이 먼저 물리부터 검증

예전에 구슬 레이스 첫 버전을 화면을 보지 않고 숫자만 보고 만들었다가 품질이 낮다는 평가를 받은 적이 있습니다.
그래서 이번에는 두 단계로 검증했습니다.

  1. 헤드리스 물리 시뮬레이션: Node에서 화면 없이 2~30명 × 시드 12개 = 84판을 끝까지 돌렸습니다.
  2. 헤드리스 브라우저 스크린샷: 실제 화면을 단계별로 찍어서 눈으로 확인했습니다.

첫 시뮬레이션 결과는 이랬습니다.

{ games: 84, fails: 0, worstSec: '25.0', forced: 34 }

1:1 매칭은 다 됐지만 34번이나 25초 타임아웃으로 강제 배치가 일어났습니다. 원인을 추적해 보니 두 가지였습니다.

① 터널링: 빠른 공이 얇은 뚜껑을 뚫고 지나감

닫힌 칸 뚜껑 두께는 6px이었는데, 높은 곳에서 떨어진 공은 한 스텝에 그보다 많이 움직입니다.
그래서 이미 닫힌 칸 안으로 공이 들어가 버렸습니다.
→ 뚜껑을 칸 안의 공 바로 위까지 두껍게 채우고, 착지 판정도 "칸 바닥 근처에 도달했을 때"로 바꿨습니다.

② 닫힌 뚜껑 위에서 공이 멈춤

빈 칸 쪽으로 살짝 미는 힘을 주는데도 공 속도가 0.4px/스텝에서 더 오르지 않았습니다.
matter-js는 마찰 계수를 두 물체 중 작은 값으로 쓰지만, 정지 마찰(frictionStatic)은 큰 값을 씁니다.
정적 바디의 기본 정지 마찰은 0.5라서, 공이 계속 붙잡혀 있었던 겁니다.
→ 공·핀·벽·뚜껑 모두 friction: 0, frictionStatic: 0으로 맞췄습니다.

수정 후 결과입니다.

{ games: 84, fails: 0, worstSec: '11.8', forced: 0 }

강제 배치 0건, 공 하나당 평균 4~5초가 됐습니다.

화면으로 확인하고 다듬은 것

스크린샷으로 보니 숫자로는 보이지 않던 문제가 나왔습니다.

  • 참가자가 30명이면 핀이 촘촘해지면서 판이 납작해지고 위아래가 텅 빔
    → 줄 수에 상한이 걸리면 줄 간격을 벌려서 판 높이를 유지
  • 좁은 칸에서 "꽝" 같은 한 글자까지 세로로 눕혀져 어색함
    → 글자가 칸 폭에 들어가면 가로 그대로, 넘칠 때만 세로로
  • 30명일 때 옆 목록에서 지금 떨어지는 사람이 안 보임
    → 목록만 자동 스크롤 (페이지는 움직이지 않게)
  • 휴대폰에서 판 위아래에 빈 공간이 큼
    → 무대를 판 비율(9:10)에 맞춤

3. 🎡 돌림판 룰렛

동작 방식

  • 오른쪽 칸에 항목을 한 줄에 하나씩 쓰면 바로 돌림판에 반영됩니다.
  • 벌칙*3 → 칸 크기와 당첨 확률이 둘 다 3배가 됩니다. 눈에 보이는 칸 크기가 곧 확률이라 시청자가 납득하기 쉽습니다.
  • 버튼, 돌림판 클릭, 스페이스바로 돌립니다. 방송 중에는 키보드가 편합니다.
  • 칸 경계를 넘을 때마다 바늘이 튕기며 "딱" 소리가 납니다.
  • 짧게 / 보통 / 길게, 뽑힌 항목 빼기·되돌리기, 기록·복사를 지원합니다.

결과를 먼저 뽑고, 돌림판은 연출만

룰렛을 물리적으로 돌려서 멈춘 곳을 결과로 삼으면, 감속 곡선이나 프레임 속도에 따라 확률이 미묘하게 틀어질 수 있습니다.
그래서 순서를 뒤집었습니다.

  1. 돌리기 전에 가중치대로 당첨 항목을 먼저 뽑습니다 (암호학적 난수).
  2. 그 칸 안에서 멈출 위치를 고르고, 거기서 멈추는 최종 회전 각도를 역산합니다.
  3. 돌림판은 그 각도까지 감속 곡선(1 - (1-t)^4)으로 돌아가기만 합니다.
/** 당첨 칸의 angle 위치가 바늘 아래에 오도록 하는 최종 회전값 — 지금보다 최소 turns 바퀴 더 돈다 */
export function targetRotation(current: number, angle: number, turns: number): number {
  const base = -Math.PI / 2 - angle;
  const k = Math.ceil((current + turns * TAU - base) / TAU);
  return base + k * TAU;
}

"넘어갈 듯 말 듯" 연출

룰렛의 재미는 마지막에 다음 칸으로 넘어갈 듯 말 듯하는 순간에 있습니다.
30% 확률로 멈출 위치를 칸의 끝자락(3~10% 지점) 으로 잡았습니다.

여기서 방향이 중요합니다.
돌림판이 시계 방향으로 돌면 바늘은 칸에 a1 쪽으로 들어와 a0 쪽으로 나갑니다.
그래서 a0 끝자락에 멈춰야 "아슬아슬하게 못 넘어간" 장면이 됩니다.
이 연출은 이미 뽑힌 칸 안에서 위치만 고르는 것이라 확률에는 영향을 주지 않습니다.

검증

가중치 A:B:C:D:꽝 = 1:3:1:5:2 로 2만 번 추첨
→ A 0.084 / B 0.247 / C 0.083 / D 0.419 / 꽝 0.167   (기대값 0.083 / 0.25 / 0.083 / 0.417 / 0.167)

멈춤 각도 8만 회 계산 → 바늘이 가리키는 칸 ≠ 미리 뽑은 칸 : 0건

화면으로 확인하고 다듬은 것

  • 긴 당첨 항목("다음 방송 때 시청자 신청곡 세 곡 부르기")이 당첨 띠에서 "다음 판 …"으로 잘림
    → 줄이지 않고 두 줄까지 표시
  • 휴대폰에서 당첨 띠와 돌리기 버튼이 돌림판을 가림
    → 위아래 자리를 비워 두고, 그 사이에 바늘까지 포함한 높이로 돌림판 크기를 계산
  • 긴 항목 글씨가 칸에 맞추느라 읽을 수 없을 만큼 작아짐
    → 일정 크기 아래로는 줄이지 않고 뒤를 "…"로 자름

4. 협업 흐름

두 도구 모두 팀 규칙대로 진행했습니다.

  • 방송 도구는 담당자가 정해진 게임 카드가 아니라 웹 공통 구성(프론트) 작업입니다.
    그래서 브랜치를 feature/fe-tool-<도구id>로 만들었습니다.
  • 도구 폴더 하나로 완결하고, registry.ts의 TOOLS 맨 뒤에 한 줄만 추가했습니다.
    라우트(/tools/:toolId)와 전체 화면 버튼은 공통 ToolPage가 자동으로 처리합니다.
  • PR 전에 pnpm typecheck · lint · format:check · build를 모두 통과시켰습니다.
  • 팀원 리뷰 → Squash and merge → Cloudflare Pages 자동 배포 순서로 반영했습니다.
    웹만 바뀐 PR이라 API·D1 배포 워크플로는 돌지 않습니다.

5. 배운 점

  1. "공정한 결과"와 "공정해 보이는 연출"을 분리하자.
    결과는 암호학적 난수로 먼저 정하고, 물리나 애니메이션은 그 결과를 보여 주는 역할만 맡깁니다.
    이렇게 하면 연출을 아무리 극적으로 바꿔도 확률이 틀어지지 않습니다.
  2. 물리 엔진은 기본값을 믿지 말자.
    matter-js의 마찰 결합 방식(동마찰은 작은 값, 정지 마찰은 큰 값)과 터널링은 직접 돌려 보기 전에는 몰랐을 문제였습니다.
  3. 화면 없는 시뮬레이션 + 스크린샷, 두 단계로 검증하자.
    시뮬레이션은 "끝까지 잘 끝나는가"를 대량으로 확인해 주고, 스크린샷은 "보기에 괜찮은가"를 잡아 줍니다.
    둘 중 하나만으로는 이번에 고친 문제의 절반밖에 못 찾았을 겁니다.

다음에 해 보고 싶은 것

  • 랜덤 팀 나누기: 시청자 참여 게임용 "N명씩 M팀" 나누기
  • 폭탄 돌리기: 시간이 다 되면 탈락하는 서바이벌 추첨
  • 채팅 연동: 방송 채팅으로 참가 신청을 받아 바로 추첨에 넣기 (백엔드 작업이 필요해서 팀과 논의 예정)

심심오락실: https://simsim-arcade.pages.dev
방송 도구: /tools/pinball-ladder, /tools/roulette-wheel

0개의 댓글