Build a Board Game in JavaScript

Build a browser-based Can't Stop board game with a bot opponent.

Introduction

30 Second Summary

Board games are all about decisions. Do you push your luck for a bigger payoff, or play it safe and keep what you have? That tension is what makes games like Can't Stop so addictive.

In this project, you will build a fully playable browser-based version of the classic push-your-luck dice game Can't Stop, complete with a bot opponent that uses probability-based strategy to challenge you.

What You'll Build

A complete board game built with vanilla JavaScript and ES Modules, deployed live on GitHub Pages for anyone to play.

By the end of this project, you'll have:

  • A responsive game board rendered with HTML and CSS Grid showing all 11 columns with the correct number of spaces.
  • Core game logic handling dice rolls, pair formation, runner placement, busting, and column claiming.
  • A bot opponent using a probability-based heuristic called the Rule of 28 to decide when to stop.
  • Your game deployed live on GitHub Pages with proper Git version control history.
  • Secret Mission: Add dice roll animations and a game history log that tracks each player's moves.

Are there any prerequisites?

None. This guide walks you through installing every tool you need, including Git and the Live Server extension for VS Code. You just need a computer with VS Code and a free GitHub account.

Before We Start

Before we can build our board game, we need a few tools in place. Git tracks every change you make to your code, and GitHub gives you a place to store it online.

In this step, you'll install Git, set up a code-previewing tool in VS Code, and create the project folder where all your game files will live.

In this step, get ready to:
  • Install Git for version control.
  • Install the Live Server extension in VS Code.
  • Create and clone your GitHub repository with the project file structure.
Install Git via Xcode Command Line Tools

Git is the version control tool that lets you save snapshots of your code and push them to GitHub. On macOS, Git comes bundled with Apple's Command Line Tools.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Enter to open it.
  • Check if Git is already installed by running this command:
git --version

✔️ I see a version number

You already have Git installed. Skip ahead to the next substep.

ⓧ I see a popup dialog or error

If you see a dialog saying the command requires the command line developer tools, click Install in the dialog and wait for the download to finish.

Alternatively, install the tools by running this command:

xcode-select --install

What does this command do?

This installs Apple's Command Line Tools package, which includes Git and other developer utilities. The download is around 1-2 GB and may take a few minutes depending on your internet connection.

  • Once the installation finishes, verify Git is ready by running:
git --version

You should see something like git version 2.39.5 (your exact number may differ).

Still not seeing a version number?

Try closing Terminal and opening a fresh window, then run git --version again. If the installer appears to have stalled, check System Settings > General > Software Update to see if the download is still in progress.

Help me troubleshoot my Git installation on macOS.

Install the Live Server extension in VS Code

Live Server launches a local web server and automatically refreshes your browser whenever you save a file. You need this because ES Modules (the import/export syntax we'll use) cannot load from the file:// protocol.

  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code and press Enter to open it.
  • Open the Extensions panel by pressing Cmd+Shift+X.
  • Type Live Server in the search bar.
  • Find the result by Ritwick Dey (it has 78M+ installs).
  • Click Install.

How do I know it worked?

After installation, you'll see a Go Live button in the bottom status bar of VS Code. You'll use this button later to preview your game in the browser.

Don't see the Go Live button?

Try reloading VS Code by pressing Cmd+Shift+P, typing Reload Window, and pressing Enter. The Go Live button should appear in the status bar after the reload.

Help me get the Live Server Go Live button to appear in VS Code.

Create and clone your GitHub repository

Now you'll create a repository on GitHub to hold your game code, then clone it to your machine so you can work on it locally.

  • Open your browser and go to github.com/new.
  • Enter cant-stop-game in the Repository name field.
  • Leave the repository set to Public.
  • Check the box for Add a README file.
  • Click Create repository.

Why add a README?

Adding a README creates an initial commit on the main branch. Without it, the repository would be empty and you wouldn't be able to clone it with a branch ready to go.

  • On your new repository page, click the green Code button.
  • Make sure HTTPS is selected, then click the copy icon to copy the URL.
  • Switch back to Terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Clone your repository by running this command:
git clone [[REPO_URL="https://github.com/your-username/cant-stop-game.git"]]
  • Move into the project folder by running:
cd cant-stop-game

Now create the folders and files that will hold your game code.

  • Create the project file structure by running these commands:
mkdir css js
touch index.html css/style.css js/main.js js/game.js js/board.js js/dice.js js/bot.js js/ui.js

What does this create?

You now have the skeleton for a modular JavaScript project. Each .js file will handle one responsibility: game.js for game state, dice.js for dice logic, bot.js for the AI opponent, ui.js for rendering, and main.js to wire everything together.

  • Confirm the files exist by running:
ls

You should see css, index.html, and js in the output.

  • Check inside the js folder by running:
ls js

You should see board.js, bot.js, dice.js, game.js, main.js, and ui.js.

Missing files or folders?

Make sure you're inside the cant-stop-game folder when you run the mkdir and touch commands. Run pwd to check your current location. It should end with /Desktop/cant-stop-game.

Help me figure out why my project files weren't created in the right place.

  • Finally, open the project in VS Code by running:
code .

What if the code command isn't recognized?

In VS Code, press Cmd+Shift+P, type Shell Command: Install 'code' command in PATH, and press Enter. Then try running code . again.

You should see all your project files and folders in the VS Code Explorer sidebar on the left.

Your development environment is ready and your project structure is in place. Next up, you'll build the game board with HTML and CSS.

Build the Game Board with HTML and CSS

Your development environment is ready. Now it's time to build the visual foundation of Can't Stop.

Every board game needs a playing surface. In this step, you'll create the HTML structure that holds the game board, dice display, and player controls. Then you'll style everything with CSS so it looks like a real game.

In this step, get ready to:
  • Build the HTML page structure with containers for the board, dice, and buttons.
  • Style the board layout and all game elements with CSS.
  • Launch the page in your browser and commit your progress with Git.
Create the HTML game structure

The HTML file defines all the containers that your JavaScript will later fill with game content. You need areas for the board columns, dice results, pair choices, and control buttons.

  • In VS Code, open index.html from the cant-stop-game folder in the Explorer sidebar.
  • Replace the empty file with this HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Can't Stop</title>
    <link rel="stylesheet" href="./css/style.css">
</head>
<body>
    <div id="app">
        <h1>Can't Stop</h1>
        <p id="status">Your turn! Click "Roll Dice" to start.</p>
        <div id="board"></div>
        <div id="dice-area">
            <div id="dice-display"></div>
            <div id="pairs-display"></div>
        </div>
        <div id="controls">
            <button id="roll-btn">Roll Dice</button>
            <button id="stop-btn" disabled>Stop Turn</button>
        </div>
    </div>
    <script type="module" src="./js/main.js"></script>
</body>
</html>

What does this code do?

  • The #app div wraps the entire game interface.
  • The #board div is empty for now. Your JavaScript will dynamically create the 11 columns inside it later.
  • The #dice-display and #pairs-display divs will show dice results and pair options during gameplay.
  • The <script type="module"> tag loads your JavaScript using ES Modules. Modules let you split code across multiple files with import and export statements.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Why is the board div empty?

The game board has 11 columns with different heights. Creating those dynamically with JavaScript (in a later step) is much cleaner than hardcoding 87 individual <div> elements by hand. The HTML just provides the container.

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html before continuing.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Can't Stop</title>
    <link rel="stylesheet" href="./css/style.css">
</head>
<body>
    <div id="app">
        <h1>Can't Stop</h1>
        <p id="status">Your turn! Click "Roll Dice" to start.</p>
        <div id="board"></div>
        <div id="dice-area">
            <div id="dice-display"></div>
            <div id="pairs-display"></div>
        </div>
        <div id="controls">
            <button id="roll-btn">Roll Dice</button>
            <button id="stop-btn" disabled>Stop Turn</button>
        </div>
    </div>
    <script type="module" src="./js/main.js"></script>
</body>
</html>
Style the page layout and game board

The CSS gives your game its dark-themed look and defines how board spaces, dice, and buttons appear. You will add the styles in two chunks.

  • Open css/style.css in VS Code.
  • Add the page foundation and board styles by pasting this code:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #eee;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    padding: 20px;
}

#app {
    max-width: 900px;
    width: 100%;
    text-align: center;
}

h1 {
    margin-bottom: 10px;
    font-size: 2rem;
    color: #e94560;
}

#status {
    margin-bottom: 20px;
    font-size: 1.1rem;
    min-height: 1.5em;
    color: #ffdd57;
}

#board {
    display: flex;
    justify-content: center;
    gap: 4px;
    margin-bottom: 30px;
}

.column {
    display: flex;
    flex-direction: column-reverse;
    align-items: center;
    gap: 3px;
}

.column-label {
    font-weight: bold;
    font-size: 0.85rem;
    margin-bottom: 4px;
    color: #aaa;
}

.space {
    width: 36px;
    height: 28px;
    border: 1px solid #333;
    border-radius: 4px;
    background-color: #16213e;
}

.space.player-permanent {
    background-color: #0f9d58;
}

.space.player-runner {
    background-color: #4caf50;
    border: 2px solid #a5d6a7;
}

.space.bot-permanent {
    background-color: #e94560;
}

.space.bot-runner {
    background-color: #ff6b6b;
    border: 2px solid #ffcdd2;
}

.space.claimed-player {
    background-color: #0f9d58;
    opacity: 0.6;
}

.space.claimed-bot {
    background-color: #e94560;
    opacity: 0.6;
}

What does this code do?

  • The * reset removes default browser margins and padding so spacing is predictable.
  • The body uses flexbox to center the game on a dark navy background.
  • The #board container uses display: flex to arrange the 11 columns side by side. Each .column uses column-reverse so spaces stack upward from the bottom.
  • The .space variants define colours for different game states: green for the player, red for the bot, and dimmed for claimed columns.
  • Below the code you just pasted, add the dice and controls styles:
#dice-area {
    margin-bottom: 20px;
}

#dice-display {
    display: flex;
    justify-content: center;
    gap: 10px;
    margin-bottom: 15px;
}

.die {
    width: 50px;
    height: 50px;
    background-color: #fff;
    color: #1a1a2e;
    font-size: 1.5rem;
    font-weight: bold;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
}

#pairs-display {
    display: flex;
    justify-content: center;
    gap: 10px;
    flex-wrap: wrap;
}

.pair-option {
    padding: 10px 16px;
    background-color: #16213e;
    border: 2px solid #0f3460;
    border-radius: 8px;
    color: #eee;
    cursor: pointer;
    font-size: 0.95rem;
    transition: background-color 0.2s, border-color 0.2s;
}

.pair-option:hover {
    background-color: #0f3460;
    border-color: #e94560;
}

#controls {
    display: flex;
    justify-content: center;
    gap: 15px;
}

#controls button {
    padding: 12px 28px;
    font-size: 1rem;
    font-weight: bold;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    transition: opacity 0.2s, transform 0.1s;
}

#controls button:active {
    transform: scale(0.95);
}

#controls button:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}

#roll-btn {
    background-color: #0f9d58;
    color: white;
}

#stop-btn {
    background-color: #e94560;
    color: white;
}

What does this code do?

  • The .die class creates white squares that display dice values. They use flexbox centering to keep the number perfectly centered.
  • The .pair-option buttons highlight in red on hover, giving the player a clear signal that they can click.
  • Disabled buttons get opacity: 0.4 and cursor: not-allowed so players can tell when an action is unavailable.
  • Save css/style.css.

✔️ Awesome, I've got everything!

Great. Make sure you have saved css/style.css before continuing.

ⓧ I'd like to double check the full code

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background-color: #1a1a2e;
    color: #eee;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    padding: 20px;
}

#app {
    max-width: 900px;
    width: 100%;
    text-align: center;
}

h1 {
    margin-bottom: 10px;
    font-size: 2rem;
    color: #e94560;
}

#status {
    margin-bottom: 20px;
    font-size: 1.1rem;
    min-height: 1.5em;
    color: #ffdd57;
}

#board {
    display: flex;
    justify-content: center;
    gap: 4px;
    margin-bottom: 30px;
}

.column {
    display: flex;
    flex-direction: column-reverse;
    align-items: center;
    gap: 3px;
}

.column-label {
    font-weight: bold;
    font-size: 0.85rem;
    margin-bottom: 4px;
    color: #aaa;
}

.space {
    width: 36px;
    height: 28px;
    border: 1px solid #333;
    border-radius: 4px;
    background-color: #16213e;
}

.space.player-permanent {
    background-color: #0f9d58;
}

.space.player-runner {
    background-color: #4caf50;
    border: 2px solid #a5d6a7;
}

.space.bot-permanent {
    background-color: #e94560;
}

.space.bot-runner {
    background-color: #ff6b6b;
    border: 2px solid #ffcdd2;
}

.space.claimed-player {
    background-color: #0f9d58;
    opacity: 0.6;
}

.space.claimed-bot {
    background-color: #e94560;
    opacity: 0.6;
}

#dice-area {
    margin-bottom: 20px;
}

#dice-display {
    display: flex;
    justify-content: center;
    gap: 10px;
    margin-bottom: 15px;
}

.die {
    width: 50px;
    height: 50px;
    background-color: #fff;
    color: #1a1a2e;
    font-size: 1.5rem;
    font-weight: bold;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 8px;
}

#pairs-display {
    display: flex;
    justify-content: center;
    gap: 10px;
    flex-wrap: wrap;
}

.pair-option {
    padding: 10px 16px;
    background-color: #16213e;
    border: 2px solid #0f3460;
    border-radius: 8px;
    color: #eee;
    cursor: pointer;
    font-size: 0.95rem;
    transition: background-color 0.2s, border-color 0.2s;
}

.pair-option:hover {
    background-color: #0f3460;
    border-color: #e94560;
}

#controls {
    display: flex;
    justify-content: center;
    gap: 15px;
}

#controls button {
    padding: 12px 28px;
    font-size: 1rem;
    font-weight: bold;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    transition: opacity 0.2s, transform 0.1s;
}

#controls button:active {
    transform: scale(0.95);
}

#controls button:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}

#roll-btn {
    background-color: #0f9d58;
    color: white;
}

#stop-btn {
    background-color: #e94560;
    color: white;
}
Launch the page in your browser and commit

ES Modules cannot load from a file:// path. They require an HTTP server. That is exactly what Live Server provides.

  • Look at the bottom-right corner of VS Code's status bar for the Go Live button.
  • Click Go Live. Your browser opens automatically with the page.

You should see the title "Can't Stop" in red, a yellow status message, and a green Roll Dice button next to a dimmed Stop Turn button. The board area is blank for now because JavaScript will create the columns in a later step.

Don't see Go Live in the status bar?

  • Make sure you have the cant-stop-game folder open in VS Code (not a single file). Live Server requires an open folder.
  • Right-click anywhere on the status bar and confirm Live Server is checked.
  • If the page opens but shows unstyled text, check that css/style.css is saved and the <link> path in your HTML matches ./css/style.css exactly.

Help me troubleshoot Live Server not working.

  • Commit this milestone by opening your terminal in VS Code with Ctrl+` (backtick key) and running:
git add . && git commit -m "Add game board layout"
  • You should see a message confirming files were committed, including index.html and css/style.css.

Seeing 'not a git repository'?

  • Make sure your terminal is inside the cant-stop-game folder. Run cd ~/Desktop/cant-stop-game to navigate there.
  • If you created the folder manually instead of cloning from GitHub, run git init to initialise a repository.

Help me fix my git commit error.

Your game has a face. Next up, you will bring it to life by implementing the core game state and dice logic in JavaScript.

Implement Core Game State and Dice Logic

Your game board is live in the browser with all 11 columns rendered correctly. Now it's time to teach your game how Can't Stop actually works.

Every board game needs rules, and in code, those rules live as functions that manage the game's state. You'll build two modules in this step: one for tracking game state (whose turn it is, where the runners are, who's winning) and one for dice rolling and pair calculation.

In this step, get ready to:
  • Create the game state object with player progress tracking.
  • Build dice rolling and pair calculation functions.
  • Implement move validation, turn management, and win detection.
Create the game state structure

Can't Stop tracks a lot of information: each player's permanent progress up the 11 columns, up to 3 temporary runners per turn, which columns have been claimed, and whose turn it is. The COLUMN_HEIGHTS constant defines how many spaces each column has, from 3 (for columns 2 and 12) up to 13 (for column 7).

  • In VS Code, open js/game.js and add the column heights constant and initial state function:
const COLUMN_HEIGHTS = {
    2: 3, 3: 5, 4: 7, 5: 9, 6: 11, 7: 13, 8: 11, 9: 9, 10: 7, 11: 5, 12: 3
};

export function createInitialState() {
    const progress = [
        {2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0},
        {2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0}
    ];

    return {
        progress,
        runners: {},
        claimed: {},
        currentPlayer: 0,
        currentDice: null,
        turnPoints: 0
    };
}

What does this code do?

COLUMN_HEIGHTS maps each column number (2 through 12) to its height. Column 7 is the tallest at 13 spaces because the sum of 7 is the most probable dice roll. Columns 2 and 12 are shortest at 3 spaces because they're hardest to roll.

createInitialState() returns a fresh game state object. The progress array tracks how far each player (index 0 = you, index 1 = bot) has permanently advanced in each column. The runners object tracks temporary positions during a turn that are lost on a bust.

  • Save js/game.js.
  • You won't see a change in the browser yet. This module gets connected to the UI in Step 4.
Build the dice rolling and pair calculation

In Can't Stop, you roll 4 dice and split them into 2 pairs. There are always exactly 3 ways to pair 4 dice (AB+CD, AC+BD, AD+BC). Each pair produces a column number that you advance a runner on.

  • Open js/dice.js and add the dice rolling and pair generation functions:
export function rollDice() {
    return [
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6)
    ];
}

export function getPossiblePairs(dice) {
    const [a, b, c, d] = dice;
    const pairings = [
        [a + b, c + d],
        [a + c, b + d],
        [a + d, b + c]
    ];

    const unique = [];
    const seen = new Set();

    for (const pair of pairings) {
        const sorted = [...pair].sort((x, y) => x - y);
        const key = sorted.join(',');
        if (!seen.has(key)) {
            seen.add(key);
            unique.push(sorted);
        }
    }

    return unique;
}

What does this code do?

rollDice() generates 4 random numbers between 1 and 6, just like rolling 4 physical dice.

getPossiblePairs(dice) takes those 4 values and computes all 3 ways to split them into 2 pairs. It sums each pair (giving column numbers), deduplicates identical results, and returns up to 3 unique options for the player to choose from.

  • Save js/dice.js.
  • To verify it works, open your game in the browser (click Go Live in the VS Code status bar if the server isn't running).
  • Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows), then click the Console tab.
  • Test your dice module by running this in the console:
import('./js/dice.js').then(m => console.log(m.rollDice()))

You should see an array of 4 random numbers between 1 and 6, like [3, 5, 1, 4].

Seeing an error in the console?

  • If you see a CORS or network error, make sure Live Server is running (look for the port number in the VS Code status bar). ES Modules require HTTP serving and cannot load from the file:// protocol.
  • If you see a syntax error, double-check that your curly braces and square brackets all match up in js/dice.js.

Help me debug my dice module import error

✔️ Awesome, I've got everything!

Great. Make sure you've saved js/dice.js before moving on.

ⓧ I'd like to double check the full code

Here is the complete js/dice.js file:

export function rollDice() {
    return [
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6),
        Math.ceil(Math.random() * 6)
    ];
}

export function getPossiblePairs(dice) {
    const [a, b, c, d] = dice;
    const pairings = [
        [a + b, c + d],
        [a + c, b + d],
        [a + d, b + c]
    ];

    const unique = [];
    const seen = new Set();

    for (const pair of pairings) {
        const sorted = [...pair].sort((x, y) => x - y);
        const key = sorted.join(',');
        if (!seen.has(key)) {
            seen.add(key);
            unique.push(sorted);
        }
    }

    return unique;
}
Implement move validation and turn management

Now for the heart of the game logic. You need functions that apply a chosen pair to the board, check whether any legal move exists (to detect busts), lock in progress when a player stops, and detect a winner.

  • Switch back to js/game.js and add the applyPair and canMakeAnyMove functions below createInitialState:
export function applyPair(pair, state) {
    const player = state.currentPlayer;

    for (const col of pair) {
        if (state.claimed[col] !== undefined) {
            continue;
        }

        const runnerCols = Object.keys(state.runners).map(Number);

        if (state.runners[col] !== undefined) {
            if (state.runners[col] < COLUMN_HEIGHTS[col]) {
                state.runners[col] += 1;
                state.turnPoints += getColumnPointValue(col);
            }
        } else if (runnerCols.length < 3) {
            const startPos = state.progress[player][col];
            if (startPos < COLUMN_HEIGHTS[col]) {
                state.runners[col] = startPos + 1;
                state.turnPoints += getColumnPointValue(col);
            }
        }
    }
}

export function canMakeAnyMove(pair, state) {
    const player = state.currentPlayer;
    const runnerCols = Object.keys(state.runners).map(Number);

    for (const col of pair) {
        if (state.claimed[col] !== undefined) {
            continue;
        }

        if (state.runners[col] !== undefined) {
            if (state.runners[col] < COLUMN_HEIGHTS[col]) {
                return true;
            }
        } else if (runnerCols.length < 3) {
            const startPos = state.progress[player][col];
            if (startPos < COLUMN_HEIGHTS[col]) {
                return true;
            }
        }
    }

    return false;
}

What does this code do?

applyPair() takes the player's chosen pair (two column numbers) and advances runners on the board. It enforces the 3-runner maximum: if you already have 3 runners active and the column isn't one of them, that die is wasted.

canMakeAnyMove() checks if a pair has at least one legal move. If no valid pair has a legal move after a roll, the player busts and loses all turn progress.

  • Save js/game.js.
  • You won't see a change yet. The next chunk adds the remaining functions that complete this module.
  • Still in js/game.js, add the turn-ending, win-detection, and point-value functions below canMakeAnyMove:
export function stopTurn(state) {
    const player = state.currentPlayer;

    for (const [col, pos] of Object.entries(state.runners)) {
        const colNum = Number(col);
        state.progress[player][colNum] = pos;

        if (pos >= COLUMN_HEIGHTS[colNum]) {
            state.claimed[colNum] = player;
            state.progress[0][colNum] = 0;
            state.progress[1][colNum] = 0;
        }
    }

    state.runners = {};
    state.turnPoints = 0;
}

export function checkWin(state) {
    for (let player = 0; player < 2; player++) {
        let claimedCount = 0;
        for (const owner of Object.values(state.claimed)) {
            if (owner === player) {
                claimedCount++;
            }
        }
        if (claimedCount >= 3) {
            return player;
        }
    }
    return null;
}

function getColumnPointValue(col) {
    const values = {2: 6, 3: 5, 4: 4, 5: 3, 6: 2, 7: 1, 8: 2, 9: 3, 10: 4, 11: 5, 12: 6};
    return values[col];
}

export { COLUMN_HEIGHTS };

What does this code do?

stopTurn() converts temporary runner positions into permanent progress. If a runner reached the top of a column, that column is claimed and both players' progress there resets to zero.

checkWin() scans the claimed columns. The first player to claim 3 columns wins the game.

getColumnPointValue() assigns point values used by the bot later to decide when to stop. Edge columns (2, 12) are worth more points because they're harder to advance. The final line exports COLUMN_HEIGHTS so other modules can use it.

  • Save js/game.js.
  • Verify the module loads by running this in the browser console:
import('./js/game.js').then(m => console.log(m.createInitialState()))

You should see an object with progress, runners, claimed, currentPlayer, and turnPoints printed to the console.

Seeing a syntax error when importing?

  • Check that every opening curly brace { has a matching closing brace } in js/game.js. Missing a brace inside a nested for loop is common here.
  • Make sure getColumnPointValue is defined before it's called in applyPair. Since getColumnPointValue is a regular function declaration (not export function), JavaScript hoists it to the top of the file, so the order in the file is fine.
  • Confirm COLUMN_HEIGHTS is at the very top of the file, before all the functions that reference it.

Help me fix a syntax error in my game.js module

✔️ Awesome, I've got everything!

Great. Make sure js/game.js is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete js/game.js file:

const COLUMN_HEIGHTS = {
    2: 3, 3: 5, 4: 7, 5: 9, 6: 11, 7: 13, 8: 11, 9: 9, 10: 7, 11: 5, 12: 3
};

export function createInitialState() {
    const progress = [
        {2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0},
        {2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0, 11: 0, 12: 0}
    ];

    return {
        progress,
        runners: {},
        claimed: {},
        currentPlayer: 0,
        currentDice: null,
        turnPoints: 0
    };
}

export function applyPair(pair, state) {
    const player = state.currentPlayer;

    for (const col of pair) {
        if (state.claimed[col] !== undefined) {
            continue;
        }

        const runnerCols = Object.keys(state.runners).map(Number);

        if (state.runners[col] !== undefined) {
            if (state.runners[col] < COLUMN_HEIGHTS[col]) {
                state.runners[col] += 1;
                state.turnPoints += getColumnPointValue(col);
            }
        } else if (runnerCols.length < 3) {
            const startPos = state.progress[player][col];
            if (startPos < COLUMN_HEIGHTS[col]) {
                state.runners[col] = startPos + 1;
                state.turnPoints += getColumnPointValue(col);
            }
        }
    }
}

export function canMakeAnyMove(pair, state) {
    const player = state.currentPlayer;
    const runnerCols = Object.keys(state.runners).map(Number);

    for (const col of pair) {
        if (state.claimed[col] !== undefined) {
            continue;
        }

        if (state.runners[col] !== undefined) {
            if (state.runners[col] < COLUMN_HEIGHTS[col]) {
                return true;
            }
        } else if (runnerCols.length < 3) {
            const startPos = state.progress[player][col];
            if (startPos < COLUMN_HEIGHTS[col]) {
                return true;
            }
        }
    }

    return false;
}

export function stopTurn(state) {
    const player = state.currentPlayer;

    for (const [col, pos] of Object.entries(state.runners)) {
        const colNum = Number(col);
        state.progress[player][colNum] = pos;

        if (pos >= COLUMN_HEIGHTS[colNum]) {
            state.claimed[colNum] = player;
            state.progress[0][colNum] = 0;
            state.progress[1][colNum] = 0;
        }
    }

    state.runners = {};
    state.turnPoints = 0;
}

export function checkWin(state) {
    for (let player = 0; player < 2; player++) {
        let claimedCount = 0;
        for (const owner of Object.values(state.claimed)) {
            if (owner === player) {
                claimedCount++;
            }
        }
        if (claimedCount >= 3) {
            return player;
        }
    }
    return null;
}

function getColumnPointValue(col) {
    const values = {2: 6, 3: 5, 4: 4, 5: 3, 6: 2, 7: 1, 8: 2, 9: 3, 10: 4, 11: 5, 12: 6};
    return values[col];
}

export { COLUMN_HEIGHTS };

Why separate game logic from the UI?

Keeping game rules in their own module (game.js) separate from rendering (ui.js) is called separation of concerns. It means you can change how the board looks without touching any game rules, or fix a bug in bust detection without worrying about DOM elements.

This is the same pattern professional codebases use. When you see job descriptions mention "modular architecture" or "clean code," this is what they mean.

Your game logic is locked and loaded. Next up, you'll wire these modules to the UI so you can actually roll dice and see runners move on the board.

Wire Up the UI and Player Interaction

You have game logic and dice math ready, but nothing appears on screen yet.

In this step you will build a rendering module that reads the game state and paints the board, then wire everything together in main.js so you can actually click buttons and play your turn.

In this step, get ready to:
  • Create a rendering module that draws the board, dice, and pair options.
  • Set up the game loop skeleton with event listeners and turn switching.
  • Implement pair selection so you can roll, pick a pair, and see runners move.
Create the board rendering module

The ui.js module is responsible for reading the game state and updating the DOM to match.

  • Open js/ui.js in VS Code and add the following code:
import { COLUMN_HEIGHTS } from './game.js';

// Cache DOM references so we don't query the document on every render
const boardEl = document.getElementById('board');
const diceDisplay = document.getElementById('dice-display');
const pairsDisplay = document.getElementById('pairs-display');
const statusEl = document.getElementById('status');

export function renderBoard(state) {
    // Clear old board and redraw from state
    boardEl.innerHTML = '';

    for (let col = 2; col <= 12; col++) {
        const colEl = document.createElement('div');
        colEl.className = 'column';

        const label = document.createElement('div');
        label.className = 'column-label';
        label.textContent = col;
        colEl.appendChild(label);

        const height = COLUMN_HEIGHTS[col];

        for (let row = 1; row <= height; row++) {
            const space = document.createElement('div');
            space.className = 'space';

            if (state.claimed[col] !== undefined) {
                // Column is claimed — colour all spaces for the owner
                space.classList.add(state.claimed[col] === 0 ? 'claimed-player' : 'claimed-bot');
            } else {
                // Show active runners
                if (state.runners[col] !== undefined && state.runners[col] === row) {
                    space.classList.add(state.currentPlayer === 0 ? 'player-runner' : 'bot-runner');
                }
                // Show permanent progress
                if (state.progress[0][col] === row) {
                    space.classList.add('player-permanent');
                }
                if (state.progress[1][col] === row) {
                    space.classList.add('bot-permanent');
                }
            }

            colEl.appendChild(space);
        }

        boardEl.appendChild(colEl);
    }
}

What does this code do?

The renderBoard function loops through columns 2 to 12, creates a div for each column, and fills it with the correct number of spaces. Each space gets a CSS class based on the game state (claimed, runner, or permanent progress).

Because the column uses flex-direction: column-reverse in your CSS, row 1 renders at the bottom and the highest row sits at the top.

  • Below the renderBoard function, add the helper functions that handle dice display, pair options, and status messages:
export function renderDice(dice) {
    // Show 4 dice values in the dice area
    diceDisplay.innerHTML = '';
    for (const value of dice) {
        const die = document.createElement('div');
        die.className = 'die';
        die.textContent = value;
        diceDisplay.appendChild(die);
    }
}

export function renderPairOptions(pairs, onChoose) {
    // Display clickable buttons for each valid pair
    pairsDisplay.innerHTML = '';
    for (const pair of pairs) {
        const btn = document.createElement('button');
        btn.className = 'pair-option';
        btn.textContent = `${pair[0]} and ${pair[1]}`;
        btn.addEventListener('click', () => onChoose(pair));
        pairsDisplay.appendChild(btn);
    }
}

export function clearPairOptions() {
    pairsDisplay.innerHTML = '';
}

export function clearDice() {
    diceDisplay.innerHTML = '';
}

export function setStatus(message) {
    statusEl.textContent = message;
}

What does this code do?

  • renderDice creates four white squares showing the dice values.
  • renderPairOptions builds a clickable button for each valid pair. When clicked, it calls the onChoose callback with the selected pair.
  • clearPairOptions, clearDice, and setStatus are simple cleanup helpers used between turns.
  • Save js/ui.js.

Seeing a syntax error in the console?

Check that you have the import statement at the very top of the file. ES Modules require imports before any other code.

Also confirm the path is exactly './game.js' with the file extension included.

Help me debug an import error in my ui.js module

✔️ Awesome, I've got everything!

Great. Double check you have saved js/ui.js before moving on.

ⓧ I'd like to double check the full code

import { COLUMN_HEIGHTS } from './game.js';

const boardEl = document.getElementById('board');
const diceDisplay = document.getElementById('dice-display');
const pairsDisplay = document.getElementById('pairs-display');
const statusEl = document.getElementById('status');

export function renderBoard(state) {
    boardEl.innerHTML = '';

    for (let col = 2; col <= 12; col++) {
        const colEl = document.createElement('div');
        colEl.className = 'column';

        const label = document.createElement('div');
        label.className = 'column-label';
        label.textContent = col;
        colEl.appendChild(label);

        const height = COLUMN_HEIGHTS[col];

        for (let row = 1; row <= height; row++) {
            const space = document.createElement('div');
            space.className = 'space';

            if (state.claimed[col] !== undefined) {
                space.classList.add(state.claimed[col] === 0 ? 'claimed-player' : 'claimed-bot');
            } else {
                if (state.runners[col] !== undefined && state.runners[col] === row) {
                    space.classList.add(state.currentPlayer === 0 ? 'player-runner' : 'bot-runner');
                }
                if (state.progress[0][col] === row) {
                    space.classList.add('player-permanent');
                }
                if (state.progress[1][col] === row) {
                    space.classList.add('bot-permanent');
                }
            }

            colEl.appendChild(space);
        }

        boardEl.appendChild(colEl);
    }
}

export function renderDice(dice) {
    diceDisplay.innerHTML = '';
    for (const value of dice) {
        const die = document.createElement('div');
        die.className = 'die';
        die.textContent = value;
        diceDisplay.appendChild(die);
    }
}

export function renderPairOptions(pairs, onChoose) {
    pairsDisplay.innerHTML = '';
    for (const pair of pairs) {
        const btn = document.createElement('button');
        btn.className = 'pair-option';
        btn.textContent = `${pair[0]} and ${pair[1]}`;
        btn.addEventListener('click', () => onChoose(pair));
        pairsDisplay.appendChild(btn);
    }
}

export function clearPairOptions() {
    pairsDisplay.innerHTML = '';
}

export function clearDice() {
    diceDisplay.innerHTML = '';
}

export function setStatus(message) {
    statusEl.textContent = message;
}
Set up the game loop skeleton in main.js

The main.js file is the conductor. It imports every module, initializes the game state, and wires button clicks to the correct game logic.

  • Open js/main.js and add the imports, state initialization, and the handleStop function:
import { createInitialState, applyPair, stopTurn, checkWin, canMakeAnyMove } from './game.js';
import { rollDice, getPossiblePairs } from './dice.js';
import { renderBoard, renderDice, renderPairOptions, clearPairOptions, clearDice, setStatus } from './ui.js';
import { botChoosePair, botShouldContinue } from './bot.js';

let gameState = createInitialState();

const rollBtn = document.getElementById('roll-btn');
const stopBtn = document.getElementById('stop-btn');

// handleRoll and handlePairChoice go here — you'll add them next

function handleStop() {
    // Lock in runner progress and end the turn
    stopTurn(gameState);
    renderBoard(gameState);
    clearDice();

    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    switchTurn();
}

What does this code do?

  • The four import lines pull in every function the game loop needs from your other modules.
  • createInitialState() builds the starting state (empty progress, no runners, player 0's turn).
  • handleStop calls stopTurn to lock runner positions into permanent progress, checks for a winner, then passes the turn to the other player.
  • Below handleStop, add the switchTurn function, the bot placeholder, the event listeners, and the initial board render:
function switchTurn() {
    // Toggle between player 0 (you) and player 1 (bot)
    gameState.currentPlayer = gameState.currentPlayer === 0 ? 1 : 0;
    gameState.runners = {};
    gameState.turnPoints = 0;
    clearDice();
    clearPairOptions();
    renderBoard(gameState);

    if (gameState.currentPlayer === 1) {
        setStatus("Bot is thinking...");
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        setTimeout(botTurn, 1000);
    } else {
        setStatus("Your turn! Click \"Roll Dice\" to start.");
        rollBtn.disabled = false;
        stopBtn.disabled = true;
    }
}

function botTurn() {
    // placeholder — implemented in next step
}

rollBtn.addEventListener('click', handleRoll);
stopBtn.addEventListener('click', handleStop);

renderBoard(gameState);

What does this code do?

  • switchTurn resets runners and turn points, redraws the board, then either starts the bot's turn (with a 1-second delay via setTimeout) or hands control back to you.
  • botTurn is a placeholder for now. You will fill it in during the next step when you build the bot player.
  • The two addEventListener calls connect your HTML buttons to the game functions.
  • renderBoard(gameState) at the bottom draws the empty board as soon as the page loads.
  • Save js/main.js. You will not test in the browser yet because handleRoll is not defined. The next substep adds it.
Implement pair selection and bust detection

When the player clicks Roll Dice, the game needs to roll four dice, calculate which pairs are valid, and show clickable options. If no pair is valid, the player busts and loses all runner progress for the turn.

  • In js/main.js, find the comment // handleRoll and handlePairChoice go here — you'll add them next and replace it with the handleRoll function:
function handleRoll() {
    // Roll 4 dice and display them
    const dice = rollDice();
    gameState.currentDice = dice;
    renderDice(dice);

    // Calculate valid pairs from the roll
    const pairs = getPossiblePairs(dice);
    const validPairs = pairs.filter(pair => canMakeAnyMove(pair, gameState));

    // Bust check — no valid moves means turn is lost
    if (validPairs.length === 0) {
        setStatus("Busted! No valid moves available.");
        gameState.runners = {};
        gameState.turnPoints = 0;
        switchTurn();
        return;
    }

    // Disable buttons while player picks a pair
    rollBtn.disabled = true;
    stopBtn.disabled = true;
    renderPairOptions(validPairs, handlePairChoice);
}

What does this code do?

  • rollDice() generates four random values (1-6) and renderDice shows them visually.
  • getPossiblePairs returns the three ways to split four dice into two pairs. The filter call keeps only pairs where at least one sum can legally advance a runner.
  • If zero pairs are valid, the player busts. Runners reset and the turn switches immediately.
  • Otherwise, both buttons are disabled and the valid pairs appear as clickable options via renderPairOptions.
  • Directly below handleRoll, add the handlePairChoice function:
function handlePairChoice(pair) {
    // Apply the chosen pair to game state
    applyPair(pair, gameState);
    clearPairOptions();
    renderBoard(gameState);

    // Check if claiming a column just won the game
    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    // Re-enable buttons so player can roll again or stop
    rollBtn.disabled = false;
    stopBtn.disabled = false;
    setStatus("Roll again or stop to keep your progress.");
}

What does this code do?

When the player clicks a pair option, applyPair advances the runners for those column sums. The board redraws to show the new runner positions. If nobody has won yet, both buttons re-enable so the player can push their luck with another roll or stop to lock in progress.

  • Save js/main.js.
  • Switch to your browser where Live Server is running. You should see the empty board rendered with 11 columns of dark spaces.
  • Click Roll Dice. Four white dice squares should appear, followed by clickable pair option buttons below them.
  • Click one of the pair options. You should see a green runner marker appear on the board in the matching column(s).

Board not rendering or buttons not responding?

  • Open the browser console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for any error messages about missing exports or file paths.
  • The import from './bot.js' will show an error because bot.js is still empty. To fix this temporarily, open js/bot.js and add placeholder exports:
export function botChoosePair() {}
export function botShouldContinue() { return false; }
  • Confirm that Live Server is running. You should see Go Live or a port number in the VS Code status bar. ES Modules cannot load over the file:// protocol.

Help me debug why my Can't Stop board is not rendering

✔️ Awesome, I've got everything!

Great. Make sure both js/ui.js and js/main.js are saved before moving on.

ⓧ I'd like to double check the full code

import { COLUMN_HEIGHTS } from './game.js';

const boardEl = document.getElementById('board');
const diceDisplay = document.getElementById('dice-display');
const pairsDisplay = document.getElementById('pairs-display');
const statusEl = document.getElementById('status');

export function renderBoard(state) {
    boardEl.innerHTML = '';

    for (let col = 2; col <= 12; col++) {
        const colEl = document.createElement('div');
        colEl.className = 'column';

        const label = document.createElement('div');
        label.className = 'column-label';
        label.textContent = col;
        colEl.appendChild(label);

        const height = COLUMN_HEIGHTS[col];

        for (let row = 1; row <= height; row++) {
            const space = document.createElement('div');
            space.className = 'space';

            if (state.claimed[col] !== undefined) {
                space.classList.add(state.claimed[col] === 0 ? 'claimed-player' : 'claimed-bot');
            } else {
                if (state.runners[col] !== undefined && state.runners[col] === row) {
                    space.classList.add(state.currentPlayer === 0 ? 'player-runner' : 'bot-runner');
                }
                if (state.progress[0][col] === row) {
                    space.classList.add('player-permanent');
                }
                if (state.progress[1][col] === row) {
                    space.classList.add('bot-permanent');
                }
            }

            colEl.appendChild(space);
        }

        boardEl.appendChild(colEl);
    }
}

export function renderDice(dice) {
    diceDisplay.innerHTML = '';
    for (const value of dice) {
        const die = document.createElement('div');
        die.className = 'die';
        die.textContent = value;
        diceDisplay.appendChild(die);
    }
}

export function renderPairOptions(pairs, onChoose) {
    pairsDisplay.innerHTML = '';
    for (const pair of pairs) {
        const btn = document.createElement('button');
        btn.className = 'pair-option';
        btn.textContent = `${pair[0]} and ${pair[1]}`;
        btn.addEventListener('click', () => onChoose(pair));
        pairsDisplay.appendChild(btn);
    }
}

export function clearPairOptions() {
    pairsDisplay.innerHTML = '';
}

export function clearDice() {
    diceDisplay.innerHTML = '';
}

export function setStatus(message) {
    statusEl.textContent = message;
}
import { createInitialState, applyPair, stopTurn, checkWin, canMakeAnyMove } from './game.js';
import { rollDice, getPossiblePairs } from './dice.js';
import { renderBoard, renderDice, renderPairOptions, clearPairOptions, clearDice, setStatus } from './ui.js';
import { botChoosePair, botShouldContinue } from './bot.js';

let gameState = createInitialState();

const rollBtn = document.getElementById('roll-btn');
const stopBtn = document.getElementById('stop-btn');

function handleRoll() {
    const dice = rollDice();
    gameState.currentDice = dice;
    renderDice(dice);

    const pairs = getPossiblePairs(dice);
    const validPairs = pairs.filter(pair => canMakeAnyMove(pair, gameState));

    if (validPairs.length === 0) {
        setStatus("Busted! No valid moves available.");
        gameState.runners = {};
        gameState.turnPoints = 0;
        switchTurn();
        return;
    }

    rollBtn.disabled = true;
    stopBtn.disabled = true;
    renderPairOptions(validPairs, handlePairChoice);
}

function handlePairChoice(pair) {
    applyPair(pair, gameState);
    clearPairOptions();
    renderBoard(gameState);

    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    rollBtn.disabled = false;
    stopBtn.disabled = false;
    setStatus("Roll again or stop to keep your progress.");
}

function handleStop() {
    stopTurn(gameState);
    renderBoard(gameState);
    clearDice();

    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    switchTurn();
}

function switchTurn() {
    gameState.currentPlayer = gameState.currentPlayer === 0 ? 1 : 0;
    gameState.runners = {};
    gameState.turnPoints = 0;
    clearDice();
    clearPairOptions();
    renderBoard(gameState);

    if (gameState.currentPlayer === 1) {
        setStatus("Bot is thinking...");
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        setTimeout(botTurn, 1000);
    } else {
        setStatus("Your turn! Click \"Roll Dice\" to start.");
        rollBtn.disabled = false;
        stopBtn.disabled = true;
    }
}

function botTurn() {
    // placeholder — implemented in next step
}

rollBtn.addEventListener('click', handleRoll);
stopBtn.addEventListener('click', handleStop);

renderBoard(gameState);

Your human turn is fully playable. You can roll dice, pick pairs, watch runners move, and stop to lock in progress. Next up, you will build a bot opponent that uses probability to decide when to push its luck.

Build the Bot Player

You have a fully interactive game board where you can roll dice, pick pairs, and advance runners. There is one problem: when it is the bot's turn, nothing happens.

In this step, you will build the bot's brain. The bot needs two skills: choosing which pair to play (strategy) and deciding when to stop rolling (risk management).

In this step, get ready to:
  • Implement a scoring system for the bot's pair selection.
  • Add a stop/continue heuristic using the Rule of 28.
  • Wire the bot into the game loop so it plays automatically.
Implement the bot's pair selection logic

When the bot rolls dice, it gets the same 3 possible pairings that you see as a human player. The bot needs a way to evaluate which pairing is the smartest choice.

The strategy scores each option on three factors: does this pair advance an existing runner, is the column near the middle of the board (higher probability), and how much progress does the bot already have in that column.

  • Open js/bot.js in VS Code and add the following code:
import { COLUMN_HEIGHTS } from './game.js';

export function botChoosePair(validPairs, state) {
    let bestPair = validPairs[0];
    let bestScore = -Infinity;

    for (const pair of validPairs) {
        let score = 0;
        for (const col of pair) {
            // Prefer pairs that advance an existing runner
            if (state.runners[col] !== undefined) {
                score += 10;
            }
            // Prefer middle columns (higher probability of being rolled)
            if (col >= 5 && col <= 9) {
                score += 5;
            }
            // Factor in existing progress toward the top
            const progress = state.progress[1][col] || 0;
            const height = COLUMN_HEIGHTS[col];
            if (height > 0) {
                score += (progress / height) * 8;
            }
        }
        if (score > bestScore) {
            bestScore = score;
            bestPair = pair;
        }
    }

    return bestPair;
}

What does this code do?

The botChoosePair function loops through every valid pairing and assigns a score based on three factors.

  • +10 points if the pair advances a runner already on the board (momentum).
  • +5 points for columns 5 through 9 (statistically more likely to be rolled with two dice).
  • Up to +8 points scaled by how close the bot is to claiming that column.

You will not see a change yet. The bot also needs a way to decide when to stop rolling.

Add the stop/continue decision using Rule of 28

A bot that never stops rolling will eventually bust every turn. The bot needs a heuristic to know when it has gained enough progress to lock it in.

  • In js/bot.js, add the following function below botChoosePair:
export function botShouldContinue(state) {
    // Stop when accumulated turn points reach 28 or more
    return state.turnPoints < 28;
}

What is the Rule of 28?

The Rule of 28 is a community heuristic for Can't Stop. Each column advancement is worth points based on how hard that column is to roll: columns 2 and 12 are worth 6 points, columns 3 and 11 are worth 5, columns 4 and 10 are worth 4, columns 5 and 9 are worth 3, columns 6 and 8 are worth 2, and column 7 is worth 1.

When the bot's accumulated points for the current turn reach 28, it has gained enough progress to justify stopping. The turnPoints value is already tracked by applyPair in game.js using those exact point values.

  • Save js/bot.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import { COLUMN_HEIGHTS } from './game.js';

export function botChoosePair(validPairs, state) {
    let bestPair = validPairs[0];
    let bestScore = -Infinity;

    for (const pair of validPairs) {
        let score = 0;
        for (const col of pair) {
            if (state.runners[col] !== undefined) {
                score += 10;
            }
            if (col >= 5 && col <= 9) {
                score += 5;
            }
            const progress = state.progress[1][col] || 0;
            const height = COLUMN_HEIGHTS[col];
            if (height > 0) {
                score += (progress / height) * 8;
            }
        }
        if (score > bestScore) {
            bestScore = score;
            bestPair = pair;
        }
    }

    return bestPair;
}

export function botShouldContinue(state) {
    return state.turnPoints < 28;
}
Wire the bot into the game loop

The bot's brain is ready, but the botTurn function in main.js is still a placeholder. You need to replace it with real logic that rolls dice, picks pairs, detects busts, and decides when to stop.

  • In js/main.js, find the placeholder botTurn function:
function botTurn() {
    // placeholder — implemented in next step
}
  • Replace the entire botTurn function with the following code:
function botTurn() {
    // Roll 4 dice for the bot
    const dice = rollDice();
    gameState.currentDice = dice;
    renderDice(dice);

    // Find all valid pairings the bot can play
    const pairs = getPossiblePairs(dice);
    const validPairs = pairs.filter(pair => canMakeAnyMove(pair, gameState));

    // If no valid moves exist, the bot busts
    if (validPairs.length === 0) {
        setStatus("Bot busted!");
        gameState.runners = {};
        gameState.turnPoints = 0;
        setTimeout(switchTurn, 1500);
        return;
    }

    // Bot picks the best pair using its scoring logic
    const chosenPair = botChoosePair(validPairs, gameState);
    applyPair(chosenPair, gameState);
    renderBoard(gameState);

    // Check if the bot just won
    const winner = checkWin(gameState);
    if (winner !== null) {
        setStatus("Bot won the game!");
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    // Decide whether to keep rolling or stop
    if (botShouldContinue(gameState)) {
        setStatus("Bot rolls again...");
        setTimeout(botTurn, 1000);
    } else {
        setStatus("Bot stops and keeps progress.");
        setTimeout(() => {
            stopTurn(gameState);
            renderBoard(gameState);
            clearDice();

            const winner = checkWin(gameState);
            if (winner !== null) {
                setStatus("Bot won the game!");
                rollBtn.disabled = true;
                stopBtn.disabled = true;
                return;
            }

            switchTurn();
        }, 1000);
    }
}

What does this code do?

The botTurn function follows the same flow as a human turn, but makes decisions automatically.

  • It rolls dice and checks for busts (no valid moves). If busted, it waits 1.5 seconds so you can see what happened, then switches turns.
  • It uses botChoosePair to pick the best pairing and applies it to the game state.
  • After each move, botShouldContinue checks if the turn points are below 28. If so, the bot rolls again after a 1-second delay.
  • When the bot decides to stop, it calls stopTurn to lock in progress, then hands control back to you.

Why the delays?

Without setTimeout, the bot would make all its decisions in a single frame. The 1-second delays between actions let you watch the bot think: dice appear, the board updates, then it rolls again or stops.

  • Save js/main.js.
  • Switch to your browser. The page should auto-refresh via Live Server.
  • Play a turn by clicking Roll Dice, choosing a pair, then clicking Stop Turn. After your turn ends, the status should say "Bot is thinking..." and then you should see dice appear and the board update as the bot takes its turn.

Bot not taking its turn?

  • Open the browser console (right-click the page, select Inspect, then click the Console tab). Look for import errors. If you see a message about botChoosePair not being exported, check that you included the export keyword before both functions in bot.js.
  • Make sure the import at the top of bot.js reads import { COLUMN_HEIGHTS } from './game.js' with the ./ prefix and the .js extension. Browser ES Modules require full relative paths.
  • If the bot rolls once but never stops, confirm that botShouldContinue returns state.turnPoints < 28 (not > 28).

Why isn't my bot playing its turn correctly?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete js/bot.js:

import { COLUMN_HEIGHTS } from './game.js';

export function botChoosePair(validPairs, state) {
    let bestPair = validPairs[0];
    let bestScore = -Infinity;

    for (const pair of validPairs) {
        let score = 0;
        for (const col of pair) {
            if (state.runners[col] !== undefined) {
                score += 10;
            }
            if (col >= 5 && col <= 9) {
                score += 5;
            }
            const progress = state.progress[1][col] || 0;
            const height = COLUMN_HEIGHTS[col];
            if (height > 0) {
                score += (progress / height) * 8;
            }
        }
        if (score > bestScore) {
            bestScore = score;
            bestPair = pair;
        }
    }

    return bestPair;
}

export function botShouldContinue(state) {
    return state.turnPoints < 28;
}

Here is the complete js/main.js:

import { createInitialState, applyPair, stopTurn, checkWin, canMakeAnyMove } from './game.js';
import { rollDice, getPossiblePairs } from './dice.js';
import { renderBoard, renderDice, renderPairOptions, clearPairOptions, clearDice, setStatus } from './ui.js';
import { botChoosePair, botShouldContinue } from './bot.js';

let gameState = createInitialState();

const rollBtn = document.getElementById('roll-btn');
const stopBtn = document.getElementById('stop-btn');

function handleRoll() {
    const dice = rollDice();
    gameState.currentDice = dice;
    renderDice(dice);

    const pairs = getPossiblePairs(dice);
    const validPairs = pairs.filter(pair => canMakeAnyMove(pair, gameState));

    if (validPairs.length === 0) {
        setStatus("Busted! No valid moves available.");
        gameState.runners = {};
        gameState.turnPoints = 0;
        switchTurn();
        return;
    }

    rollBtn.disabled = true;
    stopBtn.disabled = true;
    renderPairOptions(validPairs, handlePairChoice);
}

function handlePairChoice(pair) {
    applyPair(pair, gameState);
    clearPairOptions();
    renderBoard(gameState);

    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    rollBtn.disabled = false;
    stopBtn.disabled = false;
    setStatus("Roll again or stop to keep your progress.");
}

function handleStop() {
    stopTurn(gameState);
    renderBoard(gameState);
    clearDice();

    const winner = checkWin(gameState);
    if (winner !== null) {
        const winnerName = winner === 0 ? "You" : "Bot";
        setStatus(`${winnerName} won the game!`);
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    switchTurn();
}

function switchTurn() {
    gameState.currentPlayer = gameState.currentPlayer === 0 ? 1 : 0;
    gameState.runners = {};
    gameState.turnPoints = 0;
    clearDice();
    clearPairOptions();
    renderBoard(gameState);

    if (gameState.currentPlayer === 1) {
        setStatus("Bot is thinking...");
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        setTimeout(botTurn, 1000);
    } else {
        setStatus("Your turn! Click \"Roll Dice\" to start.");
        rollBtn.disabled = false;
        stopBtn.disabled = true;
    }
}

function botTurn() {
    const dice = rollDice();
    gameState.currentDice = dice;
    renderDice(dice);

    const pairs = getPossiblePairs(dice);
    const validPairs = pairs.filter(pair => canMakeAnyMove(pair, gameState));

    if (validPairs.length === 0) {
        setStatus("Bot busted!");
        gameState.runners = {};
        gameState.turnPoints = 0;
        setTimeout(switchTurn, 1500);
        return;
    }

    const chosenPair = botChoosePair(validPairs, gameState);
    applyPair(chosenPair, gameState);
    renderBoard(gameState);

    const winner = checkWin(gameState);
    if (winner !== null) {
        setStatus("Bot won the game!");
        rollBtn.disabled = true;
        stopBtn.disabled = true;
        return;
    }

    if (botShouldContinue(gameState)) {
        setStatus("Bot rolls again...");
        setTimeout(botTurn, 1000);
    } else {
        setStatus("Bot stops and keeps progress.");
        setTimeout(() => {
            stopTurn(gameState);
            renderBoard(gameState);
            clearDice();

            const winner = checkWin(gameState);
            if (winner !== null) {
                setStatus("Bot won the game!");
                rollBtn.disabled = true;
                stopBtn.disabled = true;
                return;
            }

            switchTurn();
        }, 1000);
    }
}

rollBtn.addEventListener('click', handleRoll);
stopBtn.addEventListener('click', handleStop);

renderBoard(gameState);

Your game is now fully playable. You can take turns against a bot that uses real strategy to challenge you. Next up, you will add win detection polish and deploy your game live to the internet with GitHub Pages.

Deploy to GitHub Pages

Your game is fully playable in the browser. You can roll dice, pick pairs, manage runners, and face off against the bot.

Now it is time to share it with the world. In this step, you will commit your code to GitHub and deploy the game live using GitHub Pages. Anyone with the link will be able to play your board game.

In this step, get ready to:
  • Commit all your code and push it to GitHub.
  • Enable GitHub Pages to deploy your game to a live URL.
Commit and push your code to GitHub

Git tracks changes to your project in snapshots called commits. You need to stage your files, commit them with a message, and push them to your remote repository on GitHub.

  • Switch back to your terminal in VS Code.
  • Make sure you are inside the cant-stop-game folder.
  • Stage and commit all files by running this command:
git add . && git commit -m "Complete game implementation"

What does this command do?

This is two commands joined with && so they run back to back.

  • git add . stages every file in the current folder for the next commit.
  • git commit -m "Complete game implementation" saves a snapshot of all staged files with the message describing what you did.
  • Push your commit to GitHub by running:
git push origin main
  • If your terminal asks you to sign in to GitHub, follow the prompts in the browser window that opens.
  • After a successful push, you should see output that includes a line like main -> main confirming your code was sent.

Seeing an authentication error?

If you see a message about authentication failing, GitHub needs you to sign in through the browser. Run git push origin main again and look for a browser popup asking you to authorize.

If no popup appears, make sure you cloned the repository using HTTPS (the URL starts with https://github.com/).

Help me fix my git push authentication error.

Enable GitHub Pages

GitHub Pages is a free hosting service that serves static websites directly from a GitHub repository. Since your game is plain HTML, CSS, and JavaScript, it is a perfect fit.

  • Open your browser and go to your cant-stop-game repository on GitHub.
  • Click the Settings tab near the top of the repository page.
  • In the left sidebar under Code and automation, click Pages.
  • Under Build and deployment, find the Source heading and select Deploy from a branch.
  • In the branch dropdown, select main.
  • In the folder dropdown, select / (root).
  • Click Save.

Why GitHub Pages?

GitHub Pages looks for an index.html file in the folder you selected and serves it as a website. Since your project already has index.html at the root, there is no extra configuration needed.

Your game will be live at a URL in the format https://username.github.io/cant-stop-game/ where username is your GitHub username.

  • Wait 1-2 minutes for GitHub to build and deploy your site.
  • Visit your live game by navigating to:
https://[[GITHUB_USERNAME="your-github-username"]].github.io/cant-stop-game/
  • You should see your full Can't Stop game board, ready to play.

Seeing a 404 page?

GitHub Pages takes a minute or two to activate. Refresh the page after waiting.

If it still shows a 404, double check that your index.html file is at the root of the repository (not inside a subfolder). The filename is case-sensitive.

Also confirm that you selected main as the branch and / (root) as the folder in your Pages settings.

Help me troubleshoot my GitHub Pages 404 error.

Secret mission

Add Dice Animations and Game History

Your game works, but it feels a bit static. Real board games have the satisfying clatter of dice and a shared memory of what happened each turn. In this secret mission, you'll add a rolling animation to the dice and build a history log that tracks every move.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free GitHub hosting, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local cant-stop-game folder on your Desktop.
  • GitHub repository cant-stop-game (includes GitHub Pages site).
  • Live Server VS Code extension.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your game live on GitHub Pages.

  • Your game stays live at your GitHub Pages URL.
  • The local project folder remains on your Desktop for future edits.
  • Live Server stays available in VS Code for other projects.
  • Git (installed via Xcode Command Line Tools) remains available system-wide for version control.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and repository stay intact for when you return.

  • Close the Live Server by clicking Port: 5500 in the VS Code status bar (or close VS Code entirely).
  • Your local files and GitHub repository remain untouched.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the local project folder by running this in Terminal:
cd ~/Desktop
rm -rf cant-stop-game
  • Delete the GitHub repository:
  • Go to your cant-stop-game repository on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll down to the Danger Zone section.
  • Click Delete this repository.
  • Click I want to delete this repository.
  • Click I have read and understand these effects.
  • Type the repository name to confirm, then click Delete this repository.

This also removes your GitHub Pages site automatically.

  • Uninstall the Live Server extension in VS Code:
  • Open the Extensions view with Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Find Live Server in the list.
  • Click the gear icon next to it and select Uninstall.

Nice Work!

Nice Work!

You just built a complete board game from scratch with vanilla JavaScript. You now have a fully playable Can't Stop game running live on the internet, complete with a bot opponent that makes strategic decisions.

You've learned how to:

  • Build an interactive game board using CSS Grid and DOM manipulation, rendering dynamic state to the browser in real time.
  • Structure a multi-file JavaScript project with ES Modules, separating game logic, UI rendering, dice mechanics, and bot AI into distinct, maintainable files.
  • Implement complex game logic including dice pairing, runner management, bust detection, and a probability-based bot AI using the Rule of 28 heuristic.
  • Added polish with dice roll animations and a scrollable game history log that tracks every move.

Ready to quiz yourself?