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.
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:
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 screenIn 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:
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.
// 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.



