Devlog5 min read

Perspective illusion puzzles in Unity: the one rule behind them

How Bowling Puzzle: 3D Illusions rolls a ball between pieces that only line up from one angle: one camera ray, a pixel budget and levels built from the answer.

My next game, Bowling Puzzle: 3D Illusions, is built on the line under its logo: what you see is what is. You roll a ball at a rack of pins along pieces of track that float apart from each other. Turn the world until two pieces line up on screen, and the ball rolls from one onto the other as if they were joined.

It's the trick behind Monument Valley and every Escher-style puzzle since. It looks like it needs clever physics. It doesn't: the physics stays completely ordinary, and one rule, applied at one moment, does all the work.

The one rule

When the ball rolls off an edge, cast a ray from the camera through the ball's own position on screen. If the first thing that ray meets behind the ball is a floor, the player sees the ball resting on that floor, so put it there and let it keep rolling, however far away that floor really is.

C#
using UnityEngine;

/// Puts the ball on whatever it appears to be resting on.
public class IllusionJoin : MonoBehaviour
{
    [SerializeField] Camera cam;
    [SerializeField] LayerMask floors;   // decks the ball can roll on
    [SerializeField] LayerMask solids;   // walls and blocks that hide what's behind them
    [SerializeField] float maxReach = 500f;

    readonly RaycastHit[] hits = new RaycastHit[16];

    public bool TryJoin(Vector3 ballPos, float radius, out RaycastHit floor)
    {
        floor = default;
        Vector3 screen = cam.WorldToScreenPoint(ballPos);
        if (screen.z <= 0f) return false;                     // behind the camera

        Ray ray = cam.ScreenPointToRay(screen);
        float ballDepth = Vector3.Distance(cam.transform.position, ballPos);
        int count = Physics.RaycastNonAlloc(ray, hits, maxReach, floors | solids, QueryTriggerInteraction.Ignore);

        RaycastHit nearest = default;
        float best = float.MaxValue;
        for (int i = 0; i < count; i++)
        {
            // Anything nearer than the ball is in front of it: it hides the ball, it doesn't hold it.
            if (hits[i].distance < ballDepth - radius) continue;
            if (hits[i].distance < best) { best = hits[i].distance; nearest = hits[i]; }
        }
        if (best == float.MaxValue) return false;

        // The first thing behind the ball decides: a floor catches it, a solid hides the floor.
        bool isFloor = (floors.value & (1 << nearest.collider.gameObject.layer)) != 0;
        if (!isFloor || nearest.normal.y < 0.35f) return false;

        floor = nearest;
        return true;
    }
}

Solids are in the mask on purpose. Before they were, the ray went straight through a block that stood between the ball and a far deck, and one level could be won by lining the first deck up with the pin deck through the block that was put there to stop it. The player can't see a floor behind a wall, so the ball can't land on one.

Keep the ball the same size on screen

A far deck is far away, so a ball dropped onto it would suddenly look tiny and slow, and the illusion breaks on the very frame it should land. Perspective makes apparent size proportional to size over distance, so scale the ball and its speed by how much further from the camera it now is:

C#
Vector3 camPos = cam.transform.position;
float k = Vector3.Distance(camPos, floor.point) / Vector3.Distance(camPos, ball.position);

ball.position = floor.point + floor.normal * radius * k;
ball.localScale *= k;
body.linearVelocity *= k;   // the same speed on screen

In Bowling Puzzle a ball can grow more than tenfold over a level and nobody notices, because on screen it never changes at all.

A pixel budget keeps it honest

If every ray that hits a floor counts, the ball snaps onto decks that are visibly lower, and the player feels cheated. So the ball may only drop a few pixels on screen, measured from the moment it left the edge, before the join is refused and it falls for real:

C#
float liftY;   // the ball's screen height when it left the last deck

public void LeftEdge() => liftY = cam.WorldToScreenPoint(transform.position).y;

public bool StillLooksSupported()
{
    float drop = liftY - cam.WorldToScreenPoint(transform.position).y;
    float budget = 26f * Screen.height / 1080f;   // 26 px on a 1080p screen
    return drop <= budget;
}

That one number decides the feel of the whole game: pieces that look flush connect, and pieces with a visible seam don't. The game's version also corrects it for zoom and for how steeply the camera looks down, so the same seam is judged the same way from every angle the player can reach. Without that, tilting the camera until the seams stopped showing would have been the shortest route through any level.

The camera may turn, never move

Every join is a ray from one exact camera position. Move the camera a centimetre and every join in the level shifts with it. So the camera orbits on a fixed sphere around the level and never dollies in or out.

Zoom still works, through the lens rather than the position. WorldToScreenPoint and ScreenPointToRay both read the same projection matrix, so a point goes to the screen and back along the identical ray whatever the field of view says. Bowling Puzzle zooms by replacing the projection matrix and never touches the camera's transform.

Build levels from the answer, not by eye

Placing pieces by hand until they look lined up doesn't work. A join can be a fraction of a degree off in a way nobody can see and no test catches. So every level is built from its solution camera: lay a deck, then push the next one away from the camera along the ray the camera sees it on. It lands on exactly the same pixel, however far it moves.

C#
// Push a point further from the camera along its own sight line.
static Vector3 Behind(Vector3 point, Vector3 camPos, float depth)
    => point + (point - camPos).normalized * depth;

It has to be the ray from the camera through that point. My first version pushed pieces along the camera's forward axis instead, which is only the same thing in the middle of the frame; off-centre, a piece pushed along forward drifts toward the middle of the screen. That bug was silently wrong in all five of the original hand-built levels, and only broke visibly in one of them.

Scale each pushed piece by the same factor as the ball, (distance + depth) / distance, and from the solution angle it continues the first deck exactly.

Test by playing every level

Compiling proves nothing for a game like this, and reading the code proves little. Each level is played headlessly against real physics at its own solution angle, and the test checks that it's won by crossing exactly as many joins as the level was designed with. A second check measures how wide the band of winning angles is, because a level that's only solvable within a third of a degree passes the first test and is unwinnable for a human thumb.

That's the whole trick: one ray, one pixel budget, a camera that never moves, and levels built from their answer. Bowling Puzzle: 3D Illusions is coming to Google Play soon; you can see it on my games page.

  • C#
  • Raycasting
  • Puzzle design

Written by

Logical Programmer

Solo indie developer. I build games in Unity, ship them on Google Play, and document all of it.

Keep reading

All articles
  • Tutorial4 min read

    A Unity inventory system with ScriptableObjects

    Items as ScriptableObject assets, slots as plain C#, stacking and splitting in two methods, and saves that still load after you rename or delete an item.

    Read

  • Tutorial4 min read

    Procedural dungeons in Unity with BSP

    Split the map until the pieces are room-sized, put a room in each, join every split with a corridor, and use a seed so one number always gives one dungeon.

    Read

  • Tutorial2 min read

    Object pooling in Unity, the built-in way

    Unity has shipped a generic object pool since 2021. How to use UnityEngine.Pool properly: prewarming, resetting state, and the one mistake that corrupts a pool.

    Read