Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ npm_package(
":tinyvectors",
],
package = "@tummycrypt/tinyvectors",
version = "0.3.6",
version = "0.3.7",
visibility = ["//visibility:public"],
)

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
> [tinyland-inc/bazel-registry](https://github.com/tinyland-inc/bazel-registry)
> only. See the README's Install section for the sanctioned consumption paths.

## 0.3.7 - 2026-09-01

- Idle drift cruise: `driftAngle`/`driftSpeed` — initialized per blob since 0.3.0 but never read by the physics loop — are now wired into the screensaver physics step as a constant per-substep force along each blob's persistent heading, with `driftSpeed` its terminal speed under the unchanged `*= 0.992` damping. With no pointer, scroll, or devicemotion input (i.e. every idle desktop), the only idle motion was zero-mean jitter and a slow bounded slosh, so the background read as frozen; blobs now drift gently around the field by default, reach the walls, and bounce (`recordBounce()` already re-randomizes the heading on impact). No permission grant or sensor is required, and devicemotion/pointer/scroll input still layers on top with identical feel: the cruise force is purely additive and consumes no randomness, so transient impulses decay exactly as before.
- `driftSpeed` init raised from `0.01 + rand * 0.015` (dead-code-era values, weaker than the ambient slosh) to `0.05 + rand * 0.05`: terminal cruise ≈ 3–6 units/s in the 180-unit physics field, i.e. a blob crosses the field in roughly 30–60 s.
- `prefers-reduced-motion` behavior is unchanged: the cruise lives inside the physics step, which `TinyVectors` never runs while reduced motion is active (`respectReducedMotion` defaults `true` and renders the existing static frame).
- The per-substep damping literal is extracted to a named `VELOCITY_DAMPING` constant (still `0.992`); no behavior change.

## 0.3.6 - 2026-07-25

- Adds a `respectReducedMotion?: boolean` prop (default `true`) to `TinyVectors`: when `(prefers-reduced-motion: reduce)` matches, the component renders the existing static single frame (the same path `animated={false}` already uses) instead of running the rAF loop, and switches live if the media query changes. Pass `respectReducedMotion={false}` to animate regardless (TIN-3170).
Expand Down
2 changes: 1 addition & 1 deletion MODULE.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Usage from external repo:

module(
name = "tummycrypt_tinyvectors",
version = "0.3.6",
version = "0.3.7",
compatibility_level = 1,
)

Expand Down
2 changes: 2 additions & 0 deletions docs/physics-feel-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ Avoid tests that lock exact coefficients, frame-by-frame positions, or one-off s

## Current Status

**2026-09-01 update (0.3.7):** the idle drift cruise has landed. `driftAngle`/`driftSpeed` were initialized per blob since 0.3.0 but never read by the physics loop, so "idle blobs drift" (this document's first promise) was only ever the zero-mean jitter plus a slow bounded slosh — measured on an idle desktop, coherent displacement decorrelated within seconds and the background read as frozen. The loop now applies a constant per-substep force along each blob's persistent heading with `driftSpeed` (init `0.05 + rand * 0.05`) as the terminal speed under the unchanged `*= 0.992` damping: ≈3–6 units/s in the 180-unit field, so a blob crosses the field in roughly 30–60 s, reaches walls, and bounces (`recordBounce()` re-randomizes the heading on impact). The force is purely additive and consumes no randomness, so gravity/pointer/scroll feel and transient-impulse decay are untouched, and reduced-motion behavior is unchanged (the cruise lives inside the step the component never runs under `prefers-reduced-motion`). Unit coverage: `tests/unit/idle-drift-cruise.test.ts` (differential run isolating the cruise term, wall-bounce containment, init floor).

**2026-07-25 update (0.3.6):** the fixed-timestep accumulator described in TIN-853 has landed. `BlobPhysics.tick()` still accepts the caller's variable per-frame `deltaTime`, but the simulation now always advances in fixed `1/60`s quanta (capped at 8 substeps per call) instead of integrating directly on the raw frame delta. Before 0.3.6, nothing in the physics loop actually scaled by `deltaTime` — velocity adds, damping multipliers (`*= 0.992`), and drift amounts were all raw per-invocation constants — so feel and damping implicitly scaled with whatever refresh rate the caller happened to run at. Concretely, **a 120Hz display ran physics roughly 2x too fast** (twice as many ticks, hence twice as much ambient drift/damping/deformation, per real second), and a throttled ~30Hz caller ran it roughly half as fast. The 0.3.6 feel change observed on non-60Hz displays is therefore the **correction** toward this document's refresh-rate-independence intent, not a regression: 60Hz displays (the common baseline) see no feel change, and 120Hz/30Hz displays now match 60Hz feel instead of drifting from it. The browser probe that would confirm this end-to-end still requires a local server to drive real CDP orientation/pointer events (see Test Strategy above); running it is deferred to CI or another sanctioned lane rather than ad hoc here.

- Gravity/device-orientation is routed through `InteractionField.directionalBiasField()` and cached as a bounded force outside the per-blob hot path.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tummycrypt/tinyvectors",
"version": "0.3.6",
"version": "0.3.7",
"description": "Animated vector blob backgrounds with physics simulation for Svelte 5",
"type": "module",
"packageManager": "pnpm@9.15.9",
Expand Down
31 changes: 27 additions & 4 deletions src/core/BlobPhysics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ const FIXED_TIMESTEP_SECONDS = 1 / 60;
// spiral into an unbounded number of substeps in one frame.
const MAX_PHYSICS_SUBSTEPS = 8;

// Per-substep velocity damping applied at the end of updateScreensaverPhysics.
// Terminal speed under a constant per-substep force f is
// f * VELOCITY_DAMPING / (1 - VELOCITY_DAMPING) ≈ 124 * f — the relation the
// idle cruise in updateMovementWithAccelerometer() inverts so each blob's
// driftSpeed is its terminal cruise speed.
const VELOCITY_DAMPING = 0.992;

export class BlobPhysics {
private blobs: ConvexBlob[] = [];
private config: BlobPhysicsConfig;
Expand Down Expand Up @@ -448,7 +455,7 @@ export class BlobPhysics {
wallBounceCount: 0,
lastBounceTime: 0,
driftAngle: Math.random() * Math.PI * 2,
driftSpeed: 0.01 + Math.random() * 0.015,
driftSpeed: 0.05 + Math.random() * 0.05,
territoryRadius: 100 + Math.random() * 60,
territoryX: clampedX,
territoryY: clampedY,
Expand Down Expand Up @@ -535,8 +542,8 @@ export class BlobPhysics {
this.handleWallBouncing(blob);


blob.velocityX *= 0.992;
blob.velocityY *= 0.992;
blob.velocityX *= VELOCITY_DAMPING;
blob.velocityY *= VELOCITY_DAMPING;
}

private applyAccelerometerForces(blob: ConvexBlob): void {
Expand Down Expand Up @@ -580,7 +587,23 @@ export class BlobPhysics {
blob.velocityX += brownianX;
blob.velocityY += brownianY;


// Idle cruise (0.3.7): driftAngle/driftSpeed have been initialized per
// blob since 0.3.0 but were never read by the physics loop, so with no
// pointer, scroll, or devicemotion input the only idle motion was
// zero-mean jitter and slow bounded slosh — the background read as
// frozen on every desktop. A constant per-substep force along the
// blob's persistent heading makes driftSpeed the terminal cruise speed
// under the unchanged damping: blobs drift, reach walls, and bounce
// (recordBounce() already re-randomizes driftAngle on impact), while
// every transient input — scroll, pointer, devicemotion — keeps
// exactly the same feel because the cruise force is purely additive
// and consumes no randomness.
const driftAngle = blob.driftAngle || 0;
const cruiseForce = (blob.driftSpeed ?? 0.075) * (1 - VELOCITY_DAMPING);
blob.velocityX += Math.cos(driftAngle) * cruiseForce;
blob.velocityY += Math.sin(driftAngle) * cruiseForce;


if (Math.random() < 0.002) {
blob.driftAngle = Math.random() * Math.PI * 2;
}
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/idle-drift-cruise.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { BlobPhysics } from '../../src/core/BlobPhysics.js';
import type { ConvexBlob } from '../../src/core/types.js';

// Idle drift cruise (0.3.7): with NO pointer, scroll, or devicemotion input,
// blobs must still move — driftAngle/driftSpeed are wired into the physics
// loop as a constant per-substep force whose terminal speed is driftSpeed.
//
// Math.random is mocked to a constant 0.5 so:
// - neutral drift and territorial jitter are exactly zero ((0.5 - 0.5) * k);
// - the 0.002-probability drift retarget never fires (0.5 < 0.002 is false);
// - recordBounce()'s velocity kick is exactly zero;
// - init draws identical deterministic values on every run.
// The bounded brownian slosh term is sinusoidal and RNG-free, so paired runs
// below see byte-identical brownian forces and their difference isolates the
// cruise wiring exactly.

const FRAME = 1 / 60;

let randomSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
randomSpy = vi.spyOn(Math, 'random');
randomSpy.mockReturnValue(0.5);
});

afterEach(() => {
vi.restoreAllMocks();
});

async function runIdle(
seconds: number,
configureBlob: (blob: ConvexBlob) => void
): Promise<{ dx: number; dy: number; blob: ConvexBlob }> {
const physics = new BlobPhysics(1);
await physics.init();
const blob = physics.getBlobs()[0];
configureBlob(blob);
const startX = blob.currentX;
const startY = blob.currentY;
const frames = Math.round(seconds / FRAME);
for (let i = 0; i < frames; i++) {
physics.tick(FRAME, i * FRAME);
}
return { dx: blob.currentX - startX, dy: blob.currentY - startY, blob };
}

describe('idle drift cruise', () => {
it('initializes every blob with a cruise heading and a non-trivial cruise speed', async () => {
const physics = new BlobPhysics(4);
await physics.init();
for (const blob of physics.getBlobs()) {
expect(blob.driftAngle).toBeTypeOf('number');
// Constant 0.5 draw → 0.05 + 0.5 * 0.05 exactly; the floor is the
// contract (dead-code era init was 0.01 + rand * 0.015, which the
// damping made imperceptible against the ambient slosh).
expect(blob.driftSpeed).toBeGreaterThanOrEqual(0.05);
expect(blob.driftSpeed).toBeLessThanOrEqual(0.1);
}
});

it('cruise force moves an otherwise-idle blob along its heading (differential vs driftSpeed=0)', async () => {
// Identical mocked RNG and identical brownian phase in both runs: the
// trajectories differ ONLY by the cruise term under test.
const withCruise = await runIdle(10, (blob) => {
blob.driftAngle = Math.PI; // straight toward -x
blob.driftSpeed = 0.1;
});
const withoutCruise = await runIdle(10, (blob) => {
blob.driftAngle = Math.PI;
blob.driftSpeed = 0;
});

const cruiseDx = withCruise.dx - withoutCruise.dx;
const cruiseDy = withCruise.dy - withoutCruise.dy;

// Terminal speed ≈ driftSpeed (0.1 units/substep ≈ 6 units/s in the
// 180-unit field) with a ~2s exponential spin-up: expect roughly
// -47 units over 10 simulated seconds, entirely along the heading.
expect(cruiseDx).toBeLessThan(-35);
expect(cruiseDx).toBeGreaterThan(-60);
expect(Math.abs(cruiseDy)).toBeLessThan(0.001);
});

it('a cruising blob reaches the wall and bounces instead of escaping the field', async () => {
const { blob } = await runIdle(3, (b) => {
b.currentX = -20; // near the left wall (clamp at PHYSICS_MIN + size * 0.8)
b.driftAngle = Math.PI; // heading into the wall
b.driftSpeed = 0.3;
});

expect(blob.wallBounceCount ?? 0).toBeGreaterThanOrEqual(1);
// Wall clamp held: the blob never leaves the physics field.
expect(blob.currentX).toBeGreaterThanOrEqual(-40);
});
});
Loading