Building a color-by-number Chrome extension with Manifest V3 (offline, no backend of its own)
I wanted a small, calm coloring game that opens with one click and works on a train with no signal. The result is Pixel Art Color by Number, a Manifest V3 extension that colors the free pictures from SimplePixelArt.com. It's about 1,200 lines of TypeScript with no framework. A few parts were more interesting than I expected, so here they are.
1. A tab, not a popup
A popup is too small for a canvas you zoom and drag on. The service worker just opens the game page in a tab, both on toolbar click and on first install, so people see what they added:
chrome.action.onClicked.addListener(() => { chrome.tabs.create({ url: chrome.runtime.getURL('src/app/index.html') }) }) chrome.runtime.onInstalled.addListener(({ reason }) => { if (reason === 'install') { chrome.tabs.create({ url: chrome.runtime.getURL('src/app/index.html') }) } }) Build setup is Vite + @crxjs/vite-plugin: the manifest points at .ts files and the plugin rewrites them. Permissions are just storage plus a host permission for the read-only picture API. No tabs, no content scripts, so the install prompt is short.
2. Offline with chrome.storage.local, network first
The pictures come from a public API. Every JSON response goes through one helper: try the network, save the good answer, fall back to the last saved copy when the fetch fails.
async function cachedJson<T>(url: string, cacheKey: string, keep: (j: any) => T = j => j): Promise<T> { try { const res = await fetch(url) if (!res.ok) throw new Error(String(res.status)) const j = keep(await res.json()) chrome.storage.local.set({ [cacheKey]: j }) return j } catch (e) { const got = await chrome.storage.local.get(cacheKey) if (got[cacheKey]) return got[cacheKey] throw e } } The keep argument trims each picture to the fields the game needs (size, palette, number map) before it's stored, so the cache stays small. The first page of the gallery is cached the same way, which means the gallery and every picture you started still open offline.
Progress is saved per picture as a list of "x_y" keys, debounced by 300 ms so a fast drag doesn't write on every square.
3. Wrong squares stay empty
The rule that makes it relaxing: you can't make a mistake. Each picture is a map_numbers object ("x_y" → color index). On open I flatten it into typed arrays once:
let grid = new Int16Array(w * h) // color per square, -1 = empty let filled = new Uint8Array(w * h) // 0 = empty, else paint style + 1 Filling is then a filter: a square takes paint only if it carries the selected number and isn't painted yet.
for (const i of squares) { if (grid[i] !== selected || filled[i]) continue filled[i] = style + 1 // paint it, count it } Since wrong squares are ignored, the brush can be big and sloppy (1, 3 or 5 squares wide), the bucket can flood a connected area and the "wand" can fill every square of one number. None of them can ruin the picture.
4. No gaps on fast drags
Pointer events don't arrive for every square you pass when you move fast. Two fixes:
-
pointermoveevents are coalesced to one step per animation frame (requestAnimationFrame), so drawing never falls behind the cursor. - Each step walks a Bresenham line from the previous square to the current one and fills everything on it, so a quick swipe leaves no holes.
Only the touched rectangle is redrawn, not the whole canvas, which keeps a 48×48 picture smooth even at high zoom.
5. Brush strokes that stay inside the lines
Flat fill is pixel-exact, but I also wanted watercolor, pencil, crayon and marker styles where you see the stroke. The trick is a Path2D per color made of all that color's squares, used as a clip:
function segment(stroke, x0, y0, x1, y1) { lctx.save() lctx.clip(clipFor(stroke.color)) // only this number's squares strokeSegment(lctx, stroke, x0, y0, x1, y1) lctx.restore() } The stroke follows your pointer but can never bleed into a neighbour with a different number. The paths are built once per color and cached. Strokes are saved too (capped at about 200 KB per picture, oldest first), so a picture looks the same when you come back.
6. Small things that mattered
- Find the last squares. The last 3 squares of a 2,000-square picture are hard to spot, so a search button jumps to them.
- Export. A finished picture downloads as a crisp PNG, or as a share image signed with your name.
- Ask for a rating late. The review prompt shows only once, after the third finished picture, when people have actually enjoyed it.
Try it
- Chrome extension: Pixel Art Color by Number
- Same game in the browser, no install: simplepixelart.com/color-by-number
No ads, no account. If you build extensions, I'd like to hear how you handle offline caching in MV3. chrome.storage.local has been simpler than the Cache API for JSON-sized data.
I wrote this post with help from an AI assistant; the code and the decisions are mine.