← All examples

BUILD, THEN REWIND

A scene is a function of state.

Click the plate or a piece to stack another. Pick a colour and a block, ball or drum. Then drag the history slider: the sculpture replays exactly, because nothing in the scene exists outside the Model.

Rewind for free.

The history slider only changes a number in the Model. The scene is derived from it on every render, so the 3D view follows with no extra code. With Foldkit DevTools in time-travel mode, picking any history row does the same.

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 { Schema } from 'effect';

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

/** The pieces the sculpture is built from. */
export const Piece = Schema.Literals(['block', 'ball', 'drum']);
export type Piece = typeof Piece.Type;

export const pieces: ReadonlyArray<Piece> = Piece.literals;

export const Block = Schema.Struct({
  id: Schema.Number,
  x: Schema.Int,
  y: Schema.Int,
  z: Schema.Int,
  paint: Paint,
  piece: Piece,
});
export type Block = typeof Block.Type;

/**
 * Every block ever placed, in order, and how many of them are shown. Scrubbing `shown`
 * back replays the build; placing a block while scrubbed back discards the later ones.
 */
export const Model = Schema.Struct({
  blocks: Schema.Array(Block),
  shown: Schema.Int,
  paint: Paint,
  piece: Piece,
  nextId: Schema.Number,
});
export type Model = typeof Model.Type;

const seed: ReadonlyArray<Block> = [
  { id: 0, x: 0, y: 0, z: 0, paint: 'blue', piece: 'block' },
  { id: 1, x: 1, y: 0, z: 0, paint: 'gold', piece: 'drum' },
  { id: 2, x: 0, y: 1, z: 0, paint: 'coral', piece: 'ball' },
];

export const init = () => ({
  model: {
    blocks: seed,
    shown: seed.length,
    paint: 'mint',
    piece: 'block',
    nextId: seed.length,
  } satisfies Model,
});

/** The blocks currently shown. */
export const shownBlocks = (model: Model): ReadonlyArray<Block> =>
  model.blocks.slice(0, model.shown);
message.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import { Schema } from 'effect';
import { defineMessageUnion } from 'foldkit/message';

import { Paint } from '../palette';
import { Piece } from './model';

export const Message = defineMessageUnion({
  ClickedSurface: { key: Schema.String, x: Schema.Number, z: Schema.Number },
  ClickedPaint: { paint: Paint },
  ClickedPiece: { piece: Piece },
  ChangedHistory: { shown: Schema.Int },
  ClickedUndo: {},
  ClickedRedo: {},
  ClickedClear: {},
  ReceivedSceneLifecycle: { lifecycle: EffectThreeFoldkit.SceneLifecycle },
});
export type Message = typeof Message.Type;
update.ts
import { Array as Arr, Option } from 'effect';
import { modifyFields } from 'foldkit/struct';
import type { Return } from 'foldkit/update';

import { Message } from './message';
import { type Block, type Model, shownBlocks } from './model';
import { half } from './scene';

type AppReturn = Return<Model, Message>;

const clamp = (value: number): number => Math.max(-half, Math.min(half, Math.round(value)));

/** The grid column a click lands in: the clicked block's own column, or the ground cell. */
const columnFor = (model: Model, key: string, x: number, z: number) =>
  Option.match(
    Arr.findFirst(shownBlocks(model), (block) => `block-${block.id}` === key),
    {
      onNone: () => ({ x: clamp(x), z: clamp(z) }),
      onSome: (block) => ({ x: block.x, z: block.z }),
    },
  );

/** Stacks a block on top of a column, discarding any history after the shown point. */
const place = (model: Model, key: string, x: number, z: number): Model => {
  const column = columnFor(model, key, x, z);
  const shown = shownBlocks(model);
  const height = shown.filter((block) => block.x === column.x && block.z === column.z).length;
  const block: Block = {
    id: model.nextId,
    ...column,
    y: height,
    paint: model.paint,
    piece: model.piece,
  };
  return { ...model, blocks: [...shown, block], shown: shown.length + 1, nextId: model.nextId + 1 };
};

export const update = (model: Model, message: Message): AppReturn =>
  Message.match<AppReturn>(message, {
    ClickedSurface: ({ key, x, z }) => ({ model: place(model, key, x, z) }),
    ClickedPaint: ({ paint }) => ({ model: modifyFields(model, { paint: () => paint }) }),
    ClickedPiece: ({ piece }) => ({ model: modifyFields(model, { piece: () => piece }) }),
    ChangedHistory: ({ shown }) => ({
      model: modifyFields(model, {
        shown: () => Math.max(0, Math.min(model.blocks.length, shown)),
      }),
    }),
    ClickedUndo: () => ({
      model: modifyFields(model, { shown: (shown) => Math.max(0, shown - 1) }),
    }),
    ClickedRedo: () => ({
      model: modifyFields(model, { shown: (shown) => Math.min(model.blocks.length, shown + 1) }),
    }),
    ClickedClear: () => ({ model: { ...model, blocks: [], shown: 0 } }),
    ReceivedSceneLifecycle: () => ({ model }),
  });
scene.ts
/** The sculpture as a pure function of the model: a ground plate and the shown pieces. */
import {
  ambientLight,
  box,
  cylinder,
  directionalLight,
  mesh,
  perspectiveCamera,
  scene,
  type SceneNode,
  sharedGeometry,
  sharedMaterial,
  sphere,
  standard,
} from '@opsydyn/effect-three';

import { paintColour, paints } from '../palette';
import { type Block, type Model, shownBlocks } from './model';

/** Blocks sit on a grid from -half to +half on x and z. */
export const half = 4;

const blockNode = (block: Block): SceneNode =>
  mesh({
    key: `block-${block.id}`,
    position: [block.x, block.y + 0.5, block.z],
    geometry: sharedGeometry(block.piece),
    material: sharedMaterial(block.paint),
    interactive: true,
  });

export const sceneFor = (model: Model) =>
  scene({
    camera: perspectiveCamera({ fov: 40, position: [9, 9, 12], lookAt: [0, 0.5, 0] }),
    resources: {
      geometries: [
        { id: 'block', geometry: box({ width: 0.96, height: 0.96, depth: 0.96 }) },
        { id: 'ball', geometry: sphere({ radius: 0.48 }) },
        { id: 'drum', geometry: cylinder({ radiusTop: 0.46, radiusBottom: 0.46, height: 0.96 }) },
      ],
      materials: paints.map((paint) => ({
        id: paint,
        material: standard({ color: paintColour[paint], roughness: 0.55 }),
      })),
    },
    children: [
      ambientLight({ key: 'ambient', intensity: 0.6 }),
      directionalLight({ key: 'sun', intensity: 2.2, position: [5, 10, 7] }),
      mesh({
        key: 'ground',
        position: [0, -0.1, 0],
        geometry: box({ width: half * 2 + 1, height: 0.2, depth: half * 2 + 1 }),
        material: standard({ color: 0x9aa3b5, roughness: 0.9 }),
        interactive: true,
      }),
      ...shownBlocks(model).map(blockNode),
    ],
  });
view.ts
import * as EffectThreeFoldkit from '@opsydyn/effect-three-foldkit';
import type { Document, Html, HtmlBuilder } from 'foldkit/html';

import { sceneMount } from '../environment';
import { type Paint, paintCss, paints } from '../palette';
import { Message } from './message';
import { type Model, type Piece, pieces } 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]);

const swatch = (model: Model, h: HtmlBuilder<Message>, paint: Paint): Html =>
  h.button(
    [
      h.Class('e3-button'),
      h.Type('button'),
      h.AriaPressed(String(model.paint === paint)),
      h.OnClick(Message.ClickedPaint({ paint })),
    ],
    [h.span([h.Class('e3-swatch'), h.Style({ background: paintCss(paint) })], []), paint],
  );

const piecePicker = (model: Model, h: HtmlBuilder<Message>, piece: Piece): Html =>
  h.button(
    [
      h.Class('e3-button'),
      h.Type('button'),
      h.AriaPressed(String(model.piece === piece)),
      h.OnClick(Message.ClickedPiece({ piece })),
    ],
    [piece],
  );

const history = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('e3-controls')],
    [
      button(h, 'Undo', Message.ClickedUndo()),
      h.input([
        h.Class('e3-range'),
        h.Type('range'),
        h.Min('0'),
        h.Max(String(model.blocks.length)),
        h.Value(String(model.shown)),
        h.AriaLabel('Build history'),
        h.OnInput((value) => Message.ChangedHistory({ shown: Number.parseInt(value, 10) || 0 })),
      ]),
      button(h, 'Redo', Message.ClickedRedo()),
      button(h, 'Clear', Message.ClickedClear()),
    ],
  );

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: 'Sculpture — 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, point }) => Message.ClickedSurface({ key, x: point[0], z: point[2] }),
        },
        h,
      ),
      h.div(
        [h.Class('e3-controls')],
        paints.map((paint) => swatch(model, h, paint)),
      ),
      h.div(
        [h.Class('e3-controls'), h.AriaLabel('Piece')],
        pieces.map((piece) => piecePicker(model, h, piece)),
      ),
      history(model, h),
      h.p(
        [h.Class('e3-note')],
        [
          `Showing ${model.shown} of ${model.blocks.length} pieces. Click the ground or a piece to stack a block, ball or drum. `,
          'The scene is a pure function of the model, so the history slider replays the build exactly.',
        ],
      ),
    ],
  ),
});