미스틱 로또는 의식이고, 대포 추첨은 액션이라면, 사다리 타기는 결정 도구다.
야식 메뉴 정할 때, 청소 당번 정할 때, 벌칙자 정할 때. 너무 진지하지 않고 그냥 가볍게 누가 뭘 뽑을지 운에 맡기는 거. 그래서 톤도 청록·민트로 잡았다. 신비롭지도 않고 폭발적이지도 않은, 친구들 옆에서 같이 화면 보는 그런 느낌.
만들기는 가장 쉬울 줄 알았다. 알고리즘은 단순하고, 그림도 직선뿐이니까. 그래서 가장 많이 헤맸다.
알고리즘은 정말 단순했다
사다리의 핵심은 두 가지.
- 가로선 생성 — 1:1 매칭 보장
- 출발 → 도착 경로 따라가기
가로선은 그냥 무작위로 그리면 출발 두 개가 같은 도착에 모일 수 있다. 그러면 사다리가 아니다.
해결은 의외로 한 줄.
// 같은 행에서 가로선이 인접하지 않도록
if (c - lastPlaced < 2) continue;
if (Math.random() < prob) {
row.push(c);
lastPlaced = c;
}
한 행에서 가로선이 인접하지 않으면(즉 (0,1) 다음에 (1,2) 같은 게 같은 행에 없으면), 자동으로 1:1 매칭이 보장된다. 그래서 별도의 검증이나 재시도 로직 없이 그냥 셔플해도 안전.
타기 로직도 한 줄짜리.
for (let r = 0; r < shape.rows; r++) {
const row = shape.rungs[r];
if (row.includes(col - 1)) col = col - 1;
else if (row.includes(col)) col = col + 1;
}
여기까지 30분. 게임의 본질은 이미 끝났다. 남은 건 다 시각 작업이다.
근데 그 시각 작업이 무한지옥이었다.
1단계 — 100명까지 가능해야 했다
처음엔 2~10명이면 충분할 줄 알았다. 사용자가 한마디 했다.
"10명 이상일 수도 있잖아. 100명까지 늘려야 할 텐데."
100명. 좋다. 다만 100명 이름과 결과를 다 직접 입력하라고 하면 아무도 안 쓴다. 그래서 두 가지 토글.
- 이름 자동 (P1~P100) — 디폴트 ON. OFF면 직접 입력 grid 노출
- 결과 자동 (당첨 N명 + 꽝) — 디폴트 ON. 슬라이더로 당첨자 수만 정하면 끝
둘 다 자동이면 100명도 클릭 한 번에 시작 가능. 야무진 사다리 게임을 위한 최소 조건이었다.
근데 100명이면 사다리 SVG가 가로로 엄청 넓다. 일반 모바일 화면에 안 들어간다. 가로 스크롤 컨테이너로 감싸고, SVG width는 cols × 36px 정도로 동적 계산. 사다리 영역의 실제 가용 크기를 ResizeObserver로 측정해서 height도 맞춤.
이때부터 디버깅 지옥이 시작됐다.
사다리가 화면에 안 보였다
"근데 사다리 선이 아직도 아예 안 보이는데?"
세로선과 가로선이 통째로 안 보였다. 다양한 원인 가능성:
- SVG 안의
<linearGradient>페인트 서버 참조 실패 <filter url(#...)>글로우 필터가 깨짐- 가로선의
stroke-dasharray=120 + dashoffset=120 + animation forwards패턴이 CSS animation 적용에 실패하면 영원히 invisible
이게 가장 무서운 패턴이었다. dashoffset 0이 되어야 보이는데 keyframe이 한 번이라도 안 작동하면 영원히 안 보임. fallback이 없다.
해결: 가로선 등장을 opacity 0→1 fade-in으로 교체. 같은 animation이 실패해도 opacity는 기본값(1)이라 결국엔 보인다.
// 이전 (실패 시 영구 invisible)
strokeDasharray: 120,
strokeDashoffset: 120,
animation: `ladder-draw-rung 320ms forwards`,
// 이후 (실패해도 보임)
style={{
animation: `ladder-rung-fadein 320ms ease-out both`,
}}
gradient도 단색으로 교체. 페인트 서버를 통째로 떼버려서 fallback에 대한 의존을 0으로.
교훈: 시각 효과의 fallback은 "안 보이는 것"이 아니라 "기본 모양으로라도 보이는 것"이어야 한다.
도트가 순간이동했다
"도트가 막 순간이동하는 것 같아."
CSS transition이 멋지게 적용되어 있는데 왜 부드럽게 안 가지?
// 의심
<g style={{ transition: `transform ${stepMs}ms` }}>
<circle cx={x} cy={y} ... />
</g>
<g>에 transition: transform을 줬는데, 정작 변경하는 건 circle의 cx/cy attribute. SVG attribute는 CSS transition으로 보간되지 않는다. Chrome도 Firefox도 transition이 SVG geometric attribute에 안 적용된다.
해결: cx/cy를 0으로 고정하고, <g>에 transform: translate(${x}px, ${y}px)로 위치 변경. transform은 CSS transition으로 잘 보간됨.
<g style={{
transform: `translate(${x}px, ${y}px)`,
transition: `transform ${stepMs}ms cubic-bezier(0.4, 0, 0.2, 1)`,
}}>
<circle cx={0} cy={0} ... />
</g>
이걸로 도트가 부드럽게 이동.
4번에 도착했는데 1번에 불이 들어왔다
"4번 위치에 도착해도 1번 자리에 불이 들어와."
도착 매칭 버그. 코드 보니까 arrived set이 도착 인덱스(endCol) 로 저장되는데, 하단 라벨 매칭은 시작 인덱스(startCol) 로 검사. 의미가 안 맞았다.
// 라벨 i의 owner = 그 자리에 도착하는 참가자의 startCol
const owner = mapping.indexOf(i);
const showOwner = arrived.has(owner); // ← owner는 startCol, arrived는 endCol set
arrived set을 startCol 기반으로 통일하니까 라벨 매칭이 일관되게 됐다. 단순한 의미론 일치인데 헷갈리기 쉬운 버그.
도트가 대각선으로 갔다
"실제 사다리 위에서 지나가는 게 아니라 대각선으로 이동하는 느낌이야."
tracePath가 한 step에 (row, col)을 동시에 변경했다. transform translate transition은 두 좌표를 직선 보간 → 대각선.
진짜 사다리라면 옆 → 아래 → 옆 → 아래의 cardinal(수직/수평) 이동만 해야 한다.
해결: path를 세로 sub-step + 가로 sub-step으로 분리.
if (cur.row !== prev.row) {
// 세로 먼저: 가로선까지 내려옴
out.push({ col: prev.col, row: cur.row, isHorizontal: false });
}
if (cur.col !== prev.col) {
// 가로 나중: 가로선 위에서 옆으로
out.push({ col: cur.col, row: cur.row, isHorizontal: true });
}
한 sub-step은 row 또는 col 중 하나만 변하니까 transition이 수직 또는 수평 직선.
근데 처음엔 sub-step 순서를 가로 먼저, 세로 나중으로 했었다. 그러면 도트가 한 행 위에서 옆으로 가는 것처럼 보였다 — 사다리 가로선은 cur.row 위치에 그려져 있는데 도트가 prev.row에서 옆으로 가니까 가로선 없는 공중에서 옆으로 가는 모션이 됐다.
세로 먼저(가로선 위치까지 내려옴) → 가로 나중(가로선 위에서 옆으로) 으로 바꿔야 자연스럽다. 사다리 구조와 path 시각을 정확히 맞추는 게 핵심.
라벨이 사다리 옆으로 어긋났다
"공중에서 지나가는 것 같아."
도트가 사다리 세로선 위에 있는 건 맞는데, 상단 라벨이 세로선보다 옆으로 어긋나 있어서 시각적으로 도트가 사다리 옆을 지나가는 것처럼 보였다.
원인: 라벨이 flex justify-around + padding: SIDE_PAD + width: cellW. flex 알고리즘이 라벨을 cellW 영역 가운데 배치하니까 라벨 중심이 세로선 x보다 cellW/2 어긋남.
해결: 라벨을 absolute + left: SIDE_PAD + i*cellW + transform: translateX(-50%). 라벨 중심을 세로선 x에 정확히 align.
SVG와 HTML 라벨이 같은 좌표계를 공유해야 하는데, flex 자동 배치는 그걸 보장 안 해준다.
한 박자 빠른 가로 이동
"가로 이동이 한 박자 빠르게 보여서 사다리 위에서 가는 게 아니라 더 빨리 움직여."
이건 진짜 미묘한 함정. setTimeout과 CSS transition duration이 시간상으론 일치했는데, 다음 step의 duration으로 transition을 설정해서 한 박자 빨라 보였다.
// 잘못
const next = steps[stepIndex + 1];
const upcomingMs = next ? (next.isHorizontal ? horizMs : vertMs) : vertMs;
// 새 transform 적용 시 transition duration이 *다음* step 기반
// 수정
const cur = steps[stepIndex];
const enterMs = stepIndex === 0 ? 0 : (cur.isHorizontal ? horizMs : vertMs);
// 새 transform 적용 시 transition duration = *현재* cur로 들어오는 시간
거기에 cubic-bezier(0.4, 0, 0.2, 1) 곡선은 끝부분에서 감속이라 시각적으로 이미 도착해 보이는데 transition은 끝까지 진행. setTimeout이 정확히 duration에 맞춰 발화하면 "도착 즉시 다음 step" 느낌. 그래서 40ms 버퍼도 추가.
그래서 결국 뭘 배웠나
사다리는 가장 단순한 게임이라고 생각했다. 알고리즘 30분, 시각 1시간이면 충분할 줄. 결과는 30분 + 며칠이었다.
배운 것들:
- SVG attribute는 CSS transition 못 한다. transform 써야 함
- 시각 효과 fallback은 "안 보이는 것"이 아니라 "기본 모양으로라도 보이는 것" 이어야 함
- flex 자동 배치는 SVG와 HTML 좌표 align을 보장하지 않는다. 픽셀 align이 중요하면 absolute로
- transition duration은 "현재 도달 중인" 것의 시간이지 "방금 도착한" 게 아님. 시점이 헷갈리기 쉬움
- 사다리 구조와 path 좌표가 정확히 맞아야 한다 — 가로선 y와 가로 sub-step의 row가 같지 않으면 공중에서 옆으로 감
가장 큰 교훈: "단순해 보이는 것"이 실제로 단순한 게 아니다. 알고리즘은 단순했지만 시각의 디테일이 게임의 본질이었다. 사람들이 사다리 타기에서 보고 싶은 건 "결과"가 아니라 "도트가 사다리 위로 내려가는 그 순간"이니까.