진행 기간: 2024.06 ~ 2024.12
Magical Fortune은 텀블링(Tumbling/Cascading) 메커니즘을 기반으로 한 슬롯이다.
일반 슬롯은 스핀 한 번에 결과가 결정된다. 텀블링 슬롯은 다르다. 당첨 심볼이 제거되고 빈 자리를 위에서 새 심볼이 채운 뒤, 다시 당첨 여부를 검사한다. 연속 당첨이 가능하고, 이 과정이 반복된다.
여기에 와일드 스프레드와 보너스 랜덤 트리거가 더해진다.
텀블링은 단순히 "심볼을 지우고 다시 넣는" 게 아니다. 각 텀블 사이클마다 상태를 추적해야 한다.
스핀 시작
│
▼
릴 결과 생성
│
▼
당첨 계산 ──── 당첨 없음 ──────────────────────────────▶ 스핀 종료
│
당첨 있음
│
▼
당첨 심볼 제거 + 보상 누적
│
▼
새 심볼 낙하 (텀블)
│
▼
다시 당첨 계산 ──▶ (반복)
여기서 중요한 것이 "이미 처리된 심볼을 다음 사이클에서 다시 처리하지 않는 것" 이다.
텀블 이후 새로 채워진 심볼이 다시 당첨 조건을 만들 수 있다. 그런데 이전 텀블에서 살아남은 심볼(당첨에 기여했지만 제거되지 않은 심볼)을 잘못 처리하면 같은 심볼이 중복 계산된다.
해결 방법은 각 텀블 사이클마다 "새로 생성된 심볼" 과 "이전부터 있던 심볼" 을 구분하는 것이다.
// 각 텀블 사이클에서 처리할 심볼 범위를 명시적으로 추적
Set<Position> processedPositions = new HashSet<>();
while (hasWin(window)) {
List<Position> winPositions = calculateWinPositions(window);
removeSymbols(window, winPositions);
fillNewSymbols(window); // 위에서 새 심볼 낙하
// 다음 사이클에서는 새로 채워진 심볼만 당첨 계산 대상
processedPositions.addAll(winPositions);
}
클라이언트는 "몇 번 텀블이 일어났는지"를 알아야 애니메이션을 처리할 수 있다. 처음에는 캐스캐이딩된 심볼 카운트를 내려줬는데, 클라이언트가 실제로 필요한 건 횟수(count) 였다.
// 변경 전: 제거된 심볼 개수
response.setCascadeCount(removedSymbols.size());
// 변경 후: 텀블 발생 횟수
response.setCascadeCount(tumbleCount); // 1, 2, 3...
작은 차이처럼 보이지만, 클라이언트가 "3연속 텀블"을 표현하려면 횟수가 필요하다. 심볼 개수로는 연속 횟수를 알 수 없다.
와일드 심볼이 나왔을 때 인접 셀로 퍼지는 기능이다.
와일드 스프레드에서 가장 신경 써야 할 것은 스프레드로 생성된 와일드가 또 다른 스프레드를 트리거하지 않도록 하는 것 이다.
와일드 A가 퍼져서 와일드 B, C를 만들고, 그 B, C가 다시 퍼진다면 연쇄 폭발이 일어난다. 이건 의도된 스펙이 아니다.
// 원본 와일드와 스프레드된 와일드를 구분
Set<Position> originalWilds = findOriginalWilds(window);
Set<Position> spreadPositions = new HashSet<>();
for (Position wildPos : originalWilds) {
// 원본 와일드 기준으로만 스프레드 계산
spreadPositions.addAll(calculateSpreadArea(wildPos, spreadConfig));
}
// 스프레드된 위치에 와일드 배치
applyWilds(window, spreadPositions);
// 이후 spreadPositions에 있는 와일드는 추가 스프레드 대상에서 제외
원본 와일드 집합을 먼저 확정한 뒤, 그 집합을 기준으로만 스프레드를 계산한다. 스프레드로 생성된 와일드는 스프레드 기준에서 제외한다.
일반 스핀 중 특정 확률로 보너스 피처가 발동된다. 유저가 요청한 스핀 결과와는 별개로, 추가 이벤트가 발생하는 구조다.
클라이언트가 처리해야 할 정보가 두 가지다.
처음 설계는 랜덤 트리거가 발동되면 원래 스핀 결과를 덮어쓰는 방식이었다. 클라이언트에서 문제가 생겼다. 스핀 결과가 사라지면 유저는 어떤 스핀이 나왔는지 볼 수 없다.
// 변경 전: 랜덤 트리거 결과만 반환
return SpinResult.ofRandomTrigger(bonusResult);
// 변경 후: 원래 스핀 결과 + 트리거 이벤트를 함께 반환
return SpinResult.builder()
.reelResult(originalSpinResult) // 원래 스핀은 유지
.randomTriggerEvent(bonusResult) // 트리거는 추가 필드로
.build();
원래 윈도우를 유지하면서 랜덤 트리거 이벤트를 별도 필드로 담는 방식으로 바꿨다. 클라이언트는 트리거 이벤트가 있으면 기존 스핀 결과 위에 트리거 애니메이션을 덧씌우는 방식으로 처리할 수 있다.
텀블링 슬롯의 시뮬레이터는 일반 슬롯보다 복잡하다.
텀블링 슬롯에는 세 종류의 스핀이 있다.
| 종류 | 설명 |
|---|---|
BASE | 일반 스핀 |
INIT_SPIN | 프리스핀 진입 시 첫 스핀 |
END_SPIN | 프리스핀 종료 스핀 |
처음 시뮬레이터를 만들 때 BASE 스핀만 집계했다. RTP를 계산하면 이론값과 차이가 났다.
원인은 INIT_SPIN과 END_SPIN의 보상이 집계에서 빠진 것이었다. 프리스핀 구간에서 발생하는 보상이 포함되지 않으니 실제보다 낮은 RTP가 나왔다.
// 수정 전: BASE 스핀만 집계
if (spinType == BASE) {
accumulate(spinResult);
}
// 수정 후: 모든 스핀 타입 집계
accumulate(spinResult); // 타입에 관계없이 항상 집계
RTP 집계는 스핀 타입에 관계없이 유저가 받은 모든 보상을 포함해야 한다.
RTP만이 아니라 윈 레인지(win range) 도 중요한 시뮬레이터 항목이다. "베팅 대비 몇 배 당첨이 몇 번 발생했는가"를 구간별로 집계한다.
0 ~ 1x : 30%
1x ~ 5x : 40%
5x ~ 20x: 20%
20x ~ : 5%
프리스핀 : 5% ← 이게 집계에서 빠졌다
프리스핀은 별도 루프로 진행되는데, 윈 레인지 집계 로직이 BASE 스핀 루프 안에만 있었다. 프리스핀 루프를 돌면서 발생한 보상이 윈 레인지에 합산되지 않았다.
프리스핀 결과를 BASE 스핀 결과와 합산한 뒤 윈 레인지를 계산하도록 순서를 바꿨다.
텀블링 슬롯은 "처리 순서"가 핵심이다. 각 텀블 사이클에서 무엇을 처리하고 무엇을 보존할지, 어떤 심볼이 새로 생성된 것인지 구분하는 로직이 버그의 대부분을 만든다. 상태를 명시적으로 추적하는 코드가 훨씬 안전하다.
클라이언트와의 계약을 먼저 정의해야 한다. 랜덤 트리거 응답 설계처럼, 서버에서 "편한 방식"으로 응답을 만들면 클라이언트가 처리하기 어렵다. 클라이언트가 어떤 정보를 어떤 순서로 필요로 하는지부터 생각해야 한다.
시뮬레이터는 모든 경로를 커버해야 한다. RTP 시뮬레이터에서 일부 스핀 타입이 빠지면 결과가 조용히 틀려진다. 시뮬레이터 집계 로직을 짤 때는 "유저가 받을 수 있는 모든 보상 경로"를 체크리스트로 만들어두는 게 효과적이다.