Canvas to WebGL: why we cut the 2D fallback
RealPool started as a 2D game. The table, the balls, the aiming line, all of it was drawn with the plain canvas 2D context. It was the right way to start. You can get a pool table on screen in an afternoon, it runs everywhere, and there is nothing to debug in the graphics layer when the physics is what you actually care about.
A few days ago I deleted that renderer. RealPool now draws the table and balls with WebGL only, and the old 2D scene renderer no longer runs. The heads-up overlays are a separate story, more on that below. Here is why I made that call, and what it cost.
Why 2D canvas was the right start
For most of development the visuals did not need a GPU. A pool table is a flat green rectangle with some lines on it. Balls are circles with a number. The 2D context handled it fine, and every minute I did not spend on rendering went into the part that makes or breaks a pool game, which is how the balls move and collide.
So the renderer stayed simple on purpose for a long time. I had no reason to touch it until the look of the game started to matter.
What forced the switch
The reason was cosmetics. RealPool's one paid surface is going to be cosmetic items, and the interesting ones are not flat stickers. They are things like a ball texture that rotates correctly as the ball rolls, glitter that catches the light, particle trails that spawn and fade.
You cannot do that well in a 2D context. To rotate a ball texture believably you need an actual sphere with the texture wrapped around it, lit, redrawn every frame. To run hundreds of particles without the frame rate falling over you need the GPU. The 2D path could fake a little of this, but every fake had a ceiling, and I kept hitting it.
So the game scene moved to WebGL, built on a top-down 3D view: real ball spheres that track their own rotation, a lit table surface, and instanced particles drawn in one batch instead of one at a time.
The actual decision: delete the fallback
Moving to WebGL was the easy part. The real decision was what to do with the old 2D renderer. The safe-sounding answer is to keep it as a fallback: use WebGL when you can, drop to 2D when you cannot, so nobody gets locked out.
I cut it instead, and I think the safe-sounding answer was a trap.
Two renderers means you build every visual feature twice. A new cosmetic, a new aiming overlay, a tweak to how a pocketed ball animates, all of it has to land in both paths and look the same in both. There was no automated check that the two agreed, so the moment one player in a thousand actually used the 2D path, they would see a different, half-finished game. An unused code path that is "kept just in case" does not stay correct. It rots quietly until someone falls into it.
So now the game checks for WebGL2 once at startup. If it is there, the game runs. If it is not, the player gets a clear message saying the game needs it, rather than a silent drop to a worse experience. I would rather block honestly than degrade behind your back.
This does lock out anything without WebGL2. In practice that is a very old browser or a locked-down device, since WebGL2 has been standard for years, but it is a real cost and worth naming. I decided one renderer done well beats two renderers half-done. If that ever turns out wrong, the data will say so and I will revisit it.
What stayed in 2D
Not everything moved. The overlays that sit on top of the table, the aiming line, the curve preview for a massé, the power meter, the cue stick itself, are still drawn with the 2D context on a transparent canvas above the WebGL scene.
That is on purpose. Those overlays are sharp vector lines and they change every frame as you aim. The 2D context is genuinely good at exactly that, and there was no reason to force them through the GPU. The GPU draws the world, the table and the balls and the effects. The 2D layer draws the heads-up display over it.
Was it worth it
For the game itself, yes. The code got simpler the day the second renderer went away, and the cosmetics that were the whole point are now possible. The cost is a hard requirement on WebGL2 and a context-loss path I had to write so a lost GPU context recovers instead of crashing. That felt like a fair trade for not maintaining two versions of every pixel.