Build Neon Block Drop Arcade

Build a falling-block game with JavaScript and the Canvas API.

Introduction

30 Second Summary

Arcade games turn a few spare minutes into a test of timing and quick decisions. A great one makes every key press feel immediate.

In this project, you will build Neon Block Drop inside a responsive neon arcade cabinet. The Canvas 2D API and plain JavaScript power its falling pieces, controls, scoring, previews, and game states.

What You'll Build

Your finished cabinet opens to a glowing game board where you can stack pieces, clear rows, and watch the level speed climb.

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

  • A responsive arcade cabinet that keeps the game board and future-game cards clear across desktop and narrow screens.
  • A complete block-drop game where all seven piece shapes rotate, stack, clear rows, and increase your score.
  • Arcade controls and feedback that let you move, soft-drop, hard-drop, pause, restart, preview pieces, and follow a ghost landing guide.
  • Secret Mission: Unlock the hold slot so the player can save one piece and swap it back into play.

Are there any prerequisites?

You need a Windows, macOS, or Linux computer. The project guides you through setting up Visual Studio Code and Google Chrome.

Before We Start

A reusable arcade starts with a clear audience and a plan beyond its first cabinet. This checkpoint connects Neon Block Drop to the game you want to add next.

Set Up the Arcade Workspace

A complex browser game becomes difficult to debug when the editor or browser setup is uncertain. A verified workspace keeps later problems focused on your game code.

In this step, Visual Studio Code gives you one place to manage the arcade files. Google Chrome gives you a reliable way to open the local page.

In this step, get ready to:
  • Verify Visual Studio Code is available.
  • Verify Google Chrome is available.
  • Create a three-file workspace that renders a local setup page.
Prepare your editor and browser

Your editor stores the source files for the arcade. Your browser turns index.html into the page you can inspect.

  • Check whether Visual Studio Code opens by choosing the tab that matches your computer.

✔️ Visual Studio Code opens

  • Press Cmd+Space on macOS or the Windows key on Windows. Use your application search on Linux.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor.

You should see a Visual Studio Code window.

ⓧ I need to install Visual Studio Code

macOS

  • Visit the official Visual Studio Code setup page.
  • Download the current stable .dmg for macOS.
  • Open the downloaded .dmg.
  • Drag Visual Studio Code.app into Applications.
  • Press Cmd+Space to open the search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor.

You should see a Visual Studio Code window.

Windows

  • Visit the official Visual Studio Code setup page.
  • Download the current stable User Setup installer for Windows.
  • Open the downloaded .exe.
  • Follow the installer prompts.
  • Press the Windows key to open the search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open the editor.

You should see a Visual Studio Code window.

Linux

Linux package choices can be fiddly because distributions use different formats.

  • Visit the official Visual Studio Code setup page.
  • Download the package for your Linux distribution.
  • Choose the .deb package when your distribution supports that format.
  • Choose the .rpm package when your distribution supports that format.
  • Open the downloaded package with your distribution's package installer.
  • Follow the installer prompts.
  • Open Visual Studio Code from your application search.

You should see a Visual Studio Code window.

No account required

Visual Studio Code works as a code editor without signing in. This project uses the standard editing features.

  • Check whether Google Chrome opens by choosing the tab that matches your computer.

✔️ Google Chrome opens

  • Press Cmd+Space on macOS or the Windows key on Windows. Use your application search on Linux.
  • Type Google Chrome into the search bar.
  • Press Enter to open the browser.

You should see a new Google Chrome window.

ⓧ I need to install Google Chrome

  • Visit the official Google Chrome page.
  • Download the installation file for your computer.
  • Open the downloaded installation file.
  • Follow the installer instructions.
  • Press Cmd+Space on macOS or the Windows key on Windows. Use your application search on Linux.
  • Type Google Chrome into the search bar.
  • Press Enter to open the browser.

You should see a new Google Chrome window.

Having trouble installing the apps?

  • Reopen the downloaded installation file if the installer closed early.
  • Complete every installer prompt before searching for the app again.
  • Download the Linux package format supported by your distribution.

Ask for help with your specific computer: help me install Visual Studio Code or Google Chrome.

That clears the setup uncertainty. Both tools now open before the arcade code becomes complex.

Create the arcade workspace

Visual Studio Code treats an opened folder as a workspace. Keeping every source file inside neon-arcade gives each later step the same starting point.

  • Switch back to Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder....

Your computer's folder picker opens.

  • Choose your Desktop as the location.
  • Select New Folder.
  • Enter neon-arcade as the folder name.
  • Confirm the folder creation.

The neon-arcade folder now exists on your Desktop.

  • Select the new neon-arcade folder.
  • Select Select Folder on Windows or Linux. Select Open on macOS.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You should see neon-arcade at the top of the Explorer view.

  • Select the Explorer view in the Activity Bar.
  • Select New File... to create index.html.
  • Select New File... to create style.css.

The Explorer now lists index.html with style.css.

  • Select New File... to create script.js.

You should now see all three files beneath neon-arcade in the Explorer.

What will these files contain?

  • The index.html file contains the arcade's HTML structure.
  • The style.css file contains the arcade's CSS styles.
  • The script.js file contains the JavaScript game logic.

Missing a project file?

Check that the Explorer root says neon-arcade. A different name means another folder is open.

Select the neon-arcade folder before using New File... again.

Get help checking the workspace: help me find a missing file in my neon-arcade workspace.

Your arcade now has a clean three-file home. Each later feature has a clear place to live.

Render the setup page

A visible heading proves that Google Chrome can read your local HTML file. This creates a working checkpoint before the cabinet interface replaces it.

  • Select index.html in the Explorer.
  • Replace its empty contents with this document:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Neon Arcade | Block Drop</title>
  </head>
  <body>
    <h1>Neon Arcade setup ready</h1>
  </body>
</html>

What does this page prove?

  • The document declaration tells the browser to use modern HTML rules.
  • The metadata prepares the page for readable text.
  • The viewport setting prepares the page for different screen widths.
  • The heading creates a visible checkpoint that confirms the local file rendered.
  • Save index.html.

Before you open the file, what heading do you expect Google Chrome to display?

  • Switch back to Google Chrome.
  • Press Cmd+O on macOS or Ctrl+O on Windows and Linux.
  • Navigate to the neon-arcade folder on your Desktop.
  • Double-click index.html in the file picker.

You should see Neon Arcade setup ready on the page. Your local folder, editor, HTML file, and browser are working together.

Heading missing in Chrome?

Save index.html before loading it again. Unsaved editor changes do not reach the file Chrome reads.

Check that the file is named exactly index.html. A different extension prevents Chrome from loading the intended HTML page.

Ask for help with the local page: help me find why my index.html heading is missing in Google Chrome.

✔️ Awesome, I've got everything!

Great. Your local setup page renders in Google Chrome.

  • Keep style.css empty for the next step.
  • Keep script.js empty for the next step.

ⓧ I'd like to double check the full code

Compare your complete index.html file with this reference.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Neon Arcade | Block Drop</title>
  </head>
  <body>
    <h1>Neon Arcade setup ready</h1>
  </body>
</html>
  • Confirm style.css is empty.
  • Confirm script.js is empty.

Your local workspace is proven and ready. Next, you will replace the setup page with the neon arcade cabinet.

Light Up the Arcade Cabinet

Your setup page proves the local workflow works. The arcade still needs a visible cabinet before you add moving pieces or game rules.

This step combines semantic HTML with responsive CSS. You will also use the Canvas API to draw a blank 10-column by 20-row playfield.

In this step, get ready to:
  • Build the arcade cabinet interface.
  • Style the cabinet for desktop and narrow screens.
  • Render the empty board with JavaScript.
Build the cabinet structure

Semantic HTML gives each part of the cabinet a clear purpose. The canvases provide drawing surfaces while the surrounding elements keep labels and controls accessible.

  • Switch back to index.html in Visual Studio Code.
  • Replace the existing setup document with this starting structure:
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Neon Arcade | Block Drop</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="arcade-shell">
      <header class="hero">
        <div>
          <p class="eyebrow">PLAYER ONE PRESENTS</p>
          <h1>NEON ARCADE</h1>
          <p class="tagline">One cabinet now. A whole arcade next.</p>
        </div>
        <div class="status-pill">
          <span aria-hidden="true">●</span>
          <span id="status-text">READY</span>
        </div>
      </header>
    </main>

    <script src="script.js"></script>
  </body>
</html>

What does this structure do?

  • The stylesheet link connects style.css to the page.
  • The hero introduces the arcade and exposes the current cabinet status.
  • The script element loads script.js after the page content exists.
  • Save index.html.
  • Refresh the local page in Google Chrome.

You should see the Neon Arcade heading with a READY status. The page is still unstyled at this point.

  • Find the closing </main> tag in index.html.
  • Insert the game screen directly above that tag:
      <section class="machine" aria-labelledby="game-title">
        <div class="screen-column">
          <div class="screen-header">
            <div>
              <p class="eyebrow">CABINET 01</p>
              <h2 id="game-title">NEON BLOCK DROP</h2>
            </div>
            <span class="live-badge">LIVE</span>
          </div>

          <div class="screen-frame">
            <canvas
              id="game-canvas"
              width="300"
              height="600"
              aria-label="Neon Block Drop game board"
            >
              A falling-block puzzle game. Use the controls listed beside the board.
            </canvas>
          </div>
        </div>

        <aside class="side-panel" aria-label="Game information and controls">
        </aside>
      </section>

Why use a canvas here?

The game board changes every frame during play. A canvas provides one drawing surface that JavaScript can clear and redraw.

The intrinsic size is 300 by 600 pixels. That preserves the board's 1:2 shape.

  • Save index.html.
  • Refresh Google Chrome.

You should see the Neon Block Drop heading above an empty canvas area.

  • Find the empty side-panel element in index.html.
  • Add the two preview panels inside it:
          <div class="preview-grid">
            <section class="panel-card">
              <p class="panel-label">NEXT</p>
              <canvas
                id="next-canvas"
                width="120"
                height="120"
                aria-label="Next piece preview"
              >
                Next piece preview.
              </canvas>
            </section>

            <section class="panel-card">
              <p class="panel-label">SECRET HOLD</p>
              <canvas
                id="hold-canvas"
                width="120"
                height="120"
                aria-label="Locked hold-piece slot"
              >
                Hold-piece preview slot.
              </canvas>
            </section>
          </div>

What do the preview canvases represent?

  • The next-canvas surface reserves space for the upcoming piece.
  • The hold-canvas surface displays the locked Secret Mission slot.
  • Save index.html.
  • Refresh Google Chrome.

You should see NEXT and SECRET HOLD labels beside the main board.

  • Find the closing preview-grid tag.
  • Add the statistics panel directly below it:
          <section class="stats" aria-label="Game statistics">
            <div class="stat">
              <span>SCORE</span>
              <strong id="score">000000</strong>
            </div>
            <div class="stat">
              <span>LINES</span>
              <strong id="lines">0</strong>
            </div>
            <div class="stat">
              <span>LEVEL</span>
              <strong id="level">1</strong>
            </div>
          </section>

How does the HUD stay updateable?

Each changing value has a unique element ID. Later game logic can update the score, cleared lines, or level without rebuilding the panel.

  • Save index.html.
  • Refresh Google Chrome.

You should see starting values for SCORE, LINES, and LEVEL.

  • Find the closing stats tag.
  • Add the controls and buttons directly below it:
          <section class="controls">
            <p class="panel-label">CONTROLS</p>
            <div class="control-row"><kbd>←</kbd><kbd>→</kbd><span>Move</span></div>
            <div class="control-row"><kbd>↓</kbd><span>Soft drop</span></div>
            <div class="control-row"><kbd>↑</kbd><span>Rotate</span></div>
            <div class="control-row"><kbd>Space</kbd><span>Hard drop</span></div>
            <div class="control-row secret-control"><kbd>C</kbd><span>Hold, secret mission</span></div>
          </section>

          <div class="button-row">
            <button id="start-button" type="button">Start / Restart</button>
            <button id="pause-button" class="secondary" type="button">Pause</button>
          </div>

Why keep controls outside the canvas?

The keyboard guide and buttons remain regular page elements. This keeps the controls readable while the canvas focuses on game graphics.

  • Save index.html.
  • Refresh Google Chrome.

You should see the full control guide with Start / Restart and Pause buttons.

  • Find the closing machine tag.
  • Add the arcade library directly below it:
      <section class="library" aria-labelledby="library-title">
        <div class="library-heading">
          <div>
            <p class="eyebrow">EXPANSION ROADMAP</p>
            <h2 id="library-title">ARCADE LIBRARY</h2>
          </div>
          <span>1 / 4 ONLINE</span>
        </div>

        <div class="game-grid">
        </div>
      </section>

What is the arcade library for?

The library turns Block Drop into the first cabinet in a larger arcade. Its status makes the current scope visible while reserving space for later games.

  • Find the empty game-grid element.
  • Add the Block Drop and Pong cards inside it:
          <article class="game-card active">
            <span class="game-number">01</span>
            <h3>BLOCK DROP</h3>
            <p>Stack, rotate, clear, survive.</p>
            <span class="card-status">PLAYABLE</span>
          </article>
          <article class="game-card">
            <span class="game-number">02</span>
            <h3>PONG</h3>
            <p>Fast rallies and smarter physics.</p>
            <span class="card-status">NEXT CABINET</span>
          </article>

How do active cards differ?

Block Drop carries the active class because it is the playable cabinet. Pong appears as the next planned cabinet.

  • Save index.html.
  • Refresh Google Chrome.

You should see two roadmap entries below the cabinet.

  • Add the Snake and Breakout cards below the Pong card:
          <article class="game-card">
            <span class="game-number">03</span>
            <h3>SNAKE</h3>
            <p>Combos, speed, and arena hazards.</p>
            <span class="card-status">PLANNED</span>
          </article>
          <article class="game-card">
            <span class="game-number">04</span>
            <h3>BREAKOUT</h3>
            <p>Power-ups and reactive bricks.</p>
            <span class="card-status">PLANNED</span>
          </article>

What does the finished markup provide?

The page now contains the entire cabinet interface. Every canvas, HUD value, button, and roadmap card has a stable place for styling or later game logic.

  • Save index.html.
  • Refresh Google Chrome.

You should see all four arcade cards beneath the unstyled cabinet.

Missing part of the cabinet?

Check that every opening section, div, article, and canvas has a matching closing tag. A misplaced closing tag can move the side panel or library outside the cabinet.

Use the indentation in the snippets to confirm the machine and library both sit inside arcade-shell.

Help me find an HTML nesting problem in my Neon Arcade interface.

Style the neon cabinet

The markup establishes the cabinet's structure. The stylesheet now turns that structure into a responsive neon interface with crisp canvas scaling.

  • Switch back to the empty style.css file.
  • Add the color system and page background:
:root {
  color-scheme: dark;
  --ink: #f5f7ff;
  --muted: #9ca9ca;
  --panel: rgba(8, 13, 32, 0.88);
  --panel-strong: #090f24;
  --line: #24305f;
  --cyan: #42f5e9;
  --pink: #ff4fd8;
  --yellow: #ffe66d;
  --purple: #8d6bff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  background:
    radial-gradient(circle at 20% 10%, rgba(141, 107, 255, 0.2), transparent 32%),
    radial-gradient(circle at 80% 20%, rgba(255, 79, 216, 0.16), transparent 28%),
    linear-gradient(180deg, #080b19 0%, #02040b 100%);
  overflow-x: hidden;
}

What does this CSS do?

Custom properties keep the cabinet palette consistent. Layered gradients create the dark arcade backdrop.

  • Save style.css.
  • Refresh Google Chrome.

You should see a dark background with cyan and purple light around the page.

  • Add the grid overlay and page spacing below the existing CSS:
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background-image:
    linear-gradient(rgba(66, 245, 233, 0.035) 1px, transparent 1px),
    linear-gradient(90deg, rgba(66, 245, 233, 0.035) 1px, transparent 1px);
  background-size: 32px 32px;
  mask-image: linear-gradient(to bottom, black, transparent 90%);
}

button,
kbd {
  font: inherit;
}

.arcade-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0 64px;
}

Why use a background overlay?

The fixed grid adds depth without intercepting clicks. The centered shell keeps the cabinet within the viewport.

  • Save style.css.
  • Refresh Google Chrome.

You should see a subtle square grid behind the centered page content.

  • Add the shared header layout and small labels:
.hero,
.screen-header,
.library-heading,
.button-row,
.control-row {
  display: flex;
  align-items: center;
}

.hero,
.screen-header,
.library-heading {
  justify-content: space-between;
  gap: 24px;
}

.hero {
  margin-bottom: 24px;
}

.eyebrow,
.panel-label,
.stat span,
.card-status {
  margin: 0;
  color: var(--cyan);
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.16em;
}

How do shared selectors help?

Related interface rows reuse one alignment rule. Labels share the same cyan color and compact spacing.

  • Save style.css.
  • Refresh Google Chrome.

You should see the hero status move beside the title. Small labels should now appear in cyan.

  • Add the title and status styling:
h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 6px;
  color: white;
  font-size: clamp(2.2rem, 7vw, 5rem);
  line-height: 0.9;
  letter-spacing: -0.06em;
  text-shadow: 0 0 12px var(--cyan), 0 0 28px var(--purple);
}

h2,
h3 {
  margin-bottom: 0;
}

.tagline {
  margin-bottom: 0;
  color: var(--muted);
}

.status-pill,
.live-badge {
  border: 1px solid rgba(66, 245, 233, 0.5);
  color: var(--cyan);
  background: rgba(66, 245, 233, 0.08);
  box-shadow: 0 0 24px rgba(66, 245, 233, 0.12);
}

What creates the neon title?

Responsive type sizing keeps the title readable across screen widths. Cyan and purple shadows create the glow.

  • Save style.css.
  • Refresh Google Chrome.

You should see a large glowing NEON ARCADE title with a muted tagline.

  • Add the status pill and cabinet shell styling:
.status-pill {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 10px 14px;
  border-radius: 999px;
  font-size: 0.8rem;
  font-weight: 800;
}

.status-pill > span:first-child {
  text-shadow: 0 0 10px var(--cyan);
}

.machine {
  display: grid;
  grid-template-columns: minmax(340px, 1fr) 300px;
  gap: 20px;
  padding: 20px;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: linear-gradient(145deg, rgba(19, 27, 62, 0.96), rgba(5, 8, 22, 0.98));
  box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5), inset 0 0 40px rgba(141, 107, 255, 0.05);
}

How is the cabinet arranged?

The machine uses a wide game column beside a fixed side panel. Borders and layered shadows make the two columns feel like one cabinet.

  • Save style.css.
  • Refresh Google Chrome.

You should see the game screen beside a 300-pixel information panel.

  • Add the shared panel surfaces and screen header styling:
.screen-column,
.side-panel,
.panel-card,
.stats,
.controls,
.game-card {
  border: 1px solid var(--line);
  background: var(--panel);
}

.screen-column {
  padding: 18px;
  border-radius: 18px;
}

.screen-header {
  margin-bottom: 16px;
}

.live-badge {
  padding: 7px 10px;
  border-radius: 8px;
  font-size: 0.7rem;
  font-weight: 900;
}

Why repeat the panel surface?

A shared border and background connect the cabinet sections visually. Individual padding rules preserve each section's hierarchy.

  • Save style.css.
  • Refresh Google Chrome.

You should see bordered panels with a compact LIVE badge.

  • Add the game screen and canvas scaling rules:
.screen-frame {
  display: grid;
  place-items: center;
  min-height: 620px;
  padding: 10px;
  border: 1px solid rgba(66, 245, 233, 0.3);
  border-radius: 14px;
  background: #030611;
  box-shadow: inset 0 0 28px rgba(66, 245, 233, 0.08), 0 0 24px rgba(66, 245, 233, 0.1);
}

canvas {
  display: block;
  max-width: 100%;
  height: auto;
  image-rendering: pixelated;
}

#game-canvas {
  width: min(100%, 300px);
  aspect-ratio: 1 / 2;
  border: 1px solid #26325e;
  background: #050816;
}

How does the canvas stay crisp?

The canvas keeps its intrinsic dimensions while CSS scales it within the cabinet. The pixelated rendering rule preserves hard block edges.

The 1:2 aspect ratio matches a 10-column by 20-row board.

  • Save style.css.
  • Refresh Google Chrome.

You should see a tall dark playfield centered inside a glowing frame.

  • Add the side panel and preview layout:
.side-panel {
  display: grid;
  align-content: start;
  gap: 14px;
  padding: 14px;
  border-radius: 18px;
}

.preview-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.panel-card {
  padding: 12px;
  border-radius: 12px;
}

.panel-card canvas {
  width: 100%;
  margin-top: 8px;
  border-radius: 8px;
  background: #050816;
}

How are the previews positioned?

The preview grid gives NEXT and SECRET HOLD equal columns. Their canvases scale to the width of each card.

  • Save style.css.
  • Refresh Google Chrome.

You should see two evenly sized preview cards at the top of the side panel.

  • Add the statistics and controls styling:
.stats {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1px;
  overflow: hidden;
  border-radius: 12px;
  background: var(--line);
}

.stat {
  display: flex;
  justify-content: space-between;
  padding: 12px;
  background: var(--panel-strong);
}

.stat strong {
  color: white;
}

.controls {
  display: grid;
  gap: 9px;
  padding: 14px;
  border-radius: 12px;
}

How do the HUD rows separate?

A one-pixel grid gap exposes the stats container background between rows. This creates dividers without adding borders to every value.

  • Save style.css.
  • Refresh Google Chrome.

You should see three separated statistic rows above the controls.

  • Add the keyboard key styling:
.control-row {
  gap: 7px;
  color: var(--muted);
  font-size: 0.78rem;
}

.control-row span {
  margin-left: auto;
}

kbd {
  min-width: 28px;
  padding: 5px 7px;
  border: 1px solid #3f4e83;
  border-bottom-width: 3px;
  border-radius: 6px;
  color: white;
  background: #111936;
  text-align: center;
}

.secret-control {
  color: #7782a6;
}

What makes the controls readable?

Each key receives a compact raised-key treatment. The control descriptions align on the opposite side of each row.

  • Save style.css.
  • Refresh Google Chrome.

You should see outlined keyboard keys with right-aligned action labels.

  • Add the cabinet button styling:
.button-row {
  gap: 10px;
}

button {
  flex: 1;
  min-height: 44px;
  border: 1px solid var(--cyan);
  border-radius: 10px;
  color: #03100f;
  background: var(--cyan);
  font-size: 0.76rem;
  font-weight: 900;
  cursor: pointer;
  box-shadow: 0 0 18px rgba(66, 245, 233, 0.2);
}

button:hover {
  filter: brightness(1.08);
}

button.secondary {
  color: var(--cyan);
  background: transparent;
}

How do the buttons show priority?

Start / Restart uses a filled cyan style as the primary action. Pause uses an outlined secondary style.

  • Save style.css.
  • Refresh Google Chrome.

You should see two equal-width buttons at the bottom of the side panel.

  • Add the arcade library grid styling:
.library {
  margin-top: 26px;
}

.library-heading {
  margin-bottom: 14px;
}

.library-heading > span {
  color: var(--muted);
  font-size: 0.76rem;
}

.game-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
}

How is the roadmap arranged?

The desktop layout gives each planned game one equal column. The library sits below the main cabinet with its own heading.

  • Save style.css.
  • Refresh Google Chrome.

You should see four equal card columns beneath the cabinet.

  • Add the game card surfaces and active state:
.game-card {
  position: relative;
  min-height: 180px;
  padding: 18px;
  overflow: hidden;
  border-radius: 16px;
}

.game-card::after {
  content: "";
  position: absolute;
  right: -42px;
  bottom: -58px;
  width: 120px;
  height: 120px;
  border: 18px solid rgba(141, 107, 255, 0.1);
  transform: rotate(30deg);
}

.game-card.active {
  border-color: rgba(66, 245, 233, 0.55);
  box-shadow: inset 0 0 30px rgba(66, 245, 233, 0.07);
}

How does the playable card stand out?

Every card receives a decorative purple shape. The active Block Drop card gains a brighter cyan border.

  • Save style.css.
  • Refresh Google Chrome.

You should see rounded roadmap cards with Block Drop highlighted.

  • Add the card typography below the existing CSS:
.game-number {
  color: var(--pink);
  font-size: 0.72rem;
  font-weight: 900;
}

.game-card h3 {
  margin-top: 30px;
}

.game-card p {
  min-height: 40px;
  margin: 8px 0 24px;
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.5;
}

What creates the card hierarchy?

Pink cabinet numbers lead into white game titles. Muted descriptions preserve attention for each card's status.

  • Save style.css.
  • Refresh Google Chrome.

You should see distinct numbers, titles, descriptions, and status labels on every card.

  • Add the tablet layout rules:
@media (max-width: 820px) {
  .machine {
    grid-template-columns: 1fr;
  }

  .screen-frame {
    min-height: auto;
  }

  .game-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

What changes on smaller screens?

The cabinet switches to one column when the screen narrows. The arcade library changes to two columns.

  • Save style.css.
  • Narrow the Google Chrome window below 820 pixels.

You should see the side panel move below the game screen. The arcade cards should form two columns.

  • Add the narrow-screen layout rules:
@media (max-width: 520px) {
  .arcade-shell {
    width: min(100% - 18px, 1120px);
    padding-top: 20px;
  }

  .hero,
  .screen-header,
  .library-heading {
    align-items: flex-start;
  }

  .hero {
    flex-direction: column;
  }

  .machine {
    padding: 10px;
  }

  .screen-column {
    padding: 10px;
  }

  .game-grid {
    grid-template-columns: 1fr;
  }
}

How does the mobile layout fit?

Narrow screens receive smaller outer spacing. The hero stacks vertically while the library becomes one column.

  • Save style.css.
  • Narrow the Google Chrome window below 520 pixels.

You should see one game card per row. The cabinet should remain inside the viewport.

Cabinet still looks unstyled?

Confirm style.css is saved beside index.html. Check that the stylesheet link in the HTML still uses the exact filename.

Refresh Google Chrome after saving. A stale page can continue showing the earlier layout.

Help me troubleshoot why style.css is not styling my local Neon Arcade page.

Render the empty board

JavaScript can draw directly into each canvas through a 2D rendering context. The board uses 10 columns and 20 rows with 30-pixel cells.

  • Switch back to the empty script.js file.
  • Add the board dimensions and canvas references:
"use strict";

const COLS = 10;
const ROWS = 20;
const BLOCK = 30;

const gameCanvas = document.getElementById("game-canvas");
const gameContext = gameCanvas.getContext("2d");
const nextCanvas = document.getElementById("next-canvas");
const nextContext = nextCanvas.getContext("2d");
const holdCanvas = document.getElementById("hold-canvas");
const holdContext = holdCanvas.getContext("2d");

What does this code connect?

  • The constants describe a 10-column by 20-row board with 30-pixel blocks.
  • Each canvas reference uses its unique HTML ID.
  • Each 2D context provides the drawing methods used by the cabinet.
  • Save script.js.
  • Refresh Google Chrome.

The page should reload without changing the cabinet layout. This confirms the canvas references resolve without stopping the script.

  • Add the board state and cell renderer below the context references:
let board = createBoard();

function createBoard() {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(""));
}

function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

How is the board represented?

The board is an array containing 20 row arrays. Each row starts with 10 empty strings.

The cell renderer supports filled blocks and outlined ghost blocks. Later steps reuse it for moving pieces.

  • Save script.js.
  • Refresh Google Chrome.

The cabinet should remain visible with an empty dark playfield.

  • Add the empty-board renderer below drawCell():
function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }
}

How does the grid appear?

The renderer clears the previous frame before painting the dark board. Nested loops draw one outlined rectangle for every row and column position.

  • Save script.js.
  • Refresh Google Chrome.

The playfield remains dark because the renderer is defined but has not been called.

  • Add the locked hold renderer below drawBoard():
function drawLockedHold() {
  holdContext.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#050816";
  holdContext.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#7782a6";
  holdContext.font = "700 14px ui-monospace, monospace";
  holdContext.textAlign = "center";
  holdContext.fillText("LOCKED", holdCanvas.width / 2, holdCanvas.height / 2 + 5);
}

What does the hold renderer show?

The function clears the preview surface before painting its dark background. It centers LOCKED inside the hold canvas to reserve the future mechanic.

  • Add the initial drawing calls at the bottom of script.js:
drawBoard();
drawLockedHold();

Why call both functions now?

These calls produce the cabinet's first rendered state as soon as the script loads. The game loop arrives in the next step.

  • Save script.js.

Before you refresh, what details do you expect to appear inside the two relevant canvases?

  • Refresh the local index.html page in Google Chrome.
  • Widen the browser window to check the desktop layout.
  • Narrow the browser window to check the responsive layout.

You should see the neon title, a blank 10 by 20 game grid, score panels, controls, and four arcade cards. The hold preview should display LOCKED.

Grid or LOCKED label missing?

Confirm the canvas IDs in index.html match the IDs passed to document.getElementById(). ID matching is case-sensitive.

Check that script.js loads after the page content. The script element should remain immediately before the closing body tag.

Help me troubleshoot why the game grid or LOCKED hold label is missing from my Canvas interface.

✔️ Awesome, I've got everything!

Your responsive cabinet is ready. Double check that index.html, style.css, and script.js are saved.

ⓧ I'd like to double check the full code

Compare these complete files with the versions in your neon-arcade folder.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Neon Arcade | Block Drop</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="arcade-shell">
      <header class="hero">
        <div>
          <p class="eyebrow">PLAYER ONE PRESENTS</p>
          <h1>NEON ARCADE</h1>
          <p class="tagline">One cabinet now. A whole arcade next.</p>
        </div>
        <div class="status-pill">
          <span aria-hidden="true">●</span>
          <span id="status-text">READY</span>
        </div>
      </header>

      <section class="machine" aria-labelledby="game-title">
        <div class="screen-column">
          <div class="screen-header">
            <div>
              <p class="eyebrow">CABINET 01</p>
              <h2 id="game-title">NEON BLOCK DROP</h2>
            </div>
            <span class="live-badge">LIVE</span>
          </div>

          <div class="screen-frame">
            <canvas
              id="game-canvas"
              width="300"
              height="600"
              aria-label="Neon Block Drop game board"
            >
              A falling-block puzzle game. Use the controls listed beside the board.
            </canvas>
          </div>
        </div>

        <aside class="side-panel" aria-label="Game information and controls">
          <div class="preview-grid">
            <section class="panel-card">
              <p class="panel-label">NEXT</p>
              <canvas
                id="next-canvas"
                width="120"
                height="120"
                aria-label="Next piece preview"
              >
                Next piece preview.
              </canvas>
            </section>

            <section class="panel-card">
              <p class="panel-label">SECRET HOLD</p>
              <canvas
                id="hold-canvas"
                width="120"
                height="120"
                aria-label="Locked hold-piece slot"
              >
                Hold-piece preview slot.
              </canvas>
            </section>
          </div>

          <section class="stats" aria-label="Game statistics">
            <div class="stat">
              <span>SCORE</span>
              <strong id="score">000000</strong>
            </div>
            <div class="stat">
              <span>LINES</span>
              <strong id="lines">0</strong>
            </div>
            <div class="stat">
              <span>LEVEL</span>
              <strong id="level">1</strong>
            </div>
          </section>

          <section class="controls">
            <p class="panel-label">CONTROLS</p>
            <div class="control-row"><kbd>←</kbd><kbd>→</kbd><span>Move</span></div>
            <div class="control-row"><kbd>↓</kbd><span>Soft drop</span></div>
            <div class="control-row"><kbd>↑</kbd><span>Rotate</span></div>
            <div class="control-row"><kbd>Space</kbd><span>Hard drop</span></div>
            <div class="control-row secret-control"><kbd>C</kbd><span>Hold, secret mission</span></div>
          </section>

          <div class="button-row">
            <button id="start-button" type="button">Start / Restart</button>
            <button id="pause-button" class="secondary" type="button">Pause</button>
          </div>
        </aside>
      </section>

      <section class="library" aria-labelledby="library-title">
        <div class="library-heading">
          <div>
            <p class="eyebrow">EXPANSION ROADMAP</p>
            <h2 id="library-title">ARCADE LIBRARY</h2>
          </div>
          <span>1 / 4 ONLINE</span>
        </div>

        <div class="game-grid">
          <article class="game-card active">
            <span class="game-number">01</span>
            <h3>BLOCK DROP</h3>
            <p>Stack, rotate, clear, survive.</p>
            <span class="card-status">PLAYABLE</span>
          </article>
          <article class="game-card">
            <span class="game-number">02</span>
            <h3>PONG</h3>
            <p>Fast rallies and smarter physics.</p>
            <span class="card-status">NEXT CABINET</span>
          </article>
          <article class="game-card">
            <span class="game-number">03</span>
            <h3>SNAKE</h3>
            <p>Combos, speed, and arena hazards.</p>
            <span class="card-status">PLANNED</span>
          </article>
          <article class="game-card">
            <span class="game-number">04</span>
            <h3>BREAKOUT</h3>
            <p>Power-ups and reactive bricks.</p>
            <span class="card-status">PLANNED</span>
          </article>
        </div>
      </section>
    </main>

    <script src="script.js"></script>
  </body>
</html>
:root {
  color-scheme: dark;
  --ink: #f5f7ff;
  --muted: #9ca9ca;
  --panel: rgba(8, 13, 32, 0.88);
  --panel-strong: #090f24;
  --line: #24305f;
  --cyan: #42f5e9;
  --pink: #ff4fd8;
  --yellow: #ffe66d;
  --purple: #8d6bff;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  background:
    radial-gradient(circle at 20% 10%, rgba(141, 107, 255, 0.2), transparent 32%),
    radial-gradient(circle at 80% 20%, rgba(255, 79, 216, 0.16), transparent 28%),
    linear-gradient(180deg, #080b19 0%, #02040b 100%);
  overflow-x: hidden;
}

body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  background-image:
    linear-gradient(rgba(66, 245, 233, 0.035) 1px, transparent 1px),
    linear-gradient(90deg, rgba(66, 245, 233, 0.035) 1px, transparent 1px);
  background-size: 32px 32px;
  mask-image: linear-gradient(to bottom, black, transparent 90%);
}

button,
kbd {
  font: inherit;
}

.arcade-shell {
  width: min(1120px, calc(100% - 32px));
  margin: 0 auto;
  padding: 40px 0 64px;
}

.hero,
.screen-header,
.library-heading,
.button-row,
.control-row {
  display: flex;
  align-items: center;
}

.hero,
.screen-header,
.library-heading {
  justify-content: space-between;
  gap: 24px;
}

.hero {
  margin-bottom: 24px;
}

.eyebrow,
.panel-label,
.stat span,
.card-status {
  margin: 0;
  color: var(--cyan);
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0.16em;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  margin-bottom: 6px;
  color: white;
  font-size: clamp(2.2rem, 7vw, 5rem);
  line-height: 0.9;
  letter-spacing: -0.06em;
  text-shadow: 0 0 12px var(--cyan), 0 0 28px var(--purple);
}

h2,
h3 {
  margin-bottom: 0;
}

.tagline {
  margin-bottom: 0;
  color: var(--muted);
}

.status-pill,
.live-badge {
  border: 1px solid rgba(66, 245, 233, 0.5);
  color: var(--cyan);
  background: rgba(66, 245, 233, 0.08);
  box-shadow: 0 0 24px rgba(66, 245, 233, 0.12);
}

.status-pill {
  display: flex;
  gap: 10px;
  align-items: center;
  padding: 10px 14px;
  border-radius: 999px;
  font-size: 0.8rem;
  font-weight: 800;
}

.status-pill > span:first-child {
  text-shadow: 0 0 10px var(--cyan);
}

.machine {
  display: grid;
  grid-template-columns: minmax(340px, 1fr) 300px;
  gap: 20px;
  padding: 20px;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: linear-gradient(145deg, rgba(19, 27, 62, 0.96), rgba(5, 8, 22, 0.98));
  box-shadow: 0 28px 80px rgba(0, 0, 0, 0.5), inset 0 0 40px rgba(141, 107, 255, 0.05);
}

.screen-column,
.side-panel,
.panel-card,
.stats,
.controls,
.game-card {
  border: 1px solid var(--line);
  background: var(--panel);
}

.screen-column {
  padding: 18px;
  border-radius: 18px;
}

.screen-header {
  margin-bottom: 16px;
}

.live-badge {
  padding: 7px 10px;
  border-radius: 8px;
  font-size: 0.7rem;
  font-weight: 900;
}

.screen-frame {
  display: grid;
  place-items: center;
  min-height: 620px;
  padding: 10px;
  border: 1px solid rgba(66, 245, 233, 0.3);
  border-radius: 14px;
  background: #030611;
  box-shadow: inset 0 0 28px rgba(66, 245, 233, 0.08), 0 0 24px rgba(66, 245, 233, 0.1);
}

canvas {
  display: block;
  max-width: 100%;
  height: auto;
  image-rendering: pixelated;
}

#game-canvas {
  width: min(100%, 300px);
  aspect-ratio: 1 / 2;
  border: 1px solid #26325e;
  background: #050816;
}

.side-panel {
  display: grid;
  align-content: start;
  gap: 14px;
  padding: 14px;
  border-radius: 18px;
}

.preview-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.panel-card {
  padding: 12px;
  border-radius: 12px;
}

.panel-card canvas {
  width: 100%;
  margin-top: 8px;
  border-radius: 8px;
  background: #050816;
}

.stats {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1px;
  overflow: hidden;
  border-radius: 12px;
  background: var(--line);
}

.stat {
  display: flex;
  justify-content: space-between;
  padding: 12px;
  background: var(--panel-strong);
}

.stat strong {
  color: white;
}

.controls {
  display: grid;
  gap: 9px;
  padding: 14px;
  border-radius: 12px;
}

.control-row {
  gap: 7px;
  color: var(--muted);
  font-size: 0.78rem;
}

.control-row span {
  margin-left: auto;
}

kbd {
  min-width: 28px;
  padding: 5px 7px;
  border: 1px solid #3f4e83;
  border-bottom-width: 3px;
  border-radius: 6px;
  color: white;
  background: #111936;
  text-align: center;
}

.secret-control {
  color: #7782a6;
}

.button-row {
  gap: 10px;
}

button {
  flex: 1;
  min-height: 44px;
  border: 1px solid var(--cyan);
  border-radius: 10px;
  color: #03100f;
  background: var(--cyan);
  font-size: 0.76rem;
  font-weight: 900;
  cursor: pointer;
  box-shadow: 0 0 18px rgba(66, 245, 233, 0.2);
}

button:hover {
  filter: brightness(1.08);
}

button.secondary {
  color: var(--cyan);
  background: transparent;
}

.library {
  margin-top: 26px;
}

.library-heading {
  margin-bottom: 14px;
}

.library-heading > span {
  color: var(--muted);
  font-size: 0.76rem;
}

.game-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
}

.game-card {
  position: relative;
  min-height: 180px;
  padding: 18px;
  overflow: hidden;
  border-radius: 16px;
}

.game-card::after {
  content: "";
  position: absolute;
  right: -42px;
  bottom: -58px;
  width: 120px;
  height: 120px;
  border: 18px solid rgba(141, 107, 255, 0.1);
  transform: rotate(30deg);
}

.game-card.active {
  border-color: rgba(66, 245, 233, 0.55);
  box-shadow: inset 0 0 30px rgba(66, 245, 233, 0.07);
}

.game-number {
  color: var(--pink);
  font-size: 0.72rem;
  font-weight: 900;
}

.game-card h3 {
  margin-top: 30px;
}

.game-card p {
  min-height: 40px;
  margin: 8px 0 24px;
  color: var(--muted);
  font-size: 0.8rem;
  line-height: 1.5;
}

@media (max-width: 820px) {
  .machine {
    grid-template-columns: 1fr;
  }

  .screen-frame {
    min-height: auto;
  }

  .game-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (max-width: 520px) {
  .arcade-shell {
    width: min(100% - 18px, 1120px);
    padding-top: 20px;
  }

  .hero,
  .screen-header,
  .library-heading {
    align-items: flex-start;
  }

  .hero {
    flex-direction: column;
  }

  .machine {
    padding: 10px;
  }

  .screen-column {
    padding: 10px;
  }

  .game-grid {
    grid-template-columns: 1fr;
  }
}
"use strict";

const COLS = 10;
const ROWS = 20;
const BLOCK = 30;

const gameCanvas = document.getElementById("game-canvas");
const gameContext = gameCanvas.getContext("2d");
const nextCanvas = document.getElementById("next-canvas");
const nextContext = nextCanvas.getContext("2d");
const holdCanvas = document.getElementById("hold-canvas");
const holdContext = holdCanvas.getContext("2d");

let board = createBoard();

function createBoard() {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(""));
}

function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }
}

function drawLockedHold() {
  holdContext.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#050816";
  holdContext.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#7782a6";
  holdContext.font = "700 14px ui-monospace, monospace";
  holdContext.textAlign = "center";
  holdContext.fillText("LOCKED", holdCanvas.width / 2, holdCanvas.height / 2 + 5);
}

drawBoard();
drawLockedHold();

Your arcade now has a responsive cabinet and a rendered playfield. Next, you will create the first tetromino and make it fall.

Make the First Piece Fall

Your neon cabinet now has a responsive Canvas board with controls beside it. The cabinet looks ready to play, but the board cannot create or move a piece yet.

This step adds the first playable slice with plain JavaScript. You will model the seven tetrominoes, animate one with frame timestamps, and connect the keyboard controls.

In this step, get ready to:
  • Represent the seven tetrominoes as matrices with matching colours.
  • Create a shuffled piece bag with a falling-piece animation.
  • Control the current piece with the keyboard.
Model the seven tetrominoes

A matrix represents each piece as rows of occupied or empty cells. A value of 1 marks a block that the Canvas renderer should draw.

  • In script.js, find the line that creates holdContext.
  • Add the remaining interface references below that line by pasting this code:
const scoreElement = document.getElementById("score");
const linesElement = document.getElementById("lines");
const levelElement = document.getElementById("level");
const statusElement = document.getElementById("status-text");
const startButton = document.getElementById("start-button");
const pauseButton = document.getElementById("pause-button");

What do these references connect?

  • The HUD references let the game update Score, Lines, Level, and the cabinet status.
  • The button references let JavaScript react when the player starts or pauses the game.
  • Save script.js.
  • Refresh the page in Google Chrome. You should still see the empty grid with LOCKED in the hold panel.

Did the cabinet disappear?

Check that every ID matches the existing HTML exactly. A misspelled ID returns no matching element.

Use the browser console to look for a message mentioning an undefined reference. Help me find a mismatched element ID in script.js.

  • Add the seven piece matrices below the interface references by pasting this code:
const SHAPES = {
  I: [[1, 1, 1, 1]],
  J: [
    [1, 0, 0],
    [1, 1, 1],
  ],
  L: [
    [0, 0, 1],
    [1, 1, 1],
  ],
  O: [
    [1, 1],
    [1, 1],
  ],
  S: [
    [0, 1, 1],
    [1, 1, 0],
  ],
  T: [
    [0, 1, 0],
    [1, 1, 1],
  ],
  Z: [
    [1, 1, 0],
    [0, 1, 1],
  ],
};

How do the matrices become pieces?

  • Each property name identifies one tetromino type.
  • Each nested array forms one horizontal row of the piece.
  • Each occupied cell becomes one coloured square when the renderer reads the matrix.
  • Add the colour map directly below SHAPES by pasting this code:
const COLORS = {
  I: "#42f5e9",
  J: "#4f7cff",
  L: "#ff9f43",
  O: "#ffe66d",
  S: "#4dff88",
  T: "#b56cff",
  Z: "#ff4f76",
};

Why use a colour map?

The renderer can look up a colour with the same type letter used by SHAPES. This keeps each matrix focused on shape instead of styling.

  • Find the existing let board = createBoard(); line.
  • Replace that line with the complete game-state declarations below:
let board = createBoard();
let bag = [];
let piece = null;
let nextType = null;
let score = 0;
let lines = 0;
let level = 1;
let state = "ready";
let dropCounter = 0;
let dropInterval = 800;
let lastTime = 0;

What does the game state track?

  • The board and piece values describe what belongs on the game canvas.
  • The bag and next type values control which shape appears next.
  • The counters track the HUD plus the time between automatic drops.
  • The state value controls whether the game is ready or playing.
  • Save script.js.
  • Refresh the local page. You should see the grid and cabinet render without a browser error.

Seeing a blank page?

Check the commas between the SHAPES entries. Check that both object declarations end with a closing brace.

Compare every colour value against the snippet above. Help me fix a syntax error in my SHAPES or COLORS object.

The piece bag shuffles all seven type letters into a queue. Drawing from that queue produces every shape once before the next shuffle begins.

  • Add the matrix-copy and bag functions below the existing createBoard() function by pasting this code:
function cloneMatrix(matrix) {
  return matrix.map((row) => [...row]);
}

function refillBag() {
  bag = Object.keys(SHAPES);
  for (let index = bag.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    [bag[index], bag[swapIndex]] = [bag[swapIndex], bag[index]];
  }
}

function takeFromBag() {
  if (bag.length === 0) {
    refillBag();
  }
  return bag.pop();
}

How does the bag work?

  • The matrix copy gives each piece its own rows to rotate later.
  • The refill function shuffles the seven type letters in place.
  • The take function refills an empty bag before returning its final entry.
  • Add the piece creation functions directly below takeFromBag() by pasting this code:
function createPiece(type) {
  const matrix = cloneMatrix(SHAPES[type]);
  return {
    type,
    matrix,
    x: Math.floor((COLS - matrix[0].length) / 2),
    y: 0,
  };
}

function spawnPiece() {
  if (nextType === null) {
    nextType = takeFromBag();
  }

  piece = createPiece(nextType);
  nextType = takeFromBag();
}

How does a piece enter the board?

  • The creation function copies the chosen matrix so rotations cannot change the original shape definition.
  • The horizontal position centres the matrix across the ten-column board.
  • The spawn function promotes the queued type before drawing another type from the bag.
  • Save script.js.
  • Refresh the local page. You should see the cabinet remain ready with its empty board intact.

Does the grid fail to load?

Make sure the existing createBoard() function remains above the new helpers. Check that SHAPES uses the same uppercase type letters as the colour map.

Help me troubleshoot the tetromino bag and piece creation functions.

Render and animate the current piece

The renderer visits every occupied matrix cell and converts its position into Canvas coordinates. The game loop then uses the timestamp supplied by requestAnimationFrame() to decide when the piece moves down.

  • Replace the existing cell-drawing helper in script.js with this version:
function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

What does the cell renderer do?

  • The position values are multiplied by the block size to place each square on the Canvas grid.
  • The inset fill leaves a narrow gap between neighbouring blocks.
  • The lighter strip gives every block a neon highlight.
  • Add the matrix renderer directly below drawCell() by pasting this code:
function drawMatrix(context, matrix, offsetX, offsetY, type, size = BLOCK, ghost = false) {
  matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      const drawY = offsetY + rowIndex;
      if (value && drawY >= 0) {
        drawCell(context, offsetX + columnIndex, drawY, COLORS[type], size, ghost);
      }
    });
  });
}

How does the matrix become pixels?

The nested loops calculate each occupied cell's board position from the piece offset. The renderer ignores empty matrix cells.

  • Save script.js.
  • Refresh the local page. You should see the ten-column grid retain its neon cell outlines.

Did the grid styling change?

Check that the default block size still points to BLOCK. Confirm that both drawing functions close with matching braces.

Help me compare my Canvas drawing helpers with the expected code.

  • Find the existing drawBoard() function.
  • Replace the complete function with this version:
function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }

  if (piece && state !== "ready") {
    drawMatrix(gameContext, piece.matrix, piece.x, piece.y, piece.type);
  }
}

What changed in the board renderer?

The function still redraws the board background and its grid. It now draws the current piece after the board so the moving blocks remain visible.

  • Add the shared draw and HUD helpers below the existing drawLockedHold() function by pasting this code:
function draw() {
  drawBoard();
  drawLockedHold();
}

function updateHud() {
  scoreElement.textContent = String(score).padStart(6, "0");
  linesElement.textContent = String(lines);
  levelElement.textContent = String(level);
}

function updateStatus(message) {
  statusElement.textContent = message;
}

What does each update handle?

  • The draw function refreshes both the game board and locked hold panel.
  • The HUD function formats the score with six digits before updating all three counters.
  • The status function sends the current game state to the cabinet header.
  • Add the reset function directly below updateStatus() by pasting this code:
function resetGame() {
  board = createBoard();
  bag = [];
  piece = null;
  nextType = takeFromBag();
  score = 0;
  lines = 0;
  level = 1;
  dropInterval = 800;
  dropCounter = 0;
  state = "playing";
  pauseButton.textContent = "Pause";
  updateStatus("PLAYING");
  updateHud();
  spawnPiece();
}

How does restart create a fresh game?

The reset clears the board, bag, piece, counters, and timer. It then changes the state to playing before spawning the first piece.

  • Add the animation loop near the bottom of script.js by pasting this code:
function gameLoop(timestamp) {
  const delta = Math.min(timestamp - lastTime, 100);
  lastTime = timestamp;

  if (state === "playing") {
    dropCounter += delta;
    if (dropCounter >= dropInterval) {
      playerDrop();
    }
  }

  draw();
  requestAnimationFrame(gameLoop);
}

How does the animation stay consistent?

  • The timestamp measures the elapsed time since the previous frame.
  • The drop counter accumulates that elapsed time while the game is playing.
  • The loop schedules itself again after drawing the current frame.

Using the frame timestamp keeps the drop timing tied to elapsed time. This prevents a higher display refresh rate from making the piece fall faster.

Connect the controls and test the fall

Keyboard input changes the current piece before the next frame is drawn. The controls use the physical key codes shown on the cabinet.

  • Add the movement and rotation functions below spawnPiece() by pasting this code:
function movePiece(offsetX, offsetY) {
  if (state !== "playing") {
    return false;
  }

  piece.x += offsetX;
  piece.y += offsetY;
  return true;
}

function rotateMatrix(matrix) {
  return matrix[0].map((_, column) => matrix.map((row) => row[column]).reverse());
}

function rotatePiece() {
  if (state !== "playing") {
    return;
  }

  piece.matrix = rotateMatrix(piece.matrix);
}

How do movement and rotation work?

  • Movement changes the piece's horizontal or vertical offset.
  • Rotation turns matrix columns into rows in reverse order.
  • Both operations only respond while the game state is playing.
  • Add the drop functions directly below rotatePiece() by pasting this code:
function playerDrop() {
  piece.y += 1;
  dropCounter = 0;
}

function hardDrop() {
  if (state !== "playing") {
    return;
  }

  piece.y += ROWS;
  dropCounter = 0;
}

What do the drop functions change?

A normal drop advances the row by one. A hard drop advances by the board's full height in one move.

  • Add the keyboard listener below the movement functions by pasting this code:
document.addEventListener("keydown", (event) => {
  const gameKeys = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Space"];

  if (gameKeys.includes(event.code)) {
    event.preventDefault();
  }

  if (state !== "playing") {
    return;
  }

  if (event.code === "ArrowLeft") {
    movePiece(-1, 0);
  } else if (event.code === "ArrowRight") {
    movePiece(1, 0);
  } else if (event.code === "ArrowDown") {
    playerDrop();
  } else if (event.code === "ArrowUp") {
    rotatePiece();
  } else if (event.code === "Space") {
    hardDrop();
  }
});

How does the keyboard listener respond?

  • The arrow codes map movement, soft drop, and rotation to their matching functions.
  • The Space code calls the hard-drop function.
  • The default browser action is prevented for game keys so the page stays in place while you play.
  • Add the start-button listener above gameLoop() by pasting this code:
startButton.addEventListener("click", resetGame);

What starts the game?

The click listener calls resetGame(). This creates a fresh board before placing the first piece at the top.

  • Add the initial HUD and animation calls at the end of script.js by pasting this code:
updateHud();
updateStatus("READY");
requestAnimationFrame(gameLoop);

Why start the loop immediately?

The first scheduled frame draws the ready board. Selecting Start changes the state so the same loop begins advancing the piece.

  • Save script.js.
  • Refresh index.html in Google Chrome.

Before you test the game, where do you think the falling piece will stop?

  • Select Start / Restart.
  • Move the falling piece with the left and right arrow keys.
  • Rotate the piece with the up arrow key.
  • Move the piece downward with the down arrow key.
  • Restart the game with Start / Restart.
  • Watch the automatic fall until the piece reaches the bottom edge.

The piece continues below the bottom edge instead of stopping. That shortfall is intentional and proves the game has movement without a collision model.

Piece not moving?

Select Start / Restart before using the controls. The listener ignores movement while the state remains ready.

Click the page once if the browser has not focused it. Help me troubleshoot why my tetromino does not move or fall.

✔️ Awesome, I've got everything!

Your first tetromino now spawns, falls, rotates, and responds to the keyboard. Save script.js before continuing.

ⓧ I'd like to double check the full code

Compare your complete script.js file with this version.

"use strict";

const COLS = 10;
const ROWS = 20;
const BLOCK = 30;

const gameCanvas = document.getElementById("game-canvas");
const gameContext = gameCanvas.getContext("2d");
const nextCanvas = document.getElementById("next-canvas");
const nextContext = nextCanvas.getContext("2d");
const holdCanvas = document.getElementById("hold-canvas");
const holdContext = holdCanvas.getContext("2d");

const scoreElement = document.getElementById("score");
const linesElement = document.getElementById("lines");
const levelElement = document.getElementById("level");
const statusElement = document.getElementById("status-text");
const startButton = document.getElementById("start-button");
const pauseButton = document.getElementById("pause-button");

const SHAPES = {
  I: [[1, 1, 1, 1]],
  J: [
    [1, 0, 0],
    [1, 1, 1],
  ],
  L: [
    [0, 0, 1],
    [1, 1, 1],
  ],
  O: [
    [1, 1],
    [1, 1],
  ],
  S: [
    [0, 1, 1],
    [1, 1, 0],
  ],
  T: [
    [0, 1, 0],
    [1, 1, 1],
  ],
  Z: [
    [1, 1, 0],
    [0, 1, 1],
  ],
};

const COLORS = {
  I: "#42f5e9",
  J: "#4f7cff",
  L: "#ff9f43",
  O: "#ffe66d",
  S: "#4dff88",
  T: "#b56cff",
  Z: "#ff4f76",
};

let board = createBoard();
let bag = [];
let piece = null;
let nextType = null;
let score = 0;
let lines = 0;
let level = 1;
let state = "ready";
let dropCounter = 0;
let dropInterval = 800;
let lastTime = 0;

function createBoard() {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(""));
}

function cloneMatrix(matrix) {
  return matrix.map((row) => [...row]);
}

function refillBag() {
  bag = Object.keys(SHAPES);
  for (let index = bag.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    [bag[index], bag[swapIndex]] = [bag[swapIndex], bag[index]];
  }
}

function takeFromBag() {
  if (bag.length === 0) {
    refillBag();
  }
  return bag.pop();
}

function createPiece(type) {
  const matrix = cloneMatrix(SHAPES[type]);
  return {
    type,
    matrix,
    x: Math.floor((COLS - matrix[0].length) / 2),
    y: 0,
  };
}

function spawnPiece() {
  if (nextType === null) {
    nextType = takeFromBag();
  }

  piece = createPiece(nextType);
  nextType = takeFromBag();
}

function movePiece(offsetX, offsetY) {
  if (state !== "playing") {
    return false;
  }

  piece.x += offsetX;
  piece.y += offsetY;
  return true;
}

function rotateMatrix(matrix) {
  return matrix[0].map((_, column) => matrix.map((row) => row[column]).reverse());
}

function rotatePiece() {
  if (state !== "playing") {
    return;
  }

  piece.matrix = rotateMatrix(piece.matrix);
}

function playerDrop() {
  piece.y += 1;
  dropCounter = 0;
}

function hardDrop() {
  if (state !== "playing") {
    return;
  }

  piece.y += ROWS;
  dropCounter = 0;
}

function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

function drawMatrix(context, matrix, offsetX, offsetY, type, size = BLOCK, ghost = false) {
  matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      const drawY = offsetY + rowIndex;
      if (value && drawY >= 0) {
        drawCell(context, offsetX + columnIndex, drawY, COLORS[type], size, ghost);
      }
    });
  });
}

function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }

  if (piece && state !== "ready") {
    drawMatrix(gameContext, piece.matrix, piece.x, piece.y, piece.type);
  }
}

function drawLockedHold() {
  holdContext.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#050816";
  holdContext.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#7782a6";
  holdContext.font = "700 14px ui-monospace, monospace";
  holdContext.textAlign = "center";
  holdContext.fillText("LOCKED", holdCanvas.width / 2, holdCanvas.height / 2 + 5);
}

function draw() {
  drawBoard();
  drawLockedHold();
}

function updateHud() {
  scoreElement.textContent = String(score).padStart(6, "0");
  linesElement.textContent = String(lines);
  levelElement.textContent = String(level);
}

function updateStatus(message) {
  statusElement.textContent = message;
}

function resetGame() {
  board = createBoard();
  bag = [];
  piece = null;
  nextType = takeFromBag();
  score = 0;
  lines = 0;
  level = 1;
  dropInterval = 800;
  dropCounter = 0;
  state = "playing";
  pauseButton.textContent = "Pause";
  updateStatus("PLAYING");
  updateHud();
  spawnPiece();
}

document.addEventListener("keydown", (event) => {
  const gameKeys = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Space"];

  if (gameKeys.includes(event.code)) {
    event.preventDefault();
  }

  if (state !== "playing") {
    return;
  }

  if (event.code === "ArrowLeft") {
    movePiece(-1, 0);
  } else if (event.code === "ArrowRight") {
    movePiece(1, 0);
  } else if (event.code === "ArrowDown") {
    playerDrop();
  } else if (event.code === "ArrowUp") {
    rotatePiece();
  } else if (event.code === "Space") {
    hardDrop();
  }
});

startButton.addEventListener("click", resetGame);

function gameLoop(timestamp) {
  const delta = Math.min(timestamp - lastTime, 100);
  lastTime = timestamp;

  if (state === "playing") {
    dropCounter += delta;
    if (dropCounter >= dropInterval) {
      playerDrop();
    }
  }

  draw();
  requestAnimationFrame(gameLoop);
}

updateHud();
updateStatus("READY");
requestAnimationFrame(gameLoop);

You now have a timestamp-driven piece that responds to all five controls. Next, you will give the board collision rules so pieces stop, stack, and clear completed rows.

Stop, Stack, and Clear Lines

Your tetrominoes move across the cabinet, but they disappear through the floor because the board cannot reject an invalid position.

This step adds collision detection so pieces stop at the floor or another block. You will turn those stopped pieces into a stack that clears rows and updates the score.

In this step, get ready to:
  • Stop tetrominoes at the board boundaries.
  • Lock blocked pieces into the board.
  • Clear full rows while updating the HUD.
Stop pieces at the board edges

Each tetromino uses a matrix to describe its occupied cells. The collision check maps those cells onto the board before allowing a move.

  • Switch back to script.js in the Visual Studio Code window from earlier.
  • Find the closing brace of spawnPiece().
  • Add the collision helper directly below it using this code:
function collides(testPiece, matrix = testPiece.matrix, offsetX = 0, offsetY = 0) {
  for (let row = 0; row < matrix.length; row += 1) {
    for (let column = 0; column < matrix[row].length; column += 1) {
      if (!matrix[row][column]) {
        continue;
      }

      const boardX = testPiece.x + column + offsetX;
      const boardY = testPiece.y + row + offsetY;

      if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
        return true;
      }

      if (boardY >= 0 && board[boardY][boardX]) {
        return true;
      }
    }
  }

  return false;
}

What does this code do?

  • The nested loops inspect every occupied cell in the proposed piece position.
  • The boundary check rejects cells beyond the left edge, right edge, or floor.
  • The board check rejects cells that overlap a locked tetromino.
  • A position is legal when every occupied cell passes both checks.
  • Save script.js.
  • Switch back to Google Chrome.
  • Refresh the arcade page.

You should see the cabinet redraw with its ready state. This confirms the new helper parses without stopping the game loop.

Did the cabinet stop drawing?

Check the closing braces inside collides(). Each loop needs its own closing brace before the function returns false.

Confirm the function sits outside spawnPiece(). A missing brace can accidentally nest one function inside another.

Help me fix the collides function in my falling-block game.

The collision helper only reports whether a position is blocked. Movement must consult that result before changing the piece coordinates.

  • Find the existing movePiece() function in script.js.
  • Replace the entire function with this collision-aware version:
function movePiece(offsetX, offsetY) {
  if (state !== "playing" || collides(piece, piece.matrix, offsetX, offsetY)) {
    return false;
  }

  piece.x += offsetX;
  piece.y += offsetY;
  return true;
}

How movement becomes safe

  • The state check prevents movement while the game is inactive.
  • The collision check tests the requested offset before changing the coordinates.
  • The Boolean return value tells the drop logic whether the movement succeeded.
  • Save script.js.
  • Refresh the page in Google Chrome.
  • Select Start / Restart.
  • Press ArrowLeft repeatedly while the piece is visible.

You should see the piece stop at the left edge instead of moving beyond the board.

Can the piece still leave the board?

Confirm movePiece() calls collides() before updating piece.x or piece.y.

Check that the horizontal bounds compare boardX against 0 and COLS.

Help me debug why movePiece allows a tetromino beyond the board edge.

Rotation changes the dimensions of a piece matrix. Small horizontal kick attempts give a rotated piece room to fit when it is close to a wall.

  • Find the current rotation helpers in script.js.
  • Replace rotateMatrix() and rotatePiece() with this code:
function rotateMatrix(matrix) {
  return matrix[0].map((_, column) => matrix.map((row) => row[column]).reverse());
}

function rotatePiece() {
  if (state !== "playing") {
    return;
  }

  const rotated = rotateMatrix(piece.matrix);
  const kicks = [0, -1, 1, -2, 2];

  for (const kick of kicks) {
    if (!collides(piece, rotated, kick, 0)) {
      piece.matrix = rotated;
      piece.x += kick;
      return;
    }
  }
}

How wall kicks work

  • The matrix transformation turns columns into reversed rows to produce a clockwise rotation.
  • The kick list tests the current position before trying nearby horizontal positions.
  • The first legal position becomes the piece's new rotation.
  • The original matrix remains unchanged when every kick collides.
  • Save script.js.
  • Refresh the arcade page.
  • Select Start / Restart.
  • Move a piece beside a wall with ArrowLeft or ArrowRight.
  • Rotate the piece with ArrowUp.

You should see legal rotations stay inside the board. A piece shifts sideways when one of the horizontal kick positions provides enough room.

Does rotation cross the wall?

Check that rotatePiece() tests the rotated matrix with collides() before assigning it to the piece.

Confirm the kick list is exactly [0, -1, 1, -2, 2].

Help me troubleshoot wall rotation in rotatePiece().

Lock pieces and clear full rows

A blocked downward move is the moment an active tetromino becomes part of the board. Each occupied cell stores the piece type so the renderer can preserve its color.

  • Find rotatePiece() in script.js.
  • Add mergePiece() directly below the rotation helpers using this code:
function mergePiece() {
  piece.matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      if (value) {
        const boardY = piece.y + rowIndex;
        if (boardY >= 0) {
          board[boardY][piece.x + columnIndex] = piece.type;
        }
      }
    });
  });
}

How a piece joins the board

  • The nested loops visit every occupied cell in the active matrix.
  • Each occupied cell writes the current piece type into its matching board position.
  • The board can now redraw that tetromino after the active piece changes.
  • Save script.js.
  • Refresh the arcade page.

You should see the cabinet return to its ready state. The successful redraw confirms the board merge helper is valid JavaScript.

Did the board disappear?

Check that the inner loop uses row.forEach(). The outer loop must use piece.matrix.forEach().

Confirm the board assignment stores piece.type at the calculated row and column.

Help me fix mergePiece after the arcade stopped rendering.

A complete row needs to disappear before the next piece spawns. The empty replacement row goes at the top so the board always remains 20 rows tall.

  • Add clearCompletedLines() directly below mergePiece() using this code:
function clearCompletedLines() {
  let cleared = 0;

  for (let row = ROWS - 1; row >= 0; row -= 1) {
    if (board[row].every(Boolean)) {
      board.splice(row, 1);
      board.unshift(Array(COLS).fill(""));
      cleared += 1;
      row += 1;
    }
  }

  if (cleared > 0) {
    const scoreValues = [0, 100, 300, 500, 800];
    score += scoreValues[cleared] * level;
    lines += cleared;
    level = Math.floor(lines / 10) + 1;
    dropInterval = Math.max(120, 800 - (level - 1) * 70);
    updateHud();
  }
}

How clearing changes the game

  • The loop scans upward from the bottom because removing a row shifts higher rows down.
  • The score table rewards clearing more rows with one piece.
  • Every 10 cleared rows raises the level.
  • The drop interval shrinks as the level rises until it reaches its minimum value.
  • Save script.js.
  • Refresh the arcade page.

You should see the ready cabinet redraw normally. This confirms the row scan and score calculations parse correctly.

Does the page fail after refreshing?

Confirm scoreValues contains [0, 100, 300, 500, 800].

Check that board.unshift() receives a new row created with Array(COLS).fill("").

Help me debug clearCompletedLines in my game.

The lock flow connects collision detection to the permanent board. A failed downward move now merges the active piece before checking for completed rows.

  • Find the naive playerDrop() function that increases the piece row.
  • Replace it with this locking flow:
function lockPiece() {
  mergePiece();
  clearCompletedLines();
  spawnPiece();
}

function playerDrop() {
  if (!movePiece(0, 1)) {
    lockPiece();
  }
  dropCounter = 0;
}

How the drop cycle closes

  • A successful downward move keeps the active piece falling.
  • A blocked downward move calls lockPiece().
  • The lock flow merges the piece before clearing rows.
  • A new piece spawns after the board state is settled.

Before you test the new drop flow, where do you expect the active piece to stop?

  • Save script.js.
  • Refresh the page in Google Chrome.
  • Select Start / Restart.
  • Use ArrowDown to move the first piece toward the floor.

The piece now stops on the floor. A new piece appears above it while the first piece remains stored on the board.

Does the piece still cross the floor?

Confirm playerDrop() calls movePiece(0, 1) inside its condition.

Check that the failed movement path calls lockPiece().

Help me debug why playerDrop still lets pieces cross the floor.

Test stacking, scoring, and speed

A hard drop repeats legal downward moves until the next row is blocked. Each successful row adds two points before the piece locks.

  • Find the existing hardDrop() function in script.js.
  • Replace the entire function with this collision-aware version:
function hardDrop() {
  if (state !== "playing") {
    return;
  }

  while (movePiece(0, 1)) {
    score += 2;
  }

  updateHud();
  lockPiece();
  dropCounter = 0;
}

How hard drop scores

  • The loop moves the piece down while each new position remains legal.
  • Every successful row adds two points to the score.
  • The HUD updates before the piece joins the board.
  • The drop counter resets so the new piece receives a full interval.
  • Save script.js.
  • Refresh the page in Google Chrome.

Before the final test, do you expect a hard-dropped piece to disappear or remain in the stack?

  • Select Start / Restart.
  • Move the active piece with ArrowLeft or ArrowRight.
  • Rotate the piece with ArrowUp.
  • Hard-drop several pieces with Space.
  • Fill a complete row across the board.
  • Continue until the LINES counter reaches 10.

You should see pieces stop on the floor or stack on occupied cells. A full row disappears while the score and line counters increase.

When the lines counter reaches 10, you should see the level change to 2. New pieces then fall faster because the drop interval has decreased.

Is stacking or scoring incorrect?

If pieces overlap, confirm collides() checks board[boardY][boardX] for an existing piece type.

If rows remain full, check that lockPiece() calls clearCompletedLines() after mergePiece().

Help me diagnose stacking, line clearing, or scoring in Neon Block Drop.

✔️ Awesome, I've got everything!

Your saved script.js now stops, stacks, clears, scores, and increases the game speed.

ⓧ I'd like to double check the full code

The complete script.js for this stage includes the falling-piece logic from earlier plus the new board rules.

"use strict";

const COLS = 10;
const ROWS = 20;
const BLOCK = 30;

const gameCanvas = document.getElementById("game-canvas");
const gameContext = gameCanvas.getContext("2d");
const nextCanvas = document.getElementById("next-canvas");
const nextContext = nextCanvas.getContext("2d");
const holdCanvas = document.getElementById("hold-canvas");
const holdContext = holdCanvas.getContext("2d");

const scoreElement = document.getElementById("score");
const linesElement = document.getElementById("lines");
const levelElement = document.getElementById("level");
const statusElement = document.getElementById("status-text");
const startButton = document.getElementById("start-button");
const pauseButton = document.getElementById("pause-button");

const SHAPES = {
  I: [[1, 1, 1, 1]],
  J: [
    [1, 0, 0],
    [1, 1, 1],
  ],
  L: [
    [0, 0, 1],
    [1, 1, 1],
  ],
  O: [
    [1, 1],
    [1, 1],
  ],
  S: [
    [0, 1, 1],
    [1, 1, 0],
  ],
  T: [
    [0, 1, 0],
    [1, 1, 1],
  ],
  Z: [
    [1, 1, 0],
    [0, 1, 1],
  ],
};

const COLORS = {
  I: "#42f5e9",
  J: "#4f7cff",
  L: "#ff9f43",
  O: "#ffe66d",
  S: "#4dff88",
  T: "#b56cff",
  Z: "#ff4f76",
};

let board = createBoard();
let bag = [];
let piece = null;
let nextType = null;
let score = 0;
let lines = 0;
let level = 1;
let state = "ready";
let dropCounter = 0;
let dropInterval = 800;
let lastTime = 0;

function createBoard() {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(""));
}

function cloneMatrix(matrix) {
  return matrix.map((row) => [...row]);
}

function refillBag() {
  bag = Object.keys(SHAPES);
  for (let index = bag.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    [bag[index], bag[swapIndex]] = [bag[swapIndex], bag[index]];
  }
}

function takeFromBag() {
  if (bag.length === 0) {
    refillBag();
  }
  return bag.pop();
}

function createPiece(type) {
  const matrix = cloneMatrix(SHAPES[type]);
  return {
    type,
    matrix,
    x: Math.floor((COLS - matrix[0].length) / 2),
    y: 0,
  };
}

function spawnPiece() {
  if (nextType === null) {
    nextType = takeFromBag();
  }

  piece = createPiece(nextType);
  nextType = takeFromBag();

  if (collides(piece)) {
    state = "gameover";
    updateStatus("GAME OVER");
  }
}

function collides(testPiece, matrix = testPiece.matrix, offsetX = 0, offsetY = 0) {
  for (let row = 0; row < matrix.length; row += 1) {
    for (let column = 0; column < matrix[row].length; column += 1) {
      if (!matrix[row][column]) {
        continue;
      }

      const boardX = testPiece.x + column + offsetX;
      const boardY = testPiece.y + row + offsetY;

      if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
        return true;
      }

      if (boardY >= 0 && board[boardY][boardX]) {
        return true;
      }
    }
  }

  return false;
}

function movePiece(offsetX, offsetY) {
  if (state !== "playing" || collides(piece, piece.matrix, offsetX, offsetY)) {
    return false;
  }

  piece.x += offsetX;
  piece.y += offsetY;
  return true;
}

function rotateMatrix(matrix) {
  return matrix[0].map((_, column) => matrix.map((row) => row[column]).reverse());
}

function rotatePiece() {
  if (state !== "playing") {
    return;
  }

  const rotated = rotateMatrix(piece.matrix);
  const kicks = [0, -1, 1, -2, 2];

  for (const kick of kicks) {
    if (!collides(piece, rotated, kick, 0)) {
      piece.matrix = rotated;
      piece.x += kick;
      return;
    }
  }
}

function mergePiece() {
  piece.matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      if (value) {
        const boardY = piece.y + rowIndex;
        if (boardY >= 0) {
          board[boardY][piece.x + columnIndex] = piece.type;
        }
      }
    });
  });
}

function clearCompletedLines() {
  let cleared = 0;

  for (let row = ROWS - 1; row >= 0; row -= 1) {
    if (board[row].every(Boolean)) {
      board.splice(row, 1);
      board.unshift(Array(COLS).fill(""));
      cleared += 1;
      row += 1;
    }
  }

  if (cleared > 0) {
    const scoreValues = [0, 100, 300, 500, 800];
    score += scoreValues[cleared] * level;
    lines += cleared;
    level = Math.floor(lines / 10) + 1;
    dropInterval = Math.max(120, 800 - (level - 1) * 70);
    updateHud();
  }
}

function lockPiece() {
  mergePiece();
  clearCompletedLines();
  spawnPiece();
}

function playerDrop() {
  if (!movePiece(0, 1)) {
    lockPiece();
  }
  dropCounter = 0;
}

function hardDrop() {
  if (state !== "playing") {
    return;
  }

  while (movePiece(0, 1)) {
    score += 2;
  }

  updateHud();
  lockPiece();
  dropCounter = 0;
}

function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

function drawMatrix(context, matrix, offsetX, offsetY, type, size = BLOCK, ghost = false) {
  matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      const drawY = offsetY + rowIndex;
      if (value && drawY >= 0) {
        drawCell(context, offsetX + columnIndex, drawY, COLORS[type], size, ghost);
      }
    });
  });
}

function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }

  if (piece && state !== "ready") {
    drawMatrix(gameContext, piece.matrix, piece.x, piece.y, piece.type);
  }
}

function drawLockedHold() {
  holdContext.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#050816";
  holdContext.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#7782a6";
  holdContext.font = "700 14px ui-monospace, monospace";
  holdContext.textAlign = "center";
  holdContext.fillText("LOCKED", holdCanvas.width / 2, holdCanvas.height / 2 + 5);
}

function draw() {
  drawBoard();
  drawLockedHold();
}

function updateHud() {
  scoreElement.textContent = String(score).padStart(6, "0");
  linesElement.textContent = String(lines);
  levelElement.textContent = String(level);
}

function updateStatus(message) {
  statusElement.textContent = message;
}

function resetGame() {
  board = createBoard();
  bag = [];
  piece = null;
  nextType = takeFromBag();
  score = 0;
  lines = 0;
  level = 1;
  dropInterval = 800;
  dropCounter = 0;
  state = "playing";
  pauseButton.textContent = "Pause";
  updateStatus("PLAYING");
  updateHud();
  spawnPiece();
}

document.addEventListener("keydown", (event) => {
  const gameKeys = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Space"];

  if (gameKeys.includes(event.code)) {
    event.preventDefault();
  }

  if (state !== "playing") {
    return;
  }

  if (event.code === "ArrowLeft") {
    movePiece(-1, 0);
  } else if (event.code === "ArrowRight") {
    movePiece(1, 0);
  } else if (event.code === "ArrowDown") {
    playerDrop();
  } else if (event.code === "ArrowUp") {
    rotatePiece();
  } else if (event.code === "Space") {
    hardDrop();
  }
});

startButton.addEventListener("click", resetGame);

function gameLoop(timestamp) {
  const delta = Math.min(timestamp - lastTime, 100);
  lastTime = timestamp;

  if (state === "playing") {
    dropCounter += delta;
    if (dropCounter >= dropInterval) {
      playerDrop();
    }
  }

  draw();
  requestAnimationFrame(gameLoop);
}

updateHud();
updateStatus("READY");
requestAnimationFrame(gameLoop);

Your cabinet now has the rules that make every placement matter. Next, you will add previews and explicit game states so each decision is easier to read.

Add Advanced Game Feel

Your pieces now stop, stack, clear rows, and increase the score. The remaining gap is feedback that helps the player understand each move and control the game.

In this step, you will complete the JavaScript game state. You will add landing guidance, piece previews, overlays, pause controls, restart controls, and the final play test.

In this step, get ready to:
  • Draw the ghost landing guide and next-piece preview.
  • Connect the HUD, overlays, pause control, and restart control.
  • Test every game state across desktop and narrow layouts.
Draw the ghost and next preview

A ghost piece marks the lowest legal row for the active tetromino. The next preview reveals the upcoming piece so the player can plan the current move.

  • In script.js from earlier, add the landing calculation immediately before drawCell() by using this code:
function getGhostY() {
  let landingY = piece.y;

  while (!collides(piece, piece.matrix, 0, landingY + 1 - piece.y)) {
    landingY += 1;
  }

  return landingY;
}

How Does the Landing Calculation Work?

  • The calculation starts at the active piece's current row.
  • The loop tests lower rows with the existing collides() function.
  • The returned row becomes the ghost piece's landing position.
  • Replace the existing drawCell() function with this version:
function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

What Does This Drawing Helper Do?

The ghost parameter switches the cell between a filled block and an outlined guide. Both versions use the active tetromino's color.

  • Replace the existing drawMatrix() function with this version:
function drawMatrix(context, matrix, offsetX, offsetY, type, size = BLOCK, ghost = false) {
  matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      const drawY = offsetY + rowIndex;
      if (value && drawY >= 0) {
        drawCell(context, offsetX + columnIndex, drawY, COLORS[type], size, ghost);
      }
    });
  });
}

How Does Matrix Drawing Change?

The matrix renderer passes the new ghost setting into every occupied cell. This lets the same renderer draw active pieces, previews, and landing outlines.

  • Save script.js.
  • Return to Google Chrome from earlier.
  • Refresh index.html.
  • Select Start / Restart.

Your active piece should still fall, move, and lock normally. This confirms the shared drawing helpers still render the playable board.

Did the Blocks Disappear?

Check that ghost = false appears in both function parameter lists. Confirm that drawMatrix() passes ghost into drawCell().

Ask for help with the rendering update if the board remains blank: help me compare my drawCell and drawMatrix functions with the completed versions

  • In drawBoard(), replace the active-piece drawing block with this version:
  if (piece && state !== "ready") {
    if (state === "playing") {
      drawMatrix(
        gameContext,
        piece.matrix,
        piece.x,
        getGhostY(),
        piece.type,
        BLOCK,
        true,
      );
    }
    drawMatrix(gameContext, piece.matrix, piece.x, piece.y, piece.type);
  }

How Does the Ghost Reach the Board?

The outlined matrix is drawn first at the row returned by getGhostY(). The filled active piece is drawn afterward so it stays visually dominant.

  • Save script.js.
  • Refresh index.html in Chrome.
  • Select Start / Restart.
  • Move the active piece with the arrow keys.

You should see a colored outline marking the piece's lowest legal position. The outline moves when the active piece moves or rotates.

Ghost Outline Missing?

Confirm that the ghost call passes true as its last argument. Check that getGhostY() uses the existing collides() function.

Ask for help if the outline appears in the wrong row: help me debug the ghost landing position in my Canvas game

  • Add the preview renderer immediately before drawLockedHold() by using this code:
function drawPreview(context, canvas, type) {
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = "#050816";
  context.fillRect(0, 0, canvas.width, canvas.height);

  if (!type) {
    return;
  }

  const matrix = SHAPES[type];
  const size = 24;
  const offsetX = (canvas.width / size - matrix[0].length) / 2;
  const offsetY = (canvas.height / size - matrix.length) / 2;
  drawMatrix(context, matrix, offsetX, offsetY, type, size);
}

What Does the Preview Renderer Do?

  • The preview canvas is cleared before each frame so old pieces do not remain visible.
  • The selected matrix uses smaller cells so every tetromino fits inside the preview panel.
  • The offsets center the matrix inside the available canvas space.
  • Replace the existing draw() function with this version:
function draw() {
  drawBoard();
  drawPreview(nextContext, nextCanvas, nextType);
  drawLockedHold();
}

What Does Each Frame Include?

Every frame now redraws the board and the upcoming piece. The existing hold panel stays locked for the optional Secret Mission.

  • Save script.js.
  • Refresh index.html in Chrome.
  • Select Start / Restart.

You should see the upcoming tetromino centered in the NEXT panel. The SECRET HOLD panel should still display LOCKED.

Next Preview Blank?

Check that draw() passes nextContext, nextCanvas, and nextType into drawPreview().

Ask for help if the piece is clipped or off-center: help me debug the next-piece preview in my Canvas game

Connect the game states

Explicit game states keep animation, controls, status text, and overlays synchronized. The player can now tell whether the cabinet is ready, playing, paused, or finished.

  • Add the overlay renderer immediately before draw() by using this code:
function drawOverlay() {
  let title = "";
  let detail = "";

  if (state === "ready") {
    title = "PRESS START";
    detail = "Build the first stack";
  } else if (state === "paused") {
    title = "PAUSED";
    detail = "Press Pause to continue";
  } else if (state === "gameover") {
    title = "GAME OVER";
    detail = "Restart the cabinet";
  } else {
    return;
  }

  gameContext.fillStyle = "rgba(3, 6, 17, 0.82)";
  gameContext.fillRect(0, 245, gameCanvas.width, 110);
  gameContext.textAlign = "center";
  gameContext.fillStyle = "#42f5e9";
  gameContext.font = "800 25px ui-monospace, monospace";
  gameContext.fillText(title, gameCanvas.width / 2, 290);
  gameContext.fillStyle = "#9ca9ca";
  gameContext.font = "12px ui-monospace, monospace";
  gameContext.fillText(detail, gameCanvas.width / 2, 320);
}

How Do Overlays Reflect State?

The function selects a title and instruction from the current state value. It returns immediately during active play so the board stays unobstructed.

  • Add this call at the end of drawBoard() before its closing brace:
  drawOverlay();

Why Draw the Overlay Last?

The overlay is drawn after the grid and tetrominoes. This keeps each state message readable above the current board.

  • Save script.js.
  • Refresh index.html in Chrome.

You should see PRESS START across the board while the cabinet is in its ready state.

Overlay Missing or Permanent?

Place drawOverlay() inside drawBoard() after the active-piece block. Confirm that the function returns when state is playing.

Ask for help if the overlay covers active gameplay: help me debug my ready, paused, and game-over overlays

  • Replace the existing HUD helper with these completed HUD and status helpers:
function updateHud() {
  scoreElement.textContent = String(score).padStart(6, "0");
  linesElement.textContent = String(lines);
  levelElement.textContent = String(level);
}

function updateStatus(message) {
  statusElement.textContent = message;
}

How Does the Interface Stay Synchronized?

The HUD helper formats the score and copies the current counters into the page. The status helper gives the cabinet header one place to display the active game state.

  • Replace the existing spawnPiece() function with this completed version:
function spawnPiece() {
  if (nextType === null) {
    nextType = takeFromBag();
  }

  piece = createPiece(nextType);
  nextType = takeFromBag();

  if (collides(piece)) {
    state = "gameover";
    updateStatus("GAME OVER");
  }
}

How Does Game Over Begin?

A new piece always starts at the top of the board. An immediate collision means the stack has filled its spawn area, so the state changes to gameover.

  • Save script.js.
  • Refresh index.html in Chrome.
  • Select Start / Restart.

The score should begin at six digits. The status pill should change from READY to PLAYING when a game starts.

  • Replace the Canvas and HUD reference group near the top of script.js with this complete group:
const scoreElement = document.getElementById("score");
const linesElement = document.getElementById("lines");
const levelElement = document.getElementById("level");
const statusElement = document.getElementById("status-text");
const startButton = document.getElementById("start-button");
const pauseButton = document.getElementById("pause-button");

What Do These References Connect?

These references connect the central game state to the visible counters, cabinet status, and two control buttons. Each ID matches an element in index.html.

  • Replace the existing reset logic with these completed state-control functions:
function resetGame() {
  board = createBoard();
  bag = [];
  piece = null;
  nextType = takeFromBag();
  score = 0;
  lines = 0;
  level = 1;
  dropInterval = 800;
  dropCounter = 0;
  state = "playing";
  pauseButton.textContent = "Pause";
  updateStatus("PLAYING");
  updateHud();
  spawnPiece();
}

function togglePause() {
  if (state === "playing") {
    state = "paused";
    pauseButton.textContent = "Resume";
    updateStatus("PAUSED");
  } else if (state === "paused") {
    state = "playing";
    pauseButton.textContent = "Pause";
    updateStatus("PLAYING");
  }
}

What Do the State Controls Reset?

A restart clears the board, bag, counters, timing, and active piece before spawning a fresh tetromino. Pausing changes the state and button label while preserving the current board.

  • Save script.js.
  • Refresh index.html in Chrome.
  • Select Start / Restart.

You should see a fresh board, a falling piece, a new next preview, and reset counters.

  • Add these button listeners immediately after the existing keyboard listener:
startButton.addEventListener("click", resetGame);
pauseButton.addEventListener("click", togglePause);

How Do the Buttons Reach the Game?

Each button listener connects a click to its state-control function. The controls reuse the same state values read by the animation loop and overlay renderer.

  • Save script.js.
  • Refresh index.html in Chrome.
  • Select Start / Restart.
  • Select Pause.

The active piece should stop moving. You should see PAUSED on the board and in the cabinet status pill.

  • Select Resume.

The overlay should disappear. The falling piece should continue from its saved position.

Pause Button Does Nothing?

Confirm that pauseButton uses the pause-button element ID. Check that its listener passes togglePause without parentheses.

Ask for help if the label changes but the piece keeps falling: help me synchronize my pause state with the game loop

Test the finished cabinet

The animation loop must advance gameplay only while the state is playing. Its timestamp keeps the drop timer connected to elapsed time across different display refresh rates.

  • Replace the existing animation loop and startup calls with this completed version:
function gameLoop(timestamp) {
  const delta = Math.min(timestamp - lastTime, 100);
  lastTime = timestamp;

  if (state === "playing") {
    dropCounter += delta;
    if (dropCounter >= dropInterval) {
      playerDrop();
    }
  }

  draw();
  requestAnimationFrame(gameLoop);
}

updateHud();
updateStatus("READY");
requestAnimationFrame(gameLoop);

How Does the Final Loop Behave?

  • The timestamp difference measures how much time passed since the previous frame.
  • The timer advances only during the playing state.
  • The loop always redraws the canvases so overlays and previews respond immediately.
  • The final calls initialize the HUD, set the cabinet to READY, and start the animation cycle.
  • Save script.js.
  • Refresh index.html in Chrome.

Your responsive rules from earlier already cover desktop and narrow layouts. The final check proves those rules still support the completed game.

Before you begin the final play test, do you expect pausing to freeze the drop timer while keeping the current board visible?

  • Select Start / Restart.
  • Move pieces with the left and right arrow keys.
  • Rotate a piece with the up arrow key.
  • Soft-drop a piece with the down arrow key.
  • Hard-drop a piece with the space bar.
  • Complete a row with stacked pieces.
  • Select Pause.
  • Select Resume.

You should see pieces stop and stack, a completed row disappear, and the HUD update. You should also see the ghost guide, next preview, pause overlay, and increasing drop speed as the level rises.

  • Stack pieces to the top of the board.
  • Select Start / Restart after the game ends.
  • Narrow the Chrome window until the cabinet switches to one column.

You should see GAME OVER when a new piece cannot spawn. Restarting should restore an empty board, while all four arcade cards remain visible in the narrow layout.

Final Play Test Failing?

If pausing does not stop the piece, confirm that dropCounter changes only inside the state === "playing" condition. If restarting keeps old blocks, confirm that resetGame() assigns a new board from createBoard().

Ask for help with the failing behavior: help me debug the final Neon Block Drop game-state test

✔️ Awesome, I've got everything!

Great work. Double check that script.js is saved in your neon-arcade folder.

ⓧ I'd like to double check the full code

Compare your complete script.js file with this finished version.

"use strict";

const COLS = 10;
const ROWS = 20;
const BLOCK = 30;

const gameCanvas = document.getElementById("game-canvas");
const gameContext = gameCanvas.getContext("2d");
const nextCanvas = document.getElementById("next-canvas");
const nextContext = nextCanvas.getContext("2d");
const holdCanvas = document.getElementById("hold-canvas");
const holdContext = holdCanvas.getContext("2d");

const scoreElement = document.getElementById("score");
const linesElement = document.getElementById("lines");
const levelElement = document.getElementById("level");
const statusElement = document.getElementById("status-text");
const startButton = document.getElementById("start-button");
const pauseButton = document.getElementById("pause-button");

const SHAPES = {
  I: [[1, 1, 1, 1]],
  J: [
    [1, 0, 0],
    [1, 1, 1],
  ],
  L: [
    [0, 0, 1],
    [1, 1, 1],
  ],
  O: [
    [1, 1],
    [1, 1],
  ],
  S: [
    [0, 1, 1],
    [1, 1, 0],
  ],
  T: [
    [0, 1, 0],
    [1, 1, 1],
  ],
  Z: [
    [1, 1, 0],
    [0, 1, 1],
  ],
};

const COLORS = {
  I: "#42f5e9",
  J: "#4f7cff",
  L: "#ff9f43",
  O: "#ffe66d",
  S: "#4dff88",
  T: "#b56cff",
  Z: "#ff4f76",
};

let board = createBoard();
let bag = [];
let piece = null;
let nextType = null;
let score = 0;
let lines = 0;
let level = 1;
let state = "ready";
let dropCounter = 0;
let dropInterval = 800;
let lastTime = 0;

function createBoard() {
  return Array.from({ length: ROWS }, () => Array(COLS).fill(""));
}

function cloneMatrix(matrix) {
  return matrix.map((row) => [...row]);
}

function refillBag() {
  bag = Object.keys(SHAPES);
  for (let index = bag.length - 1; index > 0; index -= 1) {
    const swapIndex = Math.floor(Math.random() * (index + 1));
    [bag[index], bag[swapIndex]] = [bag[swapIndex], bag[index]];
  }
}

function takeFromBag() {
  if (bag.length === 0) {
    refillBag();
  }
  return bag.pop();
}

function createPiece(type) {
  const matrix = cloneMatrix(SHAPES[type]);
  return {
    type,
    matrix,
    x: Math.floor((COLS - matrix[0].length) / 2),
    y: 0,
  };
}

function spawnPiece() {
  if (nextType === null) {
    nextType = takeFromBag();
  }

  piece = createPiece(nextType);
  nextType = takeFromBag();

  if (collides(piece)) {
    state = "gameover";
    updateStatus("GAME OVER");
  }
}

function collides(testPiece, matrix = testPiece.matrix, offsetX = 0, offsetY = 0) {
  for (let row = 0; row < matrix.length; row += 1) {
    for (let column = 0; column < matrix[row].length; column += 1) {
      if (!matrix[row][column]) {
        continue;
      }

      const boardX = testPiece.x + column + offsetX;
      const boardY = testPiece.y + row + offsetY;

      if (boardX < 0 || boardX >= COLS || boardY >= ROWS) {
        return true;
      }

      if (boardY >= 0 && board[boardY][boardX]) {
        return true;
      }
    }
  }

  return false;
}

function movePiece(offsetX, offsetY) {
  if (state !== "playing" || collides(piece, piece.matrix, offsetX, offsetY)) {
    return false;
  }

  piece.x += offsetX;
  piece.y += offsetY;
  return true;
}

function rotateMatrix(matrix) {
  return matrix[0].map((_, column) => matrix.map((row) => row[column]).reverse());
}

function rotatePiece() {
  if (state !== "playing") {
    return;
  }

  const rotated = rotateMatrix(piece.matrix);
  const kicks = [0, -1, 1, -2, 2];

  for (const kick of kicks) {
    if (!collides(piece, rotated, kick, 0)) {
      piece.matrix = rotated;
      piece.x += kick;
      return;
    }
  }
}

function mergePiece() {
  piece.matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      if (value) {
        const boardY = piece.y + rowIndex;
        if (boardY >= 0) {
          board[boardY][piece.x + columnIndex] = piece.type;
        }
      }
    });
  });
}

function clearCompletedLines() {
  let cleared = 0;

  for (let row = ROWS - 1; row >= 0; row -= 1) {
    if (board[row].every(Boolean)) {
      board.splice(row, 1);
      board.unshift(Array(COLS).fill(""));
      cleared += 1;
      row += 1;
    }
  }

  if (cleared > 0) {
    const scoreValues = [0, 100, 300, 500, 800];
    score += scoreValues[cleared] * level;
    lines += cleared;
    level = Math.floor(lines / 10) + 1;
    dropInterval = Math.max(120, 800 - (level - 1) * 70);
    updateHud();
  }
}

function lockPiece() {
  mergePiece();
  clearCompletedLines();
  spawnPiece();
}

function playerDrop() {
  if (!movePiece(0, 1)) {
    lockPiece();
  }
  dropCounter = 0;
}

function hardDrop() {
  if (state !== "playing") {
    return;
  }

  while (movePiece(0, 1)) {
    score += 2;
  }

  updateHud();
  lockPiece();
  dropCounter = 0;
}

function getGhostY() {
  let landingY = piece.y;

  while (!collides(piece, piece.matrix, 0, landingY + 1 - piece.y)) {
    landingY += 1;
  }

  return landingY;
}

function drawCell(context, x, y, color, size = BLOCK, ghost = false) {
  if (ghost) {
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.strokeRect(x * size + 3, y * size + 3, size - 6, size - 6);
    return;
  }

  context.fillStyle = color;
  context.fillRect(x * size + 1, y * size + 1, size - 2, size - 2);
  context.fillStyle = "rgba(255, 255, 255, 0.22)";
  context.fillRect(x * size + 4, y * size + 4, size - 8, 4);
}

function drawMatrix(context, matrix, offsetX, offsetY, type, size = BLOCK, ghost = false) {
  matrix.forEach((row, rowIndex) => {
    row.forEach((value, columnIndex) => {
      const drawY = offsetY + rowIndex;
      if (value && drawY >= 0) {
        drawCell(context, offsetX + columnIndex, drawY, COLORS[type], size, ghost);
      }
    });
  });
}

function drawBoard() {
  gameContext.clearRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.fillStyle = "#050816";
  gameContext.fillRect(0, 0, gameCanvas.width, gameCanvas.height);
  gameContext.strokeStyle = "#15214a";
  gameContext.lineWidth = 1;

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < COLS; column += 1) {
      gameContext.strokeRect(column * BLOCK, row * BLOCK, BLOCK, BLOCK);
      const type = board[row][column];
      if (type) {
        drawCell(gameContext, column, row, COLORS[type]);
      }
    }
  }

  if (piece && state !== "ready") {
    if (state === "playing") {
      drawMatrix(
        gameContext,
        piece.matrix,
        piece.x,
        getGhostY(),
        piece.type,
        BLOCK,
        true,
      );
    }
    drawMatrix(gameContext, piece.matrix, piece.x, piece.y, piece.type);
  }

  drawOverlay();
}

function drawPreview(context, canvas, type) {
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.fillStyle = "#050816";
  context.fillRect(0, 0, canvas.width, canvas.height);

  if (!type) {
    return;
  }

  const matrix = SHAPES[type];
  const size = 24;
  const offsetX = (canvas.width / size - matrix[0].length) / 2;
  const offsetY = (canvas.height / size - matrix.length) / 2;
  drawMatrix(context, matrix, offsetX, offsetY, type, size);
}

function drawLockedHold() {
  holdContext.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#050816";
  holdContext.fillRect(0, 0, holdCanvas.width, holdCanvas.height);
  holdContext.fillStyle = "#7782a6";
  holdContext.font = "700 14px ui-monospace, monospace";
  holdContext.textAlign = "center";
  holdContext.fillText("LOCKED", holdCanvas.width / 2, holdCanvas.height / 2 + 5);
}

function drawOverlay() {
  let title = "";
  let detail = "";

  if (state === "ready") {
    title = "PRESS START";
    detail = "Build the first stack";
  } else if (state === "paused") {
    title = "PAUSED";
    detail = "Press Pause to continue";
  } else if (state === "gameover") {
    title = "GAME OVER";
    detail = "Restart the cabinet";
  } else {
    return;
  }

  gameContext.fillStyle = "rgba(3, 6, 17, 0.82)";
  gameContext.fillRect(0, 245, gameCanvas.width, 110);
  gameContext.textAlign = "center";
  gameContext.fillStyle = "#42f5e9";
  gameContext.font = "800 25px ui-monospace, monospace";
  gameContext.fillText(title, gameCanvas.width / 2, 290);
  gameContext.fillStyle = "#9ca9ca";
  gameContext.font = "12px ui-monospace, monospace";
  gameContext.fillText(detail, gameCanvas.width / 2, 320);
}

function draw() {
  drawBoard();
  drawPreview(nextContext, nextCanvas, nextType);
  drawLockedHold();
}

function updateHud() {
  scoreElement.textContent = String(score).padStart(6, "0");
  linesElement.textContent = String(lines);
  levelElement.textContent = String(level);
}

function updateStatus(message) {
  statusElement.textContent = message;
}

function resetGame() {
  board = createBoard();
  bag = [];
  piece = null;
  nextType = takeFromBag();
  score = 0;
  lines = 0;
  level = 1;
  dropInterval = 800;
  dropCounter = 0;
  state = "playing";
  pauseButton.textContent = "Pause";
  updateStatus("PLAYING");
  updateHud();
  spawnPiece();
}

function togglePause() {
  if (state === "playing") {
    state = "paused";
    pauseButton.textContent = "Resume";
    updateStatus("PAUSED");
  } else if (state === "paused") {
    state = "playing";
    pauseButton.textContent = "Pause";
    updateStatus("PLAYING");
  }
}

document.addEventListener("keydown", (event) => {
  const gameKeys = ["ArrowLeft", "ArrowRight", "ArrowDown", "ArrowUp", "Space"];

  if (gameKeys.includes(event.code)) {
    event.preventDefault();
  }

  if (state !== "playing") {
    return;
  }

  if (event.code === "ArrowLeft") {
    movePiece(-1, 0);
  } else if (event.code === "ArrowRight") {
    movePiece(1, 0);
  } else if (event.code === "ArrowDown") {
    playerDrop();
  } else if (event.code === "ArrowUp") {
    rotatePiece();
  } else if (event.code === "Space") {
    hardDrop();
  }
});

startButton.addEventListener("click", resetGame);
pauseButton.addEventListener("click", togglePause);

function gameLoop(timestamp) {
  const delta = Math.min(timestamp - lastTime, 100);
  lastTime = timestamp;

  if (state === "playing") {
    dropCounter += delta;
    if (dropCounter >= dropInterval) {
      playerDrop();
    }
  }

  draw();
  requestAnimationFrame(gameLoop);
}

updateHud();
updateStatus("READY");
requestAnimationFrame(gameLoop);

You did it. Your neon cabinet now delivers a complete falling-block game with planning cues, responsive controls, visible states, scoring, and replay support.

Secret mission

Unlock the Hold Slot

Unlock the cabinet's Hold panel with a controlled piece swap. You will let players store one tetromino, swap it back later, and enforce one hold for each active piece.

Clean Up Your Resources

Clean Up Your Resources

Your Neon Arcade runs entirely from local files in Google Chrome with no ongoing costs. Choose whether to keep the folder, pause your work, or delete it.

Resources you used:

  • The neon-arcade project folder containing your complete local arcade.

Keep everything running

No action is needed. This option keeps the arcade ready for more testing.

  • Keep neon-arcade in its current location.
  • Keep the Neon Arcade tab open in Google Chrome while you continue testing.
  • Leave Visual Studio Code installed for future projects.

Pause - I'll come back to this later

There is no server or cloud resource to stop. This option preserves every file while you close the workspace.

  • Close the Neon Arcade tab in Google Chrome.
  • Close Visual Studio Code.
  • Keep the neon-arcade folder in its current location.

Delete - I don't want to use this again

This option removes the only project resource, your local folder. Visual Studio Code and Google Chrome stay installed for future projects.

  • Close the Neon Arcade tab in Google Chrome.
  • In the neon-arcade workspace already open in Visual Studio Code, right-click the neon-arcade folder in the Explorer sidebar.

macOS

  • Choose the option that reveals the folder in your computer's file browser.
  • Close Visual Studio Code.
  • Move neon-arcade to the Trash.
  • Empty the Trash.

You should no longer see the neon-arcade folder in its previous location.

Windows

  • Choose the option that reveals the folder in your computer's file browser.
  • Close Visual Studio Code.
  • Move neon-arcade to the Recycle Bin.
  • Empty the Recycle Bin.

You should no longer see the neon-arcade folder in its previous location.

Linux

  • Choose the option that reveals the folder in your computer's file browser.
  • Close Visual Studio Code.
  • Move neon-arcade to your desktop environment's trash.
  • Empty your desktop environment's trash.

You should no longer see the neon-arcade folder in its previous location.

Nice Work!

Nice Work!

Arcade complete! You built a responsive Neon Arcade cabinet in plain JavaScript. Its falling-block game now runs locally in Google Chrome.

You learned how to:

  • Built a responsive neon cabinet that stays readable across desktop screens. It also adapts to narrow screens. Four arcade cards turn the page into a reusable home for future games.
  • Rendered all seven tetrominoes with matrix-based pieces through the Canvas 2D API. Drove each fall with a timestamp-based animation loop. Connected keyboard controls for movement. Added separate controls for rotation. Added soft-drop controls. Added hard-drop controls.
  • Implemented collision detection that stops pieces at the floor or stacked blocks. Added horizontal wall kicks near the board edge. Cleared completed rows while updating the score. Increased the falling speed as the level rose. Finished the game with a ghost landing guide. Added the next-piece preview. Added pause states. Added restart behavior. Added a game-over state.
  • Secret Mission: Added a one-use-per-piece hold mechanic. The Hold panel now displays the stored tetromino. Pressing KeyC swaps the held piece without consuming the next-piece preview.

Ready to quiz yourself?