A devlog by Neeraj Kotwani, who builds every game on Elipar.
Grid Runner started as a CSS mockup somebody sent me: a neon grid road receding to a horizon, built out of a div with perspective and rotateX(70deg). No game logic, no JavaScript at all — just markup and colours.
The obvious move would have been to rebuild it in Three.js, since most of the 3D games on this site are. I deliberately kept the CSS technique. It is a genuinely different and valid rendering pattern, and the mockup already looked good. The road stays a tilted div used purely as decoration, and the player ship and the obstacles are plain absolutely-positioned 2D sprites layered on top, whose position and scale are driven every frame to simulate flying toward the camera.
The critical detail is that those sprites are not children of the road. If they were, the road's transform would apply to them and they would be squashed into the tilted plane. Which means they need to be positioned in ordinary screen coordinates — and that is where I got it wrong.
A transformed element's rect is a projection
To line a sprite up with a lane, I needed to know where the lanes are horizontally. So I asked the road for its bounding rectangle and computed the lanes as fractions across its width.
That returned a box 1029 pixels wide inside a game view only about 450 pixels across.
getBoundingClientRect() returns the bounding box of the element as rendered, with all transforms applied. The road has a live rotateX, so its rect is the heavily foreshortened screen-space projection of a tilted plane — a trapezoid's bounding box, including the parts that have been swung toward and away from the viewer. It is not the flat lane geometry that the untransformed 2D sprites need to align with. It is not wrong; it answers a different question than the one I was asking.
The player and the obstacles landed off-lane, and the number was so far out that the first instinct is to look for a units bug or a stray scale factor rather than at the method itself.
Use the layout box, or a box you control
The fix is to never ask a 3D-transformed element for layout maths. Lane positions now come from the game view's own untransformed rectangle plus the road's static CSS geometry — a known width, centred — and the lanes are fractions of that.
I verified it against offsetLeft and offsetWidth as ground truth. Those are layout-box properties and CSS transforms never affect them, unlike getBoundingClientRect. If you ever need to know where a transformed element "really" is in layout terms, those two are the honest answer.
The general rule, which is now written down for the next CSS-3D game here: keep gameplay-relevant sprites as untransformed 2D siblings, and never derive their coordinates from a transformed ancestor's bounding rect.
The stale server that answered on the same port
While testing this I hit something unrelated that cost more time than the actual bug, and it is worth knowing about.
I got a chunk-loading error that looked like a build problem. The cause: a server process from earlier testing in the same session had never actually been killed — the kill calls had silently done nothing — so a later server failed to bind the port, and my health check kept hitting the old, pre-Grid-Runner server instead. Which correctly returned 404 for files that did not exist when it started.
At the HTTP level, a stale server answering on the right port is indistinguishable from a fresh one. A 200 proves something is listening, not that it is the thing you just built. So the check is to confirm exactly one server process is running and that its process id is new, before trusting any response from it.
There is a related trap in the same family, which I have now walked into more than once: a pattern-matching kill command whose pattern appears in the invoking shell's own command line will kill the shell, so every later command in that chain silently never runs while the previous build's output is still sitting on disk making "did it rebuild?" checks pass. Both failures look like a broken build and neither is.
What the game is
Three lanes, obstacles and orbs flying up at you from a horizon point, and a ship that slides between the lanes. The sprites lerp from a small scale at the horizon to full scale at the player's position, which is the entire 3D effect — there is no depth buffer, no camera, no projection matrix. Just two numbers interpolated per object per frame over a decorative tilted div.
It is a cheap trick and it holds up, which is the nicest kind.
Grid Runner is free in a browser, no account, no download. The ship is in the lane it looks like it is in.