A 3D solar system in Go, drawn with Canvas 2D
May 5, 2026 • 6 min read
I wrote a 3D engine in Go and let the browser draw 2D circles. No three.js, no WebGL, no JS math libraries. Just a list of 2D draw commands per frame.
TL;DR
- I wrote a small 3D engine and orbital simulator in Go, then rendered it in the browser using only Canvas 2D primitives (
arc,fillRect,createRadialGradient). - No
three.js, noWebGL, no JS math libraries. Go does the camera, the perspective projection, and the brightness shading. The browser draws circles. - This isn’t a clever workaround. It’s the natural shape of a godom app: Go owns the work, the browser is just a screen.
The premise nobody asks for
When somebody says “3D in the browser,” everyone thinks WebGL. Or three.js, which is WebGL with a friendlier API. Or maybe Babylon.js. The browser, after all, has a GPU on the other side of <canvas>, and the typical play is to throw geometry and shaders at it.
I did the opposite. The 3D engine is in Go. The browser doesn’t get geometry. It doesn’t even get 3D. It gets a list of 2D circles to draw, recomputed by Go every frame, sent over WebSocket, and rendered on a regular old <canvas> with a 2D context.
This sounds like a workaround. It isn’t. It’s just what godom does, taken to the natural conclusion: the Go process owns the work, the browser is the screen, and a “3D simulator” is just another thing where Go pushes pixels and the browser draws them.
The architecture, from one end to the other
There are four pieces:
1. Bodies and orbits, in Go.
Each celestial body is a struct with a name, color, orbital radius, angular speed, and a list of moons. Every tick, the angle advances and the moons advance with it.
type Body struct {
Name string
Radius float64
OrbitRadius float64
Speed float64
Angle float64
Color string
HasRings bool
Moons []*Body
}
func (b *Body) Position(parent Vec3) Vec3 {
return Vec3{
X: parent.X + b.OrbitRadius*math.Cos(b.Angle),
Y: 0,
Z: parent.Z + b.OrbitRadius*math.Sin(b.Angle),
}
}
func (b *Body) Update(dt float64) {
b.Angle += b.Speed * dt
for _, m := range b.Moons { m.Update(dt) }
}
The whole solar system is about 30 lines of data and a recursive update. Moons orbit their planets, planets orbit the sun, the system advances in lockstep on a 16ms ticker.
2. A camera that orbits the scene, also in Go.
The camera has a center it orbits around, a distance, a tilt angle, a rotation, and a field of view. Click-and-drag updates rotation and tilt. Scroll wheel updates distance. Selecting a planet from the sidebar moves the camera’s center to that planet’s position so the camera tracks it.
The actual perspective projection is a small chunk of vector math:
// Camera position from rotation/tilt/distance:
camX := c.Center.X + c.Distance*math.Sin(c.Rotation)*math.Cos(c.Tilt)
camY := c.Center.Y + c.Distance*math.Sin(c.Tilt)
camZ := c.Center.Z + c.Distance*math.Cos(c.Rotation)*math.Cos(c.Tilt)
// Forward direction (toward center)
fwd := Vec3{c.Center.X - camX, c.Center.Y - camY, c.Center.Z - camZ}.Normalize()
// Right is fwd × world-up; up is right × fwd. Standard view-matrix construction.
// Project a point:
depth := dx*fwd.X + dy*fwd.Y + dz*fwd.Z
scale := c.FOV / depth
sx := c.Width/2 + px*scale
sy := c.Height/2 - py*scale
sr := radius * scale
That’s perspective divide, hand-written, in Go. It’s the same math any 3D engine does; it’s just sitting on the wrong side of the network for what people expect.
3. A list of 2D draw commands, sent over WebSocket.
Every frame, Go computes the screen position, the screen-radius, and a 0-to-1 brightness for every body, and stuffs them into a flat slice:
type DrawCmd struct {
X, Y, Radius float64 `json:"..."`
Color string `json:"color"`
Glow bool `json:"glow,omitempty"`
Ring bool `json:"ring,omitempty"`
RingColor string `json:"ringColor,omitempty"`
Brightness float64 `json:"brightness"`
}
Then it sorts so the sun (with its glow) renders behind the planets, and the largest items render last, and ships the whole thing to the browser as the value of one Go field. godom diffs against the previous frame and patches what changed.
4. A small Canvas 2D bridge, in JS.
The browser doesn’t do any of the 3D. It loops over the draw commands and calls arc:
godom.register("canvas3d", {
update: function(el, data) {
var ctx = el.__ctx;
ctx.fillStyle = "#050510";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (var i = 0; i < data.commands.length; i++) {
var c = data.commands[i];
if (c.glow) { /* radial gradient for sun */ }
else {
ctx.globalAlpha = 0.3 + 0.7 * c.brightness;
if (c.ring) { /* ellipse for Saturn's ring */ }
ctx.fillStyle = _radialGradient(c.color, c.r);
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fill();
}
}
}
});
That’s the whole rendering layer. The browser doesn’t know about 3D. It draws circles where Go tells it to. Saturn’s ring is an ellipse call. The sun’s glow is a createRadialGradient. The planet shading (so each one looks like a sphere instead of a flat disc) is also a radial gradient, with the lighter color offset to a fixed light direction also computed in Go.
Mouse and scroll, routed through Go
The interaction is the part that should feel weird but doesn’t.
godom directives g-mousedown, g-mousemove, g-mouseup, and g-wheel route browser events to Go methods, with the cursor coordinates as (x, y float64) arguments. The mouse-move handler is throttled to one event per animation frame so we don’t drown the WebSocket.
<canvas g-plugin:canvas3d="SolarSystem"
g-mousedown="MouseDown"
g-mousemove="MouseMove"
g-mouseup="MouseUp"
g-wheel="Wheel"></canvas>
func (a *App) MouseMove(x, y float64) {
if !a.dragging { return }
dx := x - a.lastX
dy := y - a.lastY
a.cam.Rotation += dx * 0.005
a.cam.Tilt = clamp(0.1, 1.4, a.cam.Tilt - dy*0.005)
a.lastX, a.lastY = x, y
}
func (a *App) Wheel(deltaY float64) {
a.cam.Distance = clamp(10, 2000, a.cam.Distance + deltaY*0.5)
}
The drag is computed in Go. The camera rotation lives in Go. The next frame’s positions are recomputed in Go. If you closed the browser tab and reopened it, the camera would be exactly where you left it, because the camera is a Go struct, and the Go process didn’t go anywhere.
This is the part I keep coming back to. There’s no client-side state. There’s no JS-side input handling. There’s no transport other than “browser sends mouse events, Go sends draw commands.” The whole thing is a one-direction render loop with a backchannel for input.
How it actually performs
The simulator runs at ~60fps (a 16ms ticker). Each frame:
- Updates all body angles
- Re-projects 7 bodies and their 4 moons through the camera (11 sphere draws per frame)
- Sorts them by depth and glow
- Diffs the resulting struct against the previous frame
- Sends a binary patch over WebSocket
The patch is small. We’re updating six floats per body per frame, which after Protocol Buffer encoding is well under a kilobyte per frame. The bandwidth required is laughable. The browser has nothing to do but parse a tiny patch and call arc around a dozen times.
It’s smooth. It looks like a normal 3D scene. It doesn’t feel like there’s a network in the loop.
There’s a round trip on every mouse event, of course. On localhost that’s invisible.
What this is actually for
I am not telling anyone to build games this way, and I am not pretending this is faster than WebGL. WebGL is the right answer when you have lots of geometry or shaders or anything that benefits from running on the GPU.
What this is for: any time the simulation is the interesting part and the rendering is incidental. Astronomy. Trading dashboards with custom visualizations. Physics demos. Engineering simulations. Anything where you want the behavior of the system to live in your real language, with your real types, your real tests, your real debugger, and the screen is just a window onto it.
It’s also a useful proof for godom’s whole thesis. The architecture (Go owns the DOM, browser is dumb) handles a 3D solar system with mouse interaction at 60fps without anything special. That’s a reasonable existence proof.
Where to look at the code
The whole example is at examples/solar-system/ in the godom repo. Five files: main.go (app struct, ticker loop, event handlers), solar.go (the bodies and orbits), engine.go (the camera and projection), vec3.go (vector helpers), and canvas-bridge.js (the JS that draws circles).
Clone, go run ./examples/solar-system, drag the planets around. Four files of Go and one small JS plugin: that’s the whole 3D engine.