A devlog by Neeraj Kotwani, who builds every game on Elipar.
A rollercoaster game needs to answer one question at every point along the track: which way is up? Everything hangs off that answer. The rails are drawn relative to it, the cart sits on it, each lane offset is measured sideways from it, and ducking is a nudge downward along it.
Three.js will hand you that answer for free. Give a curve to computeFrenetFrames and it returns a tangent, a normal and a binormal at every sample. It is one line, it is what the documentation points you at, and for this track it was wrong in a way that made the game unplayable without ever looking broken.
Up was pointing sideways for most of the ride
A Frenet frame takes its up-vector from the curve's curvature. That is mathematically tidy and it means a vertical loop accumulates twist: the frame rotates as it goes round, and it does not rotate back. Then, because this track is a closed loop, three.js distributes whatever twist is left over at the seam around the entire curve so the two ends meet.
So I sampled the frame along the track and read the up-vector's world Y. On the long flat straight after the loop — constant height, the section where almost all the dodging happens — it was about 0.29. On level ground it should be 1. The track surface, the cart, every barrier and every coin on the main gameplay stretch were rolled roughly 70 degrees sideways.
That is not a cosmetic problem, because the controls are defined in that frame. Ducking is a push along the frame's up-axis, so ducking shoved the cart sideways instead of down. Lane changes moved it diagonally. The game was fighting the player with no error, no warning, and a picture that looked like a rollercoaster.
The worst part is that this had already been noticed once, and mis-diagnosed. An earlier pass had seen a tilted horizon and fixed it by hard-coding the camera's up-vector to world up. That makes the screenshot look right, which is exactly why it survived — the picture was level while the cart, the obstacles and the controls stayed skewed. When a frame looks wrong, fix the frame, not the thing reading it.
Parallel transport is worse, and here is why
The textbook answer to Frenet twist is a rotation-minimising frame: carry the previous frame forward and rotate it as little as possible to stay perpendicular. I tried it, and I am writing it down so nobody tries it again here.
Carrying a frame around a loop has holonomy. It comes back rotated relative to where it started, and the amount depends on the path. That same flat straight came out with its up-vector at -0.16 and the lane axis pointing almost straight down — rolled about 90 degrees, so slightly worse than the thing I was replacing.
Minimal rotation is a reasonable goal for extruding a pipe. It is not what a rollercoaster wants.
Bank against the force the rider feels
What a real coaster does is bank into the net force on the rider. So: up equals gravity-opposing plus centripetal, normalised.
On level track the centripetal term is zero and up is exactly world up. Inside the loop the centripetal term points at the loop's centre and overwhelms gravity, so up rotates smoothly through fully inverted at the top and back out again. It needs no seam correction, and — the property that actually matters — it cannot drift, because each frame is computed from local geometry rather than from its predecessor. There is no accumulated error to spread around.
One subtlety, which cost a round of tuning. A single weight on the centripetal term cannot work. The track's ordinary horizontal bends have real curvature too, so any weight large enough to invert the loop also banks every flat corner steeply — measured, the level straight came out between 0.65 and 0.9 and the crest at 0.24, which is just the original bug in a different place.
Only vertical curvature should be allowed to roll the track over. So the centripetal vector is split: the vertical component gets a weight of 26, the horizontal component gets 1.0, which is enough to add a natural lean into bends and not enough to tip anything over. Those two numbers were landed by sweeping the horizontal weight and measuring the worst roll on level track against whether the loop still inverted.
Final numbers: the main dodging straight sits at 0.97 to 0.997, the loop still inverts fully at -0.978, the worst lean anywhere on level track is 14 degrees — and that one is a bend, where leaning is the point. Orthonormality error across all 800 frames is zero.
Two details in the implementation are load-bearing. The curvature comes from a central difference on the tangents, then gets smoothed across a 25-sample window, because a Catmull-Rom spline's second derivative is lumpy between control points and unsmoothed curvature makes the cart twitch. And the result is projected back onto the plane perpendicular to the tangent before being normalised, with a fallback for the degenerate case where gravity and curvature are both parallel to the tangent — rare, but a basis has to stay a basis.
With a frame worth trusting, the camera's up could finally follow the track again instead of being pinned to world up. That fixed a second bug the pinned version had: with a fixed up-vector, lookAt is degenerate when the view direction goes vertical, so the picture flipped inside the loop and the trees rendered hanging from the sky. Both symptoms, one root cause.
And then: 79% of the ride had nothing in it
While measuring the frames I measured where the obstacles were, and the answer was embarrassing. They existed in two narrow bands past the halfway mark. 57% of the track held nothing at all, the first object arrived 52% of the way round, and on a real hands-off run that is 24.7 seconds of a 31.3 second ride with nothing to react to. Press start, watch an animation for twenty-five seconds, die seven seconds later. Twelve hazards in the whole course.
I mapped which stretches can fairly hold obstacles by sampling the frame's up-vector and the tangent's pitch, then filled the two wasted upright ones: a gentle warm-up on the opening crest (coins first, then one barrier, then one duck — it should teach, not kill) and a real section on the big drop, which is upright and dramatic because speed is rising. The loop is still deliberately empty. It is the one genuinely inverted stretch, dodging there would be unfair, and it earns its place as the spectacle beat now that it is five seconds of a ride rather than the tail end of twenty-five seconds of nothing.
Now: 21 hazards and 20 coins, 67% of the track carrying objects, first object at 4% instead of 52%, time to first hazard 4.1 seconds instead of 24.7. Denser is only better if a good player can still win, so that was checked with a bot that reads upcoming hazards and dodges optimally: it reaches the finish gate with no lives lost and all 20 coins, while a hands-off player now dies at 9.6 seconds.
The catalogue copy had to change too. It used to promise that the drop and the loop were obstacle-free so you could enjoy the ride. Half of that is now a lie, and a game description that describes a previous version is its own kind of bug.
Coaster Rush is free in a browser, no account, no download. The track is level where it should be level and upside down where it should be upside down.