A sliding puzzle CAPTCHA with two identical gaps and a piece that travels a curved, vertically wobbling path — so solving it means deciding which gap the piece belongs to, not just how far to drag.
Zero dependencies, ~2200 lines of vanilla ES modules, works without a build step.
Live demo → · 简体中文 · Protocol · Security model
Two identical gaps, one piece. The lamp stand only lines up through one of them — that continuity is the whole puzzle.
The demo runs a mock server inside your browser so it can be hosted on GitHub Pages. It generates and grades challenges on the client, which means it is a visual demo and not a security boundary. Production requires a server — two reference implementations are included.
A conventional slider CAPTCHA has one notch. Finding a single dark puzzle-shaped region in an image is a few lines of OpenCV, so the puzzle collapses into "find the blob, drag there".
Two identical gaps break that. The piece carries the pristine image content that used to sit at the target, so exactly one gap's surroundings continue it. A bot must do local patch matching rather than blob detection — and if it cannot, it is down to a coin flip, with one attempt per challenge.
The generator enforces the two properties that make this honest: gaps are only placed on content with enough local detail for the piece to be recognisable, and the two gaps must look sufficiently different from each other that only one can be right. Without those checks the puzzle is unsolvable for humans too.
docs/SECURITY.md states exactly what this stops and what it does not, with measured numbers.
git clone https://github.com/Evil0ctal/Wobble-Captcha.git
cd Wobble-Captcha
python3 -m http.server 8080Open http://127.0.0.1:8080.
<link rel="stylesheet" href="/wobble-captcha/styles/captcha.css">
<div id="captcha"></div>
<script type="module">
import { createCaptcha } from "/wobble-captcha/src/index.js";
const captcha = createCaptcha("#captcha", {
mock: false,
apiBase: "https://api.example.com",
getCsrfToken: () => document.querySelector('meta[name="csrf-token"]')?.content
});
captcha.addEventListener("success", ({ detail }) => {
// Send this with the real form submission. The business endpoint redeems it.
document.querySelector("#captcha-token").value = detail.verificationToken;
});
</script>createCaptcha returns a WobbleCaptcha, which is an EventTarget emitting
success, failure and statechange. A captcha:success event also bubbles
through the DOM if you would rather listen on a container.
| Option | Default | Purpose |
|---|---|---|
mock |
false |
Run the in-browser mock instead of calling a server. Demo only. |
apiBase |
"" |
Origin of the challenge service. |
challengePath |
/api/captcha/challenge |
|
verifyPath |
/api/captcha/verify |
|
requestTimeoutMs |
8000 |
|
requestCredentials |
"same-origin" |
Use "include" for a cross-origin API. |
getCsrfToken |
null |
Function returning a token for the x-csrf-token header. |
locale |
null |
null detects from navigator.languages. "zh-CN" or "en" to force. |
traceMaxPoints |
180 |
Bounded 24–500. |
refreshCooldownMs |
700 |
Minimum interval between challenge requests. |
keyboardStep |
0.0125 |
Progress per arrow key; Shift multiplies by 4. |
debug |
false |
Log diagnostics to the console. |
gapCount |
2 |
Mock only. 2–4; see the security notes before raising it. |
Both reference implementations are complete: challenge generation, grading, single-use challenges bound to a client, token issuance and rate limiting.
cd server/python
pip install -r requirements.txt
WOBBLE_BACKGROUNDS=../../assets uvicorn wobble_captcha.app:app --reload --port 8000cd server/node
npm install
WOBBLE_BACKGROUNDS=../../assets/raster npm start@napi-rs/canvas cannot read SVG, so convert the bundled backgrounds to PNG
first. The Node server imports its path generation and grading directly from
src/ — the browser mock, the test suite and the server run literally the same
security-bearing code.
Then set mock: false and apiBase: "http://127.0.0.1:8000" in config.js.
src/
core/ math, challenge validation, trace recording — DOM-free, unit tested
api/
fetch-api.js HTTP client
mock/ in-browser challenge service (demo only)
path.js gap placement — the security-critical part
scoring.js grading and trajectory analysis
generator.js scene assembly
painter.js canvas drawing
ui/ widget and markup
i18n/ zh-CN, en
styles/ widget stylesheet (themed, dark-mode aware) + demo chrome
server/ FastAPI and Express reference implementations
tests/ node:test suite
docs/ protocol, security model, audit log
- Full keyboard operation: arrows, Shift+arrows, Home, End, Enter/Space.
role="slider"with livearia-valuenow; status changes announced viaaria-live.- Keyboard submissions are exempt from trajectory scoring and given a 240-second budget — grading a keyboard user on a pointer's motion curve locks them out.
- Dark mode follows the system setting, with an explicit override.
prefers-reduced-motionis respected.
npm test # 75 tests
cd server/python && python3 -m pytest -q # 30 testsBoth suites include regression tests for the two vulnerabilities described in docs/SECURITY.md.
Chrome/Edge 88+, Firefox 90+, Safari 15.4+. Requires ES modules, Pointer Events,
aspect-ratio and AbortController. Safari below 17 ignores the canvas
filter used for per-challenge colour variation; everything else still works.
