obOB STUDIO
Godot

Building a Minimap From Scratch in GDScript

4 min readolivers-racers

I could have used a SubViewport with an orthographic camera pointing down. That's the "proper" way to do a minimap in Godot. But I'm drawing the whole HUD in code — speedometer, lap counter, boost meter, everything — so adding one more draw call felt cleaner than bolting on a second viewport that renders a copy of the entire world.

Track UV: fitting any shape into a box

The track is a random closed loop. Some runs are nearly circular. Some are elongated ellipses. Some have a weird kidney-bean shape. The minimap needs to fit any of them into a 196×196 pixel rectangle in the top-right corner.

The world calculates the track bounds during generation:

var mn := Vector2(INF, INF)
var mx := Vector2(-INF, -INF)
for i in range(m):
    mn = Vector2(minf(mn.x, track_pts[i].x), minf(mn.y, track_pts[i].z))
    mx = Vector2(maxf(mx.x, track_pts[i].x), maxf(mx.y, track_pts[i].z))
var pad := half_w + 30.0
_bmin = mn - Vector2(pad, pad)
_bmax = mx + Vector2(pad, pad)

Then any 3D world position can be mapped to a UV coordinate in [0,1]² with:

func map_uv(p: Vector3) -> Vector2:
    var d := _bmax - _bmin
    return Vector2((p.x - _bmin.x) / maxf(d.x, 1.0),
                   (p.z - _bmin.y) / maxf(d.y, 1.0))

The HUD pre-computes a PackedVector2Array of UV coordinates for the track centreline and an array of UV positions for each gate. Every frame, it maps those UVs into the minimap rectangle and draws.

Drawing the track line

func _draw_minimap(rect: Rect2, accent: Color) -> void:
    draw_rect(rect, Color(0, 0.03, 0.05, 0.45), true)
    draw_rect(rect, Color(accent.r, accent.g, accent.b, 0.5), false, 2.0)
    var pts := PackedVector2Array()
    for uv in track_uv:
        pts.append(rect.position + Vector2(uv.x * rect.size.x, uv.y * rect.size.y))
    draw_polyline(pts, Color(accent.r, accent.g, accent.b, 0.65), 2.0)

A translucent dark rectangle, a thin accent border, and a polyline through all the UV points. That's the track. Godot's draw_polyline handles hundreds of points without breaking a sweat.

Gates as dots

Each gate gets a coloured dot. The next gate the player needs to hit blinks yellow. The finish line is white. The rest are small accent-coloured circles:

for i in range(gate_uv.size()):
    var p: Vector2 = rect.position + Vector2(gate_uv[i].x * rect.size.x,
                                              gate_uv[i].y * rect.size.y)
    if i == expected_idx:
        var blink := 0.6 + 0.4 * sin(_elapsed * 6.0)
        draw_circle(p, 5.0, Color(1.0, 0.85, 0.1, blink))
    elif i == 0:
        draw_circle(p, 3.5, Color(0.95, 0.95, 0.95, 0.9))
    else:
        draw_circle(p, 2.0, Color(accent.r, accent.g, accent.b, 0.7))

The blink uses a simple sin(_elapsed * 6.0) oscillation. No tween, no animation player. Just a sine wave. Sometimes the simplest solution wins.

Other racers

Here's the fun part. Every online racer shows up as a blue dot with a truncated name tag:

for id in world.remote_nodes:
    var rc = world.remote_nodes[id]
    if not is_instance_valid(rc):
        continue
    var uv: Vector2 = world.map_uv(rc.global_position)
    var p := rect.position + Vector2(uv.x * rect.size.x, uv.y * rect.size.y)
    draw_circle(p, 3.5, Color(0.45, 0.75, 1.0))
    _txt(p + Vector2(5, 4), str(rc.pname).left(8), 11, Color(0.65, 0.85, 1.0, 0.9))

Names are clipped to 8 characters so they don't overlap. The player themself is a white directional triangle — computed from the car's forward vector projected onto the XZ plane.

Why not a texture?

I considered rendering the track once to an Image and displaying it as a TextureRect. It'd save recalculating pixel positions every frame. But the cost is trivial — a few hundred Vector2 multiplications and a polyline draw — and doing it live means the minimap instantly adapts when the track is rebuilt (new-track button, or joining a room with a different seed). No texture invalidation, no cache. Just draw.

The whole minimap is about 40 lines of code. It took me an afternoon. Most of that afternoon was spent picking the right shade of blue for online racers.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.