Skip to content

Simulation Engine

Synode's tick-based simulator advances a clock forward in discrete steps, running user journeys across a configurable time window. This guide covers every aspect of the simulation: time windows, ticks and lanes, waits and cooloffs, journey selection, the Duration type, pacing, and streaming mode.

Time Windows

The time option defines the simulated period. There are six valid combinations:

CombinationMeaning
{ duration }Rolling window ending now
{ start, duration }Fixed window from start for duration
{ end, duration }Fixed window ending at end
{ start, end }Fixed window between two dates
{ stream: true }Endless real-time stream, no end
{ duration, stream: true }Rolling window with streaming flush
typescript
// Fixed 90-day window
time: { start: new Date('2026-01-01'), duration: '90d' }

// Last 30 days (rolling)
time: { duration: '30d' }

// Up to a specific end date
time: { end: new Date('2026-03-31'), duration: '90d' }

// Between two explicit dates
time: { start: new Date('2026-01-01'), end: new Date('2026-03-31') }

// Streaming — no time limit
time: { stream: true }

// Streaming with a rolling window
time: { duration: '24h', stream: true }

Ticks, Lanes, and Scatter

Ticks

The tick interval is the smallest unit of simulated time. Every tick, the simulator wakes each active lane and advances any user whose next scheduled action falls within the current tick.

typescript
simulation: { users: 1000, lanes: 4, tick: '15m' }

Smaller ticks give finer timing resolution but more iterations. A tick of '15m' over a 90-day window produces 8,640 ticks. A tick of '1h' produces 2,160 ticks. Choose a tick that is smaller than the shortest wait you expect in your journeys.

Lanes

Lanes are fixed execution slots. Each tick, all lanes are processed concurrently. When a lane's user completes all their journeys, the next user from the pool is assigned to that lane.

typescript
simulation: { users: 50000, lanes: 8, tick: '1h' }
// 8 users are always in-flight; 50,000 users flow through over time

Scatter

Without scatter, users in the same lane tend to act on aligned tick boundaries. The scatter option adds a random offset to each user's start time, smoothing the event distribution.

typescript
simulation: { users: 5000, lanes: 4, tick: '30m', scatter: 0.15 }
// Each user's start is jittered by up to ±15% of one tick interval

A scatter value of 01 represents the fraction of one tick interval to randomize over. 0.10.2 is a good default for most simulations.

Waits and Cooloffs

Action wait

wait on an action delays the simulated clock before that action executes. Use it to model the time a user spends before clicking, scrolling, or converting.

typescript
defineAction({
  id: 'add-to-cart',
  name: 'add_to_cart',
  wait: '2m', // user pauses 2 minutes before this action
  fields: { productId: (ctx) => ctx.faker.string.uuid() },
});

Adventure wait and cooloff

wait on an adventure delays the user before entering that adventure (session gap).

cooloff prevents the adventure from being entered again until the cooloff period has elapsed since the last time it ran.

typescript
defineAdventure({
  id: 'browse',
  name: 'Browse Products',
  wait: { min: '5m', max: '30m' }, // time before this session starts
  cooloff: '1h',                    // can't run again within 1 hour
  actions: [...],
});

Journey cooloff

cooloff on a journey prevents re-entry until the period has elapsed after the journey last completed. Useful for rate-limiting flows like email campaigns or onboarding sequences.

typescript
defineJourney({
  id: 'weekly-digest',
  name: 'Weekly Digest',
  cooloff: '7d', // at most once per 7 days
  adventures: [...],
});

Journey Selection: Prerequisites and Weights

Prerequisites

Set prerequisites (or requires) to an array of journey IDs. The engine only attempts the journey after all listed journeys have completed for that user.

typescript
const purchase = defineJourney({
  id: 'purchase',
  name: 'Purchase',
  prerequisites: ['signup', 'browse'], // both must be done first
  adventures: [...],
});

Weights

When multiple journeys are eligible for a user in a given tick, the engine selects one using weighted random selection. Higher weight means the journey is chosen more often.

typescript
const pageView = defineJourney({
  id: 'page-view',
  name: 'Page View',
  weight: 10, // 10x more likely than a weight-1 journey
  adventures: [...],
});

const purchase = defineJourney({
  id: 'purchase',
  name: 'Purchase',
  weight: 1,
  prerequisites: ['page-view'],
  adventures: [...],
});

Omitting weight defaults to 1. A journey with weight: 0 is never selected automatically (it can still run via prerequisites triggering it).

Duration Format

The Duration type accepts both string shorthand and object notation.

String shorthand

FormatMeaning
'30s'30 seconds
'5m'5 minutes
'2h'2 hours
'1d'1 day
'2h30m'2 hours 30 minutes
'1d12h'1 day and 12 hours
'90d'90 days

Units can be combined: '1d2h30m15s' is valid. Units must appear in descending order (days before hours before minutes before seconds).

Object notation

typescript
{ days: 1, hours: 2, minutes: 30, seconds: 15 }
{ hours: 2, minutes: 30 }
{ minutes: 5 }

All fields are optional and default to 0. Object notation is useful when computing durations programmatically.

Range notation

Both wait and cooloff accept a range that is sampled uniformly per user:

typescript
wait: { min: '5m', max: '30m' }
cooloff: { min: '6h', max: '24h' }

Pacing

pacing controls how the simulation clock relates to wall-clock time. This is most useful in streaming mode.

typescript
// Default: run as fast as possible
simulation: { ..., pacing: 'none' }

// Match real time (1 simulated second = 1 real second)
simulation: { ..., pacing: 'realtime' }

// 60x faster than real time
simulation: { ..., pacing: { mode: 'realtime', speed: 60 } }

// Fixed wall-clock delay between ticks
simulation: { ..., pacing: { mode: 'fixed', delayMs: 100 } }

Streaming Mode

In streaming mode the simulator flushes each tick's events before advancing the clock. This allows downstream consumers to process events as they are produced rather than waiting for the full run.

typescript
await generate(journey, {
  time: { duration: '24h', stream: true },
  simulation: {
    users: 1000,
    lanes: 8,
    tick: '5m',
    pacing: { mode: 'realtime', speed: 60 }, // 24 simulated hours in 24 real minutes
  },
  adapter: new CallbackAdapter((event) => forwardToKafka(event)),
});

See the Streaming guide for more examples including continuous mode and AbortSignal.

Full Example

typescript
import {
  defineJourney,
  defineAdventure,
  defineAction,
  definePersona,
  generate,
  weighted,
  oneOf,
} from '@synode/core';
import { FileAdapter } from '@synode/adapter-file';

const persona = definePersona({
  id: 'users',
  name: 'Platform Users',
  attributes: {
    locale: weighted({ en: 0.6, de: 0.2, fr: 0.2 }),
    device: weighted({ mobile: 0.6, desktop: 0.3, tablet: 0.1 }),
  },
});

const browseJourney = defineJourney({
  id: 'browse',
  name: 'Browse',
  weight: 8,
  adventures: [
    defineAdventure({
      id: 'session',
      name: 'Session',
      wait: { min: '1m', max: '10m' },
      cooloff: '30m',
      actions: [
        defineAction({
          id: 'page-view',
          name: 'page_viewed',
          fields: { url: oneOf(['/home', '/products', '/pricing']) },
        }),
      ],
    }),
  ],
});

const purchaseJourney = defineJourney({
  id: 'purchase',
  name: 'Purchase',
  weight: 2,
  prerequisites: ['browse'],
  cooloff: '1d',
  adventures: [
    defineAdventure({
      id: 'checkout',
      name: 'Checkout',
      wait: { min: '5m', max: '20m' },
      actions: [
        defineAction({
          id: 'purchased',
          name: 'purchase_completed',
          fields: { total: (ctx) => ctx.faker.number.float({ min: 20, max: 500 }) },
        }),
      ],
    }),
  ],
});

await generate([browseJourney, purchaseJourney], {
  time: { start: new Date('2026-01-01'), duration: '90d' },
  simulation: { users: 500, lanes: 8, tick: '15m', scatter: 0.15 },
  persona,
  adapter: new FileAdapter({ dir: './output', format: 'jsonl', partitionByDay: true }),
});