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. - 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 |
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.
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>
);
}