(function () {
  "use strict";

  var STORAGE_KEY = "daily-dice-roll-v3";
  var BGM_COOKIE_NAME = "daily-dice-bgm-status";
  var BGM_COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
  var ROLL_DURATION = 1750;
  var TELEGRAM_ENDPOINT = "./telegram-roll.php";
  // Change this number to control how many times one username OR phone
  // number can roll per calendar day on this browser.
  var DAILY_ROLL_LIMIT = 1;

  // EDIT PRIZE PROBABILITY HERE.
  // These values are relative percentages. Keeping the total at 100 makes
  // them easiest to understand, but the game safely normalizes any total.
  // Set a prize to 0 to disable it completely.
  var PRIZE_CHANCES = {
    jackpot: 13, // FREE RM10 - Double 6
    big: 22, // FREE RM9 - Total 10
    lucky: 65, // FREE RM8 - Total 7
    none: 0, // No prize
  };

  var PRIZES = {
    jackpot: {
      label: "FREE RM10",
      icon: "♛",
      message: "Double six! You won FREE RM10.",
      points: 100,
    },
    big: {
      label: "FREE RM9",
      icon: "★",
      message: "A total of ten wins FREE RM9.",
      points: 50,
    },
    lucky: {
      label: "FREE RM8",
      icon: "♟",
      message: "A lucky total of seven wins FREE RM8.",
      points: 20,
    },
    none: {
      label: "Try Again Tomorrow",
      icon: "◆",
      message: "No prize this time. Come back for another roll tomorrow.",
      points: 0,
    },
  };

  var WINNING_PAIRS = {
    jackpot: [[6, 6]],
    big: [
      [4, 6],
      [5, 5],
      [6, 4],
    ],
    lucky: [
      [1, 6],
      [2, 5],
      [3, 4],
      [4, 3],
      [5, 2],
      [6, 1],
    ],
  };

  var PIP_MAP = {
    1: [4],
    2: [0, 8],
    3: [0, 4, 8],
    4: [0, 2, 6, 8],
    5: [0, 2, 4, 6, 8],
    6: [0, 2, 3, 5, 6, 8],
  };

  var FACE_DEFINITIONS = [
    {
      value: 1,
      normal: { x: 0, y: 0, z: 1 },
      center: { x: 0, y: 0, z: 1 },
      u: { x: 1, y: 0, z: 0 },
      v: { x: 0, y: 1, z: 0 },
      vertices: [
        { x: -1, y: -1, z: 1 },
        { x: 1, y: -1, z: 1 },
        { x: 1, y: 1, z: 1 },
        { x: -1, y: 1, z: 1 },
      ],
    },
    {
      value: 6,
      normal: { x: 0, y: 0, z: -1 },
      center: { x: 0, y: 0, z: -1 },
      u: { x: -1, y: 0, z: 0 },
      v: { x: 0, y: 1, z: 0 },
      vertices: [
        { x: 1, y: -1, z: -1 },
        { x: -1, y: -1, z: -1 },
        { x: -1, y: 1, z: -1 },
        { x: 1, y: 1, z: -1 },
      ],
    },
    {
      value: 3,
      normal: { x: 1, y: 0, z: 0 },
      center: { x: 1, y: 0, z: 0 },
      u: { x: 0, y: 0, z: -1 },
      v: { x: 0, y: 1, z: 0 },
      vertices: [
        { x: 1, y: -1, z: 1 },
        { x: 1, y: -1, z: -1 },
        { x: 1, y: 1, z: -1 },
        { x: 1, y: 1, z: 1 },
      ],
    },
    {
      value: 4,
      normal: { x: -1, y: 0, z: 0 },
      center: { x: -1, y: 0, z: 0 },
      u: { x: 0, y: 0, z: 1 },
      v: { x: 0, y: 1, z: 0 },
      vertices: [
        { x: -1, y: -1, z: -1 },
        { x: -1, y: -1, z: 1 },
        { x: -1, y: 1, z: 1 },
        { x: -1, y: 1, z: -1 },
      ],
    },
    {
      value: 5,
      normal: { x: 0, y: -1, z: 0 },
      center: { x: 0, y: -1, z: 0 },
      u: { x: 1, y: 0, z: 0 },
      v: { x: 0, y: 0, z: 1 },
      vertices: [
        { x: -1, y: -1, z: -1 },
        { x: 1, y: -1, z: -1 },
        { x: 1, y: -1, z: 1 },
        { x: -1, y: -1, z: 1 },
      ],
    },
    {
      value: 2,
      normal: { x: 0, y: 1, z: 0 },
      center: { x: 0, y: 1, z: 0 },
      u: { x: 1, y: 0, z: 0 },
      v: { x: 0, y: 0, z: -1 },
      vertices: [
        { x: -1, y: 1, z: 1 },
        { x: 1, y: 1, z: 1 },
        { x: 1, y: 1, z: -1 },
        { x: -1, y: 1, z: -1 },
      ],
    },
  ];

  var elements = {};
  var appData = readStorage();
  var activePlayerKey = "";
  var isRolling = false;
  var soundOn = false;
  var audioContext = null;
  var toastTimer = null;
  var popupConfettiTimer = null;
  var losingPairs = buildLosingPairs();
  var diceEngine = null;
  var activeInviteCode = "";
  var inviteBonusClaimState = {};
  var resultPopupPreviousFocus = null;

  function readStorage() {
    try {
      var stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY));
      return stored && stored.players ? stored : { players: {} };
    } catch (error) {
      return { players: {} };
    }
  }

  function writeStorage() {
    try {
      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(appData));
    } catch (error) {
      // The game remains usable when local storage is unavailable.
    }
  }

  function readCookie(name) {
    var prefix = name + "=";
    var cookieParts = String(document.cookie || "").split(";");
    for (var index = 0; index < cookieParts.length; index += 1) {
      var cookie = cookieParts[index].trim();
      if (cookie.indexOf(prefix) === 0) {
        return decodeURIComponent(cookie.slice(prefix.length));
      }
    }
    return "";
  }

  function saveBackgroundMusicStatus() {
    document.cookie =
      BGM_COOKIE_NAME +
      "=" +
      (soundOn ? "playing" : "paused") +
      "; Max-Age=" +
      BGM_COOKIE_MAX_AGE +
      "; Path=/; SameSite=Lax";
  }

  function syncSoundToggle() {
    document.body.classList.toggle("sound-muted", !soundOn);
    elements.soundToggle.setAttribute(
      "aria-label",
      soundOn ? "Pause background music" : "Play background music",
    );
    elements.soundToggle.setAttribute("aria-pressed", String(soundOn));
  }

  function startBackgroundMusic() {
    if (!soundOn || !elements.backgroundMusic) return Promise.resolve();
    elements.backgroundMusic.volume = 0.32;
    var playRequest = elements.backgroundMusic.play();
    return playRequest && typeof playRequest.catch === "function"
      ? playRequest.catch(function () {
          // Browsers may wait for the next user gesture before allowing audio.
        })
      : Promise.resolve();
  }

  function pauseBackgroundMusic() {
    if (elements.backgroundMusic) elements.backgroundMusic.pause();
  }

  function toggleBackgroundMusic() {
    if (soundOn && elements.backgroundMusic && elements.backgroundMusic.paused) {
      saveBackgroundMusicStatus();
      syncSoundToggle();
      startBackgroundMusic();
      showToast("Background music playing.");
      return;
    }

    soundOn = !soundOn;
    saveBackgroundMusicStatus();
    syncSoundToggle();

    if (soundOn) {
      startBackgroundMusic();
      showToast("Background music playing.");
      playTone(430, 0, 0.12, 0.035, "sine");
    } else {
      pauseBackgroundMusic();
      showToast("Background music paused.");
    }
  }

  function restoreBackgroundMusicStatus() {
    soundOn = readCookie(BGM_COOKIE_NAME) === "playing";
    syncSoundToggle();
    if (!soundOn) {
      pauseBackgroundMusic();
      return;
    }

    startBackgroundMusic();
    var resumeSavedMusic = function () {
      if (soundOn && elements.backgroundMusic.paused) startBackgroundMusic();
    };
    document.addEventListener("pointerdown", resumeSavedMusic, {
      once: true,
      capture: true,
    });
    document.addEventListener("keydown", resumeSavedMusic, {
      once: true,
      capture: true,
    });
  }

  function restoreRememberedDetails() {
    if (!appData.rememberDetails) return;
    var remembered = appData.rememberedPlayer || {};
    elements.rememberPlayer.checked = true;
    elements.playerName.value = cleanName(remembered.name);
    elements.phoneNumber.value = cleanPhone(remembered.phone);
  }

  function saveRememberedDetails() {
    if (!elements.rememberPlayer.checked) return;
    appData.rememberDetails = true;
    appData.rememberedPlayer = {
      name: cleanName(elements.playerName.value),
      phone: cleanPhone(elements.phoneNumber.value),
    };
    writeStorage();
  }

  function toggleRememberDetails() {
    if (elements.rememberPlayer.checked) {
      saveRememberedDetails();
      showToast("Your details will be remembered on this browser.");
    } else {
      appData.rememberDetails = false;
      delete appData.rememberedPlayer;
      writeStorage();
      showToast("Remembered username and phone number removed.");
    }
  }

  function handlePlayerInput() {
    saveRememberedDetails();
    renderPlayer();
  }

  function buildLosingPairs() {
    var pairs = [];
    for (var first = 1; first <= 6; first += 1) {
      for (var second = 1; second <= 6; second += 1) {
        if ([7, 10, 12].indexOf(first + second) === -1) {
          pairs.push([first, second]);
        }
      }
    }
    return pairs;
  }

  function todayKey() {
    var now = new Date();
    return [
      now.getFullYear(),
      String(now.getMonth() + 1).padStart(2, "0"),
      String(now.getDate()).padStart(2, "0"),
    ].join("-");
  }

  function cleanName(value) {
    return String(value || "").trim().replace(/\s+/g, " ");
  }

  function nameKey(value) {
    return cleanName(value).toLowerCase();
  }

  function cleanPhone(value) {
    return String(value || "").trim().replace(/\s+/g, " ");
  }

  function phoneDigits(value) {
    var digits = cleanPhone(value).replace(/\D/g, "");
    return digits.indexOf("60") === 0 ? "0" + digits.slice(2) : digits;
  }

  function isValidPhone(value) {
    var phone = cleanPhone(value);
    var rawDigits = phone.replace(/\D/g, "");
    if (!/^[+0-9() .-]+$/.test(phone)) return false;
    if (phone.charAt(0) === "+" && rawDigits.indexOf("60") !== 0) return false;
    return /^01(?:0|1|2|3|4|6|7|8|9)\d{6,8}$/.test(phoneDigits(phone));
  }

  function playerKey(name, phone) {
    return nameKey(name) + "::" + phoneDigits(phone);
  }

  function getActivePlayer() {
    return activePlayerKey ? appData.players[activePlayerKey] || null : null;
  }

  function ensurePlayerRecord(name, phone) {
    var key = playerKey(name, phone);
    if (!appData.players[key]) {
      appData.players[key] = {
        name: name,
        phone: phone,
        points: 0,
        lastRollDate: "",
        lastShareDate: "",
        history: [],
      };
    } else {
      appData.players[key].name = name;
      appData.players[key].phone = phone;
    }
    return appData.players[key];
  }

  function ensureActivePlayer() {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    activePlayerKey = playerKey(name, phone);
    return ensurePlayerRecord(name, phone);
  }

  function dateKey(dateValue) {
    var date = dateValue instanceof Date ? dateValue : new Date(dateValue);
    if (Number.isNaN(date.getTime())) return "";
    return [
      date.getFullYear(),
      String(date.getMonth() + 1).padStart(2, "0"),
      String(date.getDate()).padStart(2, "0"),
    ].join("-");
  }

  function playerRollCountToday(player) {
    if (!player) return 0;
    var history = Array.isArray(player.history) ? player.history : [];
    var count = history.filter(function (entry) {
      return entry && dateKey(entry.createdAt) === todayKey();
    }).length;

    if (count === 0 && history.length === 0 && player.lastRollDate === todayKey()) {
      return 1;
    }
    return count;
  }

  function dailyRollCount(name, phone) {
    var normalizedName = nameKey(name);
    var normalizedPhone = phoneDigits(phone);

    return Object.keys(appData.players).reduce(function (total, key) {
      var player = appData.players[key];
      if (!player) return total;
      var sameName = normalizedName && nameKey(player.name) === normalizedName;
      var samePhone = normalizedPhone && phoneDigits(player.phone) === normalizedPhone;
      return total + (sameName || samePhone ? playerRollCountToday(player) : 0);
    }, 0);
  }

  function bonusRollsUsedToday(player) {
    if (!player || player.bonusRollsUsedDate !== todayKey()) return 0;
    return Math.max(0, Math.floor(Number(player.bonusRollsUsedToday) || 0));
  }

  function rollLimitForPlayer(player) {
    var unspentBonusRolls = player
      ? Math.max(0, Math.floor(Number(player.bonusRolls) || 0))
      : 0;
    return DAILY_ROLL_LIMIT + unspentBonusRolls + bonusRollsUsedToday(player);
  }

  function consumeBonusRoll(player) {
    if (!player || Math.max(0, Number(player.bonusRolls) || 0) < 1) return;
    if (player.bonusRollsUsedDate !== todayKey()) {
      player.bonusRollsUsedDate = todayKey();
      player.bonusRollsUsedToday = 0;
    }
    player.bonusRolls = Math.max(0, Math.floor(Number(player.bonusRolls) || 0) - 1);
    player.bonusRollsUsedToday =
      Math.max(0, Math.floor(Number(player.bonusRollsUsedToday) || 0)) + 1;
  }

  function pluralRoll(count) {
    return count === 1 ? "roll" : "rolls";
  }

  function randomFrom(list) {
    return list[Math.floor(Math.random() * list.length)];
  }

  function choosePrize() {
    var keys = ["jackpot", "big", "lucky", "none"];
    var chances = keys.map(function (key) {
      var value = Number(PRIZE_CHANCES[key]);
      return Number.isFinite(value) && value > 0 ? value : 0;
    });
    var totalChance = chances.reduce(function (total, chance) {
      return total + chance;
    }, 0);

    if (totalChance <= 0) return "none";

    var cursor = Math.random() * totalChance;
    for (var index = 0; index < keys.length; index += 1) {
      cursor -= chances[index];
      if (cursor < 0) return keys[index];
    }
    return "none";
  }

  function pairForPrize(prizeKey) {
    return prizeKey === "none" ? randomFrom(losingPairs) : randomFrom(WINNING_PAIRS[prizeKey]);
  }

  function generateInviteCode(name) {
    if (!name) return "PLAYRHWHP";
    var compact = name.toUpperCase().replace(/[^A-Z0-9]/g, "");
    var prefix = (compact + "PLAY").slice(0, 4);
    var hash = 7;
    for (var index = 0; index < name.length; index += 1) {
      hash = (hash * 31 + name.charCodeAt(index)) >>> 0;
    }
    return (prefix + hash.toString(36).toUpperCase().padStart(4, "0").slice(-4)).slice(0, 8);
  }

  function cleanInviteCode(value) {
    var code = String(value || "").trim().toUpperCase();
    return /^[A-Z0-9]{6,16}$/.test(code) ? code : "";
  }

  function inviteCodeFromUrl() {
    try {
      return cleanInviteCode(new URLSearchParams(window.location.search).get("invite"));
    } catch (error) {
      return "";
    }
  }

  function inviteDetailsForPlayer(name) {
    var code = generateInviteCode(name);
    var baseUrl = window.location.href.split("?")[0].split("#")[0];
    return {
      code: code,
      link: baseUrl + "?invite=" + encodeURIComponent(code),
    };
  }

  function dailyEntriesForIdentity(name, phone, rollLimit) {
    var normalizedName = nameKey(name);
    var normalizedPhone = phoneDigits(phone);
    var entries = [];

    Object.keys(appData.players).forEach(function (key) {
      var storedPlayer = appData.players[key];
      if (!storedPlayer) return;
      var sameName = normalizedName && nameKey(storedPlayer.name) === normalizedName;
      var samePhone = normalizedPhone && phoneDigits(storedPlayer.phone) === normalizedPhone;
      if (!sameName && !samePhone) return;

      (storedPlayer.history || []).forEach(function (entry) {
        if (entry && dateKey(entry.createdAt) === todayKey()) entries.push(entry);
      });
    });

    return entries
      .sort(function (first, second) {
        return new Date(first.createdAt).getTime() - new Date(second.createdAt).getTime();
      })
      .slice(-Math.max(1, Number(rollLimit) || DAILY_ROLL_LIMIT));
  }

  function formatTime(dateValue) {
    return new Intl.DateTimeFormat("en", {
      hour: "2-digit",
      minute: "2-digit",
    }).format(new Date(dateValue));
  }

  function renderPlayer() {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    var nameValid = name.length >= 2;
    var phoneValid = isValidPhone(phone);
    var valid = nameValid && phoneValid;
    var showPhoneError = phone.length > 0 && !phoneValid;
    elements.phoneError.hidden = !showPhoneError;
    elements.phoneNumber.setAttribute("aria-invalid", String(showPhoneError));
    activePlayerKey = valid ? playerKey(name, phone) : "";
    var player = valid ? getActivePlayer() : null;
    var rollsUsed = valid ? dailyRollCount(name, phone) : 0;
    var rollLimit = valid ? rollLimitForPlayer(player) : DAILY_ROLL_LIMIT;
    var remainingRolls = Math.max(0, rollLimit - rollsUsed);
    var limitReached = remainingRolls === 0;

    if (valid) claimInviteBonuses(name, phone);
    var bonusClaimPending =
      valid && inviteBonusClaimState[activePlayerKey] === "pending";

    elements.rollButton.disabled =
      !valid || limitReached || isRolling || bonusClaimPending;
    elements.diceCanvas.setAttribute(
      "aria-disabled",
      String(!valid || limitReached || isRolling || bonusClaimPending),
    );
    elements.inviteCode.textContent = generateInviteCode(name);
    if (elements.rewardPoints) {
      elements.rewardPoints.textContent = player ? player.points || 0 : 0;
    }

    if (!valid) {
      elements.rollButtonText.textContent = "ROLL DICE";
      if (!nameValid && !phoneValid) {
        elements.rollHelper.innerHTML =
          '<span aria-hidden="true">▣</span> Enter your player name and phone number to start.';
      } else if (!nameValid) {
        elements.rollHelper.innerHTML =
          '<span aria-hidden="true">▣</span> Enter at least 2 characters in your player name.';
      } else {
        elements.rollHelper.innerHTML =
          '<span aria-hidden="true">▣</span> Enter a valid Malaysian mobile number and try again.';
      }
      elements.resultTitle.textContent = "Roll to reveal your prize";
      elements.resultMessage.textContent =
        "You have " +
        DAILY_ROLL_LIMIT +
        " " +
        pluralRoll(DAILY_ROLL_LIMIT) +
        " available today.";
      // elements.resultIcon.textContent = "♜";
      elements.readyStatus.className = "status-pill";
      elements.readyStatus.querySelector("span").textContent = "Ready to roll!";
      clearPrizeHighlight();
      renderHistory(null);
      diceEngine.setValues([2, 3]);
      elements.totalValue.textContent = "5";
      return;
    }

    if (limitReached) {
      elements.rollButtonText.textContent =
        rollLimit === 1 ? "TODAY'S ROLL USED" : "TODAY'S ROLLS USED";
      elements.rollHelper.innerHTML =
        '<span aria-hidden="true">✓</span> ' +
        rollsUsed +
        " of " +
        rollLimit +
        " rolls used. Come back tomorrow.";
      elements.readyStatus.className = "status-pill status-pill--used";
      elements.readyStatus.querySelector("span").textContent = "Daily limit reached";

      if (player && player.history && player.history.length) {
        var latest = player.history[0];
        renderResult(latest);
        diceEngine.setValues(latest.dice);
        elements.totalValue.textContent = latest.total;
      } else {
        clearPrizeHighlight();
        // elements.resultIcon.textContent = "◆";
        elements.resultTitle.textContent = "Daily roll limit reached";
        elements.resultMessage.textContent =
          "This username or phone number has already used today's rolls.";
        diceEngine.setValues([2, 3]);
        elements.totalValue.textContent = "5";
      }
    } else if (player && player.history && player.history.length) {
      var latest = player.history[0];
      elements.rollButtonText.textContent = "ROLL AGAIN";
      elements.rollHelper.innerHTML =
        '<span aria-hidden="true">◆</span> ' +
        rollsUsed +
        " of " +
        rollLimit +
        " rolls used. " +
        remainingRolls +
        " " +
        pluralRoll(remainingRolls) +
        " remaining today.";
      elements.readyStatus.className = "status-pill";
      elements.readyStatus.querySelector("span").textContent =
        remainingRolls + " " + pluralRoll(remainingRolls) + " left";
      renderResult(latest);
      diceEngine.setValues(latest.dice);
      elements.totalValue.textContent = latest.total;
    } else {
      elements.rollButtonText.textContent = "ROLL DICE";
      elements.rollHelper.innerHTML =
        '<span aria-hidden="true">◆</span> ' +
        rollLimit +
        " " +
        pluralRoll(rollLimit) +
        " available for " +
        escapeHtml(name) +
        " today.";
      elements.resultTitle.textContent = "Roll to reveal your prize";
      elements.resultMessage.textContent = "Your daily reward is waiting.";
      // elements.resultIcon.textContent = "♜";
      elements.readyStatus.className = "status-pill";
      elements.readyStatus.querySelector("span").textContent = "Ready to roll!";
      clearPrizeHighlight();
      if (player && player.history && player.history.length) {
        diceEngine.setValues(player.history[0].dice);
        elements.totalValue.textContent = player.history[0].total;
      } else {
        diceEngine.setValues([2, 3]);
        elements.totalValue.textContent = "5";
      }
    }

    renderHistory(player);
    elements.shareNote.textContent =
      player && player.lastShareDate === todayKey()
        ? "Every successful Invited get x1 more chance to roll."
        : "Every successful Invited get x1 more chance to roll.";
  }

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;");
  }

  function renderResult(entry) {
    var prize = PRIZES[entry.prize];
    elements.resultCard.className = "result-card panel result--" + entry.prize;
    // elements.resultIcon.textContent = prize.icon;
    elements.resultTitle.textContent = prize.label;
    elements.resultMessage.textContent = prize.message;
    clearPrizeHighlight();
    var activeRow = document.querySelector('.prize-row[data-prize="' + entry.prize + '"]');
    if (activeRow) activeRow.classList.add("is-active");
  }

  function setPopupDiePips(dieElement, value) {
    var visiblePips = PIP_MAP[value] || [];
    Array.from(dieElement.children).forEach(function (pip, index) {
      pip.classList.toggle("is-visible", visiblePips.indexOf(index) >= 0);
    });
    dieElement.setAttribute("aria-label", "Die showing " + value);
  }

  function renderPopupHistory(player) {
    var history = player && Array.isArray(player.history)
      ? player.history.slice(0, 3)
      : [];
    elements.resultPopupHistory.hidden = history.length === 0;
    elements.resultPopupHistoryList.replaceChildren();

    history.forEach(function (historyEntry) {
      var item = document.createElement("article");
      item.className = "result-popup__history-item";
      item.innerHTML =
        '<div class="result-popup__history-dice">' +
        miniDieMarkup(historyEntry.dice[0]) +
        miniDieMarkup(historyEntry.dice[1]) +
        "</div>" +
        "<div><strong>" +
        escapeHtml(PRIZES[historyEntry.prize].label) +
        "</strong><small>Total " +
        historyEntry.total +
        "</small></div>" +
        "<time>" +
        formatTime(historyEntry.createdAt) +
        "</time>";
      elements.resultPopupHistoryList.appendChild(item);
    });
  }

  function openResultPopup(entry, referralDetails) {
    var prize = PRIZES[entry.prize];
    var player = getActivePlayer();
    var bonusEarned = Math.max(
      0,
      Math.floor(Number(referralDetails && referralDetails.bonusEarned) || 0),
    );
    var usedBonus = Boolean(referralDetails && referralDetails.usedBonus);

    elements.resultPopup.className = "result-popup result-popup--" + entry.prize;
    setPopupDiePips(elements.resultPopupDieOne, entry.dice[0]);
    setPopupDiePips(elements.resultPopupDieTwo, entry.dice[1]);
    elements.resultPopupTotal.textContent = entry.total;
    elements.resultPopupPrize.textContent = prize.label;
    elements.resultPopupInviteCode.textContent = generateInviteCode(
      player ? player.name : cleanName(elements.playerName.value),
    );
    // elements.resultPopupRewardPoints.textContent = player ? player.points || 0 : 0;
    renderPopupHistory(player);
    elements.resultPopupTitle.textContent =
      entry.prize === "none" ? "Roll Complete" : "Congratulations!";
    elements.resultPopupDice.setAttribute(
      "aria-label",
      "Dice result " +
        entry.dice[0] +
        " plus " +
        entry.dice[1] +
        " equals " +
        entry.total,
    );

    var referralMessage = "";
    if (bonusEarned > 0 && usedBonus) {
      referralMessage =
        "Successful referral reward: +" +
        bonusEarned +
        " extra " +
        pluralRoll(bonusEarned) +
        " received. One bonus chance was used for this roll.";
    } else if (bonusEarned > 0) {
      referralMessage =
        "Successful referral reward: +" +
        bonusEarned +
        " extra " +
        pluralRoll(bonusEarned) +
        " received.";
    } else if (usedBonus) {
      referralMessage =
        "This roll used one extra chance earned from a successful referral.";
    }
    elements.resultPopupReferral.hidden = referralMessage === "";
    elements.resultPopupReferralText.textContent = referralMessage;

    resultPopupPreviousFocus = document.activeElement;
    elements.resultPopup.hidden = false;
    document.body.classList.add("popup-open");
    window.setTimeout(function () {
      elements.resultPopupClose.focus();
    }, 0);
  }

  function closeResultPopup() {
    if (elements.resultPopup.hidden) return;
    window.clearTimeout(popupConfettiTimer);
    elements.popupConfetti.classList.remove("is-active");
    elements.popupConfetti.replaceChildren();
    elements.resultPopup.hidden = true;
    document.body.classList.remove("popup-open");
    if (
      resultPopupPreviousFocus &&
      document.documentElement.contains(resultPopupPreviousFocus)
    ) {
      resultPopupPreviousFocus.focus();
    }
    resultPopupPreviousFocus = null;
  }

  function clearPrizeHighlight() {
    elements.resultCard.className = "result-card panel";
    Array.from(document.querySelectorAll(".prize-row")).forEach(function (row) {
      row.classList.remove("is-active");
    });
  }

  function renderHistory(player) {
    var history = player && player.history ? player.history.slice(0, 5) : [];
    elements.historyList.replaceChildren();
    elements.historyEmpty.hidden = history.length > 0;

    history.forEach(function (entry) {
      var item = document.createElement("article");
      item.className = "history-item";
      item.innerHTML =
        '<div class="history-mini-dice">' +
        miniDieMarkup(entry.dice[0]) +
        miniDieMarkup(entry.dice[1]) +
        "</div>" +
        "<div><strong>" +
        escapeHtml(PRIZES[entry.prize].label) +
        "</strong><span>Total " +
        entry.total +
        "</span></div>" +
        "<time>" +
        formatTime(entry.createdAt) +
        "</time>";
      elements.historyList.appendChild(item);
    });
  }

  function miniDieMarkup(value) {
    var visible = PIP_MAP[value];
    var html = '<span class="mini-die" aria-label="Die showing ' + value + '">';
    for (var index = 0; index < 9; index += 1) {
      html += '<i class="' + (visible.indexOf(index) >= 0 ? "is-visible" : "") + '"></i>';
    }
    return html + "</span>";
  }

  function postTelegramPayload(payload) {
    return fetch(TELEGRAM_ENDPOINT, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    })
      .then(function (response) {
        return response
          .json()
          .catch(function () {
            return {};
          })
          .then(function (body) {
            if (!response.ok || !body.ok) {
              throw new Error(body.message || "Telegram delivery failed.");
            }
            return body;
          });
      });
  }

  function sendDailyResultsToTelegram(player, entries) {
    entries.forEach(function (entry) {
      entry.telegramStatus = "pending";
    });
    writeStorage();

    return postTelegramPayload({
      event: "daily_results",
      name: player.name,
      phone: player.phone,
      inviteCode: activeInviteCode,
      results: entries.map(function (entry) {
        return {
          dice: entry.dice,
          total: entry.total,
          prize: entry.prize,
          createdAt: entry.createdAt,
        };
      }),
    })
      .then(function () {
        entries.forEach(function (entry) {
          entry.telegramStatus = "sent";
        });
        writeStorage();
        showToast(
          entries.length +
            " " +
            pluralRoll(entries.length) +
            " saved and sent together to Telegram.",
        );
      })
      .catch(function (error) {
        entries.forEach(function (entry) {
          entry.telegramStatus = "failed";
        });
        writeStorage();
        showToast("Rolls saved. " + (error.message || "Telegram delivery failed."));
      });
  }

  function sendReferralNotification(player, entry) {
    if (!activeInviteCode || activeInviteCode === generateInviteCode(player.name)) {
      return Promise.resolve();
    }

    var referralKey = todayKey() + ":" + activeInviteCode;
    if (
      player.referralNotifiedKey === referralKey ||
      player.referralPendingKey === referralKey
    ) {
      return Promise.resolve();
    }

    player.referralPendingKey = referralKey;
    writeStorage();

    return postTelegramPayload({
      event: "referral_used",
      name: player.name,
      phone: player.phone,
      inviteCode: activeInviteCode,
      createdAt: entry.createdAt,
    })
      .then(function () {
        player.referralNotifiedKey = referralKey;
        player.referralPendingKey = "";
        writeStorage();
      })
      .catch(function () {
        player.referralPendingKey = "";
        writeStorage();
      });
  }

  function registerInviteOwner(name, phone, inviteCode) {
    return postTelegramPayload({
      event: "invite_registered",
      name: name,
      phone: phone,
      inviteCode: inviteCode,
    }).catch(function () {
      // Sharing still works if the server cannot update the invite registry.
    });
  }

  function claimInviteBonuses(name, phone) {
    var key = playerKey(name, phone);
    if (inviteBonusClaimState[key]) return;
    inviteBonusClaimState[key] = "pending";

    postTelegramPayload({
      event: "claim_invite_bonus",
      name: name,
      phone: phone,
      inviteCode: generateInviteCode(name),
    })
      .then(function (body) {
        inviteBonusClaimState[key] = "done";
        var claimedRolls = Math.max(
          0,
          Math.floor(Number(body && body.bonusRolls) || 0),
        );
        if (claimedRolls < 1) {
          if (!isRolling && activePlayerKey === key) renderPlayer();
          return;
        }

        var player = ensurePlayerRecord(name, phone);
        player.bonusRolls =
          Math.max(0, Math.floor(Number(player.bonusRolls) || 0)) + claimedRolls;
        player.referralBonusNotice =
          Math.max(0, Math.floor(Number(player.referralBonusNotice) || 0)) +
          claimedRolls;
        player.inviteBonusEarnedTotal =
          Math.max(0, Math.floor(Number(player.inviteBonusEarnedTotal) || 0)) +
          claimedRolls;
        writeStorage();
        showToast(
          "Invite reward received: +" +
            claimedRolls +
            " extra " +
            pluralRoll(claimedRolls) +
            ".",
        );
        if (!isRolling && activePlayerKey === key) renderPlayer();
      })
      .catch(function () {
        inviteBonusClaimState[key] = "failed";
        if (!isRolling && activePlayerKey === key) renderPlayer();
      });
  }

  function rollDice() {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    if (name.length < 2 || isRolling) return;
    if (!isValidPhone(phone)) {
      elements.phoneError.hidden = false;
      elements.phoneNumber.setAttribute("aria-invalid", "true");
      showToast("Invalid Malaysian mobile number. Please correct it and try again.");
      return;
    }
    var playerBeforeRoll = appData.players[playerKey(name, phone)] || null;
    var rollsBefore = dailyRollCount(name, phone);
    var rollLimitBefore = rollLimitForPlayer(playerBeforeRoll);
    if (rollsBefore >= rollLimitBefore) return;
    var usesBonusRoll = rollsBefore >= DAILY_ROLL_LIMIT;

    var prizeKey = choosePrize();
    var pair = pairForPrize(prizeKey);
    var total = pair[0] + pair[1];
    isRolling = true;
    elements.rollButton.disabled = true;
    elements.rollButtonText.textContent = "DICE IN MOTION";
    elements.rollHelper.innerHTML =
      '<span aria-hidden="true">◌</span> The canvas dice are finding your result…';
    elements.rollingBadge.hidden = false;
    elements.diceArena.classList.add("is-rolling");
    elements.readyStatus.className = "status-pill status-pill--rolling";
    elements.readyStatus.querySelector("span").textContent = "Rolling…";
    elements.totalValue.textContent = "—";
    elements.resultTitle.textContent = "Dice dropping…";
    elements.resultMessage.textContent = "Watch them tumble, bounce, and settle.";
    // elements.resultIcon.textContent = "✦";
    clearPrizeHighlight();
    playRollSound();

    diceEngine.roll(pair, function () {
      var player = ensureActivePlayer();
      var referralBonusEarned = Math.max(
        0,
        Math.floor(Number(player.referralBonusNotice) || 0),
      );
      var entry = {
        id: Date.now(),
        dice: pair,
        total: total,
        prize: prizeKey,
        points: PRIZES[prizeKey].points,
        createdAt: new Date().toISOString(),
      };
      player.lastRollDate = todayKey();
      player.points = (player.points || 0) + PRIZES[prizeKey].points;
      player.history = [entry].concat(player.history || []).slice(0, 20);
      if (usesBonusRoll) consumeBonusRoll(player);
      if (referralBonusEarned > 0) player.referralBonusNotice = 0;
      writeStorage();

      isRolling = false;
      var rollsUsed = dailyRollCount(player.name, player.phone);
      var rollLimit = rollLimitForPlayer(player);
      var remainingRolls = Math.max(0, rollLimit - rollsUsed);
      var limitReached = remainingRolls === 0;
      elements.rollingBadge.hidden = true;
      elements.diceArena.classList.remove("is-rolling");
      elements.totalValue.textContent = total;
      elements.totalValue.parentElement.classList.remove("is-revealed");
      void elements.totalValue.parentElement.offsetWidth;
      elements.totalValue.parentElement.classList.add("is-revealed");
      elements.rollButton.disabled = limitReached;
      elements.diceCanvas.setAttribute("aria-disabled", String(limitReached));

      if (limitReached) {
        elements.rollButtonText.textContent =
          rollLimit === 1 ? "TODAY'S ROLL USED" : "TODAY'S ROLLS USED";
        elements.rollHelper.innerHTML =
          '<span aria-hidden="true">✓</span> ' +
          rollsUsed +
          " of " +
          rollLimit +
          " rolls used. Come back tomorrow.";
        elements.readyStatus.className = "status-pill status-pill--used";
        elements.readyStatus.querySelector("span").textContent = "Daily limit reached";
      } else {
        elements.rollButtonText.textContent = "ROLL AGAIN";
        elements.rollHelper.innerHTML =
          '<span aria-hidden="true">◆</span> ' +
          rollsUsed +
          " of " +
          rollLimit +
          " rolls used. " +
          remainingRolls +
          " " +
          pluralRoll(remainingRolls) +
          " remaining today.";
        elements.readyStatus.className = "status-pill";
        elements.readyStatus.querySelector("span").textContent =
          remainingRolls + " " + pluralRoll(remainingRolls) + " left";
      }
      renderResult(entry);
      openResultPopup(entry, {
        bonusEarned: referralBonusEarned,
        usedBonus: usesBonusRoll,
      });
      renderHistory(player);
      if (elements.rewardPoints) elements.rewardPoints.textContent = player.points;
      elements.diceCanvas.setAttribute(
        "aria-label",
        "Dice rolled " + pair[0] + " and " + pair[1] + ". Total " + total + ".",
      );
      sendReferralNotification(player, entry);
      if (limitReached) {
        sendDailyResultsToTelegram(
          player,
          dailyEntriesForIdentity(player.name, player.phone, rollLimit),
        );
      } else {
        entry.telegramStatus = "waiting";
        writeStorage();
        showToast(
          "Roll saved. Your daily results will be sent together after the final roll.",
        );
      }
      playLandingSound(total);
      if (prizeKey !== "none") {
        celebrate();
        celebratePopup();
      }
    });
  }

  function getAudioContext() {
    if (!soundOn) return null;
    try {
      if (!audioContext) {
        var AudioContextClass = window.AudioContext || window.webkitAudioContext;
        if (!AudioContextClass) return null;
        audioContext = new AudioContextClass();
      }
      if (audioContext.state === "suspended") audioContext.resume();
      return audioContext;
    } catch (error) {
      return null;
    }
  }

  function playTone(frequency, startOffset, duration, volume, type) {
    var context = getAudioContext();
    if (!context) return;
    var oscillator = context.createOscillator();
    var gain = context.createGain();
    var start = context.currentTime + startOffset;
    oscillator.type = type || "triangle";
    oscillator.frequency.setValueAtTime(frequency, start);
    oscillator.frequency.exponentialRampToValueAtTime(Math.max(45, frequency * 0.58), start + duration);
    gain.gain.setValueAtTime(volume, start);
    gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
    oscillator.connect(gain);
    gain.connect(context.destination);
    oscillator.start(start);
    oscillator.stop(start + duration);
  }

  function playRollSound() {
    if (!soundOn) return;
    for (var index = 0; index < 7; index += 1) {
      playTone(145 + index * 31, index * 0.075, 0.095, 0.025, "triangle");
    }
  }

  function playLandingSound(total) {
    if (!soundOn) return;
    playTone(76 + total * 2, 0, 0.2, 0.06, "sine");
    playTone(160 + total * 3, 0.055, 0.22, 0.035, "triangle");
  }

  function celebrate() {
    var colors = ["#ffd34f", "#38c7ff", "#9658ff", "#62ef71"];
    elements.confetti.replaceChildren();
    elements.confetti.classList.add("is-active");
    for (var index = 0; index < 36; index += 1) {
      var piece = document.createElement("i");
      piece.style.setProperty("--x", ((index * 37) % 100) + "%");
      piece.style.setProperty("--delay", (index % 10) * 70 + "ms");
      piece.style.setProperty("--color", colors[index % colors.length]);
      elements.confetti.appendChild(piece);
    }
    window.setTimeout(function () {
      elements.confetti.classList.remove("is-active");
      elements.confetti.replaceChildren();
    }, 3900);
  }

  function celebratePopup() {
    var colors = ["#ffd34f", "#38c7ff", "#9658ff", "#62ef71", "#ff4fab"];
    window.clearTimeout(popupConfettiTimer);
    elements.popupConfetti.replaceChildren();
    elements.popupConfetti.classList.add("is-active");

    for (var index = 0; index < 58; index += 1) {
      var piece = document.createElement("i");
      var drift = ((index * 29) % 130) - 65;
      piece.style.setProperty("--x", 5 + ((index * 47) % 90) + "%");
      piece.style.setProperty("--delay", (index % 12) * 55 + "ms");
      piece.style.setProperty("--color", colors[index % colors.length]);
      piece.style.setProperty("--size", 5 + (index % 5) + "px");
      piece.style.setProperty("--drift", drift + "px");
      piece.style.setProperty("--spin", 380 + (index % 7) * 95 + "deg");
      elements.popupConfetti.appendChild(piece);
    }

    popupConfettiTimer = window.setTimeout(function () {
      elements.popupConfetti.classList.remove("is-active");
      elements.popupConfetti.replaceChildren();
    }, 4400);
  }

  function inviteShareText(name, phone) {
    var player = appData.players[playerKey(name, phone)] || null;
    var latest = player && Array.isArray(player.history) ? player.history[0] : null;
    if (latest && PRIZES[latest.prize]) {
      return (
        "I rolled " +
        latest.dice[0] +
        " + " +
        latest.dice[1] +
        " = " +
        latest.total +
        " and won " +
        PRIZES[latest.prize].label +
        "! Join my Daily Dice Roll game:"
      );
    }
    return "Join my Daily Dice Roll game with this invite link:";
  }

  function awardSharePoints(platform) {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    if (name.length < 2 || !isValidPhone(phone)) {
      showToast("Enter your player name and phone number before sharing.");
      return;
    }
    var player = ensureActivePlayer();
    if (player.lastShareDate !== todayKey()) {
      player.lastShareDate = todayKey();
      player.points = (player.points || 0) + 10;
      writeStorage();
      if (elements.rewardPoints) elements.rewardPoints.textContent = player.points;
      elements.resultPopupRewardPoints.textContent = player.points;
      elements.shareNote.textContent = "Today's 10 sharing points are already collected.";
      showToast("Shared with " + platform + " — 10 points added!");
    } else {
      showToast("Shared with " + platform + ". Today's points were already collected.");
    }
  }

  function copyInviteLink() {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    if (name.length < 2 || !isValidPhone(phone)) {
      showToast("Enter your player name and phone number to create an invite link.");
      return;
    }
    var details = inviteDetailsForPlayer(name);
    registerInviteOwner(name, phone, details.code);
    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(details.link).then(
        function () {
          showToast("Invite link copied.");
        },
        function () {
          showToast("Invite code: " + details.code);
        },
      );
    } else {
      showToast("Invite code: " + details.code);
    }
  }

  function shareInviteOnPlatform(linkElement, event, platform) {
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    if (name.length < 2 || !isValidPhone(phone)) {
      event.preventDefault();
      showToast("Enter your player name and phone number before sharing.");
      return;
    }

    var details = inviteDetailsForPlayer(name);
    registerInviteOwner(name, phone, details.code);
    var shareText = inviteShareText(name, phone);
    var shareUrl = "";

    if (platform === "WhatsApp") {
      shareUrl =
        "https://wa.me/?text=" +
        encodeURIComponent(shareText + "\n" + details.link);
    } else if (platform === "LINE") {
      shareUrl =
        "https://social-plugins.line.me/lineit/share?url=" +
        encodeURIComponent(details.link) +
        "&text=" +
        encodeURIComponent(shareText);
    } else {
      shareUrl =
        "https://t.me/share/url?url=" +
        encodeURIComponent(details.link) +
        "&text=" +
        encodeURIComponent(shareText);
    }

    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(details.link).catch(function () {
        // Telegram still receives the link when clipboard permission is unavailable.
      });
    }

    linkElement.href = shareUrl;
    awardSharePoints(platform);
    showToast("Invite link copied. Select a " + platform + " contact to share it.");
  }

  function shareInviteGenerally(event) {
    event.preventDefault();
    var name = cleanName(elements.playerName.value);
    var phone = cleanPhone(elements.phoneNumber.value);
    if (name.length < 2 || !isValidPhone(phone)) {
      showToast("Enter your player name and phone number before sharing.");
      return;
    }

    var details = inviteDetailsForPlayer(name);
    var shareText = inviteShareText(name, phone);
    registerInviteOwner(name, phone, details.code);

    if (navigator.share) {
      navigator
        .share({
          title: "Daily Dice Roll",
          text: shareText,
          url: details.link,
        })
        .then(function () {
          awardSharePoints("Share");
        })
        .catch(function (error) {
          if (!error || error.name !== "AbortError") {
            showToast("Sharing was unavailable. Copying the invite link instead.");
          }
        });
      return;
    }

    if (navigator.clipboard && navigator.clipboard.writeText) {
      navigator.clipboard.writeText(shareText + "\n" + details.link).then(
        function () {
          awardSharePoints("Share");
          showToast("Result and invite link copied. Choose an app to share it.");
        },
        function () {
          showToast("Invite code: " + details.code);
        },
      );
    } else {
      showToast("Invite code: " + details.code);
    }
  }

  function showToast(message) {
    window.clearTimeout(toastTimer);
    elements.toast.textContent = message;
    elements.toast.classList.add("is-visible");
    toastTimer = window.setTimeout(function () {
      elements.toast.classList.remove("is-visible");
    }, 2300);
  }

  function cacheElements() {
    [
      "confetti",
      "soundToggle",
      "backgroundMusic",
      "readyStatus",
      "playerName",
      "phoneNumber",
      "phoneError",
      "rememberPlayer",
      "todayDate",
      "diceArena",
      "diceCanvas",
      "canvasPlus",
      "rollingBadge",
      "totalValue",
      "resultCard",
      "resultIcon",
      "resultTitle",
      "resultMessage",
      "rollButton",
      "rollButtonText",
      "rollHelper",
      "rewardPoints",
      "inviteCode",
      "copyInvite",
      "shareNote",
      "historyEmpty",
      "historyList",
      "resultPopup",
      "resultPopupClose",
      "resultPopupTitle",
      "resultPopupDice",
      "resultPopupDieOne",
      "resultPopupDieTwo",
      "resultPopupTotal",
      "resultPopupPrize",
      "resultPopupReferral",
      "resultPopupReferralText",
      "resultPopupHistory",
      "resultPopupHistoryList",
      "resultPopupRewardPoints",
      "resultPopupInviteCode",
      "resultPopupCopyInvite",
      "popupConfetti",
      "toast",
    ].forEach(function (id) {
      elements[id] = document.getElementById(id);
    });
  }

  function bindEvents() {
    elements.playerName.addEventListener("input", handlePlayerInput);
    elements.phoneNumber.addEventListener("input", handlePlayerInput);
    elements.rememberPlayer.addEventListener("change", toggleRememberDetails);
    elements.rollButton.addEventListener("click", rollDice);
    elements.diceCanvas.addEventListener("click", rollDice);
    elements.diceCanvas.addEventListener("keydown", function (event) {
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        rollDice();
      }
    });
    document.addEventListener("keydown", function (event) {
      if (!elements.resultPopup.hidden) {
        if (event.key === "Escape") closeResultPopup();
        return;
      }
      if (
        (event.key === " " || event.code === "Space") &&
        document.activeElement !== elements.playerName &&
        document.activeElement !== elements.phoneNumber &&
        document.activeElement !== elements.rollButton
      ) {
        event.preventDefault();
        rollDice();
      }
    });
    elements.soundToggle.addEventListener("click", toggleBackgroundMusic);
    elements.copyInvite.addEventListener("click", copyInviteLink);
    elements.resultPopupCopyInvite.addEventListener("click", copyInviteLink);
    elements.resultPopupClose.addEventListener("click", closeResultPopup);
    Array.from(document.querySelectorAll("[data-result-popup-close]")).forEach(
      function (button) {
        button.addEventListener("click", closeResultPopup);
      },
    );
    Array.from(document.querySelectorAll("[data-share]")).forEach(function (button) {
      button.addEventListener("click", function (event) {
        if (["Telegram", "WhatsApp", "LINE"].indexOf(button.dataset.share) >= 0) {
          shareInviteOnPlatform(button, event, button.dataset.share);
        } else {
          shareInviteGenerally(event);
        }
      });
    });
    Array.from(document.querySelectorAll("[data-toast]")).forEach(function (button) {
      button.addEventListener("click", function () {
        showToast(button.dataset.toast);
      });
    });
    window.addEventListener("focus", function () {
      var name = cleanName(elements.playerName.value);
      var phone = cleanPhone(elements.phoneNumber.value);
      if (name.length < 2 || !isValidPhone(phone) || isRolling) return;
      var key = playerKey(name, phone);
      if (inviteBonusClaimState[key] === "pending") return;
      delete inviteBonusClaimState[key];
      renderPlayer();
    });
  }

  function initialise() {
    cacheElements();
    restoreRememberedDetails();
    activeInviteCode = inviteCodeFromUrl();
    diceEngine = createCanvasDiceEngine(elements.diceCanvas);
    elements.todayDate.textContent = new Intl.DateTimeFormat("en-MY", {
      weekday: "short",
      month: "short",
      day: "numeric",
      year: "numeric",
    }).format(new Date());
    bindEvents();
    restoreBackgroundMusicStatus();
    renderPlayer();
  }

  function createCanvasDiceEngine(canvas) {
    var context = canvas.getContext("2d");
    var width = 0;
    var height = 0;
    var pixelRatio = 1;
    var animationStart = 0;
    var rolling = false;
    var completeCallback = null;
    var particles = [];
    var dice = [
      createCanvasDie(2, 0),
      createCanvasDie(3, 1),
    ];

    function createCanvasDie(value, index) {
      var finalRotation = rotationForValue(value, index);
      return {
        value: value,
        index: index,
        rx: finalRotation.x,
        ry: finalRotation.y,
        rz: finalRotation.z,
        from: finalRotation,
        to: finalRotation,
        delay: index * 90,
      };
    }

    function rotationForValue(value, index) {
      // Keep the winning face almost square to the camera. The tiny tilt
      // preserves a visible bevel without making the pips hard to read.
      var tiltX = -0.075;
      var tiltY = 0.085;
      var z = index === 0 ? -0.035 : 0.035;
      if (value === 1) return { x: tiltX, y: tiltY, z: z };
      if (value === 6) return { x: tiltX, y: Math.PI + tiltY, z: z };
      if (value === 3) return { x: tiltX, y: -Math.PI / 2 + tiltY, z: z };
      if (value === 4) return { x: tiltX, y: Math.PI / 2 + tiltY, z: z };
      if (value === 5) return { x: -Math.PI / 2 + tiltX, y: tiltY, z: z };
      return { x: Math.PI / 2 + tiltX, y: tiltY, z: z };
    }

    function resize() {
      var rect = canvas.getBoundingClientRect();
      width = Math.max(1, rect.width);
      height = Math.max(1, rect.height);
      pixelRatio = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.round(width * pixelRatio);
      canvas.height = Math.round(height * pixelRatio);
      context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
    }

    function setValues(nextValues) {
      nextValues.forEach(function (value, index) {
        var finalRotation = rotationForValue(value, index);
        dice[index].value = value;
        dice[index].rx = finalRotation.x;
        dice[index].ry = finalRotation.y;
        dice[index].rz = finalRotation.z;
        dice[index].from = finalRotation;
        dice[index].to = finalRotation;
      });
      canvas.setAttribute(
        "aria-label",
        "Two dice showing " +
          nextValues[0] +
          " and " +
          nextValues[1] +
          ". Total " +
          (nextValues[0] + nextValues[1]) +
          ".",
      );
    }

    function roll(nextValues, onComplete) {
      animationStart = performance.now();
      rolling = true;
      completeCallback = onComplete;
      particles = createParticles();
      nextValues.forEach(function (value, index) {
        var die = dice[index];
        var finalRotation = rotationForValue(value, index);
        die.value = value;
        die.from = { x: die.rx, y: die.ry, z: die.rz };
        die.to = {
          x: finalRotation.x + Math.PI * 2 * (4 + index),
          y: finalRotation.y + Math.PI * 2 * (5 + index),
          z: finalRotation.z + Math.PI * 2 * (2 + index),
        };
        die.final = finalRotation;
        die.delay = index * 90;
      });
    }

    function createParticles() {
      var output = [];
      for (var dieIndex = 0; dieIndex < 2; dieIndex += 1) {
        for (var index = 0; index < 18; index += 1) {
          var angle = (Math.PI * 2 * index) / 18 + Math.random() * 0.35;
          output.push({
            dieIndex: dieIndex,
            angle: angle,
            speed: 34 + Math.random() * 75,
            delay: Math.random() * 110,
            size: 1 + Math.random() * 2.3,
            gold: index % 4 === 0,
          });
        }
      }
      return output;
    }

    function animate(timestamp) {
      context.clearRect(0, 0, width, height);
      var elapsed = rolling ? timestamp - animationStart : 0;
      var layout = getLayout();

      dice.forEach(function (die, index) {
        var pose = getPose(die, elapsed, layout);
        drawDieShadow(pose, layout, die);
        drawDie(pose, die, layout);
      });

      if (rolling) {
        drawParticles(elapsed, layout);
        if (elapsed >= ROLL_DURATION + 110) {
          rolling = false;
          dice.forEach(function (die) {
            die.rx = die.final.x;
            die.ry = die.final.y;
            die.rz = die.final.z;
          });
          var callback = completeCallback;
          completeCallback = null;
          if (callback) window.setTimeout(callback, 0);
        }
      }

      window.requestAnimationFrame(animate);
    }

    function getLayout() {
      var minimum = Math.min(width, height);
      return {
        centers: [
          { x: width * 0.34, y: height * 0.49 },
          { x: width * 0.66, y: height * 0.49 },
        ],
        size: Math.max(42, Math.min(minimum * 0.15, width * 0.09)),
      };
    }

    function getPose(die, elapsed, layout) {
      var center = layout.centers[die.index];
      if (!rolling) {
        var idle = performance.now() * 0.001 + die.index * 1.8;
        return {
          x: center.x,
          y: center.y + Math.sin(idle) * 2.2,
          groundY: center.y,
          rx: die.rx + Math.sin(idle * 0.7) * 0.012,
          ry: die.ry + Math.cos(idle * 0.65) * 0.014,
          rz: die.rz,
          size: layout.size,
          airborne: 0,
        };
      }

      var localTime = Math.max(0, elapsed - die.delay);
      var progress = clamp(localTime / ROLL_DURATION, 0, 1);
      var easedRotation = easeOutQuart(progress);
      var heightOffset = bounceHeight(progress, height);
      var direction = die.index === 0 ? -1 : 1;
      var horizontal =
        direction * (1 - progress) * width * 0.13 +
        Math.sin(progress * Math.PI) * direction * width * 0.035;
      var impactScale = 1 + Math.sin(Math.min(1, progress / 0.7) * Math.PI) * 0.035;

      die.rx = lerp(die.from.x, die.to.x, easedRotation);
      die.ry = lerp(die.from.y, die.to.y, easedRotation);
      die.rz = lerp(die.from.z, die.to.z, easedRotation);

      return {
        x: center.x + horizontal,
        y: center.y + heightOffset,
        groundY: center.y,
        rx: die.rx,
        ry: die.ry,
        rz: die.rz,
        size: layout.size * impactScale,
        airborne: Math.min(1, Math.abs(heightOffset) / (height * 0.48)),
      };
    }

    function bounceHeight(progress, sceneHeight) {
      var keyframes = [
        [0, -0.58],
        [0.49, -0.08],
        [0.63, 0],
        [0.75, -0.16],
        [0.85, 0],
        [0.92, -0.055],
        [1, 0],
      ];
      for (var index = 0; index < keyframes.length - 1; index += 1) {
        var start = keyframes[index];
        var end = keyframes[index + 1];
        if (progress >= start[0] && progress <= end[0]) {
          var local = (progress - start[0]) / (end[0] - start[0]);
          var eased = start[1] === 0 ? Math.sin(local * Math.PI * 0.5) : easeOutCubic(local);
          return lerp(start[1], end[1], eased) * sceneHeight;
        }
      }
      return 0;
    }

    function drawDieShadow(pose, layout) {
      var shadowWidth = pose.size * (1.25 - pose.airborne * 0.46);
      var alpha = 0.34 - pose.airborne * 0.24;
      context.save();
      context.filter = "blur(" + Math.max(6, pose.size * 0.12) + "px)";
      context.fillStyle = "rgba(0, 0, 0, " + alpha + ")";
      context.beginPath();
      context.ellipse(
        pose.x,
        pose.groundY + pose.size * 1.2,
        shadowWidth,
        pose.size * 0.28,
        0,
        0,
        Math.PI * 2,
      );
      context.fill();
      context.restore();
    }

    function drawDie(pose, die) {
      var visibleFaces = FACE_DEFINITIONS.map(function (face) {
        var normal = rotatePoint(face.normal, pose.rx, pose.ry, pose.rz);
        var vertices = face.vertices.map(function (vertex) {
          return rotatePoint(vertex, pose.rx, pose.ry, pose.rz);
        });
        var averageZ =
          vertices.reduce(function (total, vertex) {
            return total + vertex.z;
          }, 0) / vertices.length;
        return {
          face: face,
          normal: normal,
          vertices: vertices,
          averageZ: averageZ,
        };
      })
        .filter(function (entry) {
          // Cull faces that are almost edge-on. Their wide bevel stroke can
          // otherwise peek past a front face as a small rectangular artifact.
          return entry.normal.z > 0.1;
        })
        .sort(function (a, b) {
          return a.averageZ - b.averageZ;
        });

      context.save();
      context.globalCompositeOperation = "source-over";
      visibleFaces.forEach(function (entry) {
        drawFace(entry, pose, die);
      });
      context.restore();
    }

    function drawFace(entry, pose) {
      var points = entry.vertices.map(function (vertex) {
        return projectPoint(vertex, pose);
      });
      // Slightly overlap neighbouring faces. This hides the one-pixel
      // anti-aliasing seams that can appear while the cube is tumbling.
      var overlapPoints = scalePolygon(points, 1.018);
      var insetPoints = scalePolygon(points, 0.9);
      var brightness = Math.round(
        clamp(
          178 + entry.normal.z * 61 - entry.normal.x * 15 - entry.normal.y * 22,
          130,
          247,
        ),
      );
      var edgeBrightness = Math.max(90, brightness - 50);
      var bounds = polygonBounds(points);
      var insetBounds = polygonBounds(insetPoints);
      var edgeGradient = context.createLinearGradient(
        bounds.left,
        bounds.top,
        bounds.right,
        bounds.bottom,
      );
      edgeGradient.addColorStop(0, "rgb(" + (brightness + 5) + "," + (brightness + 5) + "," + (brightness + 8) + ")");
      edgeGradient.addColorStop(
        1,
        "rgb(" + edgeBrightness + "," + edgeBrightness + "," + (edgeBrightness + 12) + ")",
      );
      var faceGradient = context.createLinearGradient(
        insetBounds.left,
        insetBounds.top,
        insetBounds.right,
        insetBounds.bottom,
      );
      faceGradient.addColorStop(0, "#ffffff");
      faceGradient.addColorStop(0.46, "rgb(" + brightness + "," + brightness + "," + (brightness + 4) + ")");
      faceGradient.addColorStop(
        1,
        "rgb(" + Math.max(120, brightness - 24) + "," + Math.max(120, brightness - 24) + "," + Math.max(126, brightness - 17) + ")",
      );

      context.save();
      context.beginPath();
      moveRoundedPolygon(overlapPoints, Math.max(6, pose.size * 0.15));
      context.closePath();
      context.fillStyle = edgeGradient;
      context.fill();

      context.beginPath();
      moveRoundedPolygon(insetPoints, Math.max(5, pose.size * 0.145));
      context.closePath();
      context.fillStyle = faceGradient;
      context.fill();
      context.lineWidth = Math.max(1, pose.size * 0.02);
      context.strokeStyle = "rgba(255,255,255,0.68)";
      context.stroke();
      context.restore();

      drawFacePips(entry, pose, insetPoints);
    }

    function drawFacePips(entry, pose, clipPoints) {
      var visiblePips = PIP_MAP[entry.face.value];
      var coordinates = [-0.48, 0, 0.48];
      context.save();
      context.beginPath();
      moveRoundedPolygon(clipPoints, Math.max(5, pose.size * 0.145));
      context.closePath();
      context.clip();

      visiblePips.forEach(function (pipIndex) {
        var column = pipIndex % 3;
        var row = Math.floor(pipIndex / 3);
        var localX = coordinates[column];
        var localY = coordinates[row];
        var point = {
          x:
            entry.face.center.x +
            entry.face.u.x * localX +
            entry.face.v.x * localY +
            entry.face.normal.x * -0.008,
          y:
            entry.face.center.y +
            entry.face.u.y * localX +
            entry.face.v.y * localY +
            entry.face.normal.y * -0.008,
          z:
            entry.face.center.z +
            entry.face.u.z * localX +
            entry.face.v.z * localY +
            entry.face.normal.z * -0.008,
        };
        var rotated = rotatePoint(point, pose.rx, pose.ry, pose.rz);
        var projected = projectPoint(rotated, pose);
        var pipWorldRadius = 0.108;
        var uPoint = {
          x: point.x + entry.face.u.x * pipWorldRadius,
          y: point.y + entry.face.u.y * pipWorldRadius,
          z: point.z + entry.face.u.z * pipWorldRadius,
        };
        var vPoint = {
          x: point.x + entry.face.v.x * pipWorldRadius,
          y: point.y + entry.face.v.y * pipWorldRadius,
          z: point.z + entry.face.v.z * pipWorldRadius,
        };
        var projectedU = projectPoint(rotatePoint(uPoint, pose.rx, pose.ry, pose.rz), pose);
        var projectedV = projectPoint(rotatePoint(vPoint, pose.rx, pose.ry, pose.rz), pose);
        var ux = projectedU.x - projected.x;
        var uy = projectedU.y - projected.y;
        var vx = projectedV.x - projected.x;
        var vy = projectedV.y - projected.y;

        context.save();
        context.transform(ux, uy, vx, vy, projected.x, projected.y);

        // A dark upper rim and a soft lower highlight make the pip read as a
        // shallow recess in the face instead of a black bump sitting on it.
        var rimGradient = context.createLinearGradient(0, -1.15, 0, 1.15);
        rimGradient.addColorStop(0, "rgba(25, 29, 38, 0.72)");
        rimGradient.addColorStop(0.48, "rgba(68, 75, 89, 0.34)");
        rimGradient.addColorStop(1, "rgba(255, 255, 255, 0.48)");
        context.fillStyle = rimGradient;
        context.beginPath();
        context.arc(0, 0, 1.12, 0, Math.PI * 2);
        context.fill();

        var pipGradient = context.createRadialGradient(-0.24, -0.28, 0.08, 0, 0, 1);
        pipGradient.addColorStop(0, "#252a34");
        pipGradient.addColorStop(0.52, "#0c0f15");
        pipGradient.addColorStop(1, "#010205");
        context.fillStyle = pipGradient;
        context.beginPath();
        context.arc(0, 0, 0.96, 0, Math.PI * 2);
        context.fill();

        context.beginPath();
        context.arc(-0.12, -0.16, 0.58, Math.PI * 1.04, Math.PI * 1.55);
        context.strokeStyle = "rgba(255,255,255,0.11)";
        context.lineWidth = 0.08;
        context.stroke();
        context.restore();
      });
      context.restore();
    }

    function drawParticles(elapsed, layout) {
      var impactStart = ROLL_DURATION * 0.61;
      context.save();
      context.globalCompositeOperation = "screen";
      particles.forEach(function (particle) {
        var age = (elapsed - impactStart - particle.delay) / 560;
        if (age < 0 || age > 1) return;
        var center = layout.centers[particle.dieIndex];
        var distance = particle.speed * age;
        var x = center.x + Math.cos(particle.angle) * distance;
        var y =
          center.y +
          layout.size * 0.92 +
          Math.sin(particle.angle) * distance * 0.38 +
          age * age * 22;
        context.globalAlpha = (1 - age) * 0.85;
        context.fillStyle = particle.gold ? "#ffd75c" : particle.dieIndex ? "#aa61ff" : "#35baff";
        context.shadowColor = context.fillStyle;
        context.shadowBlur = 8;
        context.beginPath();
        context.arc(x, y, particle.size, 0, Math.PI * 2);
        context.fill();
      });
      context.restore();
    }

    function rotatePoint(point, rx, ry, rz) {
      var cosX = Math.cos(rx);
      var sinX = Math.sin(rx);
      var cosY = Math.cos(ry);
      var sinY = Math.sin(ry);
      var cosZ = Math.cos(rz);
      var sinZ = Math.sin(rz);
      var y1 = point.y * cosX - point.z * sinX;
      var z1 = point.y * sinX + point.z * cosX;
      var x2 = point.x * cosY + z1 * sinY;
      var z2 = -point.x * sinY + z1 * cosY;
      return {
        x: x2 * cosZ - y1 * sinZ,
        y: x2 * sinZ + y1 * cosZ,
        z: z2,
      };
    }

    function projectPoint(point, pose) {
      var cameraDistance = 5;
      var factor = cameraDistance / Math.max(2.2, cameraDistance - point.z * 0.72);
      return {
        x: pose.x + point.x * pose.size * factor,
        y: pose.y + point.y * pose.size * factor,
        factor: factor,
      };
    }

    function polygonBounds(points) {
      return points.reduce(
        function (bounds, point) {
          bounds.left = Math.min(bounds.left, point.x);
          bounds.right = Math.max(bounds.right, point.x);
          bounds.top = Math.min(bounds.top, point.y);
          bounds.bottom = Math.max(bounds.bottom, point.y);
          return bounds;
        },
        { left: Infinity, right: -Infinity, top: Infinity, bottom: -Infinity },
      );
    }

    function scalePolygon(points, factor) {
      var center = points.reduce(
        function (result, point) {
          result.x += point.x / points.length;
          result.y += point.y / points.length;
          return result;
        },
        { x: 0, y: 0 },
      );
      return points.map(function (point) {
        return {
          x: center.x + (point.x - center.x) * factor,
          y: center.y + (point.y - center.y) * factor,
        };
      });
    }

    function moveRoundedPolygon(points, radius) {
      for (var index = 0; index < points.length; index += 1) {
        var previous = points[(index - 1 + points.length) % points.length];
        var current = points[index];
        var next = points[(index + 1) % points.length];
        var previousDistance = Math.hypot(previous.x - current.x, previous.y - current.y);
        var nextDistance = Math.hypot(next.x - current.x, next.y - current.y);
        var previousRatio = Math.min(0.24, radius / Math.max(1, previousDistance));
        var nextRatio = Math.min(0.24, radius / Math.max(1, nextDistance));
        var start = {
          x: current.x + (previous.x - current.x) * previousRatio,
          y: current.y + (previous.y - current.y) * previousRatio,
        };
        var end = {
          x: current.x + (next.x - current.x) * nextRatio,
          y: current.y + (next.y - current.y) * nextRatio,
        };
        if (index === 0) context.moveTo(start.x, start.y);
        else context.lineTo(start.x, start.y);
        context.quadraticCurveTo(current.x, current.y, end.x, end.y);
      }
    }

    function clamp(value, minimum, maximum) {
      return Math.min(maximum, Math.max(minimum, value));
    }

    function lerp(start, end, amount) {
      return start + (end - start) * amount;
    }

    function easeOutCubic(value) {
      return 1 - Math.pow(1 - value, 3);
    }

    function easeOutQuart(value) {
      return 1 - Math.pow(1 - value, 4);
    }

    resize();
    if (window.ResizeObserver) {
      new ResizeObserver(resize).observe(canvas);
    } else {
      window.addEventListener("resize", resize);
    }
    window.requestAnimationFrame(animate);

    return {
      setValues: setValues,
      roll: roll,
      resize: resize,
    };
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", initialise);
  } else {
    initialise();
  }
})();
