Imported from yoannmoinet/nipplejs (
AGENTS.md). Install upstream withnpx skills add yoannmoinet/nipplejs. Copyright stays with the author.
CLAUDE.md — NippleJS
Project Overview
NippleJS is a vanilla virtual joystick library for touch-capable interfaces. Zero dependencies, TypeScript-first, supports mouse and touch input.
Repository: github.com/yoannmoinet/nipplejs
License: MIT
Monorepo Structure
Yarn 4 workspaces ("packageManager": "yarn@4.6.0"). Run yarn install from root.
packages/
├── nipplejs/ # Core library (TypeScript, Rollup build)
├── docs/ # Documentation website (Astro 6, Tailwind 4)
├── tests/ # E2E (Playwright) and unit (Jest) tests
├── tools/ # Build/version management CLI (Clipanion)
└── assets/ # Logo images
Key Commands
yarn build # Build nipplejs library (Rollup)
yarn lint # ESLint (flat config, ESLINT_USE_FLAT_CONFIG=true)
yarn test:unit # Jest unit tests (196 tests)
yarn test:e2e # Playwright e2e tests (50 tests)
yarn typecheck # TypeScript type checking
yarn workspace @nipple/docs dev # Astro docs dev server (localhost:4321)
yarn workspace @nipple/docs build # Build static docs site
Architecture — Core Library
Class Hierarchy
Super (event system, DOM binding, logging)
├── Factory (singleton, manages all collections, document-level events)
├── Collection (manages joysticks in a zone, returned by nipplejs.create())
└── Joystick (individual joystick instance, DOM + position + direction)
Event Flow
Joystick → Collection (bubbled) → Factory (bubbled). Events are space/comma-separated strings. Handler signature: single argument (evt) => { evt.type, evt.data }.
IMPORTANT: v1 uses a single evt argument, NOT (evt, data). The old v0 two-argument signature is gone.
Key Files
packages/nipplejs/src/index.ts— Public API:create(),factory,setLogLevel(),getLogLevel()packages/nipplejs/src/Collection.ts— processOnStart/Move/End, reposition(), ResizeObserverpackages/nipplejs/src/Joystick.ts— buildEl(), start(), end(), computeDirectionAndTriggerEvents(), triggerDirectionEvents()packages/nipplejs/src/Super.ts— on/off/trigger event system, logLevel, bindEvt/unbindEvtpackages/nipplejs/src/types.ts— All TypeScript interfacespackages/nipplejs/src/constants.ts— Event bindings (pointer/touch/mouse), modes. SSR-safe (typeof windowguards).packages/nipplejs/src/utils.ts— processEvents(), distance(), angle(), etc.
Important Behaviors
moveevent fires continuously on every pointermove, not just on direction changes.coloroption accepts a string OR{ front: string, back: string }object. CSSbackgroundproperty is used, so gradients, images, andurl()work.baseDeltais included in move event data whenfollow: true. It contains the per-frame joystick base displacement.reposition()is a public method on Collection. Called automatically via ResizeObserver on the zone. Also refreshesfactory.scroll.logLeveldefaults to'warning'. Set vianipplejs.setLogLevel().log()only fires atdebuglevel.- Zone position warning — Collection constructor warns if zone has
position: static. - Pointer events are the primary binding (
pointerdown,pointermove,pointerup). Falls back to touch/mouse. preventDefault()only on move events — not onpointerdown, to avoid breaking multitouch. The zone setstouch-action: noneanduser-select: noneinstead.- SSR-safe —
windowaccess inconstants.tsis guarded withtypeof window !== 'undefined'.
Architecture — Docs Website
Astro 6 standalone (no Starlight) with Tailwind CSS 4 via @tailwindcss/vite.
Visual Theme: "Aurora Neon"
- Dark base
#08081awith 48px grid overlay - Animated gradient blobs (indigo, cyan, pink) with 80px blur
- Frosted glass surfaces (
.glassclass) - Cursor-following grid highlight via CSS mask (disabled on mobile)
- Monospace accents (JetBrains Mono)
Game Demos
5 interactive canvas games, each showcasing a nipplejs option:
| Game | File | Option |
|---|---|---|
| Neon Snake | neon-snake.ts |
mode: 'static' |
| Asteroid Dodge | asteroid-dodge.ts |
lockX: true |
| Dual-Stick Arena | dual-stick-arena.ts |
multitouch (two create() calls) |
| Space Observatory | space-observatory.ts |
follow: true + baseDelta |
| Space Drift | space-drift.ts |
restJoystick: false |
Game Architecture
- Games use
offsetWidth/offsetHeightfor canvas sizing (NOTgetBoundingClientRect()which is affected by CSS transforms). - Mobile performance:
shadowBluris disabled on mobile (isMobileflag). Logo particles and grid highlight are disabled on touch devices. - Firefox: 1.3x
speedScalemultiplier to compensate for slower Canvas2D frame rates. speedScale: computed from canvas diagonal relative to 800px reference, so fullscreen games feel proportional.- Particle effects: Explosion on enemy kill (dual-stick, asteroid-dodge), consumption sparkle on orb/target/waypoint collection (snake, observatory, space-drift). Screen shake and flash on impact.
- Memory cleanup: All game arrays cleared in
destroy()to prevent leaks between game switches. - Difficulty ramp: Asteroid Dodge and Dual-Stick Arena get progressively harder — faster enemies, shorter spawn intervals.
Game-Specific Details
- Neon Snake: Green/emerald color scheme (
#10b981), distinct from orbs. Thick body with dark border outline, highlight stripe, eyes tracking heading. - Asteroid Dodge: Canvas tilt (lerped) in movement direction. Pre-clears canvas before tilt to hide edge artifacts. Fast background motion lines.
- Dual-Stick Arena: SVG icons on joystick thumbs (move arrows, crosshair). Files at
public/assets/move.svg,public/assets/shoot.svg. - Space Observatory: Scope SVG at
public/assets/scope.svg. UsesrestOpacity: 0.8for always-visible joystick. Back gradient with hard-edge border ring. - Space Drift: Exhaust particles from ship back while joystick has force.
joystickForcepersists after release (matchesrestJoystick: false).
JoystickDemo Component
packages/docs/src/components/JoystickDemo.astro — orchestrates games with:
- Code pane (Shiki syntax highlighted, cached singleton highlighter on
globalThis) - Debug overlay (live event data + event log, cleared on restart to prevent duplication)
- Fullscreen mode (moves wrapper to
document.bodyto escape transform context) - Start overlay with game title, description, badges, and Start button
- Game buttons (fullscreen, code, debug) at z-30, clickable without starting the game
- Only one game active at a time (global
window.__nippleGames) - Code pane toggle triggers
reposition()after transition - Code pane closes on carousel navigation and game switch
Code snippets in JoystickDemo.astro must stay in sync with actual game configs in packages/docs/src/games/*.ts — especially color values, restOpacity, icon references.
Landing Page Carousel
Flex-based horizontal carousel with translateX transitions. Slides at 75% width, neighbors visible with blur/tilt (perspective/rotateY on desktop only). Full viewport width (breaks out of max-w-5xl).
- Active slide z-20 (above arrows), neighbors z-1 (behind arrows)
- Track wrapper at z-10 so active game + code pane renders above arrows
- Stops game loops for non-active slides (performance)
- Calls
reposition()on active games after 550ms transition - Closes all code panes on
goTo() - Firefox detection: skips
filter: blur()on neighbors (Bug 1125767) - Mobile: full-width slides, no perspective, particles/grid disabled
OG Images
Three sizes in packages/docs/public/assets/:
og-1200x630.png— Open Graph (Facebook, LinkedIn, Discord)og-1200x600.png— Twitter/Xsummary_large_imageog-800x800.png— Square (WhatsApp, Slack)
Captured with Playwright from the hero section. To regenerate: start the dev server, run a Playwright script that hides the header/buttons/toolbar, injects extra padding on #hero, and clips the section at exact target dimensions. The aurora background fills seamlessly — no solid color bars.
Meta tags in BaseLayout.astro: og:image, og:title, og:description, twitter:card, twitter:image.
Code Style & Conventions
- 4 spaces indentation
- ESLint flat config (
eslint.config.mjs), Prettier integration - Husky pre-commit with lint-staged
- Single quotes, trailing commas
arca/import-orderingandarca/newline-after-import-sectionfor import organization@typescript-eslint/consistent-type-imports— useimport typefor type-only imports
Testing
- Unit tests: Jest with ts-jest. Test files colocated:
*.test.tsnext to source. - E2E tests: Playwright, Chromium only. Tests in
packages/tests/src/e2e/. - Test fixtures:
packages/tests/src/_playwright/testParams.tsprovidesstartJoystick,moveJoystick,releaseJoystick,locateJoystick. - Codepen demo page:
packages/tests/src/_playwright/public/codepen-demo.html— used by e2e tests. Initial collection creation is synchronous (createCollection('dynamic')not throttled).
Known Testing Gotchas
- E2e
waitForFunctiontimeouts are 2000ms (increased from 500ms for CI reliability). - The codepen demo's
createThrottlehad a race condition with test clicks — fixed by callingcreateCollection()directly at page load. - Static indices (
Collection.index,Joystick.index) are global — test expectations depend on creation order. - Directional events e2e test needs 400ms delay between moves to let dynamic joysticks fully fade and be destroyed.
CI/CD
- CI:
.github/workflows/ci.yaml— unit tests, e2e tests, linting + typecheck (includingastro checkon docs) on PR. - Release:
.github/workflows/release.yaml— publish to NPM with OIDC provenance. Triggers on GitHub release publish or manual dispatch with optional version input. Updates and commitspackage.jsonwhen version is provided. Restricted tomasterbranch. - Docs deploy:
.github/workflows/docs.yaml— builds and deploys to GitHub Pages (on push to master + manual dispatch). Usescorepack enablefor Yarn 4. - GitHub Pages base path:
/nipplejs/
Migration Notes (v0 → v1)
maxNumberOfNipples→maxNumberOfJoysticksdestroyedevent →joystickDestroyedjoystick.el→joystick.ui.elshow(),hide(),add(),remove()removedmanager.get()→manager.getJoystickByUid()ormanager.allmanager.id→manager.uid,manager.idsremoved- Event handler:
(evt, data)→(evt)withevt.data movenow fires continuously (was direction-change only)