Back to blog
architectureportable-sequencesdirector-app

The PortableSequence Spec: One Format to Run Them All

How we designed a single wire format that carries every broadcast sequence — AI-generated, operator-built, or injected — from cloud to camera.

·Sim RaceCenter Team·5 min read
On this page

Every broadcast decision in Sim RaceCenter — whether it comes from the AI Director, an operator's personal library, or a real-time command injection — eventually becomes the same thing: a PortableSequence. This is the single wire format the Director client executes. It doesn't care where a sequence came from. It just runs the steps.

This post explains the format, why it exists, and what you need to know to implement against it.

Why a New Format

The original system used a DirectorSequence with enum-based command types — SWITCH_CAMERA, PLAY_AUDIO, SHOW_OVERLAY. Every new capability required adding a new enum value to shared type definitions on both the cloud API and the Electron client. Adding TTS support meant updating the enum. Adding OBS scene control meant updating the enum. Every change was a coordinated deploy across two repositories.

We needed a format that was:

  • Extensible without shared enums — new capabilities shouldn't require type changes
  • Parameterizable — templates with variables that get filled at runtime
  • Source-agnostic — the same format whether generated by AI, built by an operator, or injected from a command buffer
  • Forward-compatible — a client that doesn't understand a new intent should skip it, not crash

The Format

A PortableSequence has three layers: steps (what to do), variables (what's parameterized), and metadata (who made it and when).

interface PortableSequence {
  id: string;
  name?: string;
  priority?: boolean;
  steps: SequenceStep[];
  variables?: SequenceVariable[];
  metadata?: {
    totalDurationMs?: number;
    generatedAt?: string;
    source?: 'ai-director' | 'command-buffer' | 'library';
  };
}

Steps: Intent + Payload

Every step is a semantic intent in domain.action notation paired with a payload dictionary:

{
  "id": "step_1",
  "intent": "broadcast.showLiveCam",
  "payload": { "carNum": "5", "camGroup": "Chase" }
}

The intent domains map to the Director client's extension system:

DomainControlsExample Intents
broadcast.*Simulator camera, replaybroadcast.showLiveCam, broadcast.replayEvent
obs.*OBS Studioobs.switchScene, obs.toggleSource
audio.*Audio playbackaudio.play
communication.*TTS, Discordcommunication.announce
system.*Sequence controlsystem.wait

The client looks up each intent in its local registry and dispatches to the registered handler. Unknown intents are skipped — this is what makes the format forward-compatible. The cloud can start generating overlay.showGraphic intents before the client ships support for them, and nothing breaks.

Variables: Deferred Decisions

Variables are placeholders in step payloads that get resolved at different times by different systems:

{
  "variables": [
    {
      "name": "targetDriver",
      "label": "Target Driver",
      "type": "text",
      "required": true,
      "source": "cloud"
    }
  ],
  "steps": [
    {
      "id": "step_1",
      "intent": "broadcast.showLiveCam",
      "payload": { "carNum": "${targetDriver}", "camGroup": "TV1" }
    }
  ]
}

Variable sources determine who fills them in:

SourceResolved ByWhenExample
cloudAI ExecutorBefore deliverytargetDriver = "5" (from leaderboard)
contextDirector clientAt execution timesessionTime (from iRacing telemetry)
userOperatorOn demandManual camera override

The substitution is simple string replacement — ${varName} in any payload value gets replaced with the resolved value. Non-placeholder values pass through unchanged.

Priority: Interrupt Semantics

The priority flag controls execution behavior:

  • false (default) — the sequence queues for the next available slot in the Director Loop
  • true — cancel whatever is currently executing and run this immediately

Priority sequences are used for incident responses and operator overrides — moments where the broadcast needs to react now, not after the current 30-second camera hold finishes.

The Only Step That Advances Time

system.wait is the critical step. Every other intent is considered instantaneous — the client dispatches the command and immediately moves to the next step. Only system.wait pauses execution:

{ "id": "step_2", "intent": "system.wait", "payload": { "durationMs": 10000 } }

This means a sequence without any system.wait steps fires all its commands at once. A three-camera sequence without waits switches cameras three times in the same frame — the viewer sees only the last one. This is the single most common authoring mistake and why the AI Planner's prompt explicitly requires a system.wait after every camera switch.

Execution Model

The Director client processes steps sequentially:

  1. Read the step's intent
  2. Look up the handler in the local intent registry
  3. If not found, log a warning and skip to the next step
  4. Resolve any remaining ${variable} placeholders from local context
  5. If a required variable is unresolved, skip the step
  6. Dispatch the payload to the handler
  7. If metadata.timeout is set, enforce it — skip on timeout
  8. If the intent is system.wait, hold for durationMs before continuing
  9. Move to the next step

Total sequence duration is the sum of all system.wait steps. The metadata.totalDurationMs field is informational — it's what the AI planned for, but actual execution time depends on handler latency and timeout behavior.

Three Sources, One Format

The beauty of a single format is that the execution engine doesn't branch on origin:

AI-Generated — the Planner creates templates at check-in, the Executor fills variables from live race state, resolveTemplate() produces a PortableSequence. Source: ai-director.

Operator Library — the operator builds sequences in a local editor (or the cloud portal), uploads them at check-in. They arrive as PortableSequences with source: library and may contain user-source variables the operator fills manually.

Command Buffer — real-time injections (e.g., "show car #14 NOW") are wrapped as single-step PortableSequences with priority: true and source: command-buffer. They interrupt the current AI sequence.

The Director client's execution loop just reads steps and runs them. It never checks source.

Minimal Valid Sequence

The smallest useful sequence is two steps — a command and a wait:

{
  "id": "seq_1",
  "steps": [
    { "id": "s1", "intent": "broadcast.showLiveCam",
      "payload": { "carNum": "5", "camGroup": "TV1" } },
    { "id": "s2", "intent": "system.wait",
      "payload": { "durationMs": 15000 } }
  ]
}

Switch to car #5's TV1 camera. Hold for 15 seconds. Everything else — name, variables, metadata, priority — is optional context for logging, UI, and orchestration.

Implementor Checklist

If you're building a client that consumes PortableSequences:

  • Register intent handlers for each domain.action you support
  • Skip unknown intents gracefully — log and continue, never crash
  • Resolve variables from your local context for source: "context" variables
  • Skip steps with unresolved required variables rather than sending partial payloads
  • Respect priority: true — interrupt the current sequence immediately
  • Enforce step timeouts via metadata.timeout if present
  • Treat system.wait as the only blocking step — everything else is fire-and-forget
  • Ignore metadata for execution purposes — it's for observability, not control flow