Draw every line in one unbroken stroke. Never the same line twice.
A minimalist one-stroke puzzle for the browser. Put a finger on a dot, drag over every line exactly once, and do not lift off. It is Euler's bridges of Königsberg, sized for a coffee break.
- No accounts, no server, no leaderboard. Progress lives in your browser.
- ~15.5 kB gzipped, nothing imported at runtime, no web fonts, no network requests after load.
- Runs offline, in the browser and as an Android app.
- Infinite levels, generated so that every one of them is provably solvable.
- Polish and English, dark and light, pointer and keyboard.
| Touch / mouse | Press a dot and hold. Drag along the lines. Drag back to undo. Lift off and the run ends. |
| Keyboard | Enter start · arrows or WASD move · Q E Z C diagonals · Backspace undo · R restart |
Getting stuck is normal — not every dot is a legal opening. Fail three times on a level and the game rings the dots a solution can actually start from.
Haptics fire on every line, on a run ending and on a solve — on Android. iOS has never implemented the Vibration API, so an iPhone stays silent no matter what, and Android honours the system "vibrate on touch" setting. See ADR-0022.
npm install
npm run devThen open http://localhost:5173.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server with hot reload |
npm run build |
Type-check, then build to dist/ |
npm run test |
Vitest, 342 tests |
npm run lint |
ESLint with type-aware rules |
npm run typecheck |
tsc --noEmit |
npm run format |
Prettier, write |
npm run verify |
Everything CI runs, in the same order |
npm run cf:dev |
Build, then serve through Wrangler locally |
npm run deploy |
Build, then wrangler deploy |
The Android app is the web build inside a Capacitor
shell — the same dist/, the same canvas, the same gestures, packaged into the
APK so the game works with the radio off. See
ADR-0023.
Nothing in src/ is Android-specific. The native project is a container.
npm run android:apkThe APK lands in android/app/build/outputs/apk/debug/app-debug.apk.
| Script | What it does |
|---|---|
npm run android:sync |
Build the web app, copy it into android/ |
npm run android:apk |
The above, then a debug APK via Gradle |
npm run android:install |
Install the built APK on an attached device |
npm run android:open |
Open the project in Android Studio |
Requirements, once, on the build machine:
| Need | Version used here |
|---|---|
| JDK | 21 |
| Android SDK | platform 36, build-tools 36.0.0, platform-tools |
ANDROID_HOME |
pointing at the SDK (or sdk.dir in android/local.properties) |
Android Studio is optional — the command-line tools are enough. Gradle downloads itself on first build.
sdkmanager "emulator" "system-images;android-36;google_apis;x86_64"
avdmanager create avd -n loopline_pixel8 -k "system-images;android-36;google_apis;x86_64" -d pixel_8
emulator -avd loopline_pixel8 -gpu swiftshader_indirect -no-audio -no-boot-animThen npm run android:install and launch it. swiftshader_indirect renders in
software, which is slow enough that the first paint can take several seconds —
a black screen right after launch is the renderer catching up, not a failure.
Builds are local by design. No GitHub workflow touches Android or iOS; CI verifies and deploys the web app and nothing else.
src/
core/ the puzzle, with no idea a screen exists
rng.ts seeded mulberry32 — the same level everywhere, every time
graph.ts Eulerian trail theory, Hierholzer's algorithm
generator.ts builds boards by walking a trail (so they cannot be unsolvable)
levels.ts the difficulty curve, as a formula
game/
trail.ts finger position -> drawn line. The heart of how it feels.
game.ts input, frame clock, orchestration
render.ts Canvas 2D renderer
layout.ts grid coordinates -> screen pixels
particles.ts fixed-capacity, allocation-free particle pool
theme.ts canvas palettes
storage.ts localStorage, guarded against every way it can fail
ui/ DOM chrome: stats, buttons, overlays
i18n/ thirty-odd strings in two languages
tests/ 342 tests, including full pointer and keyboard playthroughs
docs/adr/ why any of this is the way it is
android/ generated Capacitor shell — a WebView holding dist/
scripts/ build helpers that have to work on more than one OS
The split that matters: core/ knows nothing about rendering, which is why
the puzzle logic can be tested exhaustively without a browser, and why the tests
can verify that levels 1–60 all have a real Eulerian solution.
The things that would have cost frames, and what was done instead:
- No
shadowBlurin the frame loop — by far the most expensive 2D canvas operation. Glow comes from radial-gradient sprites rendered once and blitted. A test asserts noshadow*property is ever touched. - One clock. Everything time-based is driven by the
requestAnimationFrametimestamp. No CSS transition is ever read back or relied on for state. - Decoration on the compositor. The animated background is CSS
transformandopacityonly, so it cannot contend with the game loop. getCoalescedEvents()replays the pointer samples the browser buffered between frames, so a fast swipe on a 120 Hz screen does not cut corners.- Batched drawing. Every undrawn edge is one path and one
stroke(). - Device pixel ratio capped at 2.5. Past that the pixels are invisible and the fill rate is not.
Hosted as an assets-only Cloudflare Worker — static files served from the edge with no compute in the request path.
Automatic. Every merge to main deploys, once two repository secrets exist:
| Secret | Where to get it |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare dashboard → My Profile → API Tokens → Edit Cloudflare Workers template |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare dashboard → Workers & Pages → Account ID |
Add them under Settings → Secrets and variables → Actions. Until they exist the deploy job skips itself and CI still passes.
Manual.
npx wrangler login
npm run deploymain is protected: no direct pushes, no force pushes, and CI must be green over
the merge result. See CONTRIBUTING.md and
ADR-0010.
Twenty-four architecture decision records live in docs/adr/.
Start with ADR-0006 (why a
level can never be unsolvable) and
ADR-0013 (why the stroke feels
the way it does).