On this page

Going deeper: the API surface

For people who want more than the ambient default — building their own sequences, their own rules.

For when the defaults aren’t enough. The library keeps its curated building blocks — looks, camera shots, verified presets — in tagged registries, and exposes the primitives to combine them. Each page here takes one surface and shows it working; the registry pages share a small tag-filter picker from examples/shared/.

1. Looks — examples/21_Looks/

What you’ll have: every NamedLook in the LOOKS registry, browsable and applied two ways.

Needs: npm i three @krusenstiern/aizawa-attractor.

examples/21_Looks/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- script-src stays 'self' (contract invariant 6). style-src allows
         inline because vite dev injects <style> tags; production output
         is a linked stylesheet only. ws: is the vite dev HMR socket. -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
    />
    <meta name="color-scheme" content="dark" />
    <title>Aizawa — looks</title>
  </head>
  <body>
    <canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
    <a class="back-link" href="../..">← back to the tutorial</a>
    <p id="notice" role="status" aria-live="polite"></p>
    <aside id="panel">
      <h1>LOOKS</h1>
      <p class="lede">
        The curated look registry — every
        <code>NamedLook</code> is a complete <code>LookSpec</code>.
        <code>morph</code> applies it via <code>transitionToLook(look)</code>,
        <code>instant</code> via <code>setLook(look.options)</code>.
      </p>
      <div class="controls" role="radiogroup" aria-label="Apply mode">
        <label
          ><input type="radio" name="mode" value="morph" checked /> morph</label
        >
        <label
          ><input type="radio" name="mode" value="instant" /> instant</label
        >
      </div>
      <h2>Looks</h2>
      <div id="picker"></div>
      <p class="status" id="status" role="status"></p>
    </aside>
    <figure id="fallback" hidden>
      <img
        src="../15_Ambient/poster.svg"
        alt="The Aizawa attractor rendered as a static image"
      />
      <figcaption>
        Interactive rendering needs WebGL2, which this browser does not provide
        — showing a still instead.
      </figcaption>
    </figure>
    <script type="module" src="src/main.ts"></script>
  </body>
</html>
examples/21_Looks/src/main.ts
import type {
  AttractorRenderer,
  LookTag,
  NamedLook,
} from '@krusenstiern/aizawa-attractor/three';
import {
  gateMotion,
  loadsRenderer,
  pageChrome,
} from '../../shared/src/page.js';
import { createPicker, type Picker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';

// The closed LookTag vocabulary, in display order — the picker builds
// one AND-filter chip per entry.
const LOOK_TAGS: readonly LookTag[] = [
  'dark',
  'bright',
  'warm',
  'cool',
  'mono',
  'neon',
];

const { canvas, notice, status, showFallback } = pageChrome();

const onContextChange = (lost: boolean): void => {
  notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};

function wireScene(
  renderer: AttractorRenderer,
  looks: readonly NamedLook[],
): void {
  const morph = (): boolean =>
    must(
      document.querySelector<HTMLInputElement>('input[name="mode"]:checked'),
      'mode radio',
    ).value === 'morph';

  let current = looks[0]!.name;

  const picker: Picker = createPicker(
    must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
    LOOK_TAGS,
    looks,
    (look) => {
      // The registry payload goes to transitionToLook; setLook takes
      // its `.options` — a NamedLook itself is rejected with a
      // TypeError by design.
      if (morph()) renderer.transitionToLook(look);
      else renderer.setLook(look.options);
      current = look.name;
      picker.select(look.name);
    },
  );
  picker.select(current);

  // Blend state is renderer stats — poll at UI rates, never per frame.
  setInterval(() => {
    const stats = renderer.stats();
    status.textContent =
      `${current} — ${stats.lookTransitioning ? 'blending' : 'settled'}` +
      ` · lookBlendTicks ${String(stats.lookBlendTicks)}`;
  }, 250);
}

if (!loadsRenderer()) {
  showFallback();
} else {
  try {
    const { LOOKS, startAttractor } =
      await import('@krusenstiern/aizawa-attractor/three');
    const handle = startAttractor(canvas, {
      onContextChange,
      autoplay: false,
    });
    if (handle === null) {
      showFallback();
    } else {
      gateMotion(handle, () => {
        handle.renderer.renderStill();
      });
      wireScene(handle.renderer, LOOKS);
    }
  } catch {
    showFallback();
  }
}

What just happened: picking a look applies it instantly (setLook(look.options)) or morphing (transitionToLook(look) — blended over lookBlendTicks); stats().lookTransitioning reports a blend in flight.

Next: shots.

2. Shots — examples/22_Shots/

What you’ll have: every NamedShot playing on a camera tour the page owns — grab the canvas to inspect.

Needs: npm i three @krusenstiern/aizawa-attractor.

examples/22_Shots/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- script-src stays 'self' (contract invariant 6). style-src allows
         inline because vite dev injects <style> tags; production output
         is a linked stylesheet only. ws: is the vite dev HMR socket. -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
    />
    <meta name="color-scheme" content="dark" />
    <title>Aizawa — shots</title>
  </head>
  <body>
    <canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
    <a class="back-link" href="../..">← back to the tutorial</a>
    <p id="notice" role="status" aria-live="polite"></p>
    <aside id="panel">
      <h1>NAMED_SHOTS</h1>
      <p class="lede">
        The curated camera-shot registry — each
        <code>NamedShot.shot</code> plays on a <code>CameraTour</code> the page
        owns (renderer created with <code>cameraTour: false</code>). Grab the
        canvas to inspect; resume to rejoin the path.
      </p>
      <div class="controls">
        <button type="button" id="tour-toggle">pause shot</button>
      </div>
      <h2>Shots</h2>
      <div id="picker"></div>
      <p class="status" id="status" role="status"></p>
    </aside>
    <figure id="fallback" hidden>
      <img
        src="../15_Ambient/poster.svg"
        alt="The Aizawa attractor rendered as a static image"
      />
      <figcaption>
        Interactive rendering needs WebGL2, which this browser does not provide
        — showing a still instead.
      </figcaption>
    </figure>
    <script type="module" src="src/main.ts"></script>
  </body>
</html>
examples/22_Shots/src/main.ts
import type { NamedShot, ShotTag } from '@krusenstiern/aizawa-attractor/three';
import {
  gateMotion,
  loadsRenderer,
  pageChrome,
} from '../../shared/src/page.js';
import { createTicker } from '../../shared/src/ticker.js';
import { createPicker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';

// The closed ShotTag vocabulary, in display order.
const SHOT_TAGS: readonly ShotTag[] = [
  'orbit',
  'flythrough',
  'close',
  'wide',
  'hold',
  'drift',
];

const { canvas, notice, status, showFallback } = pageChrome();

const onContextChange = (lost: boolean): void => {
  notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};

if (!loadsRenderer()) {
  showFallback();
} else {
  try {
    const { NAMED_SHOTS, createAttractorRenderer, createCameraTour } =
      await import('@krusenstiern/aizawa-attractor/three');
    // `cameraTour: false` — the tour is page-owned, so picking a shot
    // just swaps the CameraTour; the renderer and scene stay.
    const renderer = createAttractorRenderer(canvas, {
      cameraTour: false,
      resize: 'auto',
      onContextChange,
    });
    if (renderer === null) {
      showFallback();
    } else {
      let tour = createCameraTour(renderer.camera, renderer.controls, {
        shots: [NAMED_SHOTS[0]!.shot],
        loop: true,
      });

      // Deterministic drive — one sim tick + one camera update per
      // 1/60 s slot, one present per frame.
      const ticker = createTicker(
        () => {
          renderer.step(1);
          tour.update();
        },
        () => {
          renderer.present();
        },
      );

      const toggle = must(
        document.querySelector<HTMLButtonElement>('#tour-toggle'),
        'button#tour-toggle',
      );
      const syncToggle = (): void => {
        toggle.textContent = tour.playing ? 'pause shot' : 'resume shot';
      };
      toggle.addEventListener('click', () => {
        if (tour.playing) tour.stop();
        else tour.play();
        syncToggle();
      });

      // Grab-to-inspect: the owned-tour twin of takeOverOnGrab — a
      // grab stops the tour, OrbitControls (already enabled) drives
      // until resume.
      const grab = (): void => {
        if (tour.playing) {
          tour.stop();
          syncToggle();
          notice.show('Camera released — resume to rejoin the shot');
        }
      };
      canvas.addEventListener('pointerdown', grab);
      canvas.addEventListener('wheel', grab, { passive: true });

      const describe = (shot: NamedShot): string =>
        `${shot.name} — ${String(Math.round(shot.shot.durationTicks / 60))}s` +
        ' loop · drag to inspect';

      const picker = createPicker(
        must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
        SHOT_TAGS,
        NAMED_SHOTS,
        (named) => {
          tour = createCameraTour(renderer.camera, renderer.controls, {
            shots: [named.shot],
            loop: true,
          });
          picker.select(named.name);
          syncToggle();
          status.textContent = describe(named);
        },
      );
      picker.select(NAMED_SHOTS[0]!.name);
      status.textContent = describe(NAMED_SHOTS[0]!);

      gateMotion(ticker, () => {
        renderer.renderStill();
      });
    }
  } catch {
    showFallback();
  }
}

What just happened: the renderer is created with cameraTour: false, then createCameraTour(renderer.camera, renderer.controls, …) drives whichever shot you pick. Grabbing the canvas stops the tour — the owned-tour twin of takeOverOnGrab.

Next: presets.

3. Presets — examples/23_Presets/

What you’ll have: every VERIFIED_PRESETS entry, browsable — morph the attractor itself.

Needs: npm i three @krusenstiern/aizawa-attractor.

examples/23_Presets/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- script-src stays 'self' (contract invariant 6). style-src allows
         inline because vite dev injects <style> tags; production output
         is a linked stylesheet only. ws: is the vite dev HMR socket. -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
    />
    <meta name="color-scheme" content="dark" />
    <title>Aizawa — presets</title>
  </head>
  <body>
    <canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
    <a class="back-link" href="../..">← back to the tutorial</a>
    <p id="notice" role="status" aria-live="polite"></p>
    <aside id="panel">
      <h1>VERIFIED_PRESETS</h1>
      <p class="lede">
        The five verified-chaotic parameter sets, tagged by character. Picking
        one fires <code>transitionToPreset(preset)</code> — a retargeting morph
        over <code>transitionTicks</code>, never a hard cut.
      </p>
      <div class="controls">
        <button type="button" id="random">random preset</button>
      </div>
      <h2>Presets</h2>
      <div id="picker"></div>
      <p class="status" id="status" role="status"></p>
    </aside>
    <figure id="fallback" hidden>
      <img
        src="../15_Ambient/poster.svg"
        alt="The Aizawa attractor rendered as a static image"
      />
      <figcaption>
        Interactive rendering needs WebGL2, which this browser does not provide
        — showing a still instead.
      </figcaption>
    </figure>
    <script type="module" src="src/main.ts"></script>
  </body>
</html>
examples/23_Presets/src/main.ts
import type {
  AttractorRenderer,
  Preset,
  PresetTag,
} from '@krusenstiern/aizawa-attractor/three';
import {
  gateMotion,
  loadsRenderer,
  pageChrome,
} from '../../shared/src/page.js';
import { createPicker, type Picker } from '../../shared/src/picker.js';
import { must } from '../../shared/src/dom.js';
import '../../shared/src/style.css';

// The closed PresetTag vocabulary, in display order.
const PRESET_TAGS: readonly PresetTag[] = [
  'calm',
  'chaotic',
  'sparse',
  'dense',
  'slow',
  'fast',
];

const { canvas, notice, status, showFallback } = pageChrome();

const onContextChange = (lost: boolean): void => {
  notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};

function wireScene(
  renderer: AttractorRenderer,
  presets: readonly Preset[],
  matchPreset: (params: Preset['params']) => Preset | undefined,
): void {
  // The picker sees {name, tags}; preset tags live on `meta`.
  const entries = presets.map((preset) => ({
    preset,
    name: preset.name,
    tags: preset.meta?.tags ?? [],
  }));

  const picker: Picker = createPicker(
    must(document.querySelector<HTMLElement>('#picker'), 'div#picker'),
    PRESET_TAGS,
    entries,
    (entry) => {
      renderer.transitionToPreset(entry.preset);
      picker.select(entry.name);
    },
  );
  picker.select(presets[0]!.name);

  must(
    document.querySelector<HTMLButtonElement>('#random'),
    'button#random',
  ).addEventListener('click', () => {
    const picked = renderer.transitionToRandomPreset();
    picker.select(picked.name);
    notice.show('morphing → ' + picked.name);
  });

  // Preset identity and morph state are renderer stats — poll at UI
  // rates. `matchPreset` names the params the sim has settled on.
  setInterval(() => {
    const stats = renderer.stats();
    const settled = matchPreset(stats.params)?.name ?? 'custom';
    const target = stats.targetPreset?.name ?? '—';
    status.textContent =
      `settled on ${settled} · target ${target}` +
      (stats.transitioning ? ' · morphing' : ' · at rest') +
      ` · dead ticks ${String(stats.deadTicks)}`;
  }, 250);
}

if (!loadsRenderer()) {
  showFallback();
} else {
  try {
    const { VERIFIED_PRESETS, matchPreset, startAttractor } =
      await import('@krusenstiern/aizawa-attractor/three');
    const handle = startAttractor(canvas, {
      onContextChange,
      autoplay: false,
    });
    if (handle === null) {
      showFallback();
    } else {
      gateMotion(handle, () => {
        handle.renderer.renderStill();
      });
      wireScene(handle.renderer, VERIFIED_PRESETS, matchPreset);
    }
  } catch {
    showFallback();
  }
}

What just happened: picking fires transitionToPreset(preset) — a retargeting morph over transitionTicks, never a hard cut — or transitionToRandomPreset for a rescue-pool pick. matchPreset names the params the sim settles on.

Next: a run.

4. A run — examples/24_Runs/

What you’ll have: presets × shots sequenced into a Tour, played by the normative rules — with a look per segment.

Needs: npm i three @krusenstiern/aizawa-attractor.

examples/24_Runs/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- script-src stays 'self' (contract invariant 6). style-src allows
         inline because vite dev injects <style> tags; production output
         is a linked stylesheet only. ws: is the vite dev HMR socket. -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
    />
    <meta name="color-scheme" content="dark" />
    <title>Aizawa — a run</title>
  </head>
  <body>
    <canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
    <a class="back-link" href="../..">← back to the tutorial</a>
    <p id="notice" role="status" aria-live="polite"></p>
    <aside id="panel">
      <h1>A run</h1>
      <p class="lede">
        Presets × shots sequence into a <code>Tour</code>; a page-owned look
        schedule aligns one <code>NamedLook</code> per segment and fires
        <code>transitionToLook</code> on every segment's first tick. Played by
        the normative rules — <code>optionsForTour</code> +
        <code>locateTourTick</code> + <code>step</code>/<code>present</code>.
      </p>
      <div class="controls">
        <button type="button" id="play">play</button>
        <button type="button" id="restart">restart</button>
      </div>
      <div class="progress"><div id="progress-bar"></div></div>
      <h2>Program</h2>
      <ol class="program" id="program"></ol>
      <p class="status" id="status" role="status"></p>
    </aside>
    <figure id="fallback" hidden>
      <img
        src="../15_Ambient/poster.svg"
        alt="The Aizawa attractor rendered as a static image"
      />
      <figcaption>
        Interactive rendering needs WebGL2, which this browser does not provide
        — showing a still instead.
      </figcaption>
    </figure>
    <script type="module" src="src/main.ts"></script>
  </body>
</html>
examples/24_Runs/src/main.ts
import type {
  AttractorRenderer,
  NamedLook,
  Tour,
} from '@krusenstiern/aizawa-attractor/three';
import { loadsRenderer, pageChrome } from '../../shared/src/page.js';
import { createTicker } from '../../shared/src/ticker.js';
import { must } from '../../shared/src/dom.js';
import { boundaryFor } from './app/boundaries.js';
import '../../shared/src/style.css';

const { canvas, notice, status, showFallback } = pageChrome();

const onContextChange = (lost: boolean): void => {
  notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};

if (!loadsRenderer()) {
  showFallback();
} else {
  try {
    const {
      DEFAULT_TOUR,
      createAttractorRenderer,
      findLook,
      findPreset,
      findShot,
      locateTourTick,
      optionsForTour,
      tourDurationTicks,
    } = await import('@krusenstiern/aizawa-attractor/three');

    // The authored run: preset × shot per segment (the document), a
    // page-owned look aligned to each segment (never in the document).
    const picks = [
      { preset: 'personal-default', shot: 'drift', look: 'glacier' },
      { preset: 'variant-b', shot: 'core-crossing', look: 'ember' },
      { preset: 'variant-a', shot: 'overhead', look: 'aurora' },
      { preset: 'variant-c', shot: 'close-swing', look: 'paper' },
    ];
    const tour: Tour = {
      v: 1,
      name: 'registry-run',
      sim: { ...DEFAULT_TOUR.sim, seed: 7 },
      segments: picks.map((pick) => ({
        preset: findPreset(pick.preset)!,
        shot: findShot(pick.shot)!.shot,
      })),
    };
    const schedule: NamedLook[] = picks.map((pick) => findLook(pick.look)!);
    const totalTicks = tourDurationTicks(tour);

    const programItems = picks.map((pick, index) => {
      const item = document.createElement('li');
      const segment = tour.segments[index]!;
      const seconds = Math.round(segment.shot.durationTicks / 60);
      const name = document.createElement('span');
      name.className = 'seg-name';
      name.textContent = `${pick.preset} × ${pick.shot}`;
      item.append(
        name,
        document.createTextNode(` → look ${pick.look} · ${String(seconds)}s`),
      );
      return item;
    });
    must(
      document.querySelector<HTMLOListElement>('#program'),
      'ol#program',
    ).append(...programItems);

    const playButton = must(
      document.querySelector<HTMLButtonElement>('#play'),
      'button#play',
    );
    const progressBar = must(
      document.querySelector<HTMLElement>('#progress-bar'),
      'div#progress-bar',
    );

    let renderer: AttractorRenderer | null = null;
    let tick = 0;
    let prevIndex = -1;
    let finished = false;

    const markProgram = (index: number): void => {
      programItems.forEach((item, i) => {
        if (i === index) item.dataset.current = '';
        else delete item.dataset.current;
      });
      const pick = picks[index]!;
      status.textContent =
        `segment ${String(index + 1)}/${String(picks.length)} — ` +
        `${pick.preset} × ${pick.shot} · look ${pick.look}`;
    };

    // A run is one renderer: optionsForTour pins seed, segment-0
    // params/context, sim shape and the non-looping shot sequence;
    // orbit stays player-owned (grabbing is off — the camera is
    // authored content here).
    const fresh = (): AttractorRenderer | null => {
      renderer?.dispose();
      renderer = createAttractorRenderer(canvas, {
        ...optionsForTour(tour),
        resize: 'auto',
        orbit: { takeOverOnGrab: false },
        onContextChange,
      });
      tick = 0;
      prevIndex = -1;
      finished = false;
      programItems.forEach((item) => delete item.dataset.current);
      return renderer;
    };

    const ticker = createTicker(
      () => {
        const at = locateTourTick(tour, tick);
        if (at === null || renderer === null) {
          ticker.stop();
          finished = true;
          playButton.textContent = 'play';
          status.textContent = 'run finished — play restarts';
          return;
        }
        if (at.index !== prevIndex) {
          // Segment's first tick: morph params when they differ
          // (rule 2), blend the scheduled look unconditionally
          // (rule 5) — before advancing.
          const { params, entry } = boundaryFor(tour, schedule, at.index);
          prevIndex = at.index;
          if (params !== null) renderer.transitionTo(params);
          renderer.transitionToLook(entry);
          markProgram(at.index);
        }
        renderer.step(1);
        tick += 1;
      },
      () => {
        renderer?.present();
        progressBar.style.width = `${((tick / totalTicks) * 100).toFixed(1)}%`;
      },
    );

    playButton.addEventListener('click', () => {
      if (finished && fresh() === null) {
        showFallback();
        return;
      }
      if (ticker.running) {
        ticker.stop();
        playButton.textContent = 'play';
      } else {
        ticker.start();
        playButton.textContent = 'pause';
      }
    });
    must(
      document.querySelector<HTMLButtonElement>('#restart'),
      'button#restart',
    ).addEventListener('click', () => {
      const next = fresh();
      if (next === null) {
        showFallback();
        return;
      }
      next.renderStill();
      ticker.stop();
      playButton.textContent = 'play';
      status.textContent = 'reset — press play';
    });

    document.addEventListener('visibilitychange', () => {
      if (document.hidden && ticker.running) {
        ticker.stop();
        playButton.textContent = 'play';
      }
    });

    // Frame 0 as the page's poster — the run itself only ever plays on
    // an explicit press, which doubles as the reduced-motion path.
    const first = fresh();
    if (first === null) {
      showFallback();
    } else {
      first.renderStill();
      status.textContent =
        `4 segments · ${String(Math.round(totalTicks / 60))}s ` +
        '· seed 7 — press play';
    }
  } catch {
    showFallback();
  }
}
examples/24_Runs/src/app/boundaries.ts
import {
  paramsEqual,
  type AizawaParameters,
  type Tour,
} from '@krusenstiern/aizawa-attractor/core';

/** What the player fires on a segment's first tick. */
export interface SegmentBoundary<T> {
  /**
   * `transitionTo` payload — non-null only on segment `i > 0` whose
   * params differ structurally from segment `i−1`'s (playback rule 2).
   */
  readonly params: AizawaParameters | null;
  /**
   * `transitionToLook` payload — fired unconditionally on every
   * segment's first tick, index 0 included (playback rule 5). Generic:
   * the schedule is player-owned — `NamedLook`, `LookOptions`, or a
   * test double.
   */
  readonly entry: T;
}

/**
 * The boundary actions for `tour.segments[index]` under `schedule` —
 * the run's look schedule aligns to segment index and is never part of
 * the document (invariant 6).
 */
export function boundaryFor<T>(
  tour: Tour,
  schedule: readonly T[],
  index: number,
): SegmentBoundary<T> {
  const segment = tour.segments[index];
  const entry = schedule[index];
  if (segment === undefined || entry === undefined) {
    throw new RangeError(`no segment/schedule entry at index ${index}`);
  }
  const previous = tour.segments[index - 1];
  const params =
    index > 0 &&
    previous !== undefined &&
    !paramsEqual(previous.preset.params, segment.preset.params)
      ? segment.preset.params
      : null;
  return { params, entry };
}

What just happened: optionsForTour pins the run options, locateTourTick maps each tick to its segment, transitionTo fires at param boundaries, step(1) + present() advance and draw — and a page-owned look schedule fires transitionToLook on every segment’s first tick. The look schedule is never part of the document.

Next: a developed start.

5. A developed start — examples/25_Developed/

What you’ll have: the same ambient scene — but the first frame is already developed, and deadTicks stays zero.

Needs: npm i three @krusenstiern/aizawa-attractor.

examples/25_Developed/index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <!-- script-src stays 'self' (contract invariant 6). style-src allows
         inline because vite dev injects <style> tags; production output
         is a linked stylesheet only. ws: is the vite dev HMR socket. -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws:; base-uri 'none'"
    />
    <meta name="color-scheme" content="dark" />
    <title>Aizawa — developed</title>
  </head>
  <body>
    <canvas id="scene" aria-label="Animated Aizawa attractor"></canvas>
    <a class="back-link" href="../..">← back to the tutorial</a>
    <p id="notice" role="status" aria-live="polite"></p>
    <aside id="panel">
      <h1>A developed start</h1>
      <p class="lede">
        Every fresh run starts collapsed — heads seed inside the dead-run
        thresholds and need ~10 s of fast-forward to bloom (watch
        <code>15_Ambient</code> for the signature). Raising
        <code>simulation.warmupSteps</code> past that phase burns it
        synchronously at construction: the first frame is already developed and
        <code>deadTicks</code> never climbs.
      </p>
      <p class="status" id="status" role="status"></p>
    </aside>
    <figure id="fallback" hidden>
      <img
        src="../15_Ambient/poster.svg"
        alt="The Aizawa attractor rendered as a static image"
      />
      <figcaption>
        Interactive rendering needs WebGL2, which this browser does not provide
        — showing a still instead.
      </figcaption>
    </figure>
    <script type="module" src="src/main.ts"></script>
  </body>
</html>
examples/25_Developed/src/main.ts
import type { AttractorRenderer } from '@krusenstiern/aizawa-attractor/three';
import {
  gateMotion,
  loadsRenderer,
  pageChrome,
} from '../../shared/src/page.js';
import '../../shared/src/style.css';

// The default 1200-step pre-roll covers only the collapse; re-spread
// across the attractor needs ~15–20k steps on top of it (observed
// range, not a bound per seed). Warmup runs no dead-run detection —
// steps are steps — so the whole phase burns before the first tick.
const DEVELOPED_WARMUP_STEPS = 20_000;

const { canvas, notice, status, showFallback } = pageChrome();

const onContextChange = (lost: boolean): void => {
  notice.show(lost ? 'Graphics paused' : 'Graphics resumed');
};

function wireStatus(renderer: AttractorRenderer): void {
  setInterval(() => {
    const stats = renderer.stats();
    status.textContent =
      `warmup ${String(DEVELOPED_WARMUP_STEPS)} pre-burned` +
      ` · steps ${String(stats.steps)} · ticks ${String(stats.ticks)}` +
      ` · dead ticks ${String(stats.deadTicks)}`;
  }, 250);
}

if (!loadsRenderer()) {
  showFallback();
} else {
  try {
    const { startAttractor } =
      await import('@krusenstiern/aizawa-attractor/three');
    const handle = startAttractor(canvas, {
      simulation: { warmupSteps: DEVELOPED_WARMUP_STEPS },
      onContextChange,
      autoplay: false,
    });
    if (handle === null) {
      showFallback();
    } else {
      gateMotion(handle, () => {
        handle.renderer.renderStill();
      });
      wireStatus(handle.renderer);
    }
  } catch {
    showFallback();
  }
}

What just happened: every run drains warmupSteps synchronously before its first tick, and dead-run detection never runs inside the pre-roll. The default 1200 covers only the collapse; 20000 lets the same chaotic divergence the fast-forward would chase re-spread the heads before the first presented frame. One option, one blocking drain, a developed start — decided behaviour, not a workaround (open-decisions.md: A). Prefer keeping the bloom? rescue.speedupSteps compresses it; a saved state snapshot skips it bit-exact — the library README’s “The collapsed start” section lists every lever.

— graduation: the library README.