obOB STUDIO
Physics

Color Sampling — How the Chameleon Steals Surface Colors

5 min read

The entire game hinges on one raycast

If the color sampling doesn't feel right, nothing else matters. You walk up to a mossy green wall, press E, and your lizard should become that green. If there's lag, if the color is wrong, if it doesn't work on certain surfaces — the game falls apart. I spent more time on this one interaction than on most other systems combined.

How sampling works

When the player presses the sample button (act_sample input action), the game fires a raycast from the camera:

func _cam_ray(dist: float) -> Dictionary:
    if not me or not me.cam:
        return {}
    var from := me.cam.global_position
    var dir := -me.cam.global_transform.basis.z
    var space: PhysicsDirectSpaceState3D = get_world_3d().direct_space_state
    var q := PhysicsRayQueryParameters3D.create(from, from + dir * dist)
    q.collision_mask = 3
    q.exclude = [me.get_rid()]
    return space.intersect_ray(q)

The ray goes 30 units forward from the camera. It tests against collision layers 1 (world geometry) and 2 (lizards), but excludes the player's own body. If it hits a StaticBody3D that has a col meta field, we've got a valid surface color.

func _on_sample() -> void:
    if GS.phase != GS.Phase.HIDE or GS.am_seeker:
        return
    var hit := _cam_ray(30.0)
    if hit and hit.collider is StaticBody3D and hit.collider.has_meta("col"):
        GS.add_swatch(hit.collider.get_meta("col"))
        ui.refresh_swatches()
        ui.toast(" Color stolen! Tap a body part to paint it.")
    else:
        ui.toast("Aim at a wall / floor / prop to steal its color")

Two guard clauses up top: sampling only works during the hide phase, and only if you're a hider. The seeker can't steal colors — they have no reason to.

The swatch palette

Stolen colors go into a swatch array (GS.swatches). The UI shows them as clickable squares at the bottom of the screen. You select a swatch, then tap a body part to paint it. The painting function is clean:

func set_part_color(part_name: String, c: Color) -> void:
    if part_name == "all":
        for p in GS.PARTS:
            mats[p].albedo_color = c
    elif mats.has(part_name):
        mats[part_name].albedo_color = c

Five paintable parts: body, head, tail, legs, crest. Each part has its own StandardMaterial3D stored in the mats dictionary. The eyes are never paintable — they're always white. That's a deliberate design choice from the original game: the googly eyes are the one thing the seeker can spot if they look closely enough.

The meta("col") approach

Every surface in the stage stores its color in Godot's metadata system. When I build a wall, I do:

wall.set_meta("col", Color(0.35, 0.55, 0.3))  # mossy green

This is simpler than reading the material's albedo at runtime (which would require casting through mesh surfaces and material overrides and gets messy fast). The meta approach means the "physics color" and "visual color" can differ if needed — though in practice I keep them in sync.

I initially tried reading colors directly from the material. It worked on simple surfaces but broke on multi-material meshes and CSG intersections. The meta field approach was my fallback plan, and it turned out to be the better design anyway. Explicit is better than implicit.

Paint state and network sync

When you paint yourself, the paint_dirty flag goes true:

func _apply_selected_paint() -> void:
    if me == null or GS.am_seeker or GS.sel_swatch < 0:
        return
    if GS.phase != GS.Phase.HIDE:
        return
    me.set_part_color(GS.sel_part, GS.swatches[GS.sel_swatch])
    paint_dirty = true

In online mode, the network tick checks paint_dirty and sends a paint update — but throttled to at most once every 250ms:

if paint_dirty:
    _paint_acc += delta
    if _paint_acc > 0.25:
        _paint_acc = 0.0
        _send_paint()

The paint message includes the current color of every body part as hex strings:

func get_colors() -> Dictionary:
    var d := {}
    for p in GS.PARTS:
        d[p] = mats[p].albedo_color.to_html(false)
    return d

On the receiving end, apply_colors() parses the hex strings back into Color objects. If a paint message arrives before the player's lizard has been spawned (race condition during round setup), it goes into pending_paints and gets applied when the lizard actually appears.

Camouflage scoring

After painting, the game continuously scores how well you blend in. The compute_blend() function casts rays in five directions from the lizard (down, and four diagonals), reads the surrounding surface colors, averages them with weights, then compares against your paint:

func compute_blend(world: Stage3D) -> float:
    var origin := global_position + Vector3(0, 0.6, 0)
    var dirs := [Vector3.DOWN, Vector3(1, -0.3, 0), Vector3(-1, -0.3, 0),
            Vector3(0, -0.3, 1), Vector3(0, -0.3, -1)]
    var weights := [2.0, 1.0, 1.0, 1.0, 1.0]

The downward ray gets double weight because you're usually standing on a surface. The camo percentage shows live on the HUD — "Camo 78%" — so you know how well you're hidden. The cap is 97%, never a perfect 100. There's always a chance the seeker spots you. That 3% uncertainty is what keeps the tension alive.

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.