Build a Wordle Clone and Deploy on S3

Build a Wordle game with vanilla JS and deploy it live on AWS S3.

Introduction

30 Second Summary

You know that daily word game everyone plays at breakfast? The one where you get six tries to guess a five-letter word, and the colored tiles tell you how close you are? Today you're building your own version of it from scratch.

In this project, you will build a fully functional Wordle clone with vanilla HTML, CSS, and JavaScript, then deploy it live on the internet using AWS S3 static website hosting.

What You'll Build

A complete Wordle word-guessing game with a 6x5 game board, on-screen QWERTY keyboard, physical keyboard support, and the two-pass color algorithm that correctly handles duplicate letters, deployed to a public URL on AWS.

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

  • A working Wordle game with a CSS Grid board, on-screen keyboard, and the two-pass color algorithm that handles duplicate letters correctly.
  • An S3 bucket configured for static website hosting with a public-read bucket policy.
  • A live, publicly accessible URL serving your game on the internet that you can share with anyone.
  • Secret Mission: Add tile flip animations and a localStorage-powered stats tracker to your game.

Are there any prerequisites?

You'll need an AWS account (free tier eligible), Cursor installed on your machine, and basic familiarity with HTML, CSS, and JavaScript.

No frameworks or build tools are required. Everything in this project uses vanilla web technologies.

Before We Start

Before We Start...

Set Up Your Project Files

Before you can build a game, you need a workspace. Every web project starts with a folder to hold your files and a way to preview your work in the browser as you code.

In this step, you will create your project folder, set up the HTML skeleton that defines the game's layout, and start a local server so you can see changes instantly.

In this step, get ready to:
  • Create a project folder and three starter files.
  • Write the HTML structure for the game layout.
  • Start a local development server to preview your work.
Create your project folder and files

Your Wordle clone needs three files: one for structure (HTML), one for styling (CSS), and one for game logic (JavaScript). You will create all three inside a dedicated project folder on your Desktop.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir wordle-clone
cd wordle-clone
  • Now open this folder in Cursor. Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the wordle-clone folder, and click Open.

Seeing a trust prompt?

Cursor may ask if you trust the authors of this folder. Click Yes, I trust the authors to continue. This is a standard security check for newly opened folders.

Now you need to create three empty files inside your project folder.

  • In the Explorer sidebar on the left, hover over the WORDLE-CLONE heading. You will see a small New File icon appear.
  • Click the New File icon and type index.html. Press Enter.
  • Repeat to create a second file named style.css.
  • Repeat once more to create a third file named script.js.

Your Explorer sidebar should now show all three files listed under the wordle-clone folder.

Don't see the Explorer sidebar?

If the left panel is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle it open.

If you see a "No folder opened" message, go back to File > Open Folder and select the wordle-clone folder on your Desktop.

Need help?

Write the HTML structure

Every web page needs an HTML file that defines its structure. Your game needs four main areas: a title header, a message display, a game board, and a keyboard.

  • Click on index.html in the Explorer sidebar to open it.
  • Paste the following HTML into the file:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wordle Clone</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Wordle Clone</h1>
        </header>
        <div id="message-container"></div>
        <div id="board"></div>
        <div id="keyboard"></div>
    </div>
    <script src="script.js"></script>
</body>
</html>

What does this HTML do?

  • The <link> tag connects your stylesheet so CSS rules will apply to the page.
  • The .container div wraps everything for easy centering later.
  • The #board div will hold the 6x5 grid of letter tiles. The #keyboard div will hold the on-screen QWERTY keys.
  • The <script> tag at the bottom loads your JavaScript after the page structure is ready.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

See a white dot on the file tab?

A white dot next to the filename in the tab bar means unsaved changes. Press Cmd+S (macOS) or Ctrl+S (Windows) to save. The dot disappears once the file is saved.

Help me save my file.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and that style.css and script.js are still listed in your Explorer (they should be empty for now).

ⓧ I'd like to double check the full code

Here is the complete index.html file. Make sure yours matches exactly.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wordle Clone</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Wordle Clone</h1>
        </header>
        <div id="message-container"></div>
        <div id="board"></div>
        <div id="keyboard"></div>
    </div>
    <script src="script.js"></script>
</body>
</html>
Start a local development server

Opening an HTML file directly in your browser can cause issues with loading CSS and JavaScript. A local development server serves your files the same way a real web server would, avoiding those problems.

  • Open the integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Start the local server by running this command:
npx serve .

What does this command do?

npx serve . starts a lightweight web server that hosts the files in your current folder. The . means "this directory." It uses Node.js (which you already have installed) to serve your files over a local URL.

  • The terminal will display a local URL (typically http://localhost:3000). Copy that URL and open it in your browser.

You should see a mostly blank page with the text "Wordle Clone" displayed as a heading. The page is unstyled for now because style.css is still empty.

Keep the server running

Leave this terminal tab open with the server running for the rest of the project. When you need to run other commands, open a new terminal tab by clicking the + icon in the terminal panel.

Not seeing the page?

Make sure your terminal is in the wordle-clone folder. If you opened a new terminal after selecting the folder in Cursor, it should already be in the right place.

If you see an error about a missing package, type y when prompted to allow npx to download the serve package.

Help me get my local server working.

Your workspace is ready and your page is live locally. Next up, you will build the game board grid and on-screen keyboard that players interact with.

Build the Game Board and Keyboard

Your HTML skeleton is in place and your local server is running. Now you need something to actually look at.

In this step, you will style the page with a dark theme, then write the JavaScript that dynamically creates the 6x5 game board and QWERTY keyboard. By the end, your browser will display the full Wordle interface.

In this step, get ready to:
  • Style the page layout, game board grid, and keyboard with CSS.
  • Write JavaScript functions to dynamically generate the board tiles and keyboard buttons.
  • Wire up initialization so the game board and keyboard appear on page load.
Style the page, board, and keyboard

The game needs a dark theme, a centered grid for the letter tiles, and styled keyboard buttons. You will write all of the CSS up front so the elements look correct the moment JavaScript creates them.

  • Switch to your style.css file in Cursor and paste the following styles:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Arial', sans-serif;
    background-color: #121213;
    color: #ffffff;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

.container {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    padding: 20px;
}

header h1 {
    font-size: 2rem;
    letter-spacing: 0.2rem;
    text-transform: uppercase;
}

/* Message area for win/loss feedback */
#message-container {
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.message {
    background-color: #ffffff;
    color: #121213;
    padding: 8px 16px;
    border-radius: 4px;
    font-weight: bold;
    font-size: 0.9rem;
}

/* Game board: 5 columns of 62px tiles */
#board {
    display: grid;
    grid-template-columns: repeat(5, 62px);
    grid-template-rows: repeat(6, 62px);
    gap: 5px;
}

/* Individual letter tile */
.tile {
    width: 62px;
    height: 62px;
    border: 2px solid #3a3a3c;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
    font-weight: bold;
    text-transform: uppercase;
}

/* Tile states for color feedback */
.tile[data-state="active"] {
    border-color: #565758;
}

.tile[data-state="correct"] {
    background-color: #538d4e;
    border-color: #538d4e;
}

.tile[data-state="present"] {
    background-color: #b59f3b;
    border-color: #b59f3b;
}

.tile[data-state="absent"] {
    background-color: #3a3a3c;
    border-color: #3a3a3c;
}

/* Keyboard layout */
#keyboard {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    margin-top: 10px;
}

.keyboard-row {
    display: flex;
    gap: 6px;
}

.key {
    background-color: #818384;
    color: #ffffff;
    border: none;
    border-radius: 4px;
    padding: 15px 12px;
    font-size: 0.85rem;
    font-weight: bold;
    cursor: pointer;
    min-width: 40px;
    text-transform: uppercase;
}

.key.wide {
    min-width: 65px;
    font-size: 0.75rem;
}

/* Keyboard key states for color feedback */
.key[data-state="correct"] {
    background-color: #538d4e;
}

.key[data-state="present"] {
    background-color: #b59f3b;
}

.key[data-state="absent"] {
    background-color: #3a3a3c;
}

What does this CSS do?

The styles break into three logical sections:

  • Page framework: a dark background (#121213), full viewport centering with flexbox, and a column layout for the container.
  • Board and tiles: CSS Grid creates a 5-column layout using repeat(5, 62px). Each tile is a 62px square with a subtle border. The data-state attribute selectors handle green, yellow, and grey coloring later.
  • Keyboard: flexbox rows of styled buttons. The .wide class gives Enter and Backspace extra width.
  • Save style.css and refresh your browser.
  • You should see a completely dark page with the white "Wordle Clone" title centered near the top. The board and keyboard areas are invisible because no HTML elements exist inside them yet.

Still seeing a white page?

  • Confirm that your index.html has <link rel="stylesheet" href="style.css"> in the <head> section.
  • Check that style.css is saved in the same folder as index.html (not inside a subfolder).

Why is my page still white even though I saved style.css?

Create the board and keyboard with JavaScript

Instead of writing 30 tile divs by hand in HTML, you will use JavaScript to generate them dynamically. The same approach creates the keyboard buttons.

  • Switch to your script.js file and paste the following two functions:
function createBoard() {
    // Select the board container from the DOM
    const board = document.getElementById("board");
    for (let row = 0; row < MAX_GUESSES; row++) {
        for (let col = 0; col < WORD_LENGTH; col++) {
            // Create a tile div for each cell in the grid
            const tile = document.createElement("div");
            tile.classList.add("tile");
            tile.setAttribute("id", "tile-" + row + "-" + col);
            board.appendChild(tile);
        }
    }
}

function createKeyboard() {
    // Select the keyboard container from the DOM
    const keyboard = document.getElementById("keyboard");
    const rows = [
        ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
        ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
        ["Enter", "z", "x", "c", "v", "b", "n", "m", "Backspace"]
    ];

    rows.forEach(function(row) {
        // Create a container div for each keyboard row
        const rowDiv = document.createElement("div");
        rowDiv.classList.add("keyboard-row");

        row.forEach(function(key) {
            // Create a button element for each key
            const button = document.createElement("button");
            button.classList.add("key");
            button.setAttribute("data-key", key);

            if (key === "Enter" || key === "Backspace") {
                button.classList.add("wide");
                button.textContent = key === "Backspace" ? "Del" : "Enter";
            } else {
                button.textContent = key;
            }

            // Wire up click handler for on-screen keyboard
            button.addEventListener("click", function() {
                handleInput(key);
            });

            rowDiv.appendChild(button);
        });

        keyboard.appendChild(rowDiv);
    });
}

What does this code do?

  • createBoard() loops 6 rows by 5 columns, creating 30 tile divs. Each tile gets a unique ID like tile-0-0 through tile-5-4 so you can target specific cells later.
  • createKeyboard() builds three rows of QWERTY buttons. Enter and Backspace get a .wide class for extra width. Backspace displays as "Del" to save space.
  • Each key button has a click listener that calls handleInput(key). That function does not exist yet, so clicking does nothing for now.

You will not see any changes in the browser yet. These functions need to be called before they produce any tiles or buttons.

Set up the word list and initialize the game

The game needs a list of possible target words, some constants to define the board dimensions, and an init() function that kicks everything off when the page loads.

  • In script.js, add the following code at the very top of the file, above the createBoard() function:
const WORDS = [
    "apple", "beach", "crane", "dance", "eagle",
    "flame", "grape", "house", "ivory", "joker",
    "knife", "lemon", "mango", "noble", "ocean",
    "piano", "queen", "river", "stone", "tiger",
    "ultra", "vivid", "whale", "youth", "zebra",
    "angel", "brave", "climb", "dream", "earth",
    "frost", "ghost", "heart", "image", "jewel",
    "karma", "light", "magic", "nerve", "orbit",
    "pearl", "quiet", "royal", "solar", "trend",
    "unity", "vault", "watch", "xenon", "yield"
];

// Board dimensions
const WORD_LENGTH = 5;
const MAX_GUESSES = 6;

// Game state tracking
let targetWord = "";
let currentRow = 0;
let currentCol = 0;
let gameOver = false;

function init() {
    // Pick a random word from the list as the answer
    targetWord = WORDS[Math.floor(Math.random() * WORDS.length)];
    createBoard();
    createKeyboard();
    addEventListeners();
}

What does this code do?

  • WORDS is a hardcoded array of 50 five-letter words. The game picks one at random each time the page loads.
  • WORD_LENGTH and MAX_GUESSES define the board grid. If you wanted a 4-letter variant, you would only change these constants.
  • The state variables (currentRow, currentCol, gameOver) track where the player is in the game.
  • init() selects the secret target word, builds the board and keyboard, and sets up event listeners.
  • Now add the following code at the very bottom of script.js, below the createKeyboard() function:
function addEventListeners() {
    // Listen for physical keyboard presses
    document.addEventListener("keydown", function(e) {
        if (gameOver) return;

        if (e.key === "Enter") {
            handleInput("Enter");
        } else if (e.key === "Backspace") {
            handleInput("Backspace");
        } else if (/^[a-zA-Z]$/.test(e.key)) {
            handleInput(e.key.toLowerCase());
        }
    });
}

init();

What does this code do?

  • addEventListeners() attaches a keydown listener to the document so the player can type with their physical keyboard. It filters for letters, Enter, and Backspace.
  • The bare init() call at the very end triggers everything. The moment the browser loads your script, the board and keyboard appear.
  • Save script.js and refresh your browser.
  • You should see a 6x5 grid of empty bordered tiles and a three-row QWERTY keyboard below them. The keys are clickable, though nothing visible happens yet because the handleInput() function does not exist.

Board or keyboard not appearing?

  • Open your browser's DevTools console (F12 or Cmd+Option+I) and look for red error messages. A typo in a function name or a missing closing bracket is the most common cause.
  • Confirm that your script.js has the init() call on the very last line. Without it, the functions are defined but never run.
  • Check that the WORDS array, constants, and state variables are above the createBoard() function. JavaScript needs those values declared before init() runs.

Help me debug why my Wordle board is not rendering.

✔️ Awesome, I've got everything!

Great. Double check you have saved both style.css and script.js.

ⓧ I'd like to double check the full code

Here is the complete style.css file:

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

body {
    font-family: 'Arial', sans-serif;
    background-color: #121213;
    color: #ffffff;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

.container {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 20px;
    padding: 20px;
}

header h1 {
    font-size: 2rem;
    letter-spacing: 0.2rem;
    text-transform: uppercase;
}

#message-container {
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.message {
    background-color: #ffffff;
    color: #121213;
    padding: 8px 16px;
    border-radius: 4px;
    font-weight: bold;
    font-size: 0.9rem;
}

#board {
    display: grid;
    grid-template-columns: repeat(5, 62px);
    grid-template-rows: repeat(6, 62px);
    gap: 5px;
}

.tile {
    width: 62px;
    height: 62px;
    border: 2px solid #3a3a3c;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
    font-weight: bold;
    text-transform: uppercase;
}

.tile[data-state="active"] {
    border-color: #565758;
}

.tile[data-state="correct"] {
    background-color: #538d4e;
    border-color: #538d4e;
}

.tile[data-state="present"] {
    background-color: #b59f3b;
    border-color: #b59f3b;
}

.tile[data-state="absent"] {
    background-color: #3a3a3c;
    border-color: #3a3a3c;
}

#keyboard {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    margin-top: 10px;
}

.keyboard-row {
    display: flex;
    gap: 6px;
}

.key {
    background-color: #818384;
    color: #ffffff;
    border: none;
    border-radius: 4px;
    padding: 15px 12px;
    font-size: 0.85rem;
    font-weight: bold;
    cursor: pointer;
    min-width: 40px;
    text-transform: uppercase;
}

.key.wide {
    min-width: 65px;
    font-size: 0.75rem;
}

.key[data-state="correct"] {
    background-color: #538d4e;
}

.key[data-state="present"] {
    background-color: #b59f3b;
}

.key[data-state="absent"] {
    background-color: #3a3a3c;
}

Here is the complete script.js file:

const WORDS = [
    "apple", "beach", "crane", "dance", "eagle",
    "flame", "grape", "house", "ivory", "joker",
    "knife", "lemon", "mango", "noble", "ocean",
    "piano", "queen", "river", "stone", "tiger",
    "ultra", "vivid", "whale", "youth", "zebra",
    "angel", "brave", "climb", "dream", "earth",
    "frost", "ghost", "heart", "image", "jewel",
    "karma", "light", "magic", "nerve", "orbit",
    "pearl", "quiet", "royal", "solar", "trend",
    "unity", "vault", "watch", "xenon", "yield"
];

const WORD_LENGTH = 5;
const MAX_GUESSES = 6;

let targetWord = "";
let currentRow = 0;
let currentCol = 0;
let gameOver = false;

function init() {
    targetWord = WORDS[Math.floor(Math.random() * WORDS.length)];
    createBoard();
    createKeyboard();
    addEventListeners();
}

function createBoard() {
    const board = document.getElementById("board");
    for (let row = 0; row < MAX_GUESSES; row++) {
        for (let col = 0; col < WORD_LENGTH; col++) {
            const tile = document.createElement("div");
            tile.classList.add("tile");
            tile.setAttribute("id", "tile-" + row + "-" + col);
            board.appendChild(tile);
        }
    }
}

function createKeyboard() {
    const keyboard = document.getElementById("keyboard");
    const rows = [
        ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
        ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
        ["Enter", "z", "x", "c", "v", "b", "n", "m", "Backspace"]
    ];

    rows.forEach(function(row) {
        const rowDiv = document.createElement("div");
        rowDiv.classList.add("keyboard-row");

        row.forEach(function(key) {
            const button = document.createElement("button");
            button.classList.add("key");
            button.setAttribute("data-key", key);

            if (key === "Enter" || key === "Backspace") {
                button.classList.add("wide");
                button.textContent = key === "Backspace" ? "Del" : "Enter";
            } else {
                button.textContent = key;
            }

            button.addEventListener("click", function() {
                handleInput(key);
            });

            rowDiv.appendChild(button);
        });

        keyboard.appendChild(rowDiv);
    });
}

function addEventListeners() {
    document.addEventListener("keydown", function(e) {
        if (gameOver) return;

        if (e.key === "Enter") {
            handleInput("Enter");
        } else if (e.key === "Backspace") {
            handleInput("Backspace");
        } else if (/^[a-zA-Z]$/.test(e.key)) {
            handleInput(e.key.toLowerCase());
        }
    });
}

init();

Your game board and keyboard are rendered and ready. In the next step, you will make the tiles respond to key presses so players can type their guesses.

Handle Player Input

You have a game board and keyboard on screen, but clicking keys does nothing. That's because handleInput (the function both the keyboard buttons and the keydown listener try to call) doesn't exist yet.

In this step, you'll write the three functions that bring the board to life: a router that decides what to do with each key press, a function that places letters into tiles, and a function that erases them.

In this step, get ready to:
  • Write the input routing, letter placement, and letter deletion functions.
  • Verify that both physical keyboard and on-screen keyboard input work together.
Write the input handling functions

The input system needs three cooperating functions. One routes each key press to the right handler. One places a letter into the current tile. One erases the most recent letter.

  • In script.js, find the closing } of the addEventListeners() function.
  • Add the following three functions below it (above the init(); call at the bottom):
function handleInput(key) {
    if (gameOver) return;

    if (key === "Enter") {
        submitGuess();
    } else if (key === "Backspace") {
        deleteLetter();
    } else if (/^[a-z]$/.test(key) && currentCol < WORD_LENGTH) {
        addLetter(key);
    }
}

function addLetter(letter) {
    // Find the tile at the current row and column
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    // Place the letter and mark the tile as active
    tile.textContent = letter;
    tile.setAttribute("data-state", "active");
    // Move to the next column
    currentCol++;
}

function deleteLetter() {
    // Don't delete if already at the start of the row
    if (currentCol === 0) return;
    // Step back one column
    currentCol--;
    // Clear the tile's content and state
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    tile.textContent = "";
    tile.removeAttribute("data-state");
}

What does this code do?

handleInput is the central router. It checks which key was pressed and sends it to the right function. If the game is over, it ignores all input.

addLetter builds the tile ID from currentRow and currentCol, puts the letter in that tile, sets data-state="active" (which brightens the border via your CSS), and advances the column counter.

deleteLetter does the reverse. It steps the column back, clears the tile text, and removes the data-state attribute so the border returns to its default dark style.

  • Save script.js.
  • Switch to your browser and refresh the page. Click a few letter keys on the on-screen keyboard. You should see letters appear in the first row's tiles with slightly brighter borders.

Letters not appearing when you click keys?

  • Open the browser console (press F12 or Cmd+Option+J on macOS). Look for a ReferenceError mentioning handleInput. If you see one, make sure the new functions are placed before the init(); call at the bottom of the file.
  • Check that the tile IDs in addLetter match the pattern used in createBoard: "tile-" + row + "-" + col.

Why aren't my letters showing in the tiles?

Test your physical keyboard input

The addEventListeners() function you wrote in the previous step already listens for physical keyboard presses and routes them to handleInput. Now that handleInput exists, your physical keyboard works automatically alongside the on-screen buttons.

  • Click anywhere on the game page to make sure the browser window has focus.
  • Type 5 letters on your physical keyboard. Confirm that all 5 tiles in the first row fill up and no 6th letter appears.
  • Press Backspace a few times. Confirm that letters disappear from right to left and the tile borders revert to their darker default.

What about the Enter key?

If you press Enter right now, you'll see a console error because submitGuess() hasn't been written yet. That's expected. You'll build the guess-checking logic in the next step.

Both input methods share one function

Notice how the on-screen keyboard buttons and the physical keyboard listener both call the same handleInput() function. This means any future input logic (like blocking input during animations) only needs to be written once.

✔️ Awesome, I've got everything!

Great. Double check you've saved script.js and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete script.js as it should look at the end of this step:

const WORDS = [
    "apple", "beach", "crane", "dance", "eagle",
    "flame", "grape", "house", "ivory", "joker",
    "knife", "lemon", "mango", "noble", "ocean",
    "piano", "queen", "river", "stone", "tiger",
    "ultra", "vivid", "whale", "youth", "zebra",
    "angel", "brave", "climb", "dream", "earth",
    "frost", "ghost", "heart", "image", "jewel",
    "karma", "light", "magic", "nerve", "orbit",
    "pearl", "quiet", "royal", "solar", "trend",
    "unity", "vault", "watch", "xenon", "yield"
];

const WORD_LENGTH = 5;
const MAX_GUESSES = 6;

let targetWord = "";
let currentRow = 0;
let currentCol = 0;
let gameOver = false;

function init() {
    targetWord = WORDS[Math.floor(Math.random() * WORDS.length)];
    createBoard();
    createKeyboard();
    addEventListeners();
}

function createBoard() {
    const board = document.getElementById("board");
    for (let row = 0; row < MAX_GUESSES; row++) {
        for (let col = 0; col < WORD_LENGTH; col++) {
            const tile = document.createElement("div");
            tile.classList.add("tile");
            tile.setAttribute("id", "tile-" + row + "-" + col);
            board.appendChild(tile);
        }
    }
}

function createKeyboard() {
    const keyboard = document.getElementById("keyboard");
    const rows = [
        ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
        ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
        ["Enter", "z", "x", "c", "v", "b", "n", "m", "Backspace"]
    ];

    rows.forEach(function(row) {
        const rowDiv = document.createElement("div");
        rowDiv.classList.add("keyboard-row");

        row.forEach(function(key) {
            const button = document.createElement("button");
            button.classList.add("key");
            button.setAttribute("data-key", key);

            if (key === "Enter" || key === "Backspace") {
                button.classList.add("wide");
                button.textContent = key === "Backspace" ? "Del" : "Enter";
            } else {
                button.textContent = key;
            }

            button.addEventListener("click", function() {
                handleInput(key);
            });

            rowDiv.appendChild(button);
        });

        keyboard.appendChild(rowDiv);
    });
}

function addEventListeners() {
    document.addEventListener("keydown", function(e) {
        if (gameOver) return;

        if (e.key === "Enter") {
            handleInput("Enter");
        } else if (e.key === "Backspace") {
            handleInput("Backspace");
        } else if (/^[a-zA-Z]$/.test(e.key)) {
            handleInput(e.key.toLowerCase());
        }
    });
}

function handleInput(key) {
    if (gameOver) return;

    if (key === "Enter") {
        submitGuess();
    } else if (key === "Backspace") {
        deleteLetter();
    } else if (/^[a-z]$/.test(key) && currentCol < WORD_LENGTH) {
        addLetter(key);
    }
}

function addLetter(letter) {
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    tile.textContent = letter;
    tile.setAttribute("data-state", "active");
    currentCol++;
}

function deleteLetter() {
    if (currentCol === 0) return;
    currentCol--;
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    tile.textContent = "";
    tile.removeAttribute("data-state");
}

init();

Your board now responds to every key press. Next up, you'll write the guess-checking logic that compares the player's word against the target and colors each tile green, yellow, or grey.

Implement Guess Checking and Game Logic

Your board can accept typed letters and delete them. Now it is time to make those letters actually mean something.

In this step, you will build the brain of the game. When a player submits a guess, the game needs to compare it against the target word and color each tile green, yellow, or grey. The tricky part is handling duplicate letters correctly.

In this step, get ready to:
  • Write the two-pass color algorithm that handles duplicate letters.
  • Build the guess submission flow that colors tiles and updates the keyboard.
  • Add win/loss detection and player feedback messages.
Write the two-pass color algorithm

A naive approach would check each letter once in a single pass. This breaks on duplicate letters. For example, guessing "creep" when the target is "crane" would incorrectly mark both E's as present.

What is a two-pass algorithm?

The first pass finds letters in the exact correct position (green) and removes them from consideration. The second pass then checks remaining letters against remaining target positions for yellow marking.

By nulling out matched letters after each pass, the algorithm prevents double-counting. This is the same logic the real Wordle uses.

  • In script.js, find the deleteLetter() function near the bottom of your file.
  • Add the following checkGuess function below deleteLetter() and above the init(); call at the bottom:
function checkGuess(guess) {
    // Start by assuming every letter is absent
    const result = Array(WORD_LENGTH).fill("absent");
    const targetArr = targetWord.split("");
    const guessArr = guess.split("");

    // First pass: mark correct positions (green)
    for (let i = 0; i < WORD_LENGTH; i++) {
        if (guessArr[i] === targetArr[i]) {
            result[i] = "correct";
            targetArr[i] = null;
            guessArr[i] = null;
        }
    }

    // Second pass: mark present letters (yellow)
    for (let i = 0; i < WORD_LENGTH; i++) {
        if (guessArr[i] && targetArr.includes(guessArr[i])) {
            result[i] = "present";
            targetArr[targetArr.indexOf(guessArr[i])] = null;
        }
    }

    return result;
}

What does this code do?

  • result starts as an array of 5 "absent" strings. Each pass upgrades entries as needed.
  • The first loop checks for exact position matches. When found, it sets that index to "correct" and nulls out both arrays so the letter cannot be matched again.
  • The second loop checks remaining (non-null) guess letters against remaining target letters. A match means the letter exists but in the wrong spot.
  • Nulling out matched letters is the key insight. It prevents a single target letter from being counted twice.

You will not see a change in the browser yet. The next functions will use checkGuess to color your tiles.

Build the guess submission flow

The submission flow needs three functions working together. Two helper functions apply colors to tiles and keyboard keys. Then an orchestrator function ties everything into the game loop.

  • Add the following two helper functions below checkGuess():
function revealResult(result, guess) {
    // Apply the color state to each tile in the current row
    for (let i = 0; i < WORD_LENGTH; i++) {
        const tile = document.getElementById("tile-" + currentRow + "-" + i);
        tile.setAttribute("data-state", result[i]);
        updateKeyboardKey(guess[i], result[i]);
    }
}

function updateKeyboardKey(letter, state) {
    const key = document.querySelector('.key[data-key="' + letter + '"');
    if (!key) return;

    // Priority logic: correct > present > absent
    const currentState = key.getAttribute("data-state");
    if (currentState === "correct") return;
    if (currentState === "present" && state === "absent") return;

    key.setAttribute("data-state", state);
}

What does this code do?

  • revealResult loops through the 5 tiles in the current row and sets each tile's data-state attribute to "correct", "present", or "absent". Your CSS already styles these states with green, yellow, and grey backgrounds.
  • updateKeyboardKey uses priority logic so a key never downgrades. If a key is already green, it stays green even if a later guess marks it yellow or grey.
  • Now add the submitGuess() function above checkGuess() (since handleInput already calls it when Enter is pressed):
function submitGuess() {
    // Guard: must have 5 letters before submitting
    if (currentCol < WORD_LENGTH) {
        showMessage("Not enough letters");
        return;
    }

    // Assemble the guess from the current row's tiles
    let guess = "";
    for (let i = 0; i < WORD_LENGTH; i++) {
        const tile = document.getElementById("tile-" + currentRow + "-" + i);
        guess += tile.textContent;
    }

    // Check the guess and reveal results
    const result = checkGuess(guess);
    revealResult(result, guess);

    // Check for win or loss
    if (guess === targetWord) {
        gameOver = true;
        showMessage("Excellent! You got it!");
    } else if (currentRow === MAX_GUESSES - 1) {
        gameOver = true;
        showMessage("Game over! The word was " + targetWord.toUpperCase());
    }

    // Move to the next row
    currentRow++;
    currentCol = 0;
}

What does this code do?

  • The guard at the top prevents submitting a partial word. If the player presses Enter with fewer than 5 letters, it shows a warning and returns early.
  • The loop reads each tile's textContent to build the guess string. Then checkGuess evaluates it and revealResult colors the tiles.
  • The win condition checks if the guess matches the target word exactly. The loss condition triggers on the 6th failed guess (index 5, since MAX_GUESSES - 1 equals 5).
  • Finally, currentRow advances and currentCol resets so the next guess starts on a fresh row.
  • Save script.js.
  • Refresh your browser, type any 5-letter word (a wrong guess), and press Enter.
  • You should see the tiles in your first row change to grey (or possibly yellow/green if you happen to match some letters). The on-screen keyboard keys should also change colors.

Tiles not changing color?

  • Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) and check the Console tab for errors. A typo in a function name will show as a ReferenceError.
  • Make sure your submitGuess function is placed between deleteLetter() and the init(); call. If it is inside another function by mistake, it will not be accessible.
  • Verify that the tile IDs in your code use the format tile-0-0 through tile-5-4. A mismatch between createBoard() and revealResult() will silently fail.

Help me debug why my tiles are not changing color after submitting a guess.

Add the message display function

You may have noticed the console throwing an error when certain conditions trigger. That is because submitGuess calls a showMessage function that does not exist yet. This function displays timed feedback to the player.

  • Add the showMessage function below updateKeyboardKey():
function showMessage(text) {
    const container = document.getElementById("message-container");
    container.innerHTML = "";
    const msg = document.createElement("div");
    msg.classList.add("message");
    msg.textContent = text;
    container.appendChild(msg);

    // Remove the message after 3 seconds
    setTimeout(function() {
        if (msg.parentNode) {
            msg.remove();
        }
    }, 3000);
}

What does this code do?

  • It clears any existing message, creates a new div with the message class (already styled in your CSS as a white pill-shaped banner), and appends it to #message-container.
  • The setTimeout automatically removes the message after 3 seconds so it does not clutter the screen. The if (msg.parentNode) check prevents errors if the message was already removed.
  • Save script.js and refresh your browser.
  • Press Enter without typing any letters. You should see a white "Not enough letters" message appear at the top of the board and disappear after a few seconds.
  • Now play a full game. Type 5-letter guesses and press Enter for each one. Tiles turn green, yellow, or grey. Keyboard keys update. After a correct guess, you see "Excellent! You got it!" or after 6 wrong guesses you see the target word revealed.

Message not appearing?

  • Check that #message-container exists in your index.html. The div must have the exact ID message-container.
  • If the message appears but is invisible, inspect it in DevTools. Your CSS .message class should have a white background with dark text. If that class is missing from style.css, add it.
  • If the game does not end after a correct guess, check that guess === targetWord uses triple equals. Double equals can cause unexpected behavior.

Help me figure out why my Wordle messages or game-over logic are not working.

Why does the two-pass algorithm matter for duplicate letters?

Try guessing a word with repeated letters, like "apple", when the target only has one of those letters. Notice how only the correctly positioned duplicate turns green. The other stays grey instead of incorrectly turning yellow.

Without the two-pass approach, both duplicates would be marked as present or correct. The real Wordle solves this the same way: greens first, then yellows from whatever is left.

✔️ Awesome, I've got everything!

Great. Double check you have saved script.js with all five new functions.

ⓧ I'd like to double check the full code

const WORDS = [
    "apple", "beach", "crane", "dance", "eagle",
    "flame", "grape", "house", "ivory", "joker",
    "knife", "lemon", "mango", "noble", "ocean",
    "piano", "queen", "river", "stone", "tiger",
    "ultra", "vivid", "whale", "youth", "zebra",
    "angel", "brave", "climb", "dream", "earth",
    "frost", "ghost", "heart", "image", "jewel",
    "karma", "light", "magic", "nerve", "orbit",
    "pearl", "quiet", "royal", "solar", "trend",
    "unity", "vault", "watch", "xenon", "yield"
];

const WORD_LENGTH = 5;
const MAX_GUESSES = 6;

let targetWord = "";
let currentRow = 0;
let currentCol = 0;
let gameOver = false;

function init() {
    targetWord = WORDS[Math.floor(Math.random() * WORDS.length)];
    createBoard();
    createKeyboard();
    addEventListeners();
}

function createBoard() {
    const board = document.getElementById("board");
    for (let row = 0; row < MAX_GUESSES; row++) {
        for (let col = 0; col < WORD_LENGTH; col++) {
            const tile = document.createElement("div");
            tile.classList.add("tile");
            tile.setAttribute("id", "tile-" + row + "-" + col);
            board.appendChild(tile);
        }
    }
}

function createKeyboard() {
    const keyboard = document.getElementById("keyboard");
    const rows = [
        ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
        ["a", "s", "d", "f", "g", "h", "j", "k", "l"],
        ["Enter", "z", "x", "c", "v", "b", "n", "m", "Backspace"]
    ];

    rows.forEach(function(row) {
        const rowDiv = document.createElement("div");
        rowDiv.classList.add("keyboard-row");

        row.forEach(function(key) {
            const button = document.createElement("button");
            button.classList.add("key");
            button.setAttribute("data-key", key);

            if (key === "Enter" || key === "Backspace") {
                button.classList.add("wide");
                button.textContent = key === "Backspace" ? "Del" : "Enter";
            } else {
                button.textContent = key;
            }

            button.addEventListener("click", function() {
                handleInput(key);
            });

            rowDiv.appendChild(button);
        });

        keyboard.appendChild(rowDiv);
    });
}

function addEventListeners() {
    document.addEventListener("keydown", function(e) {
        if (gameOver) return;

        if (e.key === "Enter") {
            handleInput("Enter");
        } else if (e.key === "Backspace") {
            handleInput("Backspace");
        } else if (/^[a-zA-Z]$/.test(e.key)) {
            handleInput(e.key.toLowerCase());
        }
    });
}

function handleInput(key) {
    if (gameOver) return;

    if (key === "Enter") {
        submitGuess();
    } else if (key === "Backspace") {
        deleteLetter();
    } else if (/^[a-z]$/.test(key) && currentCol < WORD_LENGTH) {
        addLetter(key);
    }
}

function addLetter(letter) {
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    tile.textContent = letter;
    tile.setAttribute("data-state", "active");
    currentCol++;
}

function deleteLetter() {
    if (currentCol === 0) return;
    currentCol--;
    const tile = document.getElementById("tile-" + currentRow + "-" + currentCol);
    tile.textContent = "";
    tile.removeAttribute("data-state");
}

function submitGuess() {
    if (currentCol < WORD_LENGTH) {
        showMessage("Not enough letters");
        return;
    }

    let guess = "";
    for (let i = 0; i < WORD_LENGTH; i++) {
        const tile = document.getElementById("tile-" + currentRow + "-" + i);
        guess += tile.textContent;
    }

    const result = checkGuess(guess);
    revealResult(result, guess);

    if (guess === targetWord) {
        gameOver = true;
        showMessage("Excellent! You got it!");
    } else if (currentRow === MAX_GUESSES - 1) {
        gameOver = true;
        showMessage("Game over! The word was " + targetWord.toUpperCase());
    }

    currentRow++;
    currentCol = 0;
}

function checkGuess(guess) {
    const result = Array(WORD_LENGTH).fill("absent");
    const targetArr = targetWord.split("");
    const guessArr = guess.split("");

    for (let i = 0; i < WORD_LENGTH; i++) {
        if (guessArr[i] === targetArr[i]) {
            result[i] = "correct";
            targetArr[i] = null;
            guessArr[i] = null;
        }
    }

    for (let i = 0; i < WORD_LENGTH; i++) {
        if (guessArr[i] && targetArr.includes(guessArr[i])) {
            result[i] = "present";
            targetArr[targetArr.indexOf(guessArr[i])] = null;
        }
    }

    return result;
}

function revealResult(result, guess) {
    for (let i = 0; i < WORD_LENGTH; i++) {
        const tile = document.getElementById("tile-" + currentRow + "-" + i);
        tile.setAttribute("data-state", result[i]);
        updateKeyboardKey(guess[i], result[i]);
    }
}

function updateKeyboardKey(letter, state) {
    const key = document.querySelector('.key[data-key="' + letter + '"');
    if (!key) return;

    const currentState = key.getAttribute("data-state");
    if (currentState === "correct") return;
    if (currentState === "present" && state === "absent") return;

    key.setAttribute("data-state", state);
}

function showMessage(text) {
    const container = document.getElementById("message-container");
    container.innerHTML = "";
    const msg = document.createElement("div");
    msg.classList.add("message");
    msg.textContent = text;
    container.appendChild(msg);

    setTimeout(function() {
        if (msg.parentNode) {
            msg.remove();
        }
    }, 3000);
}

init();

Your Wordle clone is fully playable. Tiles turn green, yellow, and grey. The keyboard reflects each guess. Win and loss messages appear. Next up, you will take this game live on the internet by deploying it to AWS S3.

Deploy to AWS S3

Your Wordle clone is fully functional on your local machine. Now it is time to share it with the world.

In this step, you will deploy your three project files to AWS S3 (Simple Storage Service), which can serve static websites directly from a bucket. No server setup required.

In this step, get ready to:
  • Create an S3 bucket configured for public web hosting.
  • Apply a bucket policy that allows anyone on the internet to view your game.
  • Upload your files and confirm the game is live at a public URL.
Create an S3 bucket

An S3 bucket is a container that holds your files in the cloud. To host a website, you create a bucket, make it publicly readable, and enable static website hosting.

  • Open your browser and navigate to the AWS Management Console at https://console.aws.amazon.com/s3/.
  • Sign in with your AWS account credentials.
  • In the left navigation pane, click General purpose buckets.
  • Click Create bucket.
  • In the Bucket name field, enter a globally unique name like my-wordle-clone-yourname (replace with your own name to make it unique).
  • In the AWS Region dropdown, choose a region close to you (for example, us-east-1 or ap-southeast-2).
  • Scroll down to the Block Public Access settings for this bucket section.
  • Uncheck Block all public access.
  • A warning banner appears asking you to acknowledge the change. Check the acknowledgement checkbox to confirm you understand the bucket will be publicly accessible.
  • Leave all other settings as their defaults and click Create bucket at the bottom of the page.

Why unblock public access?

By default, S3 blocks all public access to protect your data. Since you want anyone on the internet to play your game, you need to allow public read access.

This is safe because you are only serving static HTML, CSS, and JavaScript files. No sensitive data is stored in this bucket.

Bucket name already taken?

S3 bucket names are globally unique across all AWS accounts. If you see a "Bucket already exists" error, try adding numbers or a different suffix to your name (for example, wordle-clone-jane-2026).

Help me troubleshoot my bucket name error

Enable static website hosting and add the bucket policy

Your bucket exists, but S3 does not know it should serve files as a website yet. You need to enable static website hosting and add a policy that grants public read access.

  • Click on your newly created bucket name to open it.
  • Click the Properties tab.
  • Scroll down to the Static website hosting section.
  • Click Edit.
  • Select Enable.
  • In the Index document field, type index.html.
  • Click Save changes.

After saving, you will see a Bucket website endpoint URL displayed in the Static website hosting section. Copy this URL and save it somewhere. This is the public address of your game.

What does the endpoint URL look like?

The format is region-specific. It looks something like http://my-wordle-clone-yourname.s3-website-us-west-2.amazonaws.com. The exact URL depends on your bucket name and region.

Copy whatever the console shows you. Make sure you use the website endpoint (starting with http://) and not the regular S3 object URL.

Now add the bucket policy that lets the public read your files.

  • Click the Permissions tab.
  • Scroll down to the Bucket policy section.
  • Click Edit.
  • Paste the following policy JSON into the editor, replacing the bucket name with your own:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::[[BUCKET_NAME="my-wordle-clone-yourname"]]/*"
            ]
        }
    ]
}

What does this policy do?

This policy tells AWS to allow anyone ("Principal": "*") to read (s3:GetObject) any file inside your bucket.

The /* at the end of the Resource means "all objects in this bucket." Without this policy, visitors would get an Access Denied error when trying to load your game.

  • Click Save changes.

Getting an Access Denied error on save?

If the policy fails to save, go back to the Permissions tab and confirm that Block all public access is turned off. You may need to click Edit in the Block public access (bucket settings) section, clear the checkbox, and save again.

Help me fix my bucket policy Access Denied error

Upload your files and go live

Your bucket is configured. The last step is uploading your three game files so the world can play your Wordle clone.

  • Click the Objects tab.
  • Click Upload.
  • Click Add files.
  • Select all three files from your wordle-clone folder: index.html, style.css, and script.js.
  • Click Upload.
  • Wait for the upload to complete, then click Close.

Now open the S3 website endpoint URL you copied earlier in a new browser tab. Your Wordle clone should load and be fully playable.

  • Try playing a full game at the live URL to confirm everything works.
  • Share the link with a friend to verify it loads from another device.

Page loads but the game board is missing?

This usually means style.css or script.js failed to load. Open your browser's developer tools (press F12) and check the Console tab for 403 or 404 errors. Confirm that all three files appear in your bucket's Objects list and that your bucket policy is saved correctly.

Help me debug why my CSS or JS files are not loading

Secret mission

Add Flip Animations and Game Stats

Your Wordle game works, but the tile reveals feel flat. The real Wordle has a satisfying flip animation that reveals colors one tile at a time. Can you add CSS keyframe animations with staggered timing, plus a localStorage stats tracker that remembers your win streak across sessions?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and on the AWS Free Tier, so there are no ongoing costs. Decide whether to keep your resources running or delete them entirely.

Resources you used:

  • 1 S3 bucket storing your 3 game files (index.html, style.css, script.js).
  • Local wordle-clone folder on your Desktop.

Keep everything running

No action needed. Choose this if you want your game to stay live on the internet.

  • Your S3 bucket costs nothing under the AWS Free Tier (5 GB storage and 20,000 GET requests per month free for 12 months).
  • Your local wordle-clone folder stays on your Desktop for future edits.
  • Your game remains playable at the S3 website endpoint URL you noted earlier.

Pause - I'll come back to this later

Stop your local development server to free up the terminal. Your S3 bucket stays live at no cost.

  • If your local server is still running, press Ctrl+C in the terminal to stop it.
  • Your S3-hosted game remains accessible. No charges accrue under the Free Tier.
  • Your local wordle-clone folder stays on your Desktop. You can pick up where you left off any time.

Delete - I don't want to use this again

Remove all project resources and start fresh. This takes your game offline permanently.

  • Open the S3 console at https://console.aws.amazon.com/s3/.
  • In the left navigation pane, choose General purpose buckets.
  • Select the radio button next to your bucket name, then choose Empty.
  • Type permanently delete in the confirmation field, then choose Empty.
  • Return to the bucket list. Select your bucket again, then choose Delete.
  • Type your bucket name in the confirmation field, then choose Delete bucket.
  • Delete your local project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/wordle-clone

Windows

Remove-Item -Recurse -Force ~\Desktop\wordle-clone

Nice Work!

Nice Work!

You just built a complete Wordle clone from scratch and deployed it live on the internet. Anyone with your S3 URL can play your game right now.

You've learned how to:

  • Build an interactive word game with vanilla HTML, CSS, and JavaScript using DOM manipulation, CSS Grid, and keyboard event handling.
  • Implement the two-pass color algorithm that correctly handles duplicate letters by separating exact-match detection from partial-match detection.
  • Configure an S3 bucket for static website hosting with a public-read bucket policy and deploy a live, publicly accessible web game to AWS.
  • Secret Mission: Added tile flip animations with CSS keyframes and a persistent stats tracker powered by localStorage.

Ready to quiz yourself?