Spider Solitaire: Full Source Code — JavaScript, HTML, and CSS

▶ Play Spider Solitaire 📖 Learn how to build it

This page shares the complete source code of "Spider Solitaire" exactly as it runs on HinaTech Games. These aren't files rewritten for explanation — they are the real files that power the game you just played.

Spider deals two decks — 104 cards — into 10 columns and has you complete eight same-suit runs from King down to Ace. The highlights of the code are a deck builder where the 1-, 2-, and 4-suit difficulties come purely from changing suit combinations, the judgment of Spider's asymmetric rules — only same-suit descending runs can be picked up, while drops ignore suits — and a mechanism that automatically detects and removes completed runs of 13 after every move.

The rule-checking logic sits at the top of the file as browser-independent "pure functions", so automated tests (vitest) can load and verify it directly. You're free to copy the code and run it on your own computer.

Spider Solitaire is made of 3 files

Spider Solitaire runs on the files below, each with its own job. We will walk through every one of them.

FileRole
index.htmlThe skeleton of the game screen. Lays out the board, score display and buttons in HTML
style.cssThe look and feel: colors, sizes, layout and animations
game.jsThe brain that runs the game rules: input handling, logic and scoring

This split — HTML for structure, CSS for looks, JavaScript for behavior — is how almost every web page and browser game is built.

index.html — the skeleton of the game board

index.html is the first file the browser loads. It only lays out the game's "places" in HTML; none of the card-moving behavior lives here.

Broadly it has five pieces: (1) the score-area showing time, moves, difficulty, and best, (2) the "Deal Again", "Undo", and "Difficulty" buttons, (3) sol-top-row, which holds the stock and the completed-run slots, (4) sol-tableau, the 10 tableau columns, and (5) two overlays — difficulty selection and the win screen. At startup the difficulty overlay has the active class, and pressing a button starts the game.

* In the code below, the ad and analytics tags unrelated to the game (the first few lines of the page) are omitted.

index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
  <title>スパイダー・ソリティア|無料ブラウザゲーム|ひなテックGames</title>
  <meta name="description" content="定番カードゲーム・スパイダーソリティアが無料で遊べる!1・2・4スートの3段階の難易度。同じマークをKからAまでそろえて8組完成でクリア。タップだけのかんたん操作、やり直し機能つき。PC・スマホ対応の無料ブラウザゲーム。">
  <link rel="canonical" href="https://hinata-ya.tech/games/games/spider/">
  <!-- I18N:hreflang -->
  <link rel="alternate" hreflang="ja" href="https://hinata-ya.tech/games/games/spider/">
  <link rel="alternate" hreflang="en" href="https://hinata-ya.tech/games/en/games/spider/">
  <link rel="alternate" hreflang="x-default" href="https://hinata-ya.tech/games/games/spider/">
  <!-- /I18N:hreflang -->
  <meta property="og:title" content="スパイダー・ソリティア|無料ブラウザゲーム|ひなテックGames">
  <meta property="og:description" content="定番カードゲーム・スパイダーソリティアが無料で遊べる!1・2・4スートの3段階の難易度でクリアタイムに挑戦。">
  <meta property="og:type" content="website">
  <meta property="og:url" content="https://hinata-ya.tech/games/games/spider/">
  <meta property="og:site_name" content="ひなテックGames">
  <meta property="og:locale" content="ja_JP">
  <!-- SEO:meta-extra -->
  <meta property="og:image" content="https://hinata-ya.tech/games/images/og/spider.png">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta property="og:image:alt" content="スパイダー・ソリティア">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="スパイダー・ソリティア|無料ブラウザゲーム|ひなテックGames">
  <meta name="twitter:description" content="定番カードゲーム・スパイダーソリティアが無料で遊べる!1・2・4スートの3段階の難易度。同じマークをKからAまでそろえて8組完成でクリア。タップだけのかんたん操作、やり直し機能つき。PC・スマホ対応の無料ブラウザゲーム。">
  <meta name="twitter:image" content="https://hinata-ya.tech/games/images/og/spider.png">
  <!-- /SEO:meta-extra -->
  <link rel="stylesheet" href="../../css/style.css">
  <link rel="stylesheet" href="style.css?v=20260703">
  <link rel="stylesheet" href="../../css/leaderboard.css">
  <!-- FAQ:ld+json(手動管理。enhance-seo-games.js の SEO:ld+json マーカー外なので再生成で上書きされない) -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "FAQPage",
    "mainEntity": [
      {
        "@type": "Question",
        "name": "スパイダーソリティアは無料で遊べますか?",
        "acceptedAnswer": { "@type": "Answer", "text": "はい、完全無料です。料金や課金は一切ありません。" }
      },
      {
        "@type": "Question",
        "name": "ダウンロードやインストールは必要ですか?",
        "acceptedAnswer": { "@type": "Answer", "text": "不要です。ブラウザを開くだけですぐに遊べます。スマホでもカードをタップするだけのかんたん操作です。" }
      },
      {
        "@type": "Question",
        "name": "1スート・2スート・4スートの違いは何ですか?",
        "acceptedAnswer": { "@type": "Answer", "text": "使うマークの種類の数で、難易度が変わります。1スート(スペードのみ)がいちばんかんたんで初心者におすすめ。2スート(ふつう)、4スート(むずかしい)と順にステップアップできます。ランキングも難易度ごとに分かれています。" }
      },
      {
        "@type": "Question",
        "name": "山札をタップしても配れないときがあるのはなぜですか?",
        "acceptedAnswer": { "@type": "Answer", "text": "空の列があると山札からは配れないルールだからです。まずどれかのカードを空の列に移動して、すべての列にカードがある状態にしてから山札をタップしてください。" }
      },
      {
        "@type": "Question",
        "name": "ベスト記録は保存されますか?ランキングに登録できますか?",
        "acceptedAnswer": { "@type": "Answer", "text": "はい、クリアタイムが難易度ごとにベスト記録として自動で保存され、ランキングに登録することもできます。タイムが短いほど上位です。" }
      }
    ]
  }
  </script>
  <!-- SEO:ld+json -->
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "VideoGame",
    "name": "スパイダー・ソリティア",
    "description": "定番カードゲーム・スパイダーソリティアが無料で遊べる!1・2・4スートの3段階の難易度。同じマークをKからAまでそろえて8組完成でクリア。タップだけのかんたん操作、やり直し機能つき。PC・スマホ対応の無料ブラウザゲーム。",
    "url": "https://hinata-ya.tech/games/games/spider/",
    "image": "https://hinata-ya.tech/games/images/og/spider.png",
    "inLanguage": "ja",
    "genre": [
      "カード",
      "脳トレ"
    ],
    "gamePlatform": [
      "Web Browser"
    ],
    "applicationCategory": "GameApplication",
    "operatingSystem": [
      "Windows",
      "macOS",
      "iOS",
      "Android",
      "ChromeOS"
    ],
    "browserRequirements": "Requires JavaScript. Requires HTML5.",
    "isAccessibleForFree": true,
    "offers": {
      "@type": "Offer",
      "price": "0",
      "priceCurrency": "JPY",
      "availability": "https://schema.org/InStock"
    },
    "publisher": {
      "@type": "Organization",
      "name": "ひなテック",
      "url": "https://hinata-ya.tech"
    },
    "author": {
      "@type": "Organization",
      "name": "ひなテック",
      "url": "https://hinata-ya.tech"
    }
  }
  </script>
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    "itemListElement": [
      {
        "@type": "ListItem",
        "position": 1,
        "name": "ホーム",
        "item": "https://hinata-ya.tech/games/"
      },
      {
        "@type": "ListItem",
        "position": 2,
        "name": "スパイダー・ソリティア",
        "item": "https://hinata-ya.tech/games/games/spider/"
      }
    ]
  }
  </script>
  <!-- /SEO:ld+json -->
</head>
<body>
  <div id="header"></div>

  <div class="game-page">
    <h1 class="game-page-title">スパイダー・ソリティア</h1>
    <div class="game-page-tags">
      <span class="tag">カード</span>
      <span class="tag">脳トレ</span>
    </div>

    <div class="game-container">
      <!-- スコアエリア -->
      <div class="score-area">
        <div class="score-box">
          <span class="score-label">タイム</span>
          <span class="score-value" id="time-display">0:00</span>
        </div>
        <div class="score-box">
          <span class="score-label">手数</span>
          <span class="score-value" id="move-count">0</span>
        </div>
        <div class="score-box">
          <span class="score-label">難易度</span>
          <span class="score-value" id="suits-display">1スート</span>
        </div>
        <div class="score-box">
          <span class="score-label">ベスト</span>
          <span class="score-value" id="best-time">--</span>
        </div>
      </div>

      <div class="sol-controls">
        <button id="btn-new" class="sol-btn">配り直す</button>
        <button id="btn-undo" class="sol-btn sol-btn-sub">← 戻す</button>
        <button id="btn-suits" class="sol-btn sol-btn-sub">難易度</button>
      </div>

      <!-- 盤面 -->
      <div id="sol-board">
        <div id="sol-top-row">
          <div class="sol-pile" id="pile-stock"></div>
          <div id="spider-done"></div>
        </div>
        <div id="sol-tableau">
          <div class="sol-col" id="col-0"></div>
          <div class="sol-col" id="col-1"></div>
          <div class="sol-col" id="col-2"></div>
          <div class="sol-col" id="col-3"></div>
          <div class="sol-col" id="col-4"></div>
          <div class="sol-col" id="col-5"></div>
          <div class="sol-col" id="col-6"></div>
          <div class="sol-col" id="col-7"></div>
          <div class="sol-col" id="col-8"></div>
          <div class="sol-col" id="col-9"></div>
        </div>

        <!-- 警告トースト -->
        <div id="spider-toast"></div>

        <!-- 開始(難易度選択)オーバーレイ -->
        <div id="spider-start" class="sol-win active">
          <div class="sol-win-inner">
            <h2>🕷 スパイダー・ソリティア</h2>
            <p>難易度をえらんでスタート!</p>
            <div class="spider-suit-btns">
              <button class="sol-btn spider-suit-btn" data-suits="1">1スート(かんたん)</button>
              <button class="sol-btn spider-suit-btn" data-suits="2">2スート(ふつう)</button>
              <button class="sol-btn spider-suit-btn" data-suits="4">4スート(むずかしい)</button>
            </div>
          </div>
        </div>

        <!-- クリアオーバーレイ -->
        <div id="sol-win" class="sol-win">
          <div class="sol-win-inner">
            <h2>🎉 クリア!</h2>
            <p id="win-time"></p>
            <p id="win-moves"></p>
            <p class="best-result" id="win-best"></p>
            <button id="btn-again" class="sol-btn">もう一回遊ぶ</button>
          </div>
        </div>
      </div>
    </div>

    <!-- ゲーム枠直下の広告(hinata-game-below) -->
    <div class="game-ad" style="margin:1.5rem auto;text-align:center;max-width:728px;">
      <ins class="adsbygoogle"
           style="display:block"
           data-ad-client="ca-pub-7652169193675702"
           data-ad-slot="8576602390"
           data-ad-format="horizontal"
           data-full-width-responsive="true"></ins>
      <script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
    </div>

    <div class="game-instructions">
      <h3>遊び方</h3>
      <ul>
        <li>カードを<strong>タップ(クリック)するだけ</strong>で、行ける場所へ自動で移動します</li>
        <li>場札は<strong>数字が1小さいカード</strong>の上に重ねられます(マークはなんでもOK)</li>
        <li>持ち上げられるのは<strong>同じマークで順につながった列</strong>だけです</li>
        <li><strong>同じマークでKからAまで13枚</strong>つなげると自動で完成!8組そろえたらクリア</li>
        <li>左上の山札をタップすると全列に1枚ずつ配ります。<strong>空の列があると配れません</strong></li>
        <li>難易度は1スート(かんたん)・2スート(ふつう)・4スート(むずかしい)の3段階</li>
        <li>困ったら「← 戻す」で1手ずつやり直せます。クリアタイムでランキングに挑戦!</li>
      </ul>
    </div>

    <div class="game-promo">
      <h3>このゲーム、自分でも作れるよ!</h3>
      <p>
        スパイダー・ソリティアは配列と<br>
        ルール判定の組み合わせで作られています。<br><br>
        ひなテックでは、こうしたゲームの<br>
        作り方を楽しく学べます!
      </p>
      <div class="promo-links">
        <a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
        <a href="https://hinata-ya.tech/contact/" class="btn-primary">体験レッスンに申し込む →</a>
        <a href="source/" class="btn-secondary">ソースコードを見る →</a>
      </div>
    </div>

    <div class="other-games">
      <a href="../../">&larr; 他のゲームも遊ぶ</a>
    </div>
  </div>

  <div id="footer"></div>

  <script src="../../js/common.js?v=20260613c" data-base="../../"></script>
  <script src="../../js/leaderboard.js"></script>
  <script src="game.js?v=20260714"></script>
</body>
</html>

style.css — a card layout that fits 10 columns

style.css creates the look of Spider Solitaire. The cards use no images at all — they are drawn entirely with CSS. The face shows a corner rank+suit and a large centered suit symbol; the back is a striped repeating-linear-gradient.

Where Klondike has 7 columns, Spider has 10, so the card-width CSS variable is tuned smaller — --card-w: clamp(30px, 8.6vw, 52px) — to fit all 10 columns even on a phone screen. The stacking overlaps (--ov-down and --ov-up) are also calculated from the card width, so the proportions hold at any screen size. The warning toast that says you can't deal while a column is empty, and the slots where completed Kings line up, are styled in this file too.

style.css
/* ============================================
   スパイダー・ソリティア - スタイル
   ============================================ */

/* スコアエリア */
.score-area {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.8rem;
  margin-bottom: 0.6rem;
  flex-wrap: wrap;
}

.score-box {
  background: #f0fff4;
  border: 2px solid #9ae6b4;
  border-radius: 10px;
  padding: 0.4rem 1rem;
  text-align: center;
  min-width: 76px;
}

.score-label {
  display: block;
  font-size: 0.75rem;
  font-weight: 700;
  color: #276749;
}

.score-value {
  display: block;
  font-size: 1.4rem;
  font-weight: 900;
  color: #2d3748;
}

/* 操作ボタン */
.sol-controls {
  display: flex;
  justify-content: center;
  gap: 0.8rem;
  margin-bottom: 0.7rem;
}

.sol-btn {
  font-size: 0.95rem;
  font-weight: 700;
  padding: 0.45rem 1.4rem;
  border: none;
  border-radius: 999px;
  cursor: pointer;
  background: linear-gradient(135deg, #38a169, #276749);
  color: #fff;
  box-shadow: 0 3px 8px rgba(56, 161, 105, 0.35);
  transition: transform 0.15s;
}

.sol-btn:hover {
  transform: translateY(-1px);
}

.sol-btn-sub {
  background: #718096;
  box-shadow: 0 3px 8px rgba(113, 128, 150, 0.35);
}

/* 盤面(緑のフェルト)。10列なのでカード幅は小さめ */
#sol-board {
  --card-w: clamp(30px, 8.6vw, 52px);
  --card-h: calc(var(--card-w) * 1.4);
  --ov-down: calc(var(--card-h) * 0.14);
  --ov-up: calc(var(--card-h) * 0.3);
  position: relative;
  max-width: 640px;
  margin: 0 auto;
  padding: 10px;
  border-radius: 14px;
  background:
    radial-gradient(circle at 50% 20%, rgba(255, 255, 255, 0.08), transparent 60%),
    linear-gradient(160deg, #2f855a, #276749 60%, #22543d);
  border: 4px solid #22543d;
  user-select: none;
  -webkit-user-select: none;
}

#sol-top-row {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 6px;
  margin-bottom: 12px;
}

/* 完成置き場(Kが8枚並ぶ。少し重ねて省スペース) */
#spider-done {
  display: flex;
  height: var(--card-h);
}

#spider-done .sol-card,
#spider-done .sol-empty {
  position: relative;
  flex: 0 0 auto;
  margin-left: calc(var(--card-w) * -0.45);
}

#spider-done .sol-card:first-child,
#spider-done .sol-empty:first-child {
  margin-left: 0;
}

#spider-done .done-slot {
  border-color: rgba(255, 255, 255, 0.2);
}

#sol-tableau {
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  gap: 4px;
  min-height: calc(var(--card-h) + var(--ov-down) * 5 + var(--ov-up) * 16);
}

.sol-pile {
  position: relative;
  width: var(--card-w);
  height: var(--card-h);
}

.sol-col {
  position: relative;
}

/* カード */
.sol-card {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--card-w);
  height: var(--card-h);
  border-radius: 5px;
  box-sizing: border-box;
  cursor: pointer;
}

.sol-card.up {
  background: #fdfdf8;
  border: 1px solid #cbd5e0;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
  color: #1a202c;
}

.sol-card.up.red {
  color: #c53030;
}

.sol-corner {
  position: absolute;
  top: 2px;
  left: 3px;
  font-size: calc(var(--card-w) * 0.32);
  font-weight: 800;
  line-height: 1.05;
}

.sol-center {
  position: absolute;
  right: 3px;
  bottom: 0;
  font-size: calc(var(--card-w) * 0.5);
  opacity: 0.85;
}

/* 裏面 */
.sol-card.back {
  background:
    repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.14) 0 4px, transparent 4px 8px),
    linear-gradient(160deg, #4c7bd9, #2c5aa0);
  border: 2px solid #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}

/* 空きマス */
.sol-empty {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--card-w);
  height: var(--card-h);
  border: 2px dashed rgba(255, 255, 255, 0.35);
  border-radius: 5px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  color: rgba(255, 255, 255, 0.4);
  font-size: calc(var(--card-w) * 0.5);
}

/* 山札の残り配札回数 */
.sol-count {
  position: absolute;
  right: -4px;
  bottom: -4px;
  background: #22543d;
  color: #fff;
  font-size: 0.65rem;
  font-weight: 700;
  padding: 1px 6px;
  border-radius: 999px;
  pointer-events: none;
}

/* 動かせない時のプルプル */
.sol-shake {
  animation: sol-shake 0.25s ease-out;
}

@keyframes sol-shake {
  0%, 100% { transform: translateX(0); }
  30% { transform: translateX(-4px); }
  70% { transform: translateX(4px); }
}

/* 警告トースト */
#spider-toast {
  position: absolute;
  top: calc(var(--card-h) + 24px);
  left: 50%;
  transform: translateX(-50%) translateY(-6px);
  background: rgba(26, 32, 44, 0.92);
  color: #fff;
  font-size: 0.85rem;
  font-weight: 700;
  padding: 0.5rem 1.2rem;
  border-radius: 999px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s, transform 0.25s;
  z-index: 6;
  white-space: nowrap;
}

#spider-toast.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* オーバーレイ(開始・クリア共通) */
.sol-win {
  position: absolute;
  inset: 0;
  display: none;
  align-items: center;
  justify-content: center;
  background: rgba(26, 32, 44, 0.65);
  border-radius: 10px;
  z-index: 5;
}

.sol-win.active {
  display: flex;
}

.sol-win-inner {
  background: #fff;
  border-radius: 14px;
  padding: 1.6rem 2.2rem;
  text-align: center;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}

.sol-win-inner h2 {
  margin: 0 0 0.6rem;
  color: #276749;
}

.sol-win-inner p {
  margin: 0.3rem 0;
  color: #2d3748;
  font-weight: 600;
}

.sol-win-inner .sol-btn {
  margin-top: 0.9rem;
}

/* 難易度選択ボタン(縦に並べる) */
.spider-suit-btns {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin-top: 0.9rem;
}

.spider-suit-btns .sol-btn {
  margin-top: 0;
}

.spider-suit-btn[data-suits="2"] {
  background: linear-gradient(135deg, #d69e2e, #b7791f);
  box-shadow: 0 3px 8px rgba(214, 158, 46, 0.35);
}

.spider-suit-btn[data-suits="4"] {
  background: linear-gradient(135deg, #e53e3e, #c53030);
  box-shadow: 0 3px 8px rgba(229, 62, 62, 0.35);
}

.best-result {
  color: #b7791f !important;
  font-weight: 700 !important;
}

/* スマホ調整 */
@media (max-width: 600px) {
  .score-box {
    min-width: 62px;
    padding: 0.3rem 0.7rem;
  }

  .score-value {
    font-size: 1.1rem;
  }

  #sol-board {
    padding: 6px;
  }

  #sol-tableau {
    gap: 3px;
  }

  .sol-win-inner {
    padding: 1.2rem 1.4rem;
  }
}

game.js — the brain behind asymmetric rules and run detection

game.js is the heart of the game. The first half of the file is pure, browser-independent logic: buildDeck() builds the deck by switching suit combinations (1 suit = 104 spades, 2 suits = 52 spades + 52 hearts, 4 suits = 26 of each). Difficulty is decided purely by how the data is built — a very satisfying design.

Rule checking is split into two functions. isMovableRun() is the pick-up check: "from the tapped position upward, is everything face up, the same suit, and descending by one?" canDropOn() is the drop check: "is the destination's top card one rank higher? (any suit; empty columns take anything)". Picking up is strict while dropping is loose — this asymmetry is what gives Spider its strategy.

The showpiece is findCompletedIndex(). After every move and every deal it examines the top 13 cards of each column, and if they are "all face up, same suit, and in perfect order from King to Ace via 13 - (k - start)", the run is complete and gets removed from the column. Any face-down card revealed underneath flips automatically, and completing 8 runs wins the game.

Tapping the stock deals one card to all 10 columns, but dealing is not allowed while any column is empty — that's the official rule, so the game shows a warning toast and refuses. Undo uses a snapshot approach that saves the whole board as JSON, and it also saves right before each run removal, so even "the moment 13 cards vanished" can be rewound. On a win, the clear time is submitted to the leaderboard under a difficulty-specific slug (spider-1/2/4).

game.js
// ============================================
// スパイダー・ソリティア - ゲームロジック
// ============================================

(function () {
  'use strict';

  // ============================================
  // 純粋ロジック(vitest からもここだけ読み込まれる)
  // ============================================

  // シード無しFisher–Yatesシャッフル(rand を渡すと決定的にできる)
  function shuffle(arr, rand) {
    for (var i = arr.length - 1; i > 0; i--) {
      var j = Math.floor((rand ? rand() : Math.random()) * (i + 1));
      var t = arr[i];
      arr[i] = arr[j];
      arr[j] = t;
    }
    return arr;
  }

  // 2デッキぶん104枚を作る
  // suits=1: ♠×104 / suits=2: ♠♥×52ずつ / suits=4: 各スート26枚
  function buildDeck(suits, rand) {
    var deck = [];
    var suitList = suits === 1 ? [0, 0, 0, 0, 0, 0, 0, 0]
                 : suits === 2 ? [0, 1, 0, 1, 0, 1, 0, 1]
                 : [0, 1, 2, 3, 0, 1, 2, 3];        // 8グループ×13枚=104
    suitList.forEach(function (s) {
      for (var r = 1; r <= 13; r++) deck.push({ s: s, r: r, up: false });
    });
    return shuffle(deck, rand);
  }

  // col の i 枚目から上を持ち上げられるか(表向き・同スート・降順連続)
  function isMovableRun(col, i) {
    if (i < 0 || i >= col.length || !col[i].up) return false;
    for (var k = i; k < col.length - 1; k++) {
      if (!col[k + 1].up) return false;
      if (col[k].s !== col[k + 1].s) return false;
      if (col[k].r !== col[k + 1].r + 1) return false;
    }
    return true;
  }

  // destCol(配列)の上に card を置けるか(空列は何でも可、非空はrankが1大きい表向き。スート不問)
  function canDropOn(destCol, card) {
    if (!destCol.length) return true;
    var top = destCol[destCol.length - 1];
    return top.up && top.r === card.r + 1;
  }

  // 列トップから「同スート K,Q,...,A の13枚(全部表)」が並んでいれば
  // その開始index(=col.length-13)、なければ -1
  function findCompletedIndex(col) {
    if (col.length < 13) return -1;
    var start = col.length - 13;
    for (var k = start; k < col.length; k++) {
      if (!col[k].up) return -1;
      if (col[k].s !== col[start].s) return -1;
      if (col[k].r !== 13 - (k - start)) return -1;
    }
    return start;
  }

  // 全列を検査して完成した13枚runを除去する(tableau を直接書き換える)。
  // 除去後のトップが裏なら表にする。除去した run のスート番号の配列を返す。
  // onBeforeRemove(suit) は各除去の「直前」に呼ばれる(Undo用スナップショットに使う)
  function removeCompletedRuns(tableau, onBeforeRemove) {
    var removed = [];
    var found = true;
    while (found) {
      found = false;
      for (var c = 0; c < tableau.length; c++) {
        var idx = findCompletedIndex(tableau[c]);
        if (idx < 0) continue;
        var suit = tableau[c][idx].s;
        if (onBeforeRemove) onBeforeRemove(suit);
        removed.push(suit);
        tableau[c].length = idx;
        if (tableau[c].length && !tableau[c][tableau[c].length - 1].up) {
          tableau[c][tableau[c].length - 1].up = true;
        }
        found = true;
      }
    }
    return removed;
  }

  // Node.js(vitest)から読み込まれた時は純粋ロジックだけ公開して終了
  if (typeof module !== 'undefined' && module.exports) {
    module.exports = {
      shuffle: shuffle,
      buildDeck: buildDeck,
      isMovableRun: isMovableRun,
      canDropOn: canDropOn,
      findCompletedIndex: findCompletedIndex,
      removeCompletedRuns: removeCompletedRuns
    };
    return;
  }

  // ============================================
  // ここからブラウザ専用(UI・進行)
  // ============================================

  // 言語別テキスト(英語版ページが window.GAME_TEXT を定義して上書きする)
  var TEXT = window.GAME_TEXT || {};

  var SUITS = ['♠', '♥', '♦', '♣'];            // スート(1,2が赤)
  var RANK_LABEL = ['', 'A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
  var SUIT_LABEL = { 1: TEXT.suit1 || '1スート', 2: TEXT.suit2 || '2スート', 4: TEXT.suit4 || '4スート' };
  var UNDO_LIMIT = 500;                         // 戻せる手数の上限

  // 状態変数
  var suits = 1;                  // 難易度(1/2/4スート)
  var tableau = [];               // 場札10列
  var stock = [];                 // 山札(10枚×5回分)
  var completedRuns = [];         // 完成したrunのスート番号(8つでクリア)
  var moveCount = 0;
  var elapsed = 0;
  var timerId = null;
  var startTime = 0;
  var playing = false;            // 最初の1手で計時開始
  var won = false;
  var undoStack = [];
  var bestTime = null;

  // DOM要素
  var timeEl = document.getElementById('time-display');
  var movesEl = document.getElementById('move-count');
  var bestEl = document.getElementById('best-time');
  var suitsEl = document.getElementById('suits-display');
  var stockEl = document.getElementById('pile-stock');
  var doneEl = document.getElementById('spider-done');
  var toastEl = document.getElementById('spider-toast');
  var startEl = document.getElementById('spider-start');
  var winEl = document.getElementById('sol-win');
  var winTimeEl = document.getElementById('win-time');
  var winMovesEl = document.getElementById('win-moves');
  var winBestEl = document.getElementById('win-best');
  var toastTimer = null;

  function isRed(card) {
    return card.s === 1 || card.s === 2;
  }

  function fmtTime(sec) {
    var m = Math.floor(sec / 60);
    var s = sec % 60;
    return m + ':' + (s < 10 ? '0' : '') + s;
  }

  // ============================================
  // ゲーム開始
  // ============================================
  function newGame() {
    var deck = buildDeck(suits);

    // 配札: 10列。列0-3に6枚、列4-9に5枚(計54枚・各列トップのみ表)
    tableau = [[], [], [], [], [], [], [], [], [], []];
    for (var c = 0; c < 10; c++) {
      var n = c < 4 ? 6 : 5;
      for (var k = 0; k < n; k++) {
        var card = deck.pop();
        card.up = (k === n - 1);
        tableau[c].push(card);
      }
    }
    stock = deck;                 // 残り50枚(10枚×5回分)
    completedRuns = [];
    moveCount = 0;
    elapsed = 0;
    playing = false;
    won = false;
    undoStack = [];
    clearInterval(timerId);
    bestTime = parseInt(localStorage.getItem('bestSpider' + suits) || '0', 10) || null;
    winEl.classList.remove('active');
    updateHud();
    render();
  }

  // ============================================
  // 計時・HUD
  // ============================================
  function startTimerIfNeeded() {
    if (playing || won) return;
    playing = true;
    startTime = Date.now();
    timerId = setInterval(function () {
      elapsed = Math.floor((Date.now() - startTime) / 1000);
      timeEl.textContent = fmtTime(elapsed);
    }, 500);
  }

  function updateHud() {
    timeEl.textContent = fmtTime(elapsed);
    movesEl.textContent = moveCount;
    bestEl.textContent = bestTime ? fmtTime(bestTime) : '--';
    suitsEl.textContent = SUIT_LABEL[suits];
  }

  // ============================================
  // Undo(1手ごとの状態スナップショット。完成除去の直前にも積む)
  // ============================================
  function snapshot() {
    undoStack.push(JSON.stringify({ t: tableau, st: stock, cr: completedRuns }));
    if (undoStack.length > UNDO_LIMIT) undoStack.shift();
  }

  function undo() {
    if (!undoStack.length || won) return;
    var state = JSON.parse(undoStack.pop());
    tableau = state.t;
    stock = state.st;
    completedRuns = state.cr;
    moveCount++;                 // 戻すのも1手に数える
    startTimerIfNeeded();
    updateHud();
    render();
  }

  // ============================================
  // 完成検出(カード移動・配札のたびに呼ぶ)
  // ============================================
  function checkCompletion() {
    removeCompletedRuns(tableau, function (suit) {
      snapshot();                // 除去前の状態を保存(Undoで完成除去も巻き戻せる)
      completedRuns.push(suit);
    });
  }

  // ============================================
  // 操作(タップで自動移動)
  // ============================================
  function afterMove() {
    moveCount++;
    startTimerIfNeeded();
    checkCompletion();
    updateHud();
    render();
    checkWin();
  }

  // 山札タップ: 全列に1枚ずつ配る。空の列があるときは配れない
  function tapStock() {
    if (won || !stock.length) return;
    var hasEmpty = tableau.some(function (col) { return !col.length; });
    if (hasEmpty) {
      showToast(TEXT.emptyWarn || '空の列があると配れません');
      shake(stockEl);
      return;
    }
    snapshot();
    for (var c = 0; c < 10; c++) {
      var card = stock.pop();
      card.up = true;
      tableau[c].push(card);
    }
    afterMove();
  }

  // 場札のカード(とその上に乗っている同スート降順のrun)を動かす
  function tapTableau(srcCol, index) {
    if (won) return;
    var col = tableau[srcCol];
    var card = col[index];
    if (!card) return;
    if (!isMovableRun(col, index)) {
      shake(document.querySelector('#col-' + srcCol));
      return;
    }
    var moving = col.slice(index);

    // 移動先の優先順位: (1)トップが同スートの列 (2)その他の非空列 (3)空列
    // 列まるごと(index===0)を空列へ動かすのは無意味なのでスキップ
    var dest = -1;
    var c, d;
    for (c = 0; c < 10 && dest < 0; c++) {
      if (c === srcCol) continue;
      d = tableau[c];
      if (d.length && canDropOn(d, card) && d[d.length - 1].s === card.s) dest = c;
    }
    for (c = 0; c < 10 && dest < 0; c++) {
      if (c === srcCol) continue;
      d = tableau[c];
      if (d.length && canDropOn(d, card)) dest = c;
    }
    if (index > 0) {
      for (c = 0; c < 10 && dest < 0; c++) {
        if (c === srcCol) continue;
        if (!tableau[c].length) dest = c;
      }
    }
    if (dest < 0) {
      shake(document.querySelector('#col-' + srcCol));
      return;
    }

    snapshot();
    tableau[dest] = tableau[dest].concat(moving);
    col.length = index;
    if (col.length && !col[col.length - 1].up) col[col.length - 1].up = true;
    afterMove();
  }

  // 動かせない時のフィードバック
  function shake(el) {
    if (!el) return;
    el.classList.remove('sol-shake');
    void el.offsetWidth;
    el.classList.add('sol-shake');
  }

  // 警告トースト
  function showToast(msg) {
    toastEl.textContent = msg;
    toastEl.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(function () { toastEl.classList.remove('show'); }, 2000);
  }

  // ============================================
  // クリア判定
  // ============================================
  function checkWin() {
    if (completedRuns.length < 8) return;
    won = true;
    playing = false;
    clearInterval(timerId);

    winTimeEl.textContent = (TEXT.winTime || 'タイム: {t}').replace('{t}', fmtTime(elapsed));
    winMovesEl.textContent = (TEXT.winMoves || '手数: {n}手').replace('{n}', moveCount);
    if (!bestTime || elapsed < bestTime) {
      bestTime = elapsed;
      localStorage.setItem('bestSpider' + suits, String(bestTime));
      winBestEl.textContent = TEXT.newBest || '🎉 ベスト更新!';
    } else {
      winBestEl.textContent = (TEXT.best || 'ベスト: {t}').replace('{t}', fmtTime(bestTime));
    }
    updateHud();
    winEl.classList.add('active');

    // リーダーボード(難易度別slug・クリアタイム: 短いほど上位)
    if (window.Leaderboard) {
      Leaderboard.show('spider-' + suits, elapsed, { order: 'asc', format: 'time' });
    }
  }

  // ============================================
  // 描画
  // ============================================
  function cardHtml(card, extra) {
    var red = isRed(card) ? ' red' : '';
    if (!card.up) {
      return '<div class="sol-card back" ' + (extra || '') + '></div>';
    }
    return '<div class="sol-card up' + red + '" ' + (extra || '') + '>' +
      '<span class="sol-corner">' + RANK_LABEL[card.r] + SUITS[card.s] + '</span>' +
      '<span class="sol-center">' + SUITS[card.s] + '</span>' +
      '</div>';
  }

  function render() {
    // 山札(残り配札回数を表示)
    if (stock.length) {
      stockEl.innerHTML = '<div class="sol-card back"></div>' +
        '<span class="sol-count">' + (TEXT.deals || '{n}回').replace('{n}', stock.length / 10) + '</span>';
    } else {
      stockEl.innerHTML = '<div class="sol-empty"></div>';
    }

    // 完成置き場(上段右): 完成した組はKを表示
    var doneHtml = '';
    for (var i = 0; i < 8; i++) {
      if (i < completedRuns.length) {
        var s = completedRuns[i];
        doneHtml += '<div class="sol-card up done-card' + (isRed({ s: s }) ? ' red' : '') + '">' +
          '<span class="sol-corner">K' + SUITS[s] + '</span>' +
          '<span class="sol-center">' + SUITS[s] + '</span></div>';
      } else {
        doneHtml += '<div class="sol-empty done-slot"></div>';
      }
    }
    doneEl.innerHTML = doneHtml;

    // 場札10列
    for (var c = 0; c < 10; c++) {
      var colEl = document.getElementById('col-' + c);
      var col = tableau[c];
      if (!col.length) {
        colEl.innerHTML = '<div class="sol-empty" data-col="' + c + '" data-idx="-1"></div>';
        continue;
      }
      var html = '';
      var downCount = 0;
      var upCount = 0;
      for (var k = 0; k < col.length; k++) {
        var top = 'calc(' + downCount + '*var(--ov-down) + ' + upCount + '*var(--ov-up))';
        html += cardHtml(col[k], 'data-col="' + c + '" data-idx="' + k + '" style="top:' + top + '"');
        if (col[k].up) upCount++; else downCount++;
      }
      colEl.innerHTML = html;
    }
  }

  // ============================================
  // 入力(イベント委譲)
  // ============================================
  stockEl.addEventListener('click', tapStock);

  document.getElementById('sol-tableau').addEventListener('click', function (e) {
    var t = e.target.closest('[data-col]');
    if (!t) return;
    var col = parseInt(t.getAttribute('data-col'), 10);
    var idx = parseInt(t.getAttribute('data-idx'), 10);
    if (idx >= 0) tapTableau(col, idx);
  });

  document.getElementById('btn-new').addEventListener('click', newGame);
  document.getElementById('btn-suits').addEventListener('click', function () {
    startEl.classList.add('active');
  });
  document.getElementById('btn-undo').addEventListener('click', undo);
  document.getElementById('btn-again').addEventListener('click', function () {
    winEl.classList.remove('active');
    startEl.classList.add('active');
  });

  // 開始オーバーレイの難易度ボタン
  (function () {
    var btns = document.querySelectorAll('.spider-suit-btn');
    for (var i = 0; i < btns.length; i++) {
      btns[i].addEventListener('click', function () {
        suits = parseInt(this.getAttribute('data-suits'), 10);
        startEl.classList.remove('active');
        newGame();
      });
    }
  })();

  // Playwright検証用フック(盤面の読み取り・差し替え)
  // 本番では盤面差し替え→数秒クリア→ランキング登録に悪用できるため、ローカル実行時のみ公開
  if (/^(localhost|127\.0\.0\.1|)$/.test(location.hostname)) {
    window.__spiderTest = {
      getState: function () {
        return JSON.parse(JSON.stringify({
          suits: suits, tableau: tableau, stock: stock,
          completed: completedRuns, moves: moveCount, won: won
        }));
      },
      setState: function (st) {
        if (st.tableau) tableau = st.tableau;
        if (st.stock) stock = st.stock;
        updateHud();
        render();
      }
    };
  }

  // 初期化(開始オーバーレイの裏に既定難易度の盤面を敷いておく)
  newGame();
})();

What you can learn from this code

Run it on your own computer

Save each code block above using its file name, put them all in the same folder, and open index.html in your browser — the game will run. The "Copy" button in each file-name bar makes this easy.

The live game also uses a few site-wide files (shared CSS and the header), so when you open just these files the header and other shared parts will be missing. The game itself works fine, which is all you need to study or tinker with it.

Once you feel comfortable, try changing the colors or tweaking the rules. After reading code, changing it is the best way to learn.

Can I use this code?

Yes — feel free to read, copy, run and modify this source code to learn programming. Using it for school projects is welcome too. Let it spark ideas about how you would build it.

Please don't republish or distribute the code as-is as your own game or service, though. It is meant for learning.

Want to learn more?

If reading the code makes you think "I want to build this myself!", check out the step-by-step guide for Spider Solitaire. While this page shows the finished code, the guide builds the game up from nothing, one step at a time.