Snake 2

Turning an Old Snake Repo into a Small Browser Arcade

The snake repo is not just Snake anymore. I recently reworked it into a compact arcade shelf with two cartridges: Snake and a simple Breakout clone. The goal was straightforward: keep the project small, keep the stack boring, and make the page feel like a real playable game instead of a rough AngularJS demo with a keyboard-focus hack bolted onto it.

The final stack is still intentionally lightweight. The client is AngularJS, the backend is a tiny Express server, scores are persisted to a JSON file, and the entire visual layer is rendered as a grid of colored cells. What changed is the structure around it: global input works without clicking into a div, the page is cleaner, the games share infrastructure, and the score model now understands more than one cartridge.

What I Built

At a high level, the repo now has five moving parts:

  • A minimal Express app that serves the page and accepts score submissions.
  • A single AngularJS module that owns game state, cartridge selection, and rendering.
  • Two game runtimes: Snake and Breakout.
  • A tiny synthesized sound system built on the Web Audio API.
  • A shared highscore pipeline that stores scores by game.

That is the kind of architecture I like for small game projects. It is simple enough to hold in your head, but it still creates a real full-stack loop: input, simulation, rendering, audio, and persistence.

The Front-End Shell

The biggest visible change is the page itself. I removed the clutter and turned it into an arcade-style layout with a cartridge picker at the top, the active game board in the middle, and highscores at the bottom.

The UI is driven by a small game catalog:

appGame.factory('gameCatalog', function() {
  return {
    snake: {
      id: 'snake',
      title: 'Snake',
      subtitle: 'Classic grid runner. Eat the snacks and do not fold into yourself.',
      controls: 'Use W A S D or the arrow keys to steer.',
      secondaryLabel: 'Length',
      accentClass: 'cartridge-snake',
      newGameLabel: 'New Snake Game'
    },
    breakout: {
      id: 'breakout',
      title: 'Breakout',
      subtitle: 'Neon brick breaker. Clear the wall, keep the ball alive, and chase the multiplier.',
      controls: 'Use A D or the arrow keys to move the paddle.',
      secondaryLabel: 'Lives',
      accentClass: 'cartridge-breakout',
      newGameLabel: 'New Breakout Game'
    }
  };
});

That single catalog object does more than drive labels. It keeps the shell generic enough that the controller can swap games without the template needing cartridge-specific logic everywhere.

On the HTML side, the picker is just an ng-repeat over that catalog plus a single board table and a small HUD:

<button
  ng-repeat="cartridge in cartridges track by cartridge.id"
  ng-attr-id="cartridge-{{ cartridge.id }}"
  class="cartridge-card {{ cartridge.accentClass }}"
  ng-class="{ active: selectedGame === cartridge.id }"
  ng-click="selectCartridge(cartridge.id)">
  <span class="cartridge-label">
    <span class="cartridge-chip"></span>
    Cartridge
  </span>
  <h3>{{ cartridge.title }}</h3>
  <p>{{ cartridge.subtitle }}</p>
</button>

That is a small design choice, but it matters. Instead of building two separate pages or two separate Angular apps, I built one shell that can host more than one ruleset.

Input and Shared State

The original rough edge in this repo was keyboard input. The game depended on clicking into a blue div to focus the page before input would work. That is exactly the sort of UX problem that makes a simple browser game feel broken.

I fixed that by moving keyboard handling to the document level and separating one-shot input from held-key state:

appGame.run(['$document', 'globalVars', function($document, globalVars) {
  function handleKeydown(event) {
    var keyCode = normalizeKeyCode(event.which || event.keyCode);
    if (keyCode === null) {
      return;
    }

    event.preventDefault();
    globalVars.setKeyState(keyCode, true);
    globalVars.setUserInput(keyCode);
  }

  function handleKeyup(event) {
    var keyCode = normalizeKeyCode(event.which || event.keyCode);
    if (keyCode === null) {
      return;
    }

    event.preventDefault();
    globalVars.setKeyState(keyCode, false);
  }

  $document.on('keydown', handleKeydown);
  $document.on('keyup', handleKeyup);
}]);

That split ended up being useful for both games:

  • Snake consumes discrete turns, because direction changes are event-like.
  • Breakout needs held movement, because the paddle should keep moving while a key is down.

The shared state container is still intentionally plain. globalVars stores input and leaderboard data, while appState owns the selected cartridge and syncs it to the URL hash so #snake and #breakout can be linked directly.

Game Architecture

The interesting part of the client is that both games live inside one controller, but the controller is mostly an orchestrator. The real separation happens in the runtime objects and the tick functions.

The shell decides which game to start:

$scope.startGame = function() {
  soundLibrary.unlock();

  if ($scope.selectedGame === 'snake') {
    startSnake();
  } else {
    startBreakout();
  }
};

After that, the loop dispatches to the correct runtime:

function gameTick() {
  loopPromise = null;

  if ($scope.gameState !== GAME_STATE_RUNNING || $scope.isPaused) {
    return;
  }

  if ($scope.selectedGame === 'snake') {
    tickSnake();
  } else {
    tickBreakout();
  }

  if ($scope.gameState === GAME_STATE_RUNNING) {
    scheduleNextTick();
  }
}

That structure is simple, but it scales surprisingly well for a project of this size. I do not need an inheritance hierarchy or a plugin framework here. I just need a stable shell and two isolated state machines.

Snake Runtime

Snake is still grid-based and deterministic. The runtime stores the board size, direction, snake segments, and current fruit location:

function setupSnakeIdle() {
  snakeRuntime = {
    width: 40,
    height: 20,
    direction: 'right',
    lastDirection: 'right',
    snake: [{ x: 20, y: 10 }],
    fruit: { x: 28, y: 8 }
  };

  $scope.DisplayWidth = snakeRuntime.width;
  $scope.DisplayHeight = snakeRuntime.height;
  $scope.score = 0;
  $scope.secondaryStat = snakeRuntime.snake.length;
  $scope.gameState = GAME_STATE_IDLE;
  setStatus('Snake ready. Press "' + $scope.activeGame.newGameLabel + '" to start.');
  renderSnakeBoard();
}

Movement is just a head update, collision test, and either fruit growth or tail removal. That is still one of my favorite examples of how much game behavior you can get out of a plain array of segments and a predictable tick.

Breakout Runtime

The new part of the repo is Breakout. I wanted something mechanically different from Snake, but still easy to express on the same board renderer.

The runtime is just another object:

function createBreakoutRuntime(level, lives, score) {
  return {
    width: 40,
    height: 24,
    paddleWidth: 7,
    paddleX: 17,
    paddleY: 21,
    ball: {
      x: 20,
      y: 18,
      vx: 1,
      vy: -1
    },
    lives: lives,
    level: level,
    score: score,
    bricks: createBrickField(level)
  };
}

The brick field is generated from data rather than hard-coded markup, with color-coded rows and simple score values by brick type:

function createBrickField(level) {
  var rows = 5;
  var columns = 12;
  var bricks = [];
  var colors = [
    CELL_BRICK_RED,
    CELL_BRICK_ORANGE,
    CELL_BRICK_YELLOW,
    CELL_BRICK_TEAL,
    CELL_BRICK_RED
  ];
  var hitsPerBrick = 1 + Math.floor((level - 1) / 2);

  for (var rowIndex = 0; rowIndex < rows; rowIndex++) {
    for (var columnIndex = 0; columnIndex < columns; columnIndex++) {
      bricks.push({
        x: 2 + (columnIndex * 3),
        y: 2 + rowIndex,
        value: colors[rowIndex % colors.length],
        hits: hitsPerBrick
      });
    }
  }

  return bricks;
}

This is not physics-heavy Breakout, and it is not trying to be. It is a discrete grid version with enough bounce logic, collision handling, and scoring to feel good inside the same rendering model as Snake.

Rendering as Data

One thing I kept from the older version is the board-first rendering model. Both games write to a 2D array of cell objects, and the template paints the table based on numeric cell values mapped to colors.

That means the DOM is not the source of truth. The runtime is the source of truth. The renderer just projects the current board state into a table.

I like that approach for small games because it keeps the code debuggable. If something looks wrong on screen, I can inspect board state, runtime state, or the color map directly instead of trying to reason about a pile of manually mutated DOM nodes.

Sound Without Assets

I also added a small sound library, but I did not want to ship audio files just to get a few useful feedback cues. Instead, I used the Web Audio API to synthesize short tones for start, fruit collection, brick hits, wall bounces, pause, resume, life loss, death, and score submission.

The core primitive is a tiny tone generator:

function playTone(options) {
  if (!soundEnabled) {
    return;
  }

  var ctx = ensureContext();
  if (!ctx || !masterGain) {
    return;
  }

  var now = ctx.currentTime + (options.delay || 0);
  var oscillator = ctx.createOscillator();
  var gainNode = ctx.createGain();

  oscillator.type = options.type || 'square';
  oscillator.frequency.setValueAtTime(options.frequency || 220, now);
  oscillator.frequency.exponentialRampToValueAtTime(
    options.endFrequency || options.frequency || 220,
    now + (options.duration || 0.18)
  );

  oscillator.connect(gainNode);
  gainNode.connect(masterGain);
  oscillator.start(now);
}

For a project like this, synthesized sound is a good tradeoff. It keeps the repo self-contained and still makes the games feel more responsive.

Highscores and the Backend Contract

The backend is still intentionally tiny, but it is cleaner than it used to be. Scores are normalized on the client to include game, name, and score, then persisted through a single Express POST route.

function postHighscore(req, res) {
  var highscorePath = path.join(__dirname, 'public', 'app', 'restfuls.json');
  var payload = Array.isArray(req.body) ? req.body : [];

  fs.writeFile(highscorePath, JSON.stringify(payload), function(err) {
    if (err) {
      return res.status(500).json({ status: 500 });
    }

    res.status(200).json({ status: 200 });
  });
}

app.post('/', postHighscore);

That part matters because the repo is no longer storing just a flat Snake leaderboard. The score pipeline now filters per cartridge, and older entries that did not have a game field are treated as Snake by default so the original data still works.

A Small Testing Hook That Paid Off

One addition that is easy to miss but useful in practice is that I exposed a deterministic text-state hook for browser testing. The app now exposes window.render_game_to_text() and window.advanceTime(ms) so I can run short automated scenarios against both games and verify state changes without guessing from the UI alone.

That let me validate things like:

  • Snake actually consumes fruit and increments length.
  • Breakout removes bricks and updates score.
  • The shared shell swaps cartridges correctly.

For small browser games, that kind of hook is a cheap way to make iteration faster and less fragile.

What I Like About the Final Shape

What I like most about this repo now is that it is still small, but it has a clearer point of view:

  • one page
  • one board renderer
  • one shared shell
  • two different game loops
  • one score pipeline

That is enough structure to make the project feel intentional without overengineering it. It is still an old AngularJS codebase, and I am not pretending otherwise. But it is now a much better example of how I like to build interactive software: direct state, small surfaces, reusable infrastructure, and enough polish that the thing actually feels good when you play it.

try it here
https://scriptinghobby.com/example/views/index.html


Leave a Reply

Your email address will not be published. Required fields are marked *