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 짝짓기 도구로 정했습니다.
연출 후보로 핀볼 낙하(Plinko), 캡슐 뽑기, 돌림판, 카드 뒤집기를 놓고 비교한 끝에 핀볼 낙하를 골랐습니다.
물리 엔진으로 매번 다르게 튕기기 때문에 긴장감이 제일 컸습니다.
두 번째는 쓰임새가 가장 넓은데 비어 있던 돌림판 룰렛입니다.
꽝*4처럼 개수를 쓸 수 있고, 참가자보다 적으면 남는 칸은 「꽝」으로 채웁니다.결과를 처음부터 보여 줄 수도 있고, "들어가면 공개" 모드에서는 공이 들어갈 때 카드가 뒤집히며 결과가 나옵니다.
진행은 자동 / 한 명씩(스트리머가 버튼으로 떨어뜨리기)을 고를 수 있고, ×1·×2·×4 배속, 효과음, 결과 복사를 지원합니다.
핀볼은 가운데로 공이 몰리는 경향이 있습니다(이항분포).
그래서 "어느 칸에 무엇이 있는지"가 고정되어 있으면, 가운데 칸 결과가 더 잘 나오게 됩니다.
해결책은 단순합니다. 결과를 칸에 배치하는 순서를 매 판 무작위로 섞습니다.
공이 어느 칸으로 잘 가든, 그 칸에 무엇이 있을지는 균등하게 무작위입니다.
그러니 참가자 입장에서는 어떤 결과가 나올 확률이든 똑같습니다.
시드는 crypto.getRandomValues로 매 판 새로 뽑아서 미리 결과를 정할 수 없게 했습니다.
// 결과는 칸에 무작위로 섞어 놓는다 (같은 명단이라도 매 판 배치가 다르다)
this.slots = shuffle(results, this.rng).map((result, i) => ({ index: i, result, ... }));
예전에 구슬 레이스 첫 버전을 화면을 보지 않고 숫자만 보고 만들었다가 품질이 낮다는 평가를 받은 적이 있습니다.
그래서 이번에는 두 단계로 검증했습니다.
첫 시뮬레이션 결과는 이랬습니다.
{ 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초가 됐습니다.
스크린샷으로 보니 숫자로는 보이지 않던 문제가 나왔습니다.
벌칙*3 → 칸 크기와 당첨 확률이 둘 다 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건
두 도구 모두 팀 규칙대로 진행했습니다.
feature/fe-tool-<도구id>로 만들었습니다.registry.ts의 TOOLS 맨 뒤에 한 줄만 추가했습니다./tools/:toolId)와 전체 화면 버튼은 공통 ToolPage가 자동으로 처리합니다.pnpm typecheck · lint · format:check · build를 모두 통과시켰습니다.심심오락실: https://simsim-arcade.pages.dev
방송 도구:/tools/pinball-ladder,/tools/roulette-wheel