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
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
}
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);
}
}
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.
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)