Traffic Light Example

Stand at any intersection and you’ll see a state machine in action. The light is red—cars stop, pedestrians cross. After a timer expires, it turns green—cars move, pedestrians wait. Then yellow appears as a warning before cycling back to red. The light is always in exactly one state, never ambiguous, never undefined.

This example demonstrates time-based state transitions and cyclic state machines. Unlike the light bulb where user actions drive transitions, the traffic light changes states automatically based on internal timers. It also shows how to abstract external dependencies (timers) using the adapter pattern, making the core logic testable with mock implementations.

Try It Now

Watch the traffic light cycle through states automatically. Click the emergency stop button to halt the cycle. Open your browser console to see state transitions and cycle counts.

All code shown in this guide is fully functional and organized into working files you can explore:

traffic-light/
├── traffic-light.impl/       # Infrastructure fragment
│   ├── index.ts              # Entry point with init handler
│   ├── timer-api.ts          # Real timer implementation
│   └── context.ts            # Context initialization
├── traffic-light.core/       # Core fragment (platform-independent)
│   ├── index.ts              # Entry point
│   ├── process.ts            # FSM configuration
│   ├── timer-api.interface.ts # Timer API interface
│   ├── models.ts             # Reactive model + adapters
│   ├── controllers.ts        # State controllers
│   └── triggers.ts           # Event trigger
├── traffic-light.views/      # View fragment (browser-specific)
│   ├── index.ts              # Entry point
│   ├── render-traffic-light.ts # UI rendering
│   └── views.ts              # View handlers
├── shared/                   # Shared utilities
│   ├── base-class.ts         # Observable base class
│   ├── adapters.ts           # Adapter pattern
│   └── registry.ts           # Cleanup registry
└── index.html                # Browser demo

The State Machine

A traffic light cycles through three states in a fixed sequence. Each state has a duration before automatically transitioning to the next. Here’s the complete FSM definition from traffic-light.core/process.ts:

export const config = {
  key: "TrafficLight",
  transitions: [
    ["", "*", "Red"],           // Start in Red state
    ["Red", "timerExpired", "Green"],
    ["Green", "timerExpired", "Yellow"],
    ["Yellow", "timerExpired", "Red"],
    ["*", "stop", ""]           // Emergency stop from any state
  ],
  states: [
    {
      key: "Red",
      description: "Stop signal - vehicles must wait",
      events: ["timerExpired", "stop"]
    },
    {
      key: "Green",
      description: "Go signal - vehicles can proceed",
      events: ["timerExpired", "stop"]
    },
    {
      key: "Yellow",
      description: "Caution signal - prepare to stop",
      events: ["timerExpired", "stop"]
    }
  ]
};

The machine starts in the Red state. When the timer expires, it transitions to Green. Another timer expiration moves it to Yellow. From Yellow, the cycle completes by returning to Red. The global transition ["*", "stop", ""] provides an emergency exit from any state.

Fragment Architecture

This example uses a three-fragment architecture to separate concerns and enable testability:

Impl Fragment: Provides infrastructure implementations—the real timer using setTimeout. This fragment initializes the context with concrete implementations before core handlers run.

Core Fragment: Contains FSM config, models, adapters, controllers, and triggers. Declares the timer API interface but doesn’t implement it. Has no dependencies on browser or DOM. Can run anywhere with appropriate implementations injected.

View Fragment: Contains views that transform user input into model changes and display model state. Depends on DOM/browser APIs.

This separation enables testing with mock timers (synchronous, controllable) while running in production with real timers (asynchronous, time-based).

Critical Architecture Pattern: Controllers Start Timers

A common misconception is that triggers should start timers. This violates the orchestrator’s fundamental rule: triggers are read-only observers. Understanding this pattern is essential.

❌ Wrong Pattern (Don’t Do This):

// WRONG: Trigger starting timer
export async function* RedTrigger(context) {
  await timerApi.startTimer(5000);  // Triggers should never call APIs!
  yield "timerExpired";
}

✅ Correct Pattern:

// Controller starts timer
export async function RedController(context) {
  const model = getTrafficLightModel(context);
  const timerApi = getTimerApi(context);

  model.setLightColor("Red");

  // Controller starts timer; completion callback updates model
  timerApi.startTimer(STATE_DURATIONS.Red).then(() => {
    model.signalTimerExpired();
  });

  return () => { /* cleanup */ };
}

// Trigger observes model changes
export async function* TrafficLightTrigger(context) {
  const model = getTrafficLightModel(context);

  while (true) {
    await listenUpdates(model.onUpdate);

    if (!model.isRunning) {
      yield "stop";
      break;
    }

    if (model.timerTick > 0) {
      await listenUpdates(model.onTimerTick);
      yield "timerExpired";
    }
  }
}

The data flow:

  1. Controller enters state
  2. Controller starts timer via timerApi.startTimer()
  3. Timer completes (after duration)
  4. Timer’s callback updates model via model.signalTimerExpired()
  5. Model notifies all subscribers
  6. Trigger observes model change via model.onTimerTick subscription
  7. Trigger yields "timerExpired" FSM event
  8. FSM transitions to next state

This pattern keeps triggers pure observers with no side effects, making them predictable and testable.

Timer API Abstraction

The timer API demonstrates the adapter pattern for abstracting external dependencies. The interface is declared in core, but implementations live in separate fragments.

From traffic-light.core/timer-api.interface.ts:

export interface ITimerApi {
  startTimer(duration: number): Promise<void>;
  cancelAllTimers(): void;
  isTimerActive(): boolean;
}

export const [getTimerApi, setTimerApi, removeTimerApi] =
  newAdapter<ITimerApi>(
    "api:timer",
    () => {
      throw new Error(
        "Timer API not implemented. " +
        "The impl fragment must provide a timer implementation using setTimerApi()."
      );
    }
  );

The core fragment declares what it needs but doesn’t care how it’s implemented. The impl fragment provides the real implementation from traffic-light.impl/timer-api.ts:

export class TimerApiImpl implements ITimerApi {
  #activeTimers: Set<ReturnType<typeof setTimeout>> = new Set();

  async startTimer(duration: number): Promise<void> {
    return new Promise<void>((resolve) => {
      const timer = setTimeout(() => {
        this.#activeTimers.delete(timer);
        resolve();
      }, duration);
      this.#activeTimers.add(timer);
    });
  }

  cancelAllTimers(): void {
    for (const timer of this.#activeTimers) {
      clearTimeout(timer);
    }
    this.#activeTimers.clear();
  }

  isTimerActive(): boolean {
    return this.#activeTimers.size > 0;
  }
}

Tests provide a mock implementation that’s synchronous and controllable—you can manually trigger timer completion instead of waiting for real time to pass.

The Model

The model tracks application state and provides subscriptions for observing changes. From traffic-light.core/models.ts:

export class TrafficLightModel extends BaseClass {
  #cycleCount: number = 0;
  #lightColor: string = "Red";
  #isRunning: boolean = false;
  #timerTick: number = 0;

  get cycleCount() { return this.#cycleCount; }
  get lightColor() { return this.#lightColor; }
  get isRunning() { return this.#isRunning; }
  get timerTick() { return this.#timerTick; }

  onTimerTick = (callback: () => void) => {
    let tick = this.#timerTick;
    return this.onUpdate(() => {
      if (this.#timerTick !== tick) {
        tick = this.#timerTick;
        callback();
      }
    });
  };

  onLightChange = (callback: () => void) => {
    let color = this.#lightColor;
    return this.onUpdate(() => {
      if (this.#lightColor !== color) {
        color = this.#lightColor;
        callback();
      }
    });
  };

  incrementCycle() {
    this.#cycleCount++;
    this.notify();
  }

  setLightColor(color: string) {
    this.#lightColor = color;
    this.notify();
  }

  start() {
    this.#isRunning = true;
    this.notify();
  }

  stop() {
    this.#isRunning = false;
    this.notify();
  }

  signalTimerExpired() {
    this.#timerTick++;
    this.notify();
  }
}

export const STATE_DURATIONS = {
  Red: 5000,    // 5 seconds
  Green: 8000,  // 8 seconds
  Yellow: 2000  // 2 seconds
} as const;

Note that the model field is called lightColor rather than state or currentState. This avoids confusion with FSM states—they’re different concepts. The model’s lightColor is application data; FSM states are structural elements of the state machine.

The onTimerTick() method provides an optimized subscription that only fires when timerTick changes. This prevents the trigger from activating on unrelated model updates.

Controllers

Controllers implement state-specific business logic. From traffic-light.core/controllers.ts:

export async function RedController(
  context: Record<string, unknown>
): Promise<() => void> {
  const [register, cleanup] = newRegistry();
  const model = getTrafficLightModel(context);
  const timerApi = getTimerApi(context);

  model.setLightColor("Red");
  console.log(`🔴 RED - Vehicles STOP (Cycle ${model.cycleCount})`);

  // Controller starts timer; callback updates model when complete
  timerApi.startTimer(STATE_DURATIONS.Red).then(() => {
    model.signalTimerExpired();
  });

  return cleanup;
}

export async function TrafficLightController(
  context: Record<string, unknown>,
): Promise<() => void> {
  const [register, cleanup] = newRegistry();
  const model = getTrafficLightModel(context);

  model.start();
  register(() => model.stop());

  console.log("Traffic light starting...");
  register(() => {
    console.log(`Traffic light stopped after ${model.cycleCount} cycles`);
  });

  // Track light changes to count cycles
  // Increment cycle when light becomes Red (completing Yellow -> Red transition)
  let previousColor = model.lightColor;
  register(
    model.onLightChange(() => {
      const currentColor = model.lightColor;
      if (currentColor === "Red" && previousColor === "Yellow") {
        model.incrementCycle();
        console.log(`Cycle ${model.cycleCount} completed`);
      }
      previousColor = currentColor;
    }),
  );

  return cleanup;
}

The TrafficLightController handles parent-level concerns like starting the light and tracking cycle counts. Cycle counting is a parent-level concern because it spans multiple child states. The controller subscribes to model.onLightChange() to detect when the light transitions from Yellow back to Red, which completes one cycle.

Each state controller (RedController, GreenController, YellowController) handles its own timer. When the controller enters, it starts a timer. When the timer completes, the promise resolves and the callback calls model.signalTimerExpired(). The model notifies subscribers, the trigger observes this change, and yields the FSM event.

The Trigger

The traffic light uses a single root trigger—not state-specific triggers. This is important: the trigger observes model changes, which are global, not state-specific. From traffic-light.core/triggers.ts:

export async function* TrafficLightTrigger(
  context: Record<string, unknown>
): AsyncGenerator<string> {
  const model = getTrafficLightModel(context);

  async function listenUpdates(
    onUpdate: (listener: () => void) => () => void
  ): Promise<void> {
    return new Promise<void>((resolve) => {
      const cleanup = onUpdate(() => {
        resolve();
        cleanup();
      });
    });
  }

  while (true) {
    await listenUpdates(model.onUpdate);

    if (!model.isRunning) {
      yield "stop";
      break;
    }

    if (model.timerTick > 0) {
      await listenUpdates(model.onTimerTick);
      yield "timerExpired";
    }
  }
}

The trigger loops continuously, waiting for model updates. When isRunning becomes false, it yields "stop" and exits. When timerTick changes, it yields "timerExpired". The trigger never starts timers, never modifies models, never accesses DOM. It only observes and reacts.

Views

Views handle presentation and capture user input. From traffic-light.views/views.ts:

export function TrafficLightView(
  context: Record<string, unknown>
): () => void {
  const [register, cleanup] = newRegistry();
  const model = getTrafficLightModel(context);

  // Render and mount
  const root = document.querySelector("#traffic-light-demo") as HTMLElement;
  const element = renderTrafficLight();
  root.appendChild(element);
  register(() => element.remove());

  // Store DOM references
  const container = element.querySelector(".traffic-light") as HTMLElement;
  setTrafficLightContainer(context, container);
  register(() => removeTrafficLightContainer(context));

  const stopButton = element.querySelector(".stop-button") as HTMLButtonElement;
  setStopButton(context, stopButton);
  register(() => removeStopButton(context));

  // Wire up stop button
  const handleStop = () => model.stop();
  stopButton.addEventListener("click", handleStop);
  register(() => stopButton.removeEventListener("click", handleStop));

  return cleanup;
}

export function RedView(context: Record<string, unknown>): () => void {
  const [register, cleanup] = newRegistry();
  const model = getTrafficLightModel(context);

  const container = getTrafficLightContainer(context);
  const statusText = container.parentElement!.querySelector(".status-text") as HTMLElement;
  const cycleCount = container.parentElement!.querySelector(".cycle-count") as HTMLElement;

  const updateView = () => {
    container.querySelectorAll(".light").forEach(light => {
      light.classList.remove("active");
    });
    container.querySelector(".red-light")?.classList.add("active");

    statusText.textContent = "STOP";
    statusText.style.color = "#d32f2f";
    cycleCount.textContent = `Cycle ${model.cycleCount}`;
  };

  register(() => {
    container.querySelector(".red-light")?.classList.remove("active");
  });

  updateView();
  register(model.onUpdate(updateView));

  return cleanup;
}

The TrafficLightView runs once at startup, creating the DOM structure and wiring up the stop button. State-specific views (RedView, GreenView, YellowView) update the visual state and subscribe to model changes for reactive updates.

Running the Application

In the Browser

The complete working example is available in index.html. Open it in a browser to see the traffic light cycle automatically through states. The emergency stop button demonstrates how external events can interrupt the automatic flow.

For Testing

Tests use a mock timer implementation that’s synchronous and controllable. Instead of waiting for real time to pass, you can manually trigger timer expiration:

const mockTimer = new MockTimerApi();
setTimerApi(context, mockTimer);

await RedController(context);

// Manually trigger timer completion
mockTimer.tick();

// Model should be updated by timer callback
expect(model.timerTick).toBe(1);

This makes tests fast and deterministic. The same core logic runs with either real or mock timers—the implementation detail is hidden behind the interface.

Understanding the Cycle

Traffic lights demonstrate cyclic state machines—machines that loop continuously without a natural end state. The cycle Red → Green → Yellow → Red repeats indefinitely until explicitly stopped.

The timerExpired event appears in all three transitions, but each state has its own timer duration. Red lasts 5 seconds, green 8 seconds, and yellow 2 seconds. Same event, different timing, creating the familiar rhythm of traffic flow.

Cyclic machines need exit conditions. The global transition ["*", "stop", ""] provides this. When a stop event occurs from any state, the machine exits. Without this, the traffic light would run forever with no way to halt it.

Key Architectural Rules

Controllers start timers, triggers observe results. Controllers call timerApi.startTimer(). When timers complete, callbacks update models. Triggers observe model changes and yield FSM events. This separation keeps triggers pure observers.

Triggers are read-only. Triggers never modify models, never call APIs, never access DOM. They only subscribe to model changes and generate FSM events based on observations.

Single root trigger, not state-specific triggers. Model changes are global, not state-specific. One trigger observing the model is simpler and more maintainable than multiple state-specific triggers.

Parent controller handles cross-state concerns. Cycle counting spans multiple states, so it belongs in the parent TrafficLightController, not in individual state controllers.

Clear naming avoids confusion. Use lightColor for the model field, not state or currentState, which could be confused with FSM states.

Abstraction enables testing. The timer API interface in core allows real implementation in impl and mock implementation in tests, making core logic testable without waiting for real time.

Next Steps

This traffic light demonstrates automatic state transitions, timer abstraction, and cyclic state machines. Real applications combine automatic transitions with user interactions and more complex state hierarchies.

Explore more examples to see how these patterns scale:

Or dive deeper into the concepts: