Skip to content

Repository files navigation

whackmole

A whack-a-mole–style reaction-time test bed for a robotic finger.

Open the web app on a touch device (e.g. an iPad). It pairs with a Python WebSocket server on another computer that drives the robot. A red dot appears at a random location after a random delay; tapping it reports the reaction time in milliseconds back over the socket.

How it works

  1. The web app connects to the Python server over WebSocket.
  2. On Start, it picks a random pixel location inside the play area, waits a random amount of time (600–2400 ms), then renders a target.
  3. On spawn, it sends { type: "spawn", target: { x, y }, ... } so the robot can move.
  4. On tap (pointerdown), it measures the elapsed time with performance.now() and sends { type: "hit", reactionMs, tap, ... }.
  5. If the dot isn't tapped within 5 s it reports { type: "miss" } and schedules another.

All coordinates are in CSS pixels in the browser viewport (top-left origin) on the paired device. The hello and spawn messages also include viewport, screen, playArea, and devicePixelRatio so the robot side can calibrate.

Recommended setup — everything on the robot machine

Run the web app and the Python server on the same machine the robot is attached to. The iPad joins the same Wi-Fi and opens both over plain HTTP / ws://. No TLS, no tunnels, no mixed-content issues.

# On the robot machine (Linux):
git clone git@github.com:davidavidavidavi/whackmole.git
cd whackmole

# 1. Web app
npm install
npm run dev          # http://0.0.0.0:4000

# 2. Python server (in a second terminal)
cd python
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python server.py        # ws://0.0.0.0:8765

Find the robot machine's IP:

hostname -I            # e.g. 192.168.10.42

On the iPad, open:

http://192.168.10.42:4000

The WebSocket URL is pre-filled to ws://<that-same-host>:8765, so just tap Connect.

The Python terminal prints each event:

listening on ws://0.0.0.0:8765
[connect] 192.168.10.20:53412
[paired]  viewport=1024x768  screen=1024x768  dpr=2
[spawn]   id=k2x9a3qr  x=512  y=384  size=84  delay=1700ms
[hit]     id=k2x9a3qr  reaction=312ms  tap=(514, 380)

Replace the placeholder logic in python/server.py with your motion controller. Spawn messages give you the target's (x, y) in viewport pixels on the iPad.

Deploying the web app to Vercel

The web app is a vanilla Next.js project — Vercel auto-detects it.

  1. Push the repo to GitHub (already done).
  2. Go to https://vercel.com/new and import the repo.
  3. Accept the defaults and deploy. You'll get an https://*.vercel.app URL that works on any iPad.

.vercelignore keeps the python/ folder out of the build upload.

Mixed content: HTTPS pages need wss://

A Vercel deploy is served over HTTPS. From an HTTPS page, browsers block plain ws:// WebSocket connections — the connection will fail silently except for the inline warning the app shows. You need wss:// (TLS) on the Python side.

Easiest options to put TLS in front of python/server.py:

  • Tailscale Funnel — if both devices are on Tailscale.
    tailscale serve --bg --tls-terminated-tcp=443 tcp://localhost:8765
    tailscale funnel 443 on
    
    Then use wss://<your-tailnet-name>.ts.net:443 in the app.
  • Cloudflare Tunnel — no static IP needed.
    cloudflared tunnel --url http://localhost:8765
    
    Use the wss://*.trycloudflare.com URL it prints.
  • Caddy in front, with a real domain pointing at the machine.

For local development the dev server is plain HTTP, so plain ws:// works fine — no proxy needed.

Message protocol

Client → server:

// On connect
{ "type": "hello", "viewport": {...}, "screen": {...}, "playArea": {...}, "devicePixelRatio": 2, "userAgent": "...", "ts": 0 }

// When a target is rendered
{ "type": "spawn", "id": "...", "target": { "x": 512, "y": 384 }, "local": {...}, "playArea": {...}, "size": 84, "delayMs": 1700, "viewport": {...}, "screen": {...}, "devicePixelRatio": 2, "ts": 0 }

// When the user taps the target
{ "type": "hit", "id": "...", "reactionMs": 312, "tap": { "x": 514, "y": 380 }, "ts": 0 }

// When the target times out unhit
{ "type": "miss", "id": "...", "reason": "timeout", "ts": 0 }

// When the operator hits Stop
{ "type": "stop", "ts": 0 }

The server doesn't need to send anything back, though the connection stays open so it can if you extend the protocol.

Notes for iPad use

  • pointerdown is used so the timer captures touch start, not click (avoids the synthetic-click delay).
  • Pinch zoom and tap-zoom are disabled via the viewport meta and touch-action: manipulation.
  • For the cleanest fullscreen experience, add the page to the home screen.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages