Published on May 13, 2025

The most annoying video player of all time

Dave Kiss
By Dave Kiss8 min readEngineering

It’s 1995. The arcade is a cathedral of noise and neon. I’ve thrown my hands up in the air, again, in disbelief; the unfairness of the Area 51 sneak attacks has me all riled up. I clutch a single quarter, heart pounding, eyes locked on the “Continue?” screen. Ten seconds. Nine. Eight. The world shrinks to a single question: Can I keep playing? Should I? What time is mom supposed to be here?

That pay-to-play mechanic, while suuuuper frustrating, is suuuuper irresistible, and it became the main inspiration for my latest task while planning our Web Dev Challenge: use Media Chrome, our open-source video player toolkit, to build the worst video player I could dream up.

Read on to see the player I came up with, how it was built, and how you can battle me for the crown of the most annoying video player of all time.

LinkA video player that fights back

Modern video players often feel like the same gray rectangle. Play, pause, scrub, repeat. Yes, I know, this exists for good reasons: consistency, accessibility, practicality… but still, I wondered, what else could we come up with? The web is an amazing infinite canvas. It’s a shame to trap our creations within the confines of homogeneity.

There’s a common thread across some of my favorite websites I’ve visited: they all use motion, interactivity, video, 3D–artist tools for creating lifelike experiences and art on the web. Naturally, Three.js and its react-three-fiber counterpart felt like the perfect place to start noodling.

When I set out with this project, I wondered how I could control the video player without really using any of the traditional buttons that we're used to seeing. I wanted the player to be more like a machine that you interact with, not a form that you click.

I decided that the goal of the video player would be to pick up a coin and drop it into a coin slot. If you get it in, the coin buys you exactly three seconds of video playback. No more, no less.

With the machine mechanic in mind, I started by creating a fresh Three.js scene and building a coin box.

LinkThe coin box

The coin box is a simple cube featuring a slot cut into it. This cube became the heart of my player’s interaction; it’s modeled in 3D with real physical constraints. I used a library called three-bvh-csg to cut away the geometry of the cube with constructive solid geometry, creating a realistic, functional coin slot. There are also a few red point lights hidden within the slot to give off that alluring glow.

The coin slot
useEffect(() => { if (!boxRef.current || !faceplateRef.current) return; // Create main box brush const mainBox = new Brush(new BoxGeometry(0.4, 0.6, 0.4)); mainBox.updateMatrixWorld(); // Create slot hole brush const slotHole = new Brush(new BoxGeometry(0.05, 0.3, 0.4)); slotHole.position.x = -0.05; slotHole.updateMatrixWorld(); // Create evaluator and subtract hole from box const evaluator = new Evaluator(); const result = evaluator.evaluate(mainBox, slotHole, SUBTRACTION); // Update the box mesh geometry boxRef.current.geometry = result.geometry; // Create faceplate brush const faceplate = new Brush(new BoxGeometry(0.25, 0.40, 0.01)); faceplate.updateMatrixWorld(); // Subtract slot from faceplate const faceplateResult = evaluator.evaluate(faceplate, slotHole, SUBTRACTION); // Update the faceplate mesh geometry faceplateRef.current.geometry = faceplateResult.geometry; }, []);

There’s a bunch of other geometry that came along with this coin box—a red faceplate, “INSERT COIN TO PLAY” text, a red wire tube coming out of the side, a worn, low-pill arcade carpeting ground plane—but it’s mostly all for show.

Although it looks like a real coin slot, the box didn't actually do anything.

In a typical Three.js scene, objects don’t have collision detection by default. In other words, they’re fake. Objects in the scene are rendered in 3D, but if two objects were to intersect, they’d pass right through each other. The slot might look convincing, but without physics, it couldn’t block or accept anything. You could chuck a coin at it and it would just phase right through like a ghost.

To solve this, I brought in the react-three-rapier physics library to handle gravity and collisions. This is when I started adding colliders: physical boundaries that match the visual geometry. The important part are the cuboid colliders that block the coins from entering in the wrong spot. These were laid out carefully to align with the visual slot, forming a real pathway the coins could pass through.

The coin box
<group> {/* Right wall */} <CuboidCollider args={[0.12, 0.3, 0.2]} position={[0.08, 0.00, 0.01]} restitution={30} /> {/* Left wall */} <CuboidCollider args={[0.079, 0.18, 0.2]} position={[-0.139, 0.00, 0.01]} restitution={30} /> {/* Top wall */} <CuboidCollider args={[0.09, 0.05, 0.2]} position={[-0.11, 0.25, 0.01]} restitution={30} /> {/* Bottom wall */} <CuboidCollider args={[0.09, 0.05, 0.2]} position={[-0.11, -0.25, 0.01]} restitution={30} /> {/* Visual meshes */} <mesh ref={boxRef} receiveShadow castShadow > <meshPhysicalMaterial color="#444444" roughness={0.8} clearcoat={0.8} clearcoatRoughness={0.2} metalness={0.2} normalMap={normalMap} /> </mesh> {/* Wire tube coming out of coin box */} <mesh position={[-0.15, -0.25, -0.2]}> <tubeGeometry args={[ new THREE.CatmullRomCurve3([ new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -0.1), new THREE.Vector3(-0.1, 0, -0.2), new THREE.Vector3(-0.3, 0, -0.2), new THREE.Vector3(-0.5, 0, -0.1), new THREE.Vector3(-0.7, 0, 0) ]), 64, // tubular segments 0.02, // radius 8, // radial segments false // closed ]} /> <meshStandardMaterial color="#ff0000" roughness={0.3} metalness={0.7} /> </mesh> {/* Mux logo */} <mesh position={[0.12, -0.27, 0.201]}> <planeGeometry args={[0.1, 0.032]} /> <meshPhysicalMaterial color="#ffffff" roughness={0.1} metalness={1} opacity={0.6} transparent iridescence={1} iridescenceIOR={2} clearcoat={1} transmission={0.5} map={new THREE.TextureLoader().load('/mux-logo.png')} emissive="#00ffff" emissiveIntensity={2} > </meshPhysicalMaterial> </mesh> {/* Red faceplate */} <mesh ref={faceplateRef} position={[0, 0, 0.21]} receiveShadow castShadow > <MeshTransmissionMaterial color="#831313" background={new THREE.Color("#690F0F")} samples={10} thickness={0.1} transmission={1} roughness={0} resolution={2048} clearcoat={1} attenuationColor={"#fff"} /> </mesh> {/* Multiple red glow lights behind faceplate */} <pointLight position={[-0.05, 0, 0.1]} color="#ff0000" intensity={lightIntensity} distance={0.3} decay={2} /> <pointLight position={[0.05, 0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} /> <pointLight position={[0.05, -0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} /> <Text position={[0.04, 0.10, 0.22]} fontSize={0.075} fontWeight="bold" color="white" textAlign="center" anchorY="middle" > 25 </Text> <Text position={[0.089, 0.083, 0.22]} fontSize={0.035} fontWeight="bold" color="white" textAlign="center" anchorY="middle" > ¢ </Text> <Text position={[0.04, -0.02, 0.22]} fontSize={0.025} color="white" fontWeight="bold" textAlign="center" anchorY="middle" > INSERT </Text> <Text position={[0.04, -0.05, 0.22]} fontSize={0.022} fontWeight="bold" color="white" textAlign="center" anchorY="middle" > COIN TO </Text> <Text position={[0.04, -0.082, 0.22]} fontSize={0.04} color="white" textAlign="center" fontWeight="bold" anchorY="middle" > PLAY </Text> </group>

LinkThe coin

Of course, a coin box is useless without coins. Let’s change that.

Each coin would be modeled with its own visual mesh along with a CylinderCollider counterpart, allowing it to become a “real” object within the scene—drop a coin, and it responds realistically, bouncing off edges, rolling around the floor, challenging you to be precise.

The coin
function Coin({ position }: { position: [number, number, number] }) { const rigidBody = useRef<RapierRigidBody>(null); const visualRef = useRef<Mesh>(null); return ( <RigidBody ref={rigidBody} position={position} userData={{ type: 'coin' }} colliders={false} > <CylinderCollider args={[0.01, 0.1]} friction={1} restitution={0.1} frictionCombineRule={3} // Use maximum friction when coins touch restitutionCombineRule={1} // Use minimum bounciness when coins touch /> <mesh ref={visualRef} castShadow receiveShadow > <cylinderGeometry args={[0.10, 0.10, 0.02, 16]} /> <meshStandardMaterial color="#FFD700" metalness={0.8} roughness={0.2} emissive="#000000" emissiveIntensity={0} normalMap={normalMap} /> </mesh> </RigidBody> ); }

The cylindrical colliders for the coins were a bit tricky. I wanted them to interact naturally–bouncing off each other, stacking, and sliding realistically–but fine-tuning their friction and restitution (bounciness) proved challenging. Instead of settling calmly, the coins frequently wobble, constantly debating how to react to their physical environment. Initially, I saw this as a bug, but I soon embraced it as a feature. The wobbling coins vividly communicate that the scene is alive, encouraging you to play with it. It definitely has nothing to do with me having no clue what I’m doing. Nope.

For the drag and drop functionality, I used @use-gesture/react, yet another interactive JS library by the prolific team at Poimandres. With my ruleset, only one coin can be dragged at a time, ensuring the player can’t cheat by trying multiple coins simultaneously. Drop a coin incorrectly? Too bad, try again.

Coin drag
const [isGrabbed, setIsGrabbed] = useState(false); useFrame(({ pointer, camera, raycaster }) => { if (isGrabbed && rigidBody.current) { const coinPhysics = rigidBody.current; // Cast ray from pointer to get world position raycaster.setFromCamera(pointer, camera); // Use a vertical plane that faces the camera const intersectPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 1); const targetPoint = new THREE.Vector3(); raycaster.ray.intersectPlane(intersectPlane, targetPoint); const targetPos = { x: targetPoint.x, y: Math.max(targetPoint.y, 0.1), z: Math.max(targetPoint.z, -0.6) }; coinPhysics.setNextKinematicTranslation(targetPos); const targetRotation = new Euler(Math.PI / 2, 0, Math.PI / 2); const targetQuat = new Quaternion().setFromEuler(targetRotation); const currentQuat = new Quaternion(); const rot = coinPhysics.rotation(); const quat = new Quaternion(rot.x, rot.y, rot.z, rot.w); currentQuat.setFromEuler(new Euler().setFromQuaternion(quat)); currentQuat.slerp(targetQuat, 0.3); coinPhysics.setNextKinematicRotation(currentQuat); } }); const bind = useDrag(({ down }) => { if (!rigidBody.current) return; const coinPhysics = rigidBody.current; if (down) { // Only allow grabbing if no other coin is being dragged if (!isAnyCoinBeingDragged || isGrabbed) { isAnyCoinBeingDragged = true; setIsGrabbed(true); document.body.style.cursor = 'grabbing'; coinPhysics.setBodyType(2, true); } } else { if (isGrabbed) { isAnyCoinBeingDragged = false; setIsGrabbed(false); document.body.style.cursor = 'auto'; coinPhysics.setBodyType(0, true); coinPhysics.applyImpulse({ x: 0, y: 0, z: -0.005 }, true); } } }); return ( <RigidBody ref={rigidBody} position={position} userData={{ type: 'coin' }} colliders={false} {...bind()} > {/*...*/} )

LinkThe sensor

Insert a coin correctly, and the slot flashes, the lights dance, and a gratifying sound confirms your success. Miss your chance, and a harsh “game over” sound accompanies the resetting video, sending you back to the start, just like the arcade.

Coins are accepted via an invisible collision detection box positioned slightly behind the visible slot. When a coin passes this threshold, an event triggers the video to resume and timer reset.

The sensor
<CuboidCollider args={[0.2, 0.4, 0.2]} position={[-0.05, .1, -1.5]} sensor onIntersectionEnter={debounce(handleCoinInserted, 200)} /> // On sensor collision const handleCoinInserted = () => { onCoinInserted(); setIsFlickering(true); // Play coin sound const coinSound = new Audio('/coin.m4a'); coinSound.play(); // Reset flicker after 500ms setTimeout(() => { setIsFlickering(false); }, 500); }; // Animate the light flicker useFrame(() => { if (isFlickering) { setLightIntensity(Math.random() * 5 + 1); // Random intensity between 1 and 6 } else { setLightIntensity(2); // Default intensity } }); // ... later, update the light intensity {/* Multiple red glow lights behind faceplate */} <pointLight position={[-0.05, 0, 0.1]} color="#ff0000" intensity={lightIntensity} distance={0.3} decay={2} /> <pointLight position={[0.05, 0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} /> <pointLight position={[0.05, -0.1, 0.1]} color="#ff0000" intensity={lightIntensity * 0.7} distance={0.25} decay={2} />

LinkThe timer

The timer is always visible, always threatening, a constant reminder that your time is running out (woah… deep). When you’re almost out, the bar pulses red. The pressure is real. If the countdown hits zero, the video screeches to a halt. The dreaded “CONTINUE?” flashes on-screen, resurfacing that arcade anxiety.

Timer rules
const [timeRemaining, setTimeRemaining] = useState(0); const [maxTime, setMaxTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [continueCountdown, setContinueCountdown] = useState(0); const CONTINUE_TIME = 8; // 8 seconds to continue const WARNING_THRESHOLD = 3; // Start warning animation when 3 seconds or less remain const TIME_PER_COIN = 3; // 3 seconds per coin const handleCoinInserted = () => { const newTime = timeRemaining + TIME_PER_COIN; // Add seconds for each coin setTimeRemaining(newTime); setMaxTime(newTime); // Update max time when coins are added };
The timer
{ timeRemaining > 0 && ( <div className="absolute top-0 left-0 right-0 flex items-center bg-black border-b-4 border-[#39FF14] px-4 py-2"> <div className="text-[#39FF14] text-2xl font-vcr mr-4">TIME</div> <div className="h-6 flex-1 bg-black border-2 border-[#39FF14] overflow-hidden"> <div className={`h-full transition-all duration-1000 ease-linear ${timeRemaining <= WARNING_THRESHOLD ? 'animate-pulse' : ''}`} style={{ width: `${Math.max(((timeRemaining - 1) / (maxTime - 1)) * 100, 0)}%`, background: timeRemaining <= WARNING_THRESHOLD ? 'linear-gradient(90deg, #FF0000 0%, #FF6B00 100%)' : 'linear-gradient(90deg, #39FF14 0%, #00FF94 100%)', boxShadow: timeRemaining <= WARNING_THRESHOLD ? '0 0 10px rgba(255, 0, 0, 0.5)' : '0 0 10px rgba(57, 255, 20, 0.3)' }} /> </div> <div className={`text-2xl font-vcr ml-4 ${timeRemaining <= WARNING_THRESHOLD ? 'text-red-500 animate-pulse' : 'text-[#39FF14]'}`}> {timeRemaining} </div> </div> ) }

The consequences for failing to insert a coin in time are devastating. Your video progress is lost, and the video is reset, ready to start playback once again from the absolute beginning.

The reset
const videoRef = useRef<HTMLVideoElement>(null); useEffect(() => { if (videoRef.current) { if (isPlaying) { videoRef.current.play(); } else { videoRef.current.pause(); if (continueCountdown === 0 && timeRemaining === 0) { const gameOverSound = new Audio('/game-over.m4a'); gameOverSound.play(); videoRef.current.currentTime = 0; // Reset video to beginning when continue countdown ends } } } }, [isPlaying, continueCountdown, timeRemaining]);

This player is unforgiving. It rewards attention, punishes distraction, and refuses to apologize. It's definitely not the most performant or practical, but it is a ton of fun. Try it out for yourself at https://worst.player.style or peruse the whole codebase at https://github.com/muxinc/worst.player.style – and come ready to play.

LinkPay to play

Something strange happened while building this video player. As I spent more time flinging coins through the slot, watching them tumble and vanish into oblivion, stuffing the slot so full it jammed, and frantically racing against the clock to extend my playtime, I realized something unexpected was happening. It transported me somewhere else. I was genuinely having fun watching–or was I playing?–the video.

Was it the tactile mechanic of inserting the coins? The rush of earning another few precious seconds in this gamified world I crafted? Or was it nostalgia, recalling the countless quarters I’d poured into arcade machines throughout my childhood?

Whatever the reason, one thing is certain: I thoroughly enjoyed building this player. I set out to create the worst video player imaginable, but in the process, I might have built my favorite. If only I had a few more coins in my pocket to keep going.

LinkYour turn: can you think of a worse player UX?

Let’s not stop with what I’ve come up with. I want to see your worst (and have some prizes to give away for your submission 👀)

Build your own abomination, submit it, and you might win real arcade tokens, custom swag, or just the satisfaction of making someone yell at their screen. Get the full details at https://worst.player.style. The only rule: no boring video players.

Written By

Dave Kiss

Dave Kiss – Senior Community Engineering Lead

Was: solo-developreneur. Now: developer community person. Happy to ride a bike, hike a hike, high-five a hand, and listen to spa music.

Leave your wallet where it is

No credit card required to get started.