Skip to content

Build a physics puzzle ​

Goal ​

Push a lever past 25 degrees to raise a heavy gate. Dodge the swinging weight or use it to shove the lever; win when the gate has travelled 2 metres.

Files you will edit ​

  • src/prefabs.ts
  • src/game.ts

Steps ​

  1. Append these prefabs to the third-person template's src/prefabs.ts, using its existing prefab import. Their default layer already meets the hero's mask. None has the template's Door tag. The weight and lever can collide; each joint here connects to the world, so there is no connected body to hit.

    ts
    export const PuzzleLeverPrefab = prefab({
      name: 'puzzle-lever',
      body: {
        shape: 'box',
        dims: [0.12, 0.7, 0.12],
        kind: 'dynamic',
        mass: 5,
        layer: { defaultLayer: true },
        mask: { defaultLayer: true, player: true, staticGeometry: true },
      },
    });
    export const PuzzleGatePrefab = prefab({
      name: 'puzzle-gate',
      body: {
        shape: 'box',
        dims: [1, 1.2, 0.15],
        kind: 'dynamic',
        mass: 100,
        layer: { defaultLayer: true },
        mask: { player: true, staticGeometry: true },
      },
    });
    export const PuzzleWeightPrefab = prefab({
      name: 'puzzle-weight',
      body: {
        shape: 'sphere',
        dims: [0.35],
        kind: 'dynamic',
        mass: 20,
        layer: { defaultLayer: true },
        mask: { defaultLayer: true, player: true, staticGeometry: true },
      },
    });
  2. In src/game.ts, add physics to the SDK import and the three new prefabs to the ./prefabs import. Add this state and system above defineGame. These records are reused on every step; the motor command is only sent when the lever crosses the threshold.

    ts
    let leverJoint = 0;
    let gateJoint = 0;
    let gateOpening = false;
    let puzzleWon = false;
    const gateMotor = { mode: 'position', target: 0, maxForce: 10000 } as const;
    const gateTarget = { ...gateMotor, target: 0 };
    
    function physicsPuzzle(ctx: GameContext): void {
      const angle = physics.jointValue(leverJoint);
      const opening = angle !== null && angle > 25;
      if (opening !== gateOpening) {
        gateOpening = opening;
        gateTarget.target = opening ? 2.4 : 0;
        physics.setMotor(gateJoint, gateTarget);
      }
      const travel = physics.jointValue(gateJoint);
      if (!puzzleWon && travel !== null && travel > 2) {
        puzzleWon = true;
        console.log('physics puzzle solved');
        ctx.hud.invalidate();
      }
    }
  3. Append this setup to the existing init(ctx). Put these props in clear space, away from the arena walls. The lever's lower end is the hinge; its positive rotation about +Z tips its top toward -X. The slider's positive displacement is upward along +Y, relative to its initial position.

    ts
    gateOpening = false;
    puzzleWon = false;
    const lever = ctx.spawn(PuzzleLeverPrefab, { x: 2, y: 1, z: -3 });
    tint(lever, CHEST_COLOUR);
    leverJoint = physics.hinge(lever, 0, {
      anchor: { x: 2, y: 0.3, z: -3 },
      axis: { x: 0, y: 0, z: 1 },
      minDeg: -40,
      maxDeg: 40,
      friction: 8,
    });
    const gate = ctx.spawn(PuzzleGatePrefab, { x: 5, y: 1.3, z: -3 });
    tint(gate, DOOR_COLOUR);
    gateJoint = physics.slider(gate, 0, {
      anchor: { x: 5, y: 1.3, z: -3 },
      axis: { x: 0, y: 1, z: 0 },
      min: 0,
      max: 2.4,
    });
    gateTarget.target = 0;
    physics.setMotor(gateJoint, gateTarget);
    const weight = ctx.spawn(PuzzleWeightPrefab, { x: 2.8, y: 1.5, z: -3 });
    tint(weight, CHEST_COLOUR);
    physics.distance(weight, 0, {
      anchor: { x: 2.8, y: 1.5, z: -3 },
      anchorB: { x: 2.8, y: 4.5, z: -3 },
    });
    physics.applyImpulse(weight, -50, 0, 0);

    With no distance limits the connection is a rigid 3 m rod at its initial length, not a slack rope. A 100 kg gate weighs about 981 N: the default motor limit of 100 N cannot lift it. Here maxForce: 10000 supplies enough force to raise and hold it. Keep connected bodies apart in two-body joints: joining them does not disable their collisions.

  4. Add physicsPuzzle after locomotionSystem in the existing systems array. puzzleWon is the latched result for your HUD or subsequent game rules; the win test uses slider jointValue, not the gate's visual pose.

Verify ​

sh
npm run dev

The weight swings from its overhead anchor. Push the lever toward -X until physics.jointValue(leverJoint) exceeds 25 degrees: the gate rises. Its jointValue passes 2 metres, puzzleWon becomes true, and the console prints physics puzzle solved once. Push the lever back below the threshold and the gate closes; the win stays latched.

See also ​