DEV NOTE · SLINGSTAR

하루 하나의 9홀: DAILY 시드, 진행 저장, 이모지 스코어카드

SLINGSTAR에는 서버가 없다. 그런데도 같은 날 접속한 사람은 모두 같은 9홀을 돌고, 결과를 이모지 한 줄로 서로 비교할 수 있다. 날짜 문자열 하나가 코스 전체를 결정하도록 만들었기 때문이다. 이 글에서는 DAILY 시드가 어떻게 코스가 되는지, 중간에 나갔다 돌아올 때 무엇을 저장하는지, 그리고 1.1.0에서 타수 비교를 점수제로 바꾸면서 스코어카드와 공유 텍스트가 어떻게 달라졌는지를 정리한다.

날짜 문자열 하나로 코스 만들기

DAILY 코스의 시드는 slingstar-daily-2026-10-13 같은 문자열이다. 이 문자열을 32비트 해시로 바꾸고, 그 값으로 mulberry32 난수기를 초기화한다. 둘 다 Math.imul과 비트 연산만 쓰므로 어느 브라우저에서나 같은 난수열이 나온다. 코스 생성기는 이 난수열로 행성을 배치하고, 실제 물리로 시험 발사를 돌려 홀을 채택한다. 물리와 발사 방향 계산에서도 기기마다 결과가 달라질 수 있는 삼각함수를 쓰지 않으므로, 문자열이 같으면 9홀의 행성 위치와 기준 샷까지 전부 같다.

서버에서 오늘의 코스를 내려받는 방식도 생각할 수 있지만, 그러면 코스 데이터를 만들고 보관하고 배포하는 일이 매일 생긴다. 시드 방식은 코스를 저장하지 않고 필요할 때마다 다시 만든다. 사이트가 정적 파일로만 배포되고, 오프라인에서도 PWA로 돌아가야 한다는 조건과도 맞았다. 비행기 안에서 열어도 오늘의 DAILY가 나온다.

날짜는 기기의 현지 날짜를 쓴다. 한국에서 10월 13일 아침에 연 사람과 다른 시간대에서 그 나라의 10월 13일에 연 사람은 같은 코스를 받는다. 같은 순간에 서로 다른 날짜의 코스를 보고 있을 수는 있지만, "10월 13일 코스"라는 이름이 가리키는 홀은 어디서나 하나다.

코스 번호와 서머타임

타이틀에는 #7 같은 코스 번호가 붙는다. 공개일인 2026년 10월 7일이 #1이다. 번호는 날짜 문자열을 다시 쪼개 연·월·일을 Date.UTC에 넣고, 기준일과의 밀리초 차이를 하루 길이로 나눠 반올림해서 구한다. 현지 시각 자정끼리 빼면 서머타임이 있는 지역에서는 하루가 23시간이나 25시간이 되는 날이 생겨 번호가 하나 밀릴 수 있다. 양쪽을 모두 UTC 자정으로 바꿔 빼면 그런 날이 없다. 작은 차이지만, 공유한 결과의 번호가 친구와 다르면 같은 코스를 비교한다는 전제 자체가 흔들린다.

무엇을 언제 저장하는가

DAILY 진행은 slingstar.daily.날짜 키 하나에 들어간다. 저장 내용은 홀별 샷 수 배열, 홀별 점수 배열, 지금 홀 번호, 완료 여부다. 저장 시점은 홀을 시작할 때, 샷이 끝날 때마다(착지·정지·벌타·진입 모두), 그리고 일시정지 메뉴에서 나갈 때다. 다시 들어오면 샷 수와 점수를 복원하고 저장된 홀부터 시작한다.

혜성의 위치는 저장하지 않는다. 그래서 홀 중간에 나갔다 들어오면 그 홀에서 이미 쓴 샷 수는 남아 있고 혜성만 시작점으로 돌아온다. 샷 수를 지우는 방법이 없으니 나갔다 들어오는 것으로 나쁜 샷을 무를 수는 없다. 대신 착지로 잡아 둔 유리한 위치도 잃는다. 위치까지 저장하면 행성 표면 좌표와 비행 중 상태까지 다뤄야 해서, 지금은 샷 수만 지키는 쪽을 택했다.

완주하면 완료 표시와 함께 합계 샷, 코스 기준 샷, 총점을 저장한다. 완료된 날의 DAILY를 다시 열면 게임을 시작하지 않고 그날의 스코어카드를 바로 연다. 하루 한 코스라는 규칙을 지키는 장치다. 이 경로에는 1.1.1에서 고친 버그가 하나 있었다. 스코어카드를 바로 열면 진행 중인 홀이 없어서 혜성 상태가 비어 있는데, 렌더러는 배경 뒤로 계속 그 홀을 그리려다 매 프레임 오류를 냈다. 렌더러가 혜성이 없으면 행성과 블랙홀까지만 그리고 멈추도록 한 줄을 넣어 고쳤다.

RANDOM 코스의 시드는 free- 뒤에 현재 시각과 난수를 붙인 문자열이고, 진행은 저장하지 않는다. 완주 횟수, 최고 점수, 누적 홀인원 수는 모드와 상관없이 slingstar.stats에 쌓인다. 모든 기록은 브라우저 localStorage에만 있고 서버로 보내는 것은 없다.

타수에서 점수로: 1.1.0의 변경

1.0.0의 결과는 골프 그대로였다. 설계 문서의 공유 예시처럼 9홀 합계 샷 수를 PAR 합계와 비교해 "20 (−2)" 식으로 보여 줬다. 1.1.0에서 이것을 점수제로 바꿨다. 홀마다 기준 샷에 맞추면 1,000점, 1샷 적을 때마다 +300, 많을 때마다 −200(최소 100점)이고, 홀인원은 +500, 날아가는 동안 행성 가까이(반지름 2.2배 안쪽)를 지나갔다면 중력 어시스트로 +200을 더한다.

function holePoints(shots,par,assist){
  const d=shots-par;
  let p=d<=0?1000+(-d)*300:Math.max(100,1000-d*200);
  if(shots===1) p+=500; if(assist) p+=200; return p; }

바꾼 이유를 정리하면 두 가지다. 타수 비교는 9홀 합계가 몇 샷 차이로 몰려서, 같은 결과를 낸 사람이 많고 잘 친 홀과 운 좋게 들어간 홀이 구분되지 않는다. 그리고 게임이 권하는 플레이, 즉 행성 곁을 스치며 휘어 들어가는 샷에 보상이 없었다. 중력 어시스트 보너스는 같은 샷 수로 넣더라도 중력을 쓴 쪽을 높게 친다. 최소 100점을 둔 것은 아무리 오래 걸려도 홀을 끝냈다는 사실에는 점수를 준다는 의미다.

같은 버전에서 PAR라는 단어를 "기준 N샷"으로 바꿨다. 골프 용어를 모르는 사람에게 PAR 3은 아무 정보도 주지 않지만 "기준 3샷"은 읽는 순간 뜻이 통한다. HUD에는 "2샷 남음", "기준 도달", "기준 초과" 같은 안내를 붙였고, 홀을 끝내면 샷 수·기준·보너스 내역·획득 점수를 보여 주는 결과 카드가 뜬다.

공유 텍스트는 세 줄

스코어카드의 SHARE 버튼이 만드는 텍스트는 다음 형식이다. 첫 줄은 코스 번호와 총점, 합계 샷, 기준 샷. 둘째 줄은 홀마다 이모지 하나. 셋째 줄은 게임 주소다.

SLINGSTAR #7 · 10,500점 · 30샷 (기준 33)
🟢🐦🟢⭐🟢🔴🟢🐦🟢
https://rogagame.com/slingstar/

위 예시는 형식을 보여 주기 위해 만든 것이다. 이모지는 ⭐ 홀인원, 🦅 기준보다 2샷 이상 적게, 🐦 1샷 적게, 🟢 기준, 🔴 초과다. 홀인원은 기준 샷과 상관없이 ⭐가 우선한다. 이 줄에는 행성 위치나 쏜 방향 같은 정보가 전혀 없어서, 아직 오늘 코스를 안 친 친구가 봐도 공략이 새지 않는다. 그러면서도 몇 번 홀에서 고생했는지는 한눈에 보인다.

전송은 단계적으로 시도한다. 기기에 공유 시트(navigator.share)가 있으면 그것을 연다. 사용자가 시트를 닫으면(AbortError) 아무것도 하지 않는다. 그 외 이유로 실패하거나 공유 시트가 없으면 클립보드에 복사하고 "결과를 복사했어요"를 띄운다. 클립보드 권한까지 막혀 있으면 텍스트를 4초 동안 화면에 그대로 보여 줘서 직접 옮겨 적을 수 있게 했다.

정리

DAILY 코스는 날짜 문자열, 결정적인 난수기, 삼각함수 없는 물리, 이 세 가지가 맞물려야 성립한다. 어느 하나라도 기기마다 다른 값을 내면 "같은 코스"라는 약속이 깨진다. 저장은 샷 수와 점수만 지키도록 단순하게 두었고, 공유는 결과만 담고 공략은 담지 않게 했다. 아쉬운 점은 RANDOM 코스다. 시드가 공유 텍스트에 들어가지 않아서 재미있는 랜덤 코스를 만나도 친구에게 같은 코스를 보낼 방법이 없다. 시드를 주소에 실어 같은 코스를 여는 기능이 다음 후보다.