Magyar zászlóMagyar

Practice 5 - Hangman Completed & Pixel Art Editor

2025-03-13 6 min read GitHub

Introduction

This session has two parts. First we finish the Hangman game by adding the SVG drawing figure and a proper lose condition. Then we start a brand-new project: a Pixel Art Editor.

New concepts covered today:

  1. Array.slice() — extracting a portion of an array without modifying it
  2. SVG as data — storing drawing instructions in a JavaScript array
  3. Array.filter() for counting — deriving the mistake count from state
  4. Array.some() and Array.every() — readable alternatives to manual loops
  5. A single state object — grouping all application data in one place
  6. 2D arrays — a grid stored as rows of columns
  7. data-* attributes — embedding coordinates in HTML for click handling

Part 1 — Finishing Hangman

1. Array.slice() — Extracting a Portion of an Array

const arr = [1, 2, 3, 4, 5];

arr.slice(1, 3);  // [2, 3]  — index 1 inclusive to 3 exclusive
arr.slice(0, 2);  // [1, 2]
arr.slice(2);     // [3, 4, 5]  — from index 2 to end

slice(start, end):

  • Returns a new array — the original is never modified
  • start is inclusive, end is exclusive
  • If end is omitted, it runs to the end of the array

This is the key to progressive SVG drawing: given N mistakes, show the first N shapes.


2. SVG as Data — visualElements

The hangman figure is a sequence of SVG shapes. Instead of hard-coding them in HTML, we store them as an array of strings in JavaScript:

const visualElements = [
    `<line x1="0" y1="99%" x2="100%" y2="99%" />`,   // floor
    `<line x1="20%" y1="99%" x2="20%" y2="5%" />`,    // pole
    `<line x1="20%" y1="5%" x2="60%" y2="5%" />`,     // top bar
    `<line x1="60%" y1="5%" x2="60%" y2="20%" />`,    // rope
    `<circle cx="60%" cy="30%" r="10%" />`,            // head
    `<line x1="60%" y1="30%" x2="60%" y2="70%" />`,   // body
    `<line x1="40%" y1="50%" x2="80%" y2="50%" />`,   // arms
    `<line x1="60%" y1="70%" x2="50%" y2="90%" />`,   // left leg
    `<line x1="60%" y1="70%" x2="70%" y2="90%" />`    // right leg
];

The HTML SVG element gets an id so we can target it from JavaScript:

<svg width="200px" height="200px" stroke="black" id="littleMan"></svg>

3. Counting Mistakes

A “mistake” is a guessed letter that does not appear in targetWord. We derive this count from state with filter:

function mistakes() {
    return guessedLetters.filter(
        letter => !targetWord.toLowerCase().includes(letter.toLowerCase())
    ).length;
}

We never store a separate wrongGuesses variable. The count is always computed fresh from guessedLetters and targetWord, so it can never get out of sync with reality.


4. Drawing the Figure — renderLittleMan()

Now slice() and mistakes() snap together:

function renderLittleMan() {
    const svg = document.querySelector("#littleMan");
    svg.innerHTML = visualElements.slice(0, mistakes()).join("");
}

visualElements.slice(0, mistakes()) returns the first N elements, where N is the current wrong-guess count. Each re-render reveals exactly as many body parts as wrong guesses made so far.

This function joins the render pipeline:

function renderState() {
    renderButtons();
    renderWord();
    renderLittleMan();   // ← new this session
    checkEndGame();
}

5. Win and Lose — checkEndGame()

Previously the function only checked for a win. Now it handles both outcomes:

function checkEndGame() {
    // Lose condition — checked first
    if (mistakes() > visualElements.length) {
        gameOver = true;
        document.querySelector("#result").innerText =
            `You died. The word was: ${targetWord}.`;
        document.querySelector("#controls").style.display = "block";
        return;  // stop here — do not check win
    }

    // Win condition
    if (targetWord.toUpperCase().split("").every(
        letter => guessedLetters.includes(letter)
    )) {
        document.querySelector("#result").innerText =
            `Congratulations, you have guessed the word!`;
        document.querySelector("#controls").style.display = "block";
        gameOver = true;
    }
}

The return after the lose branch is an early return: once we know the game is lost there is nothing more to check. Without it, the win condition would also run and could display a confusing second message.

💡

Early returns keep code flat. Instead of nesting the win check inside an else, we exit as soon as we have our answer.


6. some() and every()

We used every() in the win check. Meet its sibling some():

const numbers = [1, 2, 3, 4, 5];

// every — is the condition true for ALL elements?
numbers.every(n => n % 2 === 0);  // false — 1, 3, 5 are odd
numbers.every(n => n < 10);       // true  — all are below 10

// some — is the condition true for AT LEAST ONE element?
numbers.some(n => n % 2 === 0);   // true  — 2 and 4 are even
numbers.some(n => n < 0);         // false — no negatives
MethodReturns true when…Short-circuits on
every()all elements passfirst failure
some()at least one passesfirst success

Both stop iterating as soon as they have enough information.

In the win check we use every() because the word is guessed only when all of its letters are in guessedLetters.


Complete Hangman Data Flow

Start button click
  startGame() → reset state → renderState()

Each letter click
  handleButtonClick() → push to guessedLetters → renderState()
    ├── renderButtons()    — alphabet, guessed letters disabled
    ├── renderWord()       — _ or real letter per character
    ├── renderLittleMan()  — first N SVG shapes (N = mistakes())
    └── checkEndGame()     — lose if mistakes > 9, win if every letter guessed

Part 2 — Pixel Art Editor

Project Structure

practice-05/
├── index.html   # Three-column layout
├── style.css    # Table and layout styles
└── main.js      # State object and render logic

HTML Overview

The page has three side-by-side panels:

<div id="app">

  <!-- Panel 1: grid editor -->
  <div id="editor-section">
    <div id="grid-controls">
      <label>Width:  <input type="number" id="grid-width"  value="10" /></label>
      <label>Height: <input type="number" id="grid-height" value="10" /></label>
      <button id="generate-btn">Generate Grid</button>
    </div>
    <div id="grid-container"></div>
    <button id="save-btn">Save</button>
  </div>

  <!-- Panel 2: colour picker -->
  <div id="color-section">
    <h2>Color</h2>
    <div id="current-color-display"></div>
    <input type="color" id="color-picker" value="#ff0000" />
  </div>

  <!-- Panel 3: saved arts list -->
  <div id="list-section">
    <h2>Pixel Arts</h2>
    <button id="new-btn">+ New</button>
    <div id="pixel-art-list"></div>
  </div>

</div>

The three-column layout uses a single Flexbox rule:

#app {
    display: flex;
    gap: 24px;
    align-items: flex-start;
}
ℹ️

align-items: flex-start keeps all panels top-aligned. Without it, a tall grid would stretch the colour and list panels to match its height.


1. The State Object

Previous projects used separate let variables for each piece of data. Here everything lives in one object:

let state = {
    gridWidth:    10,
    gridHeight:   10,
    pixels:       [],   // 2D array: pixels[row][col] = "#rrggbb" or null
    currentColor: '#ff0000',
};

Why a state object?

Separate variablesOne state object
Scattered across the fileAll data in one place
Easy to forget one when resettingstate = { ... } resets everything
Hard to save/loadJSON.stringify(state) captures everything

As an app grows, a single state object is much easier to reason about than a collection of loose variables.


2. The 2D Pixel Array

The grid is stored as an array of rows, where each row is an array of colour strings (or null for unpainted cells):

pixels = [
  [ "#ff0000", null,      "#0000ff" ],   // row 0
  [ null,      "#ff0000", null      ],   // row 1
  [ "#0000ff", null,      null      ],   // row 2
]

pixels[row][col] gives the colour at that cell.

When we generate a fresh grid:

state.pixels = Array.from({ length: state.gridHeight }, () =>
    Array.from({ length: state.gridWidth }, () => null)
);

Array.from({ length: N }, fn) creates an array of N elements by calling fn for each slot. The outer call builds one row per grid height; the inner call fills each row with null values.


3. Rendering the Grid — renderGrid()

function renderGrid() {
    const rows = state.pixels.map((row, rowIndex) => {
        return `<tr>` +
            row.map((color, colIndex) => {
                return `
                    <td
                        data-row="${rowIndex}"
                        data-col="${colIndex}"
                        style="${color ? 'background-color:' + color : ''}"
                    ></td>
                `;
            }).join("") +
        `</tr>`;
    }).join("");

    document.querySelector("#grid-container").innerHTML = `
        <table class="edit">
            <tbody>${rows}</tbody>
        </table>
    `;
}

Outer map — rows

state.pixels.map((row, rowIndex) => ...) iterates over each row. The second argument rowIndex is the row’s index inside the 2D array — we store it in data-row.

Inner map — cells

row.map((color, colIndex) => ...) iterates over each cell in that row. colIndex goes into data-col.

Inline style from a ternary

style="${color ? 'background-color:' + color : ''}"

If color is a truthy hex string, inject a background rule. If it is null, the attribute is empty and the cell shows the default CSS background.


4. data-* Attributes

<td data-row="2" data-col="5" ...></td>

data-* attributes let you attach arbitrary data to any HTML element. JavaScript reads them through element.dataset:

element.dataset.row   // "2"  (always a string)
element.dataset.col   // "5"

When the user clicks a cell, event.target.dataset.row and event.target.dataset.col tell us exactly which cell to paint — no DOM traversal needed.


5. CSS — Two Table Variants

Two table class variants handle editing canvas and thumbnails:

/* Editing canvas — large, visible grid lines */
table.edit td {
    width: 20px;
    height: 20px;
    border: 1px solid lightgray;
    padding: 0;
}

/* Saved-art thumbnail — tiny, no borders */
table.preview td {
    width: 3px;
    height: 3px;
    border: none;
    padding: 0;
}

The same 2D array renders into either table. The editor uses table.edit; saved-art thumbnails in the list panel will use table.preview.


6. Render Entry Point

function renderAll() {
    renderGrid();
}

renderAll();   // initial render on page load

Same pattern as Hangman: one entry point, specialised render functions below. We will add renderColorPicker() and renderList() here as the project grows.


What Comes Next

The scaffold is in place. The remaining features follow directly from the patterns above:

Painting cells — click event on #grid-container, delegation with event.target.closest("td"), read dataset.row / dataset.col, update state.pixels[row][col], call renderAll().

Colour pickerinput event on #color-picker, update state.currentColor, refresh #current-color-display.

Generate grid — click on #generate-btn, read width/height inputs into state, rebuild state.pixels as a fresh null grid, call renderAll().

Save — push a deep copy of state.pixels into a savedArts array, re-render the list panel with thumbnails.


Summary

ConceptWhere we used it
slice(start, end)Show first N SVG shapes (N = mistake count)
SVG as a data arrayvisualElements — inject with innerHTML
filter().lengthCount wrong guesses without a separate variable
Early returnExit checkEndGame() as soon as lose is confirmed
every()Win: every letter of the word must be guessed
some()At least one element satisfies a condition
State objectlet state = { ... } — single source of truth
2D arraypixels[row][col] grid
Array.from({ length }, fn)Generate a fresh N×M grid
map(item, index)Track row/column position while building HTML
data-row / data-colEncode grid coordinates in the DOM
element.datasetRead data-* values in JavaScript
CSS table variantstable.edit (20px) vs table.preview (3px)