DEV Community

Nitin Sijwali
Nitin Sijwali

Posted on

Fenceline: a pixel territory game in one HTML file, with a flood fill and no audio files

built a free Qix-style browser game with canvas, vanilla JS and WebAudio. No dependencies, no network calls, works offline."
tags: webdev, javascript, gamedev, showdev

cover_image: https://fenceline.nitinsijwali.com/og-image.png

I wanted to see how far plain HTML and canvas could go, so I built Fenceline: a free pixel territory game for the browser. No login, no ads, and it works offline.

Play it: https://fenceline.nitinsijwali.com
Source (MIT): https://github.com/nsijwali/fenceline

Fenceline gameplay, Day look

The game

You walk along the walls. Step into open ground to start drawing a fence, and reach a wall again to close it. Whatever the pterodactyl can't reach becomes yours. Claim enough land to clear the level, and the target rises each level. It's the Qix idea, dressed like the offline dino page.

Constraints I set

  • One file. Markup, CSS, JS and the pixel font are all in index.html.
  • No dependencies and no network calls. The font is embedded as base64.
  • No audio files. The music is generated live.

Claiming land is a flood fill

The playfield is a grid (96 by 64, or 64 by 96 in portrait). When you close a fence, the game doesn't try to work out which region is "inside". It does the reverse: it floods outward from the enemy and claims everything the flood couldn't reach.

function closeTrail() {
  const n0 = trail.length;
  for (const i of trail) grid[i] = 1;          // the fence becomes wall
  trail = []; drawing = false;
  const vis = new Uint8Array(N), q = new Int32Array(N); let h = 0, t = 0;
  for (const w of wisps) {                      // seed from every enemy
    const s = idx(Math.floor(w.x), Math.floor(w.y));
    if (grid[s] === 0 && !vis[s]) { vis[s] = 1; q[t++] = s; }
  }
  while (h < t) {                               // breadth-first fill
    const i = q[h++], x = i % W, y = (i / W) | 0;
    if (x > 0     && grid[i - 1] === 0 && !vis[i - 1]) { vis[i - 1] = 1; q[t++] = i - 1; }
    if (x < W - 1 && grid[i + 1] === 0 && !vis[i + 1]) { vis[i + 1] = 1; q[t++] = i + 1; }
    if (y > 0     && grid[i - W] === 0 && !vis[i - W]) { vis[i - W] = 1; q[t++] = i - W; }
    if (y < H - 1 && grid[i + W] === 0 && !vis[i + W]) { vis[i + W] = 1; q[t++] = i + W; }
  }
  let filled = 0;
  for (let i = 0; i < N; i++) if (grid[i] === 0 && !vis[i]) { grid[i] = 1; filled++; }
  // ...score, percentage, level clear
}
Enter fullscreen mode Exit fullscreen mode

Two details I like:

  • It works with any number of enemies. More pterodactyls just means more seeds, with no extra cases.
  • There's no allocation worth worrying about. The visited array and queue are typed arrays sized to the grid, and a closed fence runs one pass over a few thousand cells.

Music without samples

The soundtrack is built around a two-note whistled call and played by a small WebAudio scheduler. A timer runs every 25 ms and schedules every note that falls inside the next 0.18 seconds:

function musicTick() {
  if (!M.on || !AC) return;
  const T = TRACKS[M.track], sp = 60 / T.bpm / 4;
  while (M.nextT < AC.currentTime + .18) {
    musicStep(T, M.step, M.nextT);
    M.nextT += sp; M.step = (M.step + 1) % (T.bars.length * 16);
  }
}
Enter fullscreen mode Exit fullscreen mode

The timer can wobble, but the audio clock doesn't, so notes land on time. Each look has its own tempo and lead sound:

Look Tempo Lead
Day 92 BPM Soft triangle with echo
Night 72 BPM Sine with echo
Handheld 104 BPM Pulse wave, no echo

Three looks from CSS variables

The look changes every level. Each one is a set of CSS custom properties on [data-theme], read once into the canvas palette. Sprites are small string maps drawn with image smoothing off, so the pixels stay sharp at any size.

Night look

Shipping it

It's the public/ folder served as Cloudflare Workers static assets, with a strict Content Security Policy (default-src 'none'). Since the page has no network calls, the policy can allow almost nothing.

What I'd like feedback on

  • Does the difficulty feel fair around level 2?
  • Does it play well on your phone?
  • What should the next enemy do?

If you try it, tell me how far you got.

Top comments (0)