Touch games — the play area is the controller
prompts/pkg/llms/touch-games.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Load this beside whatever draws the game — three-js, voxel, physics, p5, worlds,
pouf-game, a plain <canvas> — whenever the app is something you play or steer in real time:
a runner, a racer, a flyer, a shooter, a snake, a launcher, a platformer, a pinball table, a
marble maze, a 3D scene you move through, a toy you drag around. Most people open a vibe on a
phone, one-handed. The game that feels great there is the one where the thumb rests on the
play area itself and swipes, holds and pulls, with nothing to hunt for.
The play area is the controller: the whole game surface carries select-none touch-none, and no fixed on-screen buttons or joystick stand in for the thumb.
The game fills the screen: the play surface is a relative h-dvh w-full root, never a card on a scrolling page — scores, records and leaderboards go behind the menu button, not below the game.
Three things make that true, and each has one home:
- The surface belongs to the finger.
select-none touch-noneon the full-screen container and the<canvas>(and the HUD inside it), plusonContextMenucancelled, so a press or drag steers the game while nearby text stays put — no selection highlight, no copy callout, no page scroll, no pinch zoom, no pull-to-refresh. - One input path for every pointer. Input runs on Pointer Events with
setPointerCaptureon one trackedpointerId, so mouse, pen and finger share one path. A 3D world you walk through gets that path fromuseWorldControls(see Walk + look below). - The game says what it understood. A short fading trail under the finger, plus an arrow, ghost stick or aim line, so the player sees which gesture the game read.
Pick the scheme from the game
| The game | The scheme | Feels like |
|---|---|---|
| Moves in steps: lanes, a grid, a snake, 2048, a turn-based dodge | Flick — each swipe is one move | Subway runner, snake |
| Steers freely in 2D: an arena, a top-down racer, a fish tank | Floating stick — a ghost stick appears under the thumb | twin-stick lite |
| Flies or drives forward: a glider, a ship, a car | Hold + steer — thumb down = throttle, offset = steer | canyon flyer |
| Aims then commits: a launcher, golf, pool, basketball | Pull back + release | slingshot |
| Moves in steps AND has one action | Flick + tap on the same surface | snake with a dash |
| Walks or flies through a 3D world: a block world, a garden, a ruin to explore | Walk + look — useWorldControls |
first-person explorer |
A desktop visitor gets the same game: the mouse drives the same pointer path, and arrow keys / WASD / Space call the same action functions the gestures call.
The surface
jsxexport default function App() {
const rootRef = React.useRef(null);
const canvasRef = React.useRef(null);
// … engine in one useEffect([]) — see the input sections below …
return (
<div
ref={rootRef}
className="relative h-dvh w-full overflow-hidden bg-background select-none touch-none"
onContextMenu={(e) => e.preventDefault()}
>
<canvas ref={canvasRef} className="absolute inset-0 block w-full h-full select-none touch-none" />
<div className="absolute top-3 left-3 pointer-events-none">
<p className="text-2xl font-bold text-foreground">{score}</p>
<p className="text-sm text-muted-foreground">swipe ← → to switch lanes · ↑ jump · ↓ slide</p>
</div>
</div>
);
}
- Full screen is the default. The game is the whole view: a
relative h-dvh w-fullroot with the canvasabsolute inset-0. Everything that is not play — the title, settings, high scores, sharing, instructions beyond the one-line hint — lives behind one menu button in the top-right corner that toggles an overlay (the worked example below shows it). When the person asks for a smaller view,three-jsshows the boxed viewport. - Listen on the root, not the canvas, so a thumb that lands on the HUD still steers. Only the
read-only HUD — score and hint text — is
pointer-events-none. Everything a finger presses (a Start or Play again button, the menu button, the menu itself) sits outside that wrapper withonPointerDown={(e) => e.stopPropagation()}, as the worked example's menu does; a start screen inside apointer-events-nonebox can never be tapped. Simplest of all: no Start button — the first touch on the play area starts the run. - The bottom-right corner belongs to the platform (the Vibes logo sits there). Keep the HUD top-left and leave the lower third open — that is where the thumb rests, and where a launcher's pull starts.
- One line of hint text names the gestures in the HUD; that line is the whole tutorial.
- HUD ink contrasts with what the canvas paints. A neon game painted near-black needs light
HUD text: either paint the canvas from the theme (
bg-background, ink read from the root's computedcolor, as the worked example does) or give the HUD aCard/bg-background/80backing so the score reads on any frame. - Start on the first touch. The touch that starts a run only starts it (it does not also jump or dash); a tap restarts after game over, after a short grace period so a frantic swipe at the moment of the crash does not skip the score screen.
Flick: a swipe fires during the move
A swipe fires during the move, the moment the finger has travelled past the threshold, never on finger-up. That single choice is what makes a lane change feel instant. The rest of the recogniser:
- Dominant axis with a ratio guard. Fire only when one axis beats the other by about 1.35×; a diagonal keeps waiting until the stroke commits.
- Chaining. After a swipe fires, the anchor follows the finger while it keeps going the same way, so a long stroke fires once — and when the finger bends, it fires again. One L-shaped stroke is "lane, then jump"; the thumb never has to lift.
- A tap is short and small. Under ~12px of travel and ~250ms. Travel between the tap limit and the swipe threshold fires nothing: that gap is what keeps taps and swipes from ever misfiring as each other.
- Buffer what can't run yet. A swipe up made mid-jump queues for ~380ms and runs on landing.
The worked example at the end of this doc carries the whole recogniser as attachSwipe — copy it
as written. The two lines that are easiest to lose are the ones that matter most: g.last and the
anchor that follows the finger along the last direction. Without them a long stroke fires a move
every 26px — one swipe up becomes four jumps.
A grid game ignores a swipe straight back into itself (a snake never reverses) and keeps a two-turn queue so a fast thumb never loses a turn.
Floating stick: the stick appears under the thumb
Wherever the thumb lands becomes the stick's centre. The offset from that point is direction and throttle: nothing inside a small dead zone, full speed a thumb-width out, on a curve that gives fine control near the centre. Past the leash, the centre drifts after the thumb, so a long drag never runs out of stick. Release lets the ship coast down instead of stopping dead.
jsxconst STICK = { deadZone: 8, fullAt: 60, leash: 74, curve: 1.35 };
// stick = { cx, cy } set on pointerdown at the touch point; call on every pointermove.
function readStick(stick, x, y) {
let dx = x - stick.cx;
let dy = y - stick.cy;
let d = Math.hypot(dx, dy);
if (d > STICK.leash) {
const k = (d - STICK.leash) / d; // drag the centre along behind the thumb
stick.cx += dx * k;
stick.cy += dy * k;
dx = x - stick.cx;
dy = y - stick.cy;
d = STICK.leash;
}
if (d < STICK.deadZone) return { x: 0, y: 0, power: 0 };
const lin = Math.min(1, (d - STICK.deadZone) / (STICK.fullAt - STICK.deadZone));
const power = Math.pow(lin, STICK.curve);
return { x: (dx / d) * power, y: (dy / d) * power, power };
}
Draw the ghost stick at the centre — a faint ring at fullAt, a knob at the thumb, an arc that
brightens at full power — and fade it out over 170ms on release. Smooth the velocity toward the
stick's target (85ms to accelerate, ~500ms to coast) so movement feels buttery, not twitchy.
Hold + steer: thumb down is the throttle
For anything that flies or drives forward. Thumb down anywhere = boost; lift = glide (slow, sink, level the wings). While held, the thumb's offset from where it landed banks and pitches, like a trackpad, with the same leash as the floating stick (~85px for full bank, a 6px dead zone, smoothed). A quick flick while holding — over ~34px at ≥1 px/ms within ~90ms — is the one special move (a barrel roll, a dodge); re-anchor the touch point after it so the flick does not also leave the ship pitched. Sell the throttle with the camera: widen the field of view (66° → 86°) and let the chase camera lag while boosting.
Hitting a wall grinds — bounce, lose speed, reset the combo, throw sparks — so one steering mistake costs momentum rather than the run.
Pull back + release
The pull starts anywhere on the play area — the thumb never has to find the projectile. The
launch goes opposite the drag, power is the drag length up to a cap (120px), and a dotted arc
previews the shot while the thumb is down. Release fires; dragging back inside a small ring at
the start point (24px) cancels, and the ring says so. The thumb usually starts in the lower
third, so put the ground at about two-thirds of the screen height and leave room below it to
pull. Preview only part of the arc (about 0.85s of flight) so aiming stays a skill.
Walk + look: move through a 3D world
A 3D world you walk, fly or look around in gets its controls from useWorldControls (from use-vibes), on phone and desktop alike. Hand it the root element and what each act means; the hook owns the gestures, keeps the root touch-none, and sizes the lens when you pass it the camera.
Left half walks: a floating stick appears under the thumb. Right half looks: drag to turn, and a finger held near the screen's edge keeps turning.
Tap acts on what is under the finger; press and hold is the second action; a quick upward flick on the look side jumps.
- The jump is judged as the finger lifts, and the flick's up/down look is held back, so the camera stays level through every jump.
mode: "player"walks, looks and flicks;mode: "free"orbits a camera that zooms with a pinch or the mouse wheel (onPinch(factor), under 1 zooms in). Withflying: true, a flick down (onFlick("down")) sinks.- The callbacks speak screen sense.
onMove(x, y)is the walk vector with forward aty < 0;onLook(dx, dy)andonTurn(yaw, pitch)arrive in radians, applied the same way;onTap,onHoldandonAimcarry client coordinates. - Call
tick(dt)once per frame from the animation loop; the edge turn, the hold timer and the finger trail advance on that clock, andkeysholds every key code down right now. - The lens follows the screen. Pass the
THREE.PerspectiveCameraascameraand the hook keeps its aspect current and widens the view on a phone held upright. - HUD controls live inside the root. A press on a button, input or link there is that
control's own and never a world tap; mark any other piece that keeps its own touches (a slider,
a chip row) with
data-world-controls-ignore.
A 🎥 button outside the menu cycles the views; on a phone the world opens in a free-cam overview that frames the whole scene. The views are Free cam → First person → Shoulder view; a desktop visitor opens in first person.
The overview's distance is the app's: scale the orbit radius by
0.36 / (tan(fov / 2) · aspect), clamped to 1–2.2×, each frame, so a phone held upright still
frames the whole scene.
The one-line hint follows the current view.
The ☰ menu holds everything else, in this order: the views by name, fullscreen, how to play, who else is here and what they did lately (players and activity), then invite a friend and all worlds.
The desktop twin is the same world. In the player views a click-to-play overlay calls
requestLock(), so the mouse looks under pointer lock; WASD or the arrows walk and Space jumps.
locked reports the lock, so the overlay comes back when the player presses Esc, and isTouch
keeps the overlay off phones. While locked, a click under the crosshair is the tap and a
right-click is the hold (onTap / onHold at the screen centre). Free cam needs no lock: drag
orbits, a click is onTap, a right-click is onHold, and the wheel zooms.
App.jsx
jsximport React from "react";
import * as THREE from "three";
import { useWorldControls } from "use-vibes";
import { Button, Card, Heading, IconButton, Stack, Text } from "@vibes.diy/pouf";
const VIEWS = ["Free cam", "First person", "Shoulder view"];
const HINT = {
"Free cam": "drag to look around · pinch to zoom · 🎥 to step in",
"First person": "left thumb walks · right thumb looks · flick up to jump",
"Shoulder view": "tap a hedge to bloom it · hold to trim it back",
};
const stop = (e) => e.stopPropagation();
export default function App() {
const rootRef = React.useRef(null);
const canvasRef = React.useRef(null);
const [camera] = React.useState(() => new THREE.PerspectiveCamera(70, 1, 0.1, 500));
const [view, setView] = React.useState(() => (matchMedia("(pointer: coarse)").matches ? "Free cam" : "First person"));
const [menuOpen, setMenuOpen] = React.useState(false);
const me = React.useRef({ pos: new THREE.Vector3(0, 0, 12), yaw: 0, pitch: 0, move: [0, 0], vy: 0, zoom: 34 });
const world = React.useRef({ hedges: [] });
const turn = (yaw, pitch) => {
me.current.yaw += yaw;
me.current.pitch = Math.max(-1.5, Math.min(1.5, me.current.pitch + pitch));
};
const look = (dx, dy) => turn(-dx, -dy); // radians, screen sense: a drag right turns right
const hedgeAt = (x, y) => {
const r = rootRef.current.getBoundingClientRect(); // taps arrive in client coordinates
const ray = new THREE.Raycaster();
ray.setFromCamera(new THREE.Vector2(((x - r.left) / r.width) * 2 - 1, 1 - ((y - r.top) / r.height) * 2), camera);
return ray.intersectObjects(world.current.hedges)[0]?.object;
};
const { isTouch, locked, requestLock, tick } = useWorldControls(rootRef, {
mode: view === "Free cam" ? "free" : "player",
camera,
onMove: (x, y) => (me.current.move = [x, y]),
onLook: look,
onTurn: look,
onPinch: (factor) => (me.current.zoom = Math.max(8, Math.min(60, me.current.zoom * factor))),
onTap: (x, y) => hedgeAt(x, y)?.material.color.set(0xff6fb1),
onHold: (x, y) => hedgeAt(x, y)?.material.color.set(0x2f7a3a),
onFlick: (dir) => dir === "up" && me.current.pos.y === 0 && (me.current.vy = 7),
});
const live = React.useRef({});
live.current = { tick, view };
React.useEffect(() => {
const root = rootRef.current;
const renderer = new THREE.WebGLRenderer({ canvas: canvasRef.current, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const size = new ResizeObserver(() => renderer.setSize(root.clientWidth, root.clientHeight, false));
size.observe(root);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9fe3ff);
scene.add(new THREE.HemisphereLight(0xffffff, 0x3b5d2a, 1.4));
const groundGeo = new THREE.PlaneGeometry(60, 60);
const ground = new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({ color: 0x6fcf5b }));
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
const hedgeGeo = new THREE.BoxGeometry(5, 2, 1);
for (let i = 0; i < 10; i++) {
const hedge = new THREE.Mesh(hedgeGeo, new THREE.MeshLambertMaterial({ color: 0x2f7a3a }));
hedge.position.set(Math.cos(i * 0.63) * 18, 1, Math.sin(i * 0.63) * 18);
hedge.lookAt(0, 1, 0);
scene.add(hedge);
world.current.hedges.push(hedge);
}
let raf = 0;
let last = performance.now();
const frame = (now) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
live.current.tick(dt);
const m = me.current;
const fwd = new THREE.Vector3(-Math.sin(m.yaw), 0, -Math.cos(m.yaw));
const side = new THREE.Vector3(-fwd.z, 0, fwd.x);
m.pos.addScaledVector(fwd, -m.move[1] * 5 * dt).addScaledVector(side, m.move[0] * 5 * dt); // forward is y < 0
m.vy -= 20 * dt;
m.pos.y = Math.max(0, m.pos.y + m.vy * dt);
if (m.pos.y === 0) m.vy = 0;
if (live.current.view === "Free cam") {
// Back the overview off on a narrow screen so the whole garden stays in frame.
const fit = Math.min(2.2, Math.max(1, 0.36 / (Math.tan((camera.fov * Math.PI) / 360) * camera.aspect)));
const d = m.zoom * fit;
camera.position.set(m.pos.x + Math.sin(m.yaw) * d, d * 0.7, m.pos.z + Math.cos(m.yaw) * d);
camera.lookAt(m.pos);
} else {
camera.position.copy(m.pos).y += 1.6;
if (live.current.view === "Shoulder view") camera.position.addScaledVector(fwd, -4).y += 1;
camera.rotation.set(m.pitch, m.yaw, 0, "YXZ");
}
renderer.render(scene, camera);
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
size.disconnect();
world.current.hedges.forEach((hedge) => hedge.material.dispose());
world.current.hedges = [];
hedgeGeo.dispose();
groundGeo.dispose();
ground.material.dispose();
renderer.dispose();
};
}, [camera]);
const cycle = () => setView(VIEWS[(VIEWS.indexOf(view) + 1) % VIEWS.length]);
return (
<div ref={rootRef} className="relative h-dvh w-full overflow-hidden bg-background text-foreground select-none touch-none">
<canvas ref={canvasRef} className="absolute inset-0 block w-full h-full select-none touch-none" />
<div className="absolute top-3 left-3 pointer-events-none">
<Text size="sm">{HINT[view]}</Text>
</div>
<div className="absolute top-3 right-3 flex gap-2" onPointerDown={stop}>
<IconButton icon="🎥" label={`View: ${view}`} onClick={cycle} />
<IconButton icon="☰" label={menuOpen ? "Hide menu" : "Show menu"} onClick={() => setMenuOpen(!menuOpen)} />
</div>
{menuOpen && (
<div className="absolute top-16 right-3 w-72" onPointerDown={stop}>
<Card>
<Stack gap={2}>
<Heading level={2}>Hedge Garden</Heading>
{VIEWS.map((v) => (
<Button key={v} variant={v === view ? "solid" : "quiet"} onClick={() => setView(v)}>
{v}
</Button>
))}
<Text size="sm" muted>
Left thumb walks, right thumb looks. Tap a hedge to bloom it, hold to trim it, flick up to jump.
</Text>
</Stack>
</Card>
</div>
)}
{!isTouch && !locked && view !== "Free cam" && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-background/80" onPointerDown={stop}>
<Card>
<Stack gap={3}>
<Heading level={2}>Hedge Garden</Heading>
<Button size="lg" onClick={requestLock}>
Click to play
</Button>
<Text size="sm" muted>
WASD walk · mouse look · Space jump · click bloom · Esc frees the mouse
</Text>
</Stack>
</Card>
</div>
)}
</div>
);
}
Grow it by changing what the acts mean: voxel makes the tap build and the hold break, a ruin
makes the tap open a door. The gesture numbers live in the hook; pass tune to adjust them.
Teach the gesture once, then get out of the way
- A ghost thumb on first run. Before the first touch, a translucent thumb with a short motion streak plays the game's one gesture on a loop, with a short caption ("hold to fly · slide to steer"). The first touch on the play area dismisses it for good; a tap on the menu button does not.
- The menu pauses the game. Opening the menu freezes the clock (and the screen shake); closing
it resumes. The menu button stops
pointerdownfrom reaching the root, so pressing it never steers, jumps or fires.
Feedback the player can read
Every game draws the finger trail and the what-fired cue; they are how a player on a phone learns the controls without a tutorial.
- Finger trail: keep the last ~260ms of pointer positions and draw them fading, on a canvas layer over the game.
- What fired: a short arrow flash at the finger for a swipe, a pulse ring for a tap, an outline arrow when an action is queued.
- Juice on every action: a few particles, a small screen shake on impacts,
navigator.vibrate?.(10)where the phone supports it, and WebAudio beeps created on the first gesture (audio can only start from a user gesture).
The engine around it
One useEffect(() => {…}, []) owns the loop, the listeners and the cleanup; the loop is one
requestAnimationFrame with dt clamped to 1/30s so a backgrounded tab does not teleport the
player. Size the canvas from the root's box with devicePixelRatio capped at 2, re-size on
resize, and remove every listener and dispose every GPU object on unmount. Keep the gesture
numbers in one tunables object at the top of App.jsx with a comment on each, so the feel can be
tuned in one place.
Worked example: a three-lane dodge, start to finish
The whole shape in one file: the surface, the mid-move swipe recogniser, a keyboard twin, a
finger trail, and an engine that cleans up after itself. Grow it into a runner, a racer or a
snake by changing what move() does.
App.jsx
jsximport React from "react";
import { Card, Heading, IconButton, Stack, Text } from "@vibes.diy/pouf";
const TUNE = {
swipePx: 26, // travel from the anchor before a swipe fires (raise to 30–34 if a resting thumb misfires)
axisRatio: 1.35, // dominant axis must beat the other by this much
chainCooldownMs: 60, // min gap between two swipes inside one touch
tapPx: 12, // a touch that travels less than this…
tapMs: 250, // …and lifts within this is a tap
trailMs: 260, // finger trail fade
laneMs: 120, // lane-change tween
};
// Pointer Events on the root: fires a swipe mid-move, chains when the stroke bends, taps on a short touch.
function attachSwipe(root, { onSwipe, onTap, onTrail }) {
const g = { id: null, ax: 0, ay: 0, sx: 0, sy: 0, t0: 0, last: null, lastAt: 0, travel: 0 };
const DIRS = { left: [-1, 0], right: [1, 0], up: [0, -1], down: [0, 1] };
const at = (e) => {
const r = root.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
};
const down = (e) => {
if (g.id !== null) return;
g.id = e.pointerId;
root.setPointerCapture(e.pointerId);
const [x, y] = at(e);
Object.assign(g, { ax: x, ay: y, sx: x, sy: y, t0: performance.now(), last: null, lastAt: 0, travel: 0 });
onTrail(x, y);
};
const move = (e) => {
if (e.pointerId !== g.id) return;
const [x, y] = at(e);
onTrail(x, y);
g.travel = Math.max(g.travel, Math.hypot(x - g.sx, y - g.sy));
if (g.last) {
const [ux, uy] = DIRS[g.last];
const along = (x - g.ax) * ux + (y - g.ay) * uy;
if (along > 0) (g.ax += ux * along), (g.ay += uy * along);
}
const dx = x - g.ax;
const dy = y - g.ay;
if (Math.hypot(dx, dy) < TUNE.swipePx) return;
const ax = Math.abs(dx);
const ay = Math.abs(dy);
const dir =
ax >= ay * TUNE.axisRatio ? (dx < 0 ? "left" : "right") : ay >= ax * TUNE.axisRatio ? (dy < 0 ? "up" : "down") : null;
if (!dir) return;
const now = performance.now();
g.ax = x;
g.ay = y;
if (dir === g.last || now - g.lastAt < TUNE.chainCooldownMs) return;
g.last = dir;
g.lastAt = now;
onSwipe(dir);
};
const up = (e) => {
if (e.pointerId !== g.id) return;
g.id = null;
const quick = performance.now() - g.t0 < TUNE.tapMs;
if (e.type === "pointerup" && !g.last && g.travel < TUNE.tapPx && quick) onTap();
};
root.addEventListener("pointerdown", down);
root.addEventListener("pointermove", move);
root.addEventListener("pointerup", up);
root.addEventListener("pointercancel", up);
return () => {
root.removeEventListener("pointerdown", down);
root.removeEventListener("pointermove", move);
root.removeEventListener("pointerup", up);
root.removeEventListener("pointercancel", up);
};
}
export default function App() {
const rootRef = React.useRef(null);
const canvasRef = React.useRef(null);
const [score, setScore] = React.useState(0);
const [over, setOver] = React.useState(false);
const [menuOpen, setMenuOpen] = React.useState(false);
React.useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
const ink = getComputedStyle(root).color; // text-foreground, so the drawing follows the theme
const s = { lane: 1, x: 1, rocks: [], t: 0, spawn: 0, dead: false, trail: [] };
let w = 0;
let h = 0;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = root.clientWidth;
h = root.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const restart = () => {
Object.assign(s, { lane: 1, x: 1, rocks: [], t: 0, spawn: 0, dead: false });
setScore(0);
setOver(false);
};
const move = (dir) => {
if (s.dead) return;
if (dir === "left") s.lane = Math.max(0, s.lane - 1);
if (dir === "right") s.lane = Math.min(2, s.lane + 1);
navigator.vibrate?.(10);
};
const keys = (e) => {
const dir = { ArrowLeft: "left", ArrowRight: "right", a: "left", d: "right" }[e.key];
if (dir) move(dir);
if (e.key === " " && s.dead) restart();
};
const detach = attachSwipe(root, {
onSwipe: move,
onTap: () => s.dead && restart(),
onTrail: (x, y) => s.trail.push({ x, y, t: performance.now() }),
});
window.addEventListener("keydown", keys);
window.addEventListener("resize", resize);
resize();
let raf = 0;
let last = performance.now();
const frame = (now) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
const laneW = w / 3;
if (!s.dead) {
s.t += dt;
s.x += (s.lane - s.x) * Math.min(1, (dt * 1000) / TUNE.laneMs);
s.spawn -= dt;
if (s.spawn <= 0) {
s.rocks.push({ lane: Math.floor(Math.random() * 3), y: -40 });
s.spawn = Math.max(0.35, 0.9 - s.t * 0.01);
}
const speed = 260 + s.t * 8;
for (const r of s.rocks) r.y += speed * dt;
s.rocks = s.rocks.filter((r) => r.y < h + 40);
const py = h * 0.78;
if (s.rocks.some((r) => Math.abs(r.lane - s.x) < 0.5 && Math.abs(r.y - py) < 30)) {
s.dead = true;
navigator.vibrate?.(40);
setOver(true);
}
setScore(Math.floor(s.t * 10));
}
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
ctx.globalAlpha = 0.15;
for (let i = 1; i < 3; i++) ctx.fillRect(laneW * i - 1, 0, 2, h);
ctx.globalAlpha = 1;
for (const r of s.rocks) ctx.fillRect(laneW * r.lane + laneW / 2 - 18, r.y - 18, 36, 36);
ctx.beginPath();
ctx.arc(laneW * s.x + laneW / 2, h * 0.78, 20, 0, Math.PI * 2);
ctx.fill();
s.trail = s.trail.filter((p) => now - p.t < TUNE.trailMs);
s.trail.forEach((p, i) => {
ctx.globalAlpha = 1 - (now - p.t) / TUNE.trailMs;
ctx.beginPath();
ctx.arc(p.x, p.y, 3 + i * 0.2, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 1;
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
detach();
window.removeEventListener("keydown", keys);
window.removeEventListener("resize", resize);
};
}, []);
return (
<div
ref={rootRef}
className="relative h-dvh w-full overflow-hidden bg-background text-foreground select-none touch-none"
onContextMenu={(e) => e.preventDefault()}
>
<canvas ref={canvasRef} className="absolute inset-0 block w-full h-full select-none touch-none" />
<div className="absolute top-3 left-3 pointer-events-none">
<p className="text-2xl font-bold">{score}</p>
<p className="text-sm text-muted-foreground">
{over ? "tap to run again" : "swipe ← → to switch lanes"}
</p>
</div>
{/* One button holds everything that is not play; pointerdown stops here so a tap on it never steers. */}
<div className="absolute top-3 right-3" onPointerDown={(e) => e.stopPropagation()}>
<IconButton icon="☰" label={menuOpen ? "Hide menu" : "Show menu"} onClick={() => setMenuOpen(!menuOpen)} />
</div>
{menuOpen && (
<div className="absolute top-16 right-3 w-72" onPointerDown={(e) => e.stopPropagation()}>
<Card>
<Stack gap={2}>
<Heading level={2}>Lane Dash</Heading>
<Text size="sm">Swipe left or right to change lanes. Arrow keys work too.</Text>
<Text size="sm" muted>
Best run so far: {score}
</Text>
</Stack>
</Card>
</div>
)}
</div>
);
}