Overview
This is a 3D penalty shootout game that runs entirely in the browser. You line up behind the ball, flick to kick, and try to beat a diving goalkeeper before the timer runs out. Hit a top corner for a bonus, build a streak, chase the high score.
The core loop is simple, but getting it to feel good - responsive controls, a keeper that reacts believably, goals that land where you aimed - took a fair bit of work. This post covers the high level details of how it's put together.
With the 2026 World Cup on, there's no better time to step up and take your own penalty - so I figured I'd share how it was built.
The whole 3D scene lives inside a single R3F <Canvas>, with the goal, ball and keeper all rendered as children of a <Physics> world.
The Kick: Flick to Shoot ๐ฏ
The only input is a drag on the ball. @use-gesture's useDrag is bound to an invisible sphere sitting around the football (slightly larger than the ball so there's room for error). Drag back and flick - the direction and speed of that flick become the shot.
Before a drag becomes a kick it has to pass validation. This stops accidental taps and slow drags from registering as wild shots:
const validateDrag = (input: DragValidationInput): DragWarning => {
const { dragX, dragY, dragDuration } = input
const dragDistance = Math.sqrt(dragX * dragX + dragY * dragY)
const dragSpeed = dragDuration > 0 ? (dragDistance / dragDuration) * 1000 : 0
if (dragY > 0) return DragWarning.Backwards // dragged the wrong way
if (dragDistance < MIN_DRAG_DISTANCE) return DragWarning.MinimumDistanceNotMet
if (dragSpeed < MIN_DRAG_SPEED) return DragWarning.FlickSpeedSlow
return DragWarning.None
}A timeout also invalidates drags that are held too long - the intent is a quick flick, not a slow aim-and-release.
From Drag to Impulse
A valid flick is converted into a 3D impulse vector. The drag is normalised, scaled by how far you dragged (a dampener, so short flicks are weaker), and split into horizontal aim, vertical lift and forward power:
const forceX = normalizedDragX * dragScale * horizontalDirectionMultiplier
const forceY = -normalizedDragY * dragScale * verticalDirectionMultiplier
const forceZ = -dragScale * forwardPower
const kickVector = new Vector3(forceX, forceY, forceZ)
if (kickVector.length() > maxSpeed) kickVector.setLength(maxSpeed)The impulse is then applied to the ball's Rapier rigid body, and physics takes over.
Predicting the Trajectory ๐ฎ
Here's the part I'm most happy with. The moment the ball is kicked, the game already knows exactly where it will end up - before the physics has simulated a single frame.
After applying the impulse I read the ball's actual velocity back from Rapier, then solve projectile motion to find where it crosses the goal plane:
// x(t) = x0 + vx * t
// y(t) = y0 + vy * t - 0.5 * g * tยฒ
// z(t) = z0 + vz * t
const gravity = Math.abs(world.gravity.y) // read straight from Rapier
const timeToGoal = (goalZ - BALL_START_Z) / vz
const goalPlaneX = vx * timeToGoal
const goalPlaneY = BALL_START_Y + vy * timeToGoal - 0.5 * gravity * timeToGoal ** 2Gravity is read straight from the Rapier world, so the prediction matches the simulation exactly. Solve for the time the ball reaches the goal's Z, plug that back in for X and Y, and you have the landing point.
That landing point is classified into one of 14 zones - 7 inside the goal frame (corners + centre column) and 7 miss zones surrounding it:
โโโโโโโโโโโฌโโโโโโโโโโฌโโโโโโโโโโ
โMissOverLโMissOverCโMissOverRโ (above crossbar)
โโโโโโโโโโโผโโโโโโโโโโผโโโโโโโโโโค
โMissTopL โ GOAL โMissTopR โ (beside posts)
โโโโโโโโโโโค FRAME โโโโโโโโโโโค
โMissBotL โ โMissBotR โ
โโโโโโโโโโโดโโโโโโโโโโดโโโโโโโโโโWhy predict at all? Because the goalkeeper needs to react.
The Goalkeeper ๐งค
The keeper is a rigged GLB model (animated in Mixamo) with a set of dive, jump and tackle animations. The trick is choosing the right animation the instant you kick.
Because we already computed the target zone, the keeper just looks it up:
const ZONE_TO_ANIMATION: Record<BallZone, KeeperActionName> = {
[BallZone.TopLeft]: KeeperActionName.HighDiveLeft,
[BallZone.BottomLeft]: KeeperActionName.LowDiveLeft,
[BallZone.TopRight]: KeeperActionName.HighDiveRight,
[BallZone.BottomRight]: KeeperActionName.LowDiveRight,
[BallZone.CenterTop]: KeeperActionName.JumpCenter,
[BallZone.CenterMiddle]: KeeperActionName.ForwardTackle,
// ...miss zones still trigger a dive attempt
}Making Saves Real, Not Faked
The keeper genuinely saves the ball - there's no scripted outcome. Each frame, a handful of BallColliders are pinned to the keeper's key bones (hands, forearms, torso, head, feet) by reading each bone's world position out of the skeleton:
useFrame(() => {
if (!hasKicked) return
bones.forEach((bone, index) => {
const collider = colliderRefs.current[index]
if (!bone || !collider) return
bone.getWorldPosition(boneWorldPos)
collider.setTranslationWrtParent({
x: boneWorldPos.x,
y: boneWorldPos.y - KEEPER_Y_POSITION,
z: boneWorldPos.z - KEEPER_Z_POSITION,
})
})
})So if the diving animation puts a hand in the ball's path, the colliders actually intercept it and it counts as a save. The animation is chosen to dive toward your shot, but whether it gets there in time depends on the real collision.
Giving the Player a Chance โฑ๏ธ
If the keeper reacted instantly it'd be hard to beat. So the reaction is delayed, and the delay scales inversely with kick speed - smash it and the keeper barely moves in time; roll it in gently and the keeper gets there. That single knob does most of the work in making the difficulty feel fair.
Goals, Misses and Corners ๐ฅ
Rather than rely solely on physics resolution, the goal outcome is detected with Rapier sensors placed in world space:
- A deep goal sensor box behind the goal line catches anything that crosses it.
- Miss sensors form a frame around the posts and crossbar; the ball intersecting-then-exiting one registers a miss.
- Two small corner-spot sensors sit just in front of the goal sensor (so they trigger first) - hit one for a bonus "corner goal".
The corner sensors firing before the main goal sensor is handled with a tiny 10ms timeout on the goal sensor, giving the corner check a chance to win the race:
onSensorCollision({
payload,
result: KickResult.Goal,
sensorHit: 'goal',
timeout: 10, // let corner sensors claim the goal first
goalSide: isLeftSide ? 'left' : 'right',
})The sensor dimensions aren't hardcoded - they're derived from the goal model's geometry (measured via vertex analysis) and stored in the Zustand store, so the sensors always line up with whatever goal model is loaded.
Camera Feel ๐ฅ
The camera is animated entirely with GSAP, no orbit controls. A few moments sell the action:
- Intro pan - on countdown the camera tweens from a wide establishing shot down behind the ball.
- Kick push - a subtle forward dolly on every kick adds urgency.
- Goal shake - on a goal, a decaying sine/cosine shake rattles the camera for a beat:
const decay = 1 - shakeData.progress
const t = shakeData.progress * SHAKE_DURATION * SHAKE_FREQUENCY * Math.PI * 2
camera.position.x = baseX + Math.sin(t) * SHAKE_INTENSITY * decay
camera.position.y = baseY + Math.cos(t * 1.3) * SHAKE_INTENSITY * decay * 0.6Every tween ends with a camera.lookAt on a fixed target so the goal stays framed throughout.
Confetti on Goal ๐
A goal fires two GPU-driven confetti bursts either side of the net. They're a single instanced points mesh animated in a custom shader - position, gravity and fade all computed on the GPU from a burst-progress uniform driven by GSAP. Keeping the animation on the GPU means a goal celebration costs almost nothing on the CPU.
Scoring & Streaks ๐ฅ
Scoring rewards consistency. A goal is worth a base amount, a corner goal more, and every consecutive goal adds a streak bonus (capped) on top:
const streakBonus = Math.min(lastGoalPoints + STREAK_BONUS_PER_GOAL, STREAK_BONUS_CAP)
const earnedPoints = points + lastGoalPointsA miss or a save resets the streak to zero, so a run of clean strikes is worth far more than the same number of scrappy goals. Results are kept in a Zustand store and the game history persists to localStorage via Zustand's persist middleware.
Performance: Holding 60fps ๐๏ธ
A few decisions keep it smooth, including on phones:
- DPR is capped at 2 - retina sharpness without rendering a needless number of pixels.
- Drei's
PerformanceMonitorwraps the scene to watch the frame rate. - The frameloop is paused (
frameloop="demand") and the physics world is paused when the game is over, so an idle screen burns no GPU. - High-frequency values stay out of React state. The keeper's bone colliders update inside
useFramevia refs, never triggering a re-render. React state is reserved for things that actually change the UI - score, game status, kick result.
That last point is the same pattern throughout: anything that updates every frame lives in a ref and is read in useFrame; React only hears about discrete events.
Closing Thoughts
The Biggest Challenge ๐๏ธ
The hardest part was the goalkeeper - specifically making saves feel earned rather than random or scripted. The breakthrough was separating the two concerns: predict the trajectory deterministically to pick a believable animation, but let real bone colliders decide the outcome. The keeper dives the right way because it knows where the ball is going, but it only saves if its hands genuinely get there in time - and that depends on the reaction delay, which scales with how hard you hit it.
Getting the sensors, the corner-vs-goal race, and the world-scaled physics to all agree took plenty of iteration too.
Wishlist ๐
- Power/curve mechanics - let players bend shots, not just aim them.
- Keeper difficulty tiers - tune the reaction delay for easy/hard modes.
- A proper run-up instead of a standing flick.