Backend Engineer (Me) Ships a Browser Game With One Unintentional System Requirement: My Monitor
This is a submission for DEV’s Summer Bug Smash: Smash Stories powered by Sentry.
Prefer Watching Instead? If reading isn’t your thing, no worries, I’ve got you covered. You can watch the video below:
Table of Contents
- Introduction: In What Universe Did I Think This Was a Good Idea?
- The Game
- The Game Loop
- The Game Physics
- It Worked on My Machine Goddammit
- Understanding the Root Cause
- The Fix: Delta Time
- But There’s a Catch: Not Everything Is Fixed With × dt
- Pattern 1: Linear operations
- Pattern 2: Exponential decay
- Pattern 3: Lerp smoothing
- Summary: The Three Patterns
- One More Thing: Re-tune Your Constants
- Pull Request
- The Takeaway
Introduction: In What Universe Did I Think This Was a Good Idea?
It was a nice, calm day. Everything was going smoothly, I was just minding my own business. Out of nowhere, a notification from @jess popped up. And I see this:
Good morning! Good afternoon! Good Evening! Welcome to our first DEV Weekend Challenge, a short focused challenge designed to fit into your weekend. Since submission window is tight, we’ve set the timing to ensure that no matter where you are in the world, you’ll have the majority of your Saturday and Sunday to participate. Let’s get started!
I don’t know what’s exactly wrong with me, but whenever I see a word “challenge”, something clicks in my brain and it starts a multi-thousand thread process to think of an idea until the idea isn’t generated. This time was no different. But in some ways, it really was quite different. Let me explain why.
So here’s my thinking process:
OK I just have the weekend. 2 days tops, that’s it. Well, sometimes just planning can take way more than that.
I’m a full-stack engineer with extensive back-end background, so back-end is my primary comfort zone. Building something fun and flashy would be great, like a... browser game? Hell yeah!!!
... well, except, I ain’t no game developer and I have never ever written a browser game.
... So I decided to build... drum rolls ... A BROWSER GAME!
If you’re not a technical person, a back-end developer building a browser game is an NFL player playing tennis. You already know this isn’t gonna end well, don’t ya?
The Game
You’re probably wondering what kind of game I created. It’s basically a driving simulator (well... kinda) - a synthwave 3D driving game where your DEV community articles appear as neon billboards along the endless highway. It fetches articles, stats, and things like that using the DEV Community by the username.
If you’d like to give it a try before continuing, here are the relevant links:
- Demo
- Submission article
- GitHub Repo
The Game Loop
Sunday DEV Drive is a browser game built on top of Three.js - a JavaScript library that handles 3D rendering in the browser. The game runs entirely client-side: no server, no backend, no build step. Just HTML, JavaScript, and your DEV Community articles fetched from a public API.
The game is built around a game loop: a function that runs over and over, many times per second. On each iteration it does the same three things:
- Read inputs - is the player pressing the accelerator? Turning left?
- Update the world - move the car, adjust the camera, apply physics
- Draw the frame - render everything to the screen
In the browser, the standard tool for this is requestAnimationFrame. You give it a function, and the browser calls that function right before it repaints the screen - as fast as the display allows:
function animate () {
requestAnimationFrame ( animate );
// "call me again next frame"
updatePhysics ();
updateCamera ();
renderScene ();
}
animate (); // start the loop
Think of it like a flipbook. Each call to animate() draws one page. The faster the pages flip, the smoother the motion looks. On a 60Hz monitor, the browser flips 60 pages per second. On a 165Hz monitor, 165 pages per second.
The Game Physics
The car’s movement is simple Newtonian-ish math applied every frame, nothing exotic, just the kind of physics you vaguely remember from high school:
- Throttle → adds to the car’s speed (acceleration)
- Speed → moves the car’s position
- Steering → rotates the car’s angle
- Friction → slowly reduces speed when no input is held
- Camera → smoothly follows the car from behind
Simple stuff. Which is exactly why the bug hiding inside it was so easy to overlook.
It Worked on My Machine Goddammit
I tested the game. Multiple times. Zero issues. The car moved naturally. Steering felt great. Acceleration was progressive. The brakes had a nice bite to them. Everything felt perfect.
Well, until I tested it on another computer.
Ladies and gentlemen, I need your attention, please:
I don’t care how certain you are that your code works: ALWAYS TEST IT ON ANOTHER COMPUTER.
I decided to flex a little and show the game to my mom and brother. So I sent them the links.
“Hey, check this out. I made a game.”
A few minutes later, they texted me back: “The visuals are really good, but the car is barely moving. Is this by design?”
“Umm... whaddaya mean ‘barely moving?’ I just tested it and it’s fine. Are you sure you’re pressing the gas?”
So I started troubleshooting. And troubleshooting. And troubleshooting some more. Nothing made sense. The controls were working. The game was running. There were no obvious errors. Yet somehow, on their machines, my beautifully engineered car had apparently decided to become a mobility scooter.
I had no idea what was going on. So I did what any developer would do in this situation:
- I Googled
- I asked AI
- I stared at the code
- I sketched things out
- I questioned my architectural decisions
- ...
- I questioned my life choices.
And then it just clicked.
I had used the monitor’s refresh rate as part of my movement calculations. Yep. My physics weren’t actually frame-rate independent. My monitor was running at 165 Hz. Their monitor was running at 120 Hz. That’s a 45 Hz difference. 45 frames per second was enough to turn my perfectly drivable car into a perfectly drivable car, but in slow motion.
The game wasn’t broken on my machine. My monitor was secretly a system dependency. I had messed up... And badly. And now it was time to fix it.
Understanding the Root Cause
My animate loop looked like this:
function animate () {
requestAnimationFrame ( animate );
carState . speed += acceleration ;
car . position . x += Math . sin ( carState . angle ) * carState . speed ;
car . position . z -= Math . cos ( carState . angle ) * carState . speed ;
}
requestAnimationFrame calls your function once per display refresh. On my 165Hz monitor, that’s 165 times per second. On a 120Hz monitor, 120 times.
The problem: every physics value was per-frame, not per-second. Look at what actually happens across different hardware:
| Frame Rate | Updates/sec | Effective speed/sec |
|---|---|---|
| 165 fps (my machine) | 165 | 0.42 × 165 = 69.3 ✅ feels right |
| 120 fps | 120 | 0.42 × 120 = 50.4 🙂 73% of intended |
| 60 fps | 60 | 0.42 × 60 = 25.2 😐 36% of intended |
| 30 fps | 30 | 0.42 × 30 = 12.6 😩 18% of intended |
I had unknowingly tuned the entire game for 165fps. Everyone else was experiencing a completely different game.
The Fix: Delta Time
The solution is to measure the actual time elapsed between frames and use it to scale all physics updates. This is called delta time.
const clock = new THREE . Clock ();
function animate () {
requestAnimationFrame ( animate );
const delta = clock . getDelta (); // real seconds since last frame
const dt = delta * 60 ; // normalized: 1.0 at 60fps baseline
// Now multiply everything by dt
carState . speed += acceleration * dt ;
car . position . x += Math . sin ( carState . angle ) * carState . speed * dt ;
car . position . z -= Math . cos ( carState . angle ) * carState . speed * dt ;
}
clock.getDelta() returns the real elapsed time in seconds. At 60fps that’s ~0.0167s. At 165fps it’s ~0.006s. At 30fps it’s ~0.033s.
I multiply by 60 to normalize: dt = delta * 60. This means:
- At 165fps →
dt ≈ 0.36(smaller steps per frame, same per second) - At 60fps →
dt ≈ 1.0(baseline) - At 30fps →
dt ≈ 2.0(larger steps per frame, same per second)
Now the same distance is covered per second regardless of hardware:
| Frame Rate | dt per frame | Movement per frame | Movement per second |
|---|---|---|---|
| 165 fps | ≈ 0.36 | speed × 0.36 | speed × 60 ✅ |
| 60 fps | ≈ 1.0 | speed × 1.0 | speed × 60 ✅ |
| 30 fps | ≈ 2.0 | speed × 2.0 | speed × 60 ✅ |
But There’s a Catch: Not Everything Is Fixed With × dt
Simple * dt fixes linear operations. But a real game has more than just linear math. In this fix alone, there were three distinct patterns, each requiring a different approach.
Pattern 1: Linear operations
The straightforward one. Any additive rate just gets multiplied by dt:
// ❌ Frame-rate dependent
carState . speed += CAR . acceleration * throttle ;
carState . speed -= CAR . brakeForce * brake ;
car . position . x += Math . sin ( carState . angle ) * carState . speed ;
carState . angle += carState . steer * carState . speed * CAR . turnSpeed ;
// ✅ Frame-rate independent
carState . speed += CAR . acceleration * throttle * dt ;
carState . speed -= CAR . brakeForce * brake * dt ;
car . position . x += Math . sin ( carState . angle ) * carState . speed * dt ;
carState . angle += carState . steer * carState . speed * CAR . turnSpeed * dt ;
If you’re adding or subtracting a value per frame, multiply it by dt. Done.
Pattern 2: Exponential decay: Math.pow(factor, dt)
This one is less obvious. When driving off-road, the car slows down using a multiplier applied each frame:
// ❌ Frame-rate dependent
carState . speed *= 0.97 ; // 3% drag per frame
At 165fps this applies 3% drag 165 times per second. At 30fps, only 30 times. The off-road drag behaves completely differently depending on hardware.
The naive fix: carState.speed *= 0.97 * dt is wrong. You can’t linearly scale a multiplier. At dt = 2 you’d get 0.97 * 2 = 1.94, which would accelerate the car instead of slowing it down.
The correct fix uses exponentiation:
// ✅ Frame-rate independent
carState . speed *= Math . pow ( 0.97 , dt );
Why does this work? Repeated multiplication is exponential decay. Consider what happens over one second:
- At 60fps (
dt = 1, 60 frames):speed after 1s = speed × 0.97^60 - At 30fps (
dt = 2, 30 frames):speed after 1s = speed × (0.97^2)^30 = speed × 0.97^60✅ identical
Math.pow(factor, dt) correctly scales the decay exponent rather than the multiplier itself. The result over any given real-world time is always the same.
The same pattern applies
Comments
No comments yet. Start the discussion.