Skip to content

2.5D Games

Olivier Biot edited this page Jun 17, 2026 · 1 revision

Part of Working in 3D.

Camera3d isn't only for full 3D arcade games like AfterBurner. A common adjacent use case is 2.5D: a perspective camera for visual depth, but gameplay that lives entirely on a flat XY slice — Paper Mario, Octopath Traveler, parallax-heavy 2D side-scrollers, anything where "depth" is camera atmosphere, not gameplay state.

The Camera3d + Octree + SAT stack handles this cleanly. The recipe:

  1. Use cameraClass: Camera3d for the perspective look.
  2. Place all gameplay entities (player, enemies, projectiles, pickups) at a shared Z — typically z = 0. Use addChild(child, 0) to set Z atomically on insertion.
  3. Place parallax layers (foreground props, background mountains, distant clouds) at distinct Z values: closer-to-camera at z < 0, farther at z > 0. The perspective projection scales them automatically — no per-frame work to keep parallax in sync.
  4. Use existing 2D SAT collision (me.collision.check, Body shapes, world.adapter.queryAABB) as-is. SAT runs on Rect/Polygon/Ellipse in the XY plane; it doesn't care which camera class is rendering.

Why it works:

Each entity's 2D bounds is treated as a point in Z at the entity's pos.z. Two entities sharing the same Z classify into the same Z octant, so the broadphase walk surfaces them as candidates and SAT runs the XY check exactly as it would in a 2D world.

Cost:

Octree carries ~1.3-2× the per-insert overhead of QuadTree and roughly 2× the tree memory vs an equivalent QuadTree at the same entity count. Negligible unless you have thousands of bodies. If you genuinely have hundreds of entities AND a Camera3d AND every entity is on the same Z plane, you can opt back to QuadTree by setting world.sortOn = "z" after stage init.

Mini example:

class PaperMarioStage extends Stage {
    onResetEvent(app) {
        // Distant background — pushed back so perspective shrinks it
        app.world.addChild(new BackdropMountains(), 200);
        // Mid parallax — slow-scroll trees
        app.world.addChild(new ParallaxTrees(), 50);
        // GAMEPLAY plane — all collidable bodies share z = 0
        app.world.addChild(player, 0);
        app.world.addChild(new EnemyManager(), 0);
        // Foreground props — closer to camera, in front of player
        app.world.addChild(new ForegroundGrass(), -30);
    }
}

const app = new Application(canvas, {
    cameraClass: Camera3d,    // perspective view
});
state.set(state.PLAY, new PaperMarioStage());

Keeping parallax out of collision queries:

The Octree partitions on Z, so distant-Z parallax will often land in different octants than gameplay and be pruned by the broadphase walk — but this is best-effort, not a guarantee. Items at the Octree's depth midpoint (z=0 in the default ±10000 root box) stay at the root level and surface in every query, and a 2D Rect query has no z so it descends into all octants from root anyway. For deterministic isolation between gameplay and parallax, give parallax renderables isKinematic = true so the broadphase skips them entirely on insert, or rely on per-pair collisionType / collisionMask filtering on the narrowphase result. Parallax that you don't want hit-tested should be kinematic — this is the same advice that already applies to 2D parallax under Camera2d + QuadTree.

Worked example

The same recipe — perspective Camera3d, parallax layers at distinct Z, gameplay on a shared plane — drives the glTF Scene example, which loads a Blender-authored platformer diorama (see Loading glTF / GLB scenes).

Clone this wiki locally