Skip to content

2.5D Games

Olivier Biot edited this page Jun 23, 2026 · 2 revisions

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. Render the characters as Sprite3d billboards — paper-thin sprites that turn to face the camera (the defining Paper Mario look). See Paper-thin characters below.
  3. 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.
  4. 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.
  5. 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. Collision stays 2D even though the character renders as a Sprite3d — the Sprite3d is just the entity's renderable; its Body keeps a flat shape.

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.
        // player / enemies render as Sprite3d billboards (see below).
        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.

Paper-thin characters with Sprite3d

What makes a scene read as Paper Mario (rather than "2D sprites in a 3D-ish scene") is that the characters are flat cards that always turn to face the camera. That's exactly Sprite3d in "cylindrical" billboard mode: it faces the camera but stays upright, so a character never goes edge-on as the camera moves. It animates through the same API as a 2D Sprite, flips to face its travel direction, and cuts its transparent background out cleanly.

import { Sprite3d } from "melonjs";

// a billboarded, animated character (the entity's renderable)
const hero = new Sprite3d(0, 0, {
    image: "hero",                // a spritesheet with a transparent background
    framewidth: 32, frameheight: 48,
    width: 64, height: 96,        // world size of the card
    billboard: "cylindrical",     // face the camera, stay upright
    alphaCutoff: 0.5,             // cut out the transparent background (opaque mesh pass)
});
hero.addAnimation("idle", [0, 1]);
hero.addAnimation("walk", [2, 3, 4, 5]);
hero.setCurrentAnimation("walk");

// later, when moving:
hero.flipX(velocityX < 0);        // face the way we're walking
app.world.addChild(hero, 0);      // gameplay plane, z = 0

Use it as an Entity's renderable (so the Body drives the 2D-SAT collision while the Sprite3d draws), or add it directly with a separate Body for hit-testing.

Two caveats (small, hand-rolled — neither blocks a basic clone):

  • Ground anchoring. A Sprite3d's quad is centered, so to plant a character's feet on the ground you offset its position by half its height (e.g. set the entity's pos.y so the bottom edge meets the floor). There's no bottom-anchor option yet.
  • Shadows. There's no 3D shadow system; fake the classic blob shadow with a flat dark quad under the character — a Sprite3d with billboard: false (a small soft-edged dark texture) laid on the ground plane, or a tinted Mesh quad.

The Billboard Sprites example is the live reference — the same animated character shown in all three billboard modes, loaded from a packed (rotated + trimmed) atlas, with transparent cut-out backgrounds.

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