← All examples

PHYSICS

Throw the toys around.

Crates and balls drop into a tray and tumble to rest, simulated by Rapier. Click a toy to kick it, drop another, or tidy up and watch them all fall again. Drag around them to orbit. Each toy is a mesh with a body described as data; Rapier owns where it is, and frames stop when everything comes to rest. A chain hangs from a hook, its links held by spherical joints, and Pour marbles adds one instanced mesh with a body per marble. Show colliders draws what Rapier simulates. In a browser with a headset, Enter VR stands you in front of the tray: point a controller at a toy and pull the trigger to kick it, glide with the left thumbstick and turn with the right; when you let go, the Model keeps where you stand.

Bodies as data.

The Model says what each toy is and where it was dropped, never where it is now: that belongs to the simulation, stepped on scene time with fixed steps, as react-three-rapier steps it. A kick is data too, an impulse with a new id that the simulation applies once. Collisions come back as Messages.

Explore the example source →

See how it works.

The source running this example. Expand a file to follow the Model, Messages and view.

model.ts
import { glide, perspectiveCamera, SceneSchema, xr } from '@opsydyn/effect-three';
import { Schema } from 'effect';

import { Paint, paintAt } from '../palette';

/** What a toy is: a crate or a ball. */
export const Kind = Schema.Literals(['crate', 'ball']);
export type Kind = typeof Kind.Type;

/**
 * A toy in the box. Where it is belongs to the simulation once it is dropped: the Model
 * holds where it was dropped from, and how often it has been kicked.
 */
export const Toy = Schema.Struct({
  id: Schema.Number,
  kind: Kind,
  paint: Paint,
  x: Schema.Number,
  z: Schema.Number,
  /** Each kick has a new id, so the simulation applies it once. */
  kick: Schema.NullOr(
    Schema.Struct({
      id: Schema.Number,
      impulse: Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]),
    }),
  ),
});
export type Toy = typeof Toy.Type;

/**
 * Virtual reality: whether the browser can present it, and whether it is. The button
 * shows only when it can.
 */
export const Vr = Schema.Literals(['checking', 'unavailable', 'ready', 'entering', 'presenting']);
export type Vr = typeof Vr.Type;

export const Model = Schema.Struct({
  toys: Schema.Array(Toy),
  nextId: Schema.Number,
  /** Tidying up starts a new round: every toy gets a new key and drops again. */
  round: Schema.Number,
  /** Collisions that began between toys and anything but marbles, as the simulation reported them. */
  bumps: Schema.Number,
  /** Marbles poured in: one instanced node, a body per marble. */
  marbles: Schema.Number,
  /** Whether the physics world draws its colliders and joints as lines. */
  showColliders: Schema.Boolean,
  camera: SceneSchema.Camera,
  vr: Vr,
  /** Where the player stands in VR: the sticks glide them, and the Model keeps where. */
  player: SceneSchema.Xr,
});
export type Model = typeof Model.Type;

/** The tray's inner half-width: toys drop inside it. */
export const reach = 2.6;

/** Where the `index`th toy drops from: a loose spiral over the middle of the tray. */
export const dropPoint = (index: number) => {
  const angle = index * 2.4;
  const radius = Math.min(reach - 0.6, 0.5 + index * 0.18);
  return { x: Math.cos(angle) * radius, z: Math.sin(angle) * radius };
};

export const toyAt = (id: number): Toy => ({
  id,
  kind: id % 3 === 0 ? 'ball' : 'crate',
  paint: paintAt(id),
  ...dropPoint(id),
  kick: null,
});

export const firstToys = 8;

/** Marbles per pour, and most in the box. */
export const pour = 16;
export const mostMarbles = 96;

export const homeCamera = perspectiveCamera({
  fov: 40,
  position: [0, 6.5, 8.5],
  lookAt: [0, 0.4, 0],
  damping: 0.1,
});

export const initialModel: Model = {
  toys: Array.from({ length: firstToys }, (_, id) => toyAt(id)),
  nextId: firstToys,
  round: 0,
  bumps: 0,
  marbles: 0,
  showColliders: false,
  camera: homeCamera,
  vr: 'checking',
  // In front of the tray, facing it.
  player: xr({ origin: [0, 0, 4.2], glide: glide() }),
};
message.ts
import { SceneSchema } from '@opsydyn/effect-three';
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';

export const Vec3 = Schema.Tuple([Schema.Number, Schema.Number, Schema.Number]);

export const Message = defineMessageUnion({
  ClickedToy: { key: Schema.String, direction: Vec3 },
  ClickedDrop: {},
  ClickedTidy: {},
  ClickedPour: {},
  ToggledColliders: {},
  /** Two bodies began or stopped touching; `marble` if either was a marble. */
  Bumped: { started: Schema.Boolean, marble: Schema.Boolean },
  MadeGesture: { gesture: SceneSchema.Gesture },
  CheckedVr: { supported: Schema.Boolean },
  ClickedEnterVr: {},
  ClickedExitVr: {},
  FailedVr: {},
  ChangedVrSession: { presenting: Schema.Boolean },
  Glided: { moved: SceneSchema.XrMovedDetail },
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { type Gesture, glided, orbit, orbitOptions, type Vec3 } from '@opsydyn/effect-three';
import { Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { CheckVr, EnterVr, ExitVr } from './command';
import { Message } from './message';
import { homeCamera, initialModel, type Model, mostMarbles, pour, toyAt, type Toy } from './model';

type AppReturn = Return<Model, Message>;

export const toyKey = (model: Model, toy: Toy): string => `toy:${model.round}:${toy.id}`;

/** The toy a scene key names, in this round. */
const toyOf = (model: Model, key: string): Option.Option<Toy> =>
  Option.fromNullishOr(model.toys.find((toy) => toyKey(model, toy) === key));

/**
 * A kick away from the camera and up: the click's ray, flattened, plus a lift. The toys
 * weigh one unit, so this is also the change in velocity.
 */
const kickAlong = (direction: Vec3): Vec3 => {
  const length = Math.hypot(direction[0], direction[2]) || 1;
  return [(direction[0] / length) * 2.5, 5, (direction[2] / length) * 2.5];
};

const kicked = (toy: Toy, direction: Vec3): Toy => ({
  ...toy,
  kick: { id: (toy.kick?.id ?? 0) + 1, impulse: kickAlong(direction) },
});

const kick = (model: Model, key: string, direction: Vec3): Model =>
  Option.match(toyOf(model, key), {
    onNone: () => model,
    onSome: (target) => ({
      ...model,
      toys: model.toys.map((toy) => (toy.id === target.id ? kicked(toy, direction) : toy)),
    }),
  });

/** Most toys a box holds before the oldest goes. */
const capacity = 24;

const dropped = (model: Model): Model => ({
  ...model,
  toys: [...model.toys, toyAt(model.nextId)].slice(-capacity),
  nextId: model.nextId + 1,
});

/** Every toy back on its drop point: a new round, so each one drops afresh. */
const tidied = (model: Model): Model => ({
  ...model,
  toys: model.toys.map((toy) => ({ ...toyAt(toy.id), kick: null })),
  round: model.round + 1,
});

const orbitLimits = orbitOptions({
  maxPolarAngle: Math.PI / 2 - 0.1,
  minDistance: 4,
  maxDistance: 18,
});

/** Orbit unless the gesture began on a toy (a click on it is a kick). */
const orbited = (model: Model, gesture: Gesture): Model =>
  'key' in gesture && gesture.key !== undefined
    ? model
    : { ...model, camera: orbit(model.camera, gesture, orbitLimits) };

export const init = () => ({ model: initialModel, commands: [CheckVr()] });

const vrAvailability = { true: 'ready', false: 'unavailable' } as const;
const vrSession = { true: 'presenting', false: 'ready' } as const;

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    ClickedToy: ({ key, direction }) => ({ model: kick(model, key, direction) }),
    ClickedDrop: () => ({ model: dropped(model) }),
    ClickedTidy: () => ({ model: tidied(model) }),
    ToggledColliders: () => ({
      model: modifyFields(model, { showColliders: (shown) => !shown }),
    }),
    ClickedPour: () => ({
      model: modifyFields(model, { marbles: (marbles) => Math.min(mostMarbles, marbles + pour) }),
    }),
    Bumped: ({ started, marble }) => ({
      // Toys bumping into things; marbles roll in and out of contact all the time.
      model: modifyFields(model, { bumps: (bumps) => bumps + Number(started && !marble) }),
    }),
    MadeGesture: ({ gesture }) => ({ model: orbited(model, gesture) }),
    CheckedVr: ({ supported }) => ({
      model: modifyFields(model, { vr: () => vrAvailability[`${supported}`] }),
    }),
    ClickedEnterVr: () => ({
      model: modifyFields(model, { vr: () => 'entering' as const }),
      commands: [EnterVr()],
    }),
    ClickedExitVr: () => ({ model, commands: [ExitVr()] }),
    FailedVr: () => ({ model: modifyFields(model, { vr: () => 'ready' as const }) }),
    ChangedVrSession: ({ presenting }) => ({
      model: modifyFields(model, { vr: () => vrSession[`${presenting}`] }),
    }),
    // The session glides the player with the sticks (pmndrs/xr's controller locomotion);
    // when they let go, the Model keeps where they stand.
    Glided: ({ moved }) => ({
      model: modifyFields(model, { player: (player) => glided(player, moved) }),
    }),
    ReceivedSceneLifecycle: () => ({ model }),
  });

export { homeCamera };
scene.ts
/**
 * The toybox: crates and balls dropped into a tray, simulated by Rapier. Each toy is a
 * mesh with a dynamic body; the tray is fixed bodies. The Model never holds where a toy
 * is now, only where it was dropped and the kicks it has had. Marbles are one instances
 * node with a body per instance, and a chain of links hangs from a hook by joints.
 */
import {
  body,
  box,
  contactShadows,
  environment,
  instance,
  instances,
  joint,
  mesh,
  physics,
  scene,
  type SceneNode,
  sphere,
  sphericalJoint,
  standard,
} from '@opsydyn/effect-three';

import { paintAt, paintColour } from '../palette';
import { type Model, reach, type Toy } from './model';
import { toyKey } from './update';

const dropHeight = 4;

const toyNode = (model: Model, toy: Toy): SceneNode =>
  mesh({
    key: toyKey(model, toy),
    // Staggered upwards by id, so toys dropped together land one after another. (Not by
    // place in the list: that shifts as toys come and go, and a new position teleports.)
    position: [toy.x, dropHeight + (toy.id % 6) * 0.6, toy.z],
    geometry:
      toy.kind === 'ball' ? sphere({ radius: 0.35 }) : box({ width: 0.6, height: 0.6, depth: 0.6 }),
    material: standard({ color: paintColour[toy.paint], roughness: 0.45 }),
    interactive: true,
    castShadow: true,
    body: body({
      collider: toy.kind === 'ball' ? 'ball' : 'cuboid',
      mass: 1,
      restitution: toy.kind === 'ball' ? 0.6 : 0.2,
      friction: 0.7,
      impulse: toy.kick,
    }),
  });

const wall = (
  key: string,
  position: readonly [number, number, number],
  width: number,
  depth: number,
): SceneNode =>
  mesh({
    key,
    position,
    geometry: box({ width, height: 0.5, depth }),
    material: standard({ color: 0xd9d6cf, roughness: 0.9 }),
    body: body({ type: 'fixed' }),
  });

const side = reach * 2 + 0.3;

const tray: ReadonlyArray<SceneNode> = [
  mesh({
    key: 'tray-floor',
    position: [0, -0.15, 0],
    geometry: box({ width: side, height: 0.3, depth: side }),
    material: standard({ color: 0xeceae4, roughness: 0.95 }),
    body: body({ type: 'fixed', friction: 0.8 }),
  }),
  wall('tray-north', [0, 0.25, -reach - 0.075], side, 0.15),
  wall('tray-south', [0, 0.25, reach + 0.075], side, 0.15),
  wall('tray-east', [reach + 0.075, 0.25, 0], 0.15, side),
  wall('tray-west', [-reach - 0.075, 0.25, 0], 0.15, side),
];

/** Where the `index`th marble pours from: a tight spiral above the tray's far side. */
const marbleAt = (index: number) => {
  const angle = index * 2.4;
  const radius = 0.15 + (index % 16) * 0.05;
  return {
    key: `m${index}`,
    position: [
      Math.cos(angle) * radius + 0.8,
      dropHeight + 1.5 + Math.floor(index / 16) * 0.4,
      Math.sin(angle) * radius - 0.8,
    ] as const,
    color: paintColour[paintAt(index)],
  };
};

const marbleNode = (model: Model): SceneNode =>
  instances({
    // New per round, as the toys' keys are, so tidying pours them again.
    key: `marbles:${model.round}`,
    geometry: sphere({ radius: 0.12 }),
    material: standard({ roughness: 0.2 }),
    castShadow: true,
    instances: Array.from({ length: model.marbles }, (_, index) => instance(marbleAt(index))),
    body: body({ collider: 'ball', mass: 0.05, restitution: 0.3, friction: 0.4 }),
  });

const hook: readonly [number, number, number] = [-1.6, 3.4, -1.6];
const linkLength = 0.45;
const links = 5;

/** A link hanging below the hook, its top on the one above's bottom; the last is a heavy ball. */
const chainLink = (index: number): SceneNode =>
  mesh({
    key: `chain:${index}`,
    position: [hook[0], hook[1] - (index + 0.5) * linkLength, hook[2]],
    geometry:
      index === links - 1
        ? sphere({ radius: 0.28 })
        : box({ width: 0.08, height: linkLength - 0.05, depth: 0.08 }),
    material: standard({ color: index === links - 1 ? 0x3a3f4b : 0x8a8f99, metalness: 0.6 }),
    castShadow: true,
    body: body({ mass: index === links - 1 ? 3 : 0.2, angularDamping: 0.2 }),
  });

const chain: ReadonlyArray<SceneNode> = [
  mesh({
    key: 'chain:hook',
    position: hook,
    geometry: sphere({ radius: 0.06 }),
    material: standard({ color: 0x3a3f4b }),
    body: body({ type: 'fixed', sensor: true }),
  }),
  ...Array.from({ length: links }, (_, index) => chainLink(index)),
];

/** Each link hangs from the one above (the first from the hook) by its top end. */
const chainJoints = Array.from({ length: links }, (_, index) =>
  joint({
    key: `chain:joint:${index}`,
    a: index === 0 ? 'chain:hook' : `chain:${index - 1}`,
    b: `chain:${index}`,
    anchorA: index === 0 ? [0, 0, 0] : [0, -linkLength / 2, 0],
    anchorB: [0, linkLength / 2, 0],
    kind: sphericalJoint(),
    // Neighbouring links overlap where they meet: let them pass through each other.
    contacts: false,
  }),
);

export const sceneFor = (model: Model) =>
  scene({
    camera: model.camera,
    gestures: 'scene',
    physics: physics({ joints: chainJoints, debug: model.showColliders }),
    // In VR the player stands where the Model says: in front of the tray, to begin with.
    xr: model.player,
    environment: environment({ environmentIntensity: 0.6 }),
    contactShadows: contactShadows({
      position: [0, 0.01, 0],
      size: side,
      near: -0.05,
      far: 1.5,
      blur: 2,
      opacity: 0.7,
    }),
    children: [
      ...tray,
      ...chain,
      marbleNode(model),
      ...model.toys.map((toy) => toyNode(model, toy)),
    ],
  });
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';

import { sceneMount } from './environment';
import { Message } from './message';
import type { Model } from './model';
import { sceneFor } from './scene';

const button = (h: HtmlBuilder<Message>, label: string, message: Message): Html =>
  h.button([h.Class('e3-button'), h.Type('button'), h.OnClick(message)], [label]);

/** Shown only where the browser can present VR; in VR, the way back out. */
const vrButtons = {
  checking: () => [],
  unavailable: () => [],
  ready: (h: HtmlBuilder<Message>) => [button(h, 'Enter VR', Message.ClickedEnterVr())],
  entering: (h: HtmlBuilder<Message>) => [button(h, 'Entering VR…', Message.ClickedExitVr())],
  presenting: (h: HtmlBuilder<Message>) => [button(h, 'Exit VR', Message.ClickedExitVr())],
} satisfies Record<Model['vr'], (h: HtmlBuilder<Message>) => ReadonlyArray<Html>>;

const vrButton = (model: Model, h: HtmlBuilder<Message>): ReadonlyArray<Html> =>
  vrButtons[model.vr](h);

const readout = (model: Model): string =>
  `${model.toys.length} toys, ${model.marbles} marbles, ${model.bumps} bumps. Click a toy to kick it; drag around them to orbit.`;

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Toybox — effect-three',
  body: h.div(
    [h.Class('e3-sample')],
    [
      EffectThreeFoldkit.view(
        {
          scene: sceneFor(model),
          mount: sceneMount,
          className: 'e3-stage',
          onLifecycle: (lifecycle) => Message.ReceivedSceneLifecycle({ lifecycle }),
          onClick: ({ key, ray }) => Message.ClickedToy({ key, direction: ray.direction }),
          onCollision: ({ started, aInstance, bInstance }) =>
            Message.Bumped({ started, marble: aInstance !== undefined || bInstance !== undefined }),
          onGesture: (gesture) => Message.MadeGesture({ gesture }),
          onXRSession: ({ mode }) => Message.ChangedVrSession({ presenting: mode !== null }),
          onXRMoved: (moved) => Message.Glided({ moved }),
        },
        h,
      ),
      h.p([h.Class('e3-readout'), h.AriaLive('polite')], [readout(model)]),
      h.div(
        [h.Class('e3-controls')],
        [
          button(h, 'Drop a toy', Message.ClickedDrop()),
          button(h, 'Pour marbles', Message.ClickedPour()),
          button(h, 'Tidy up', Message.ClickedTidy()),
          button(
            h,
            model.showColliders ? 'Hide colliders' : 'Show colliders',
            Message.ToggledColliders(),
          ),
          ...vrButton(model, h),
        ],
      ),
      h.p(
        [h.Class('e3-note')],
        [
          'Each toy is a mesh with a dynamic body; Rapier owns where it is. The Model keeps ',
          'where it was dropped and its kicks, each with a new id, applied once. The marbles ',
          'are one instanced mesh with a body per instance; the chain is links held by ',
          'spherical joints.',
        ],
      ),
    ],
  ),
});