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:
| Domain | Controls | Example Intents |
|---|---|---|
broadcast.* | Simulator camera, replay | broadcast.showLiveCam, broadcast.replayEvent |
obs.* | OBS Studio | obs.switchScene, obs.toggleSource |
audio.* | Audio playback | audio.play |
communication.* | TTS, Discord | communication.announce |
system.* | Sequence control | system.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:
| Source | Resolved By | When | Example |
|---|---|---|---|
cloud | AI Executor | Before delivery | targetDriver = "5" (from leaderboard) |
context | Director client | At execution time | sessionTime (from iRacing telemetry) |
user | Operator | On demand | Manual 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 Looptrue— 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:
- Read the step's
intent - Look up the handler in the local intent registry
- If not found, log a warning and skip to the next step
- Resolve any remaining
${variable}placeholders from local context - If a required variable is unresolved, skip the step
- Dispatch the payload to the handler
- If
metadata.timeoutis set, enforce it — skip on timeout - If the intent is
system.wait, hold fordurationMsbefore continuing - 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.actionyou 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.timeoutif present - Treat
system.waitas the only blocking step — everything else is fire-and-forget - Ignore
metadatafor execution purposes — it's for observability, not control flow