# How to Implement a Hysteresis Controller for Tier Transition Stability in Clypra

> Implement a hysteresis controller in Clypra to ensure tier transition stability. Learn how to prevent visual jitter with overshoot and dead-band thresholds.

- Repository: [Abdulkabir Musa/Clypra](https://github.com/AIEraDev/Clypra)
- Tags: how-to-guide
- Published: 2026-07-16

---

**Clypra's HysteresisController prevents visual jitter during zoom transitions by requiring a 10% overshoot threshold, maintaining a 5% dead-band, and enforcing a 200ms debounce window before committing tier changes.**

Clypra's rendering pipeline dynamically selects spatial tiers (L0, L1, etc.) based on zoom levels, but rapid boundary crossings can cause flickering and unnecessary re-renders. Implementing a hysteresis controller for tier transition stability ensures smooth visual performance by introducing deliberate delays and buffers that filter out noise. The `HysteresisController` class in [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts) provides a pure TypeScript solution that integrates seamlessly with the Spatial Rendering Pipeline (SRP).

## Why Tier Transitions Need Hysteresis

Without protection, rapid zooming can cause the spatial tier to flip back and forth at exact boundaries, producing visual jitter and thrashing the render engine. When a user hovers near a tier boundary, minute input variations trigger continuous SRP recomputations that degrade performance and create a jarring experience. The hysteresis controller solves this by making tier transitions "sticky"—once entered, a tier resists exit until the zoom level moves sufficiently far from the threshold.

## Core Safeguards in the HysteresisController

The `HysteresisController` implemented in [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts) introduces three specific safeguards to stabilize tier transitions:

- **Overshoot threshold** (`OVERSHOOT_THRESHOLD = 0.10`): The controller only accepts a tier change when the zoom level has moved **10% beyond** the current tier's boundary. This prevents an immediate flip when the pointer hovers right on the boundary edge.

- **Dead-band** (`DEAD_BAND = 0.05`): While the zoom stays within **±5%** of the current tier's min/max limits, the candidate tier is forced back to the current tier. This keeps the system stable when users slowly nudge around a threshold.

- **Debounce and rate-limit** (`COMMIT_DEBOUNCE_MS = 200`): After a candidate tier is first recognized, it must remain stable for **200ms** before it is committed, and commits are limited to one per 200ms. This eliminates short-lived spikes that would otherwise cause rapid tier thrashing.

## Internal Workflow of the Controller

When the `update()` method receives a new zoom level, the controller executes a six-step validation process before committing to a new `SpatialTier`:

1. **Current boundary lookup**: Using the active `SpatialTier`, the controller retrieves the configured `min` and `max` zoom values from the SRP config (`DEFAULT_SRP_CONFIG`).

2. **Dead-band check**: If the zoom lies inside the dead-band range, the candidate tier resets to the current tier and the method returns `null` (no commit).

3. **Overshoot evaluation**: If the target tier (the tier SRP would pick without hysteresis) differs from the current tier, the controller calls `_computeCandidateWithOvershoot`. This checks whether the zoom has crossed the 10% overshoot threshold and promotes the candidate only if true.

4. **Stability window**: The candidate must stay unchanged for `COMMIT_DEBOUNCE_MS`. The elapsed time is measured with the injected `now()` function (defaulting to `performance.now`).

5. **Rate limiting**: A second guard ensures that commits are spaced at least `COMMIT_DEBOUNCE_MS` apart, preventing rapid successive transitions.

6. **Commit**: When both conditions are satisfied, `_commit` updates `currentTier`, clears the candidate state, and records the commit timestamp.

## Implementation Example

To implement the hysteresis controller for tier transition stability in your Clypra application, instantiate the controller once per render engine and feed zoom updates through the `update()` method:

```typescript
import { HysteresisController } from '@/lib/renderEngine/hysteresis';
import { SpatialTier, DEFAULT_SRP_CONFIG } from '@/lib/renderEngine/types';

// Create a controller – typically once per RenderEngine instance
const hysteresis = new HysteresisController(
  SpatialTier.L0,               // initial tier
  DEFAULT_SRP_CONFIG           // zoom-to-tier map
);

// Feed zoom updates (e.g. from a pinch/scroll handler)
function onZoomChange(zoomLevel: number) {
  // Determine the tier SRP would pick without hysteresis
  const targetTier = computeTierFromZoom(zoomLevel, DEFAULT_SRP_CONFIG);

  // Let the controller decide if we should actually switch tiers
  const committedTier = hysteresis.update(zoomLevel, targetTier);

  if (committedTier !== null) {
    // Tier change accepted – trigger a re-render or SRP recompute
    renderEngine.setSpatialTier(committedTier);
  }
}

// Reset when the whole view mode changes (e.g., switching to a different scene)
function onModeChange(newTier: SpatialTier) {
  hysteresis.reset(newTier);
}

```

The controller is pure TypeScript with no browser API dependencies, making it straightforward to unit-test and reuse anywhere a tier decision is needed.

## Testing the Hysteresis Logic

The test suite in [`src/lib/renderEngine/__tests__/hysteresis.test.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/__tests__/hysteresis.test.ts) validates the overshoot and debounce behavior. Use the following pattern to verify that tier transitions require both threshold crossing and time stability:

```typescript
test('requires 10% overshoot before committing', () => {
  const ctrl = new HysteresisController(SpatialTier.L1, DEFAULT_SRP_CONFIG, () => 0);
  
  // Zoom just inside the upper boundary – should stay at L1
  expect(ctrl.update(0.95, SpatialTier.L2)).toBeNull();

  // Zoom 11% past the boundary – candidate becomes L2 but needs debounce
  expect(ctrl.update(1.11, SpatialTier.L2)).toBeNull();

  // After 200ms debounce passes, commit occurs
  jest.advanceTimersByTime(200);
  expect(ctrl.update(1.11, SpatialTier.L2)).toBe(SpatialTier.L2);
});

```

## Integration with the Render Engine

In [`src/lib/renderEngine/renderEngine.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/renderEngine.ts), the main render engine creates an instance of `HysteresisController` and calls `update` each time the zoom changes. The returned tier (or `null`) determines whether the engine should recompute the Spatial Rendering Pipeline (SRP) and re-render clips.

Adjusting the `SrpConfig` ranges indirectly tweaks the hysteresis behavior—larger min/max ranges provide a broader effective dead-band, while the `OVERSHOOT_THRESHOLD` and `COMMIT_DEBOUNCE_MS` constants in the controller source fine-tune the responsiveness.

## Summary

- Implement the `HysteresisController` from [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts) to eliminate tier-transition jitter in Clypra.
- Configure the overshoot threshold (10%), dead-band (5%), and debounce window (200ms) to match your performance requirements.
- Call `update(zoomLevel, targetTier)` on every zoom change and only re-render when the method returns a non-null `SpatialTier`.
- Use `reset(newTier)` when switching scenes or view modes to clear internal state.
- Leverage the pure TypeScript implementation for straightforward unit testing without browser dependencies.

## Frequently Asked Questions

### What is the optimal dead-band percentage for preventing tier flickering?

The default `DEAD_BAND = 0.05` (5%) in [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts) provides a balanced buffer for most zoom interfaces. If users report sluggish transitions, reduce this value; if flickering persists at boundaries, increase it to 0.08 or 0.10 while maintaining the 10% overshoot threshold.

### How does the hysteresis controller affect performance during rapid zoom gestures?

The controller improves performance by returning `null` from `update()` when the zoom fluctuates within the dead-band or stability window, preventing unnecessary SRP recomputations. According to the Clypra source, the rate-limiting guard ensures commits are spaced at least `COMMIT_DEBOUNCE_MS` (200ms) apart, effectively throttling render calls during rapid pinch-to-zoom gestures.

### Can I use the HysteresisController for non-spatial tier transitions?

Yes, the controller is generic and pure TypeScript. While designed for `SpatialTier` transitions in [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts), you can adapt it for any discrete state machine requiring hysteresis by replacing the `SrpConfig` with your own threshold definitions and using the same `update()`/`reset()` API pattern.

### Where are the hysteresis constants configured in the Clypra codebase?

The three key constants—`OVERSHOOT_THRESHOLD`, `DEAD_BAND`, and `COMMIT_DEBOUNCE_MS`—are defined as private static readonly properties directly in [`src/lib/renderEngine/hysteresis.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/hysteresis.ts). The tier-specific ranges used for boundary calculations are configured in `DEFAULT_SRP_CONFIG` within [`src/lib/renderEngine/types.ts`](https://github.com/AIEraDev/Clypra/blob/main/src/lib/renderEngine/types.ts).