Workstation4 / Blog / godot
godot gdscript graphics z-ordering

Bridges that move in front of or behind your car.

I am rebuilding the 1998 Game of Life board in Godot 4, painting cars across a scanned-in board image. The original art has overpasses, so a car should sometimes drive over a bridge and sometimes slip under it. That single cue does a surprising amount of work: it is the difference between a flat painting with stickers sliding across it and a scene that reads as having real depth. The part I enjoyed was how little code it needed once I stopped trying to bake the answer in.

Why a flat board fights you

A board drawn as one big image is, by default, a single plane. Cars ride on top of it. The moment you add overpasses that breaks down, because the correct layering for a bridge is not fixed. A car approaching the underpass should pass behind the bridge deck, while that same bridge should sit behind a car that is currently crossing it. Both can be true within the same second as cars move around the loop.

My first instinct was to hardcode it: mark this bridge always-on-top, that one always-behind. That falls apart immediately, because a bridge has to be in front of the cars below it and behind the cars on it at the same instant. The ordering is not a property of the bridge at all. It is a property of where the cars are right now, which means whatever decides it has to run every frame.

Put the rule on the sprite, not in the code

Instead of a special case per bridge, each bridge sprite carries its own small piece of data: the list of board tiles where it should sit on top of the cars. The bridges live in a tiny bridges.json, one entry per overpass, with a position, an image path, and an active_tiles array of 1-based nav-tile indices. A real entry looks like this:

{
  "id": 0,
  "name": "bridge_000",
  "file": "res://assets/bridges/bridge_000.png",
  "x": 678, "y": 509,
  "active_tiles": [129, 130]
}

That bridge crosses tiles 129 and 130, so those are the only two numbers it cares about. Everything else is one generic rule applied to every bridge identically: no per-bridge branch, no naming of individual overpasses in code. Add a new bridge to the art, give it a tile list, and it joins the system with zero new logic. The behavior ships next to the asset.

One small gotcha lived here. JSON numbers parse into GDScript as floats, so 129.0 would never match an integer tile index in a set lookup. The loader coerces each entry to int on the way in, which is the kind of bug that produces a feature that silently never triggers and no error to point at.

Two extreme z-index bands

Godot draws 2D nodes in order of z_index, and the legal range tops out at 4096. I lean on that hard. Each bridge sprite spawns at Z_BEHIND = -50, well below the painted board, where it is completely invisible. When a car occupies one of its tiles it jumps to Z_ABOVE = 4096, the maximum, so it draws above everything. There is no in-between state and nothing to clean up; the sprite just toggles between two extremes.

const Z_BEHIND := -50    # below the board: invisible
const Z_ABOVE  := 4096   # Godot's max: above cars and foreground

func _process(_delta: float) -> void:
    var occupied := {}                 # int -> true, used as a set
    for v in _provider.bridge_tile_set():
        occupied[int(v)] = true
    for entry in sprites:
        var above := false
        for t in entry["active_tiles"]:
            if occupied.has(int(t)):
                above = true
                break
        var z := Z_ABOVE if above else Z_BEHIND
        if entry["sprite"].z_index != z:   # only write when it changes
            entry["sprite"].z_index = z

The cars live in the middle of that range. Each car sets its own z_index to its vertical screen position every frame, so a car lower on the screen draws in front and a car higher up draws behind. That is classic y-sorting, and on this board the values run up to roughly 1700. Because 4096 is comfortably above any car and -50 is below the board, the bands never overlap. A raised bridge always beats every car, a lowered bridge always loses to the board, and no car can accidentally tie a bridge's depth value.

The painted foreground (trees, rooftops, the bits that should obscure a car driving behind them) sits at a fixed z_index of 4000. So the full stack, bottom to top, is: lowered bridges at -50, the board, cars at their world-y up to ~1700, the foreground at 4000, and raised bridges at 4096.

What counts as occupied, and the flicker

Each frame the world reports the union of every car's currently occupied tile. The subtle requirement is movement. Cars do not teleport between tiles; they tween along a Catmull-Rom curve fitted through the path, arc-length sampled so a 10-step roll takes ten times as long as a 1-step roll and the drive is actually visible to a stream viewer. If I only reported the tile a car had fully arrived at, the bridge would pop in and out as a car crossed, because for most of the journey the car is between two tiles, not on either one.

So while a car is mid-tween I report both endpoints of the path segment it is currently on, the tile it is leaving and the tile it is entering. The sampler stamps the current segment index onto the car as it moves, and the collector reads that to return the right pair:

func collect_active_nav_tiles_1based() -> Array:
    var out := []
    for p in session.registry.active():
        var st = car_state.get(p.id)
        if st != null and st["anim_active"]:
            # mid-drive: include both ends of the current segment
            var path = st["anim_path"]
            var seg = clamp(st["current_seg"], 0, path.size() - 2)
            out.append(int(path[seg]) + 1)
            out.append(int(path[seg + 1]) + 1)
        else:
            out.append(int(p.board_position_index) + 1)
    return out

That one decision is what clears the flicker. A bridge stays raised for the entire crossing, not just the couple of frames where a car sits dead center on a tile. The endpoints also keep the effect honest at the moment of arrival, since the segment endpoints always bracket the rendered position the sampler chose.

One bridge, two worlds

The same bridge component runs in two very different contexts, and I did not want two copies of it. In the path editor there is a single car under manual control; in the broadcast game there can be a dozen players' cars on the board at once. Rather than a mode flag, the bridge auto-detects its environment from its parent. If the parent exposes a bridge_tile_set() method it uses that (broadcast mode, where the game world forwards every active car's tiles). Otherwise it falls back to polling the single-car editor sibling directly.

The bridge does not know or care how many cars exist. It asks its parent for a set of occupied tiles and applies the same rule. Whether that set has one number or twenty changes nothing.

That duck-typed handshake kept the occluder a single, dumb component. The multiplayer layer satisfies the same tiny contract the editor did, so the feature came along for free when I wired it into the broadcast renderer.

Why a small system earned its keep

This is not a large feature: a few lines that run each frame, a list of tile numbers per sprite, and a method name acting as a contract. But it is a clean example of a pattern I keep returning to in this project. Push the special cases out into data, keep the runtime rule generic, and let the scene describe itself rather than be described by code.

The happy result is that the board stopped looking like a picture with stickers sliding across it and started feeling like a place, with bridges you genuinely drive over and under.

  • The ordering belongs to the cars' positions, not the bridges, so the rule must run every frame.
  • Reporting both segment endpoints during a tween is what removes the flicker mid-drive.
  • Two non-overlapping z-index bands (-50 and 4096) around y-sorted cars (~0 to 1700) give correct layering with no hardcoded order and no risk of a tie.
  • Carrying the active-tiles list on each sprite turns a pile of special cases into one generic system you extend by editing data.
  • A single duck-typed method (bridge_tile_set()) let one component serve both the single-car editor and the multi-car broadcast.
  • Watch your types: JSON integers arrive as floats, and a silent type mismatch can disable a feature with no error at all.
W4

Workstation4

A quiet workshop for cool, strange, useful iOS apps. Run by one developer who chases the weird problems for sport.

About the workshop →