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 (5)
Flooding out from the enemies instead of working out "inside" is such a clean trick, and it handles the multi-enemy case for free.
Two small things I ran into with the same setup:
visibilitychangewithAC.suspend()/AC.resume()and resettingM.nextTtoAC.currentTimeon resume keeps it clean.grid[s] === 0fails, that enemy seeds nothing, and its whole region can get claimed. Seeding from the nearest open neighbour cell (or nudging the enemy off the wall first) avoids the rare "I claimed the pterodactyl's half" bug.thanks for reading it that closely, glad the flood-from-enemies approach landed.
You were right about the second one. An enemy can overlap the trail while it's invincible after a hit, so its cell turns into fence when you close the loop, the seed check fails and its region gets claimed. I reproduced it and it now seeds from the nearest open neighbour instead. Fix is in the repo.
On the audio: hiding the tab already pauses the game and stops the scheduler, and resume resets nextT, so the gap case doesn't normally happen. Your catch-up burst point still made me add a guard that skips ahead if the scheduler falls more than 0.5s behind the audio clock. Appreciate the detail.
i have released the fix. please give it a try. share with your friends and known ones.. if you can.. this would really help me
Read through 23f1ecb, nice. Checking the wisp's own cell first and only falling back to the neighbours is the right order.
One edge case worth a look: when the wisp sits right on the new fence, its open neighbours are usually on both sides of the line, so both sides get seeded and that loop claims nothing. That's probably the fair call since it's ambiguous which side it's "in", but if it feels bad in play you could seed only the neighbour it came from (step back along -vx/-vy, or use the last entry in w.hist).
The 0.5 s skip-ahead guard looks good too. M.step keeps counting from where it stalled, so the track just picks up a few steps late, which nobody will hear.
Thanks for reading it through @arbnoravar28995 , and for the careful trace. You’re right: when a wisp sits exactly on the closing fence, both sides get seeded and that loop claims nothing. I reproduced it to be sure.
I’m going to leave it that way for now. The case needs the player to step onto a wisp’s cell while invincible, so it’s rare. Seeding only the side it came from would be a guess, because the wisp turns every 0.5 to 1.7 s and one step back is under half a cell. A wrong guess brings the original bug back, where a wisp’s region gets claimed. Claiming nothing only costs the player one fence. If it feels bad in play I’ll revisit with a stored last-open position.
And agreed on the music guard: M.step keeps its place, so the track just resumes a few steps late.