Skip to content
Driving a session with a scenario is a Pro feature. Default link degradation — offline, 3G and 4G — is free, and runs in the CLI, because the browser's own throttling dropdown has no effect on a WebTransport connection. From v1 the moqtap Extension detects a running CLI and drives it from the panel.

Scenario Files

A scenario is one run of moqtap intercept written down: what the network underneath the session does, how the two QUIC connections are configured, what the shaper does to media, what happens to individual objects, and what changes part-way through.

Terminal window
moqtap intercept -u moqt://relay.example.com --scenario bad-wifi.json

The file is read and checked before the proxy binds a port, so a scenario that cannot be honoured fails immediately rather than half-applying itself to live traffic.

The format is deeper than it first looks — queue.overflow, discipline and the action values are enumerations you cannot guess — so the schema is published, and one line at the top of a file gets you completion, enum dropdowns and inline errors in VS Code, the JetBrains IDEs, Neovim and anything else that speaks JSON Schema:

{
"$schema": "https://moqtap.com/schema/scenario/v1.json",
"version": 1,
"network": { "preset": "lte" }
}

moqtap accepts the $schema key and otherwise ignores it: it never fetches the URL, and it does not check your file against it. Your editor does that. The tool’s own check is version, which is stricter.

moqtap scenario new writes a starter with the line already in it, and moqtap scenario schema prints the schema the build in front of you actually reads. That document is generated from the very types the loader deserializes, so a build cannot describe a format it does not read — the published copy and the tool cannot drift apart in the way a hand-maintained schema does.

One line runs a whole session over a mobile network:

{ "version": 1, "network": { "preset": "lte" } }

Nothing else is required. Every top-level key except version is optional, and a file that omits all of them is a valid scenario that changes nothing.

Six worked scenarios ship with the CLI, in examples/scenarios/. CI loads every one of them on every commit — and arms what it loads, since a scenario that parses can still be one no proxy could run — so none of them can rot into an example that no longer works. Each opens with the $schema line, so they autocomplete the moment you copy one.

FileWhat it shows
lte-link.jsonThe whole file, for a session over a mobile network
drop-first-object-of-every-group.jsonOne rule, aimed at the object a player restarts from
recover-mid-run.jsonA lossy edge that clears at thirty seconds — recovery, which only shows up when conditions move
bandwidth-ceiling.jsonA rate cap, a class that claims everything, and a queue that holds two seconds then drops
relay-leg-only.jsonTransport limits on the relay leg, with the client’s connection untouched
every-top-level-key.jsonAll five keys at once, with the network written out in full rather than named

moqtap scenario new writes a starter file to edit from:

Terminal window
moqtap scenario new # the smallest file, on LTE
moqtap scenario new --preset lossy-edge --full # with a rule and a timeline step

--full is the one to read first. JSON has no comments, so a starter cannot explain itself inline — what it can do is put a worked rule and a worked timeline step in front of you before you write either from scratch.

moqtap scenario check loads a file and says what it would do, without starting a proxy or needing a relay:

Terminal window
$ moqtap scenario check harness.json
scenario harness.json: network, 1 rule(s), 1 timeline step(s) [impaired sockets: one client at a time]

It checks everything a run checks before it accepts a connection: the schema, every rule, and every network profile the file would arm — including the ones that only appear on the timeline. A rule that parses but no hook can decide, and a profile that would not become illegal until second thirty, are both refused here rather than part-way into a run.

It takes many files, checks all of them even after one has failed, and exits non-zero if any did, which is what makes it a gate rather than a convenience:

Terminal window
$ moqtap scenario check scenarios/*.json
typo.json: unknown field `netwrok`, expected one of `$schema`, `version`, `network`, `transport`, `shaping`, `rules`, `timeline` at line 3 column 11
v2.json: scenario version 2 is not supported: this build reads version 1
error: 2 of 6 scenario files did not check out
KeyWhat it sets
versionWhich revision of this format the file was written against. Must be 1
networkWhat the path underneath QUIC does: loss, delay and jitter, reordering, duplication, corruption, rate
transportQUIC transport parameters, set separately for each leg
shapingEgress shaping for media: token buckets, classes, a bounded queue
rulesWhat happens to individual units, matched and acted on one at a time
timelineChanges that land part-way through the run

Either one of seven named profiles:

"network": { "preset": "satellite" }
PresetWhat it models
lteMobile LTE: tens of milliseconds of correlated jitter, a modest uplink, and enough loss to make a retransmission strategy matter
wifiConsumer Wi-Fi: short delays with a heavy tail from contention and MAC retries, and the occasional duplicate those retries produce
satelliteGeostationary satellite: a quarter-second each way, a constrained uplink, and a reduced path MTU
three-gA 3G cellular link: around a fifth of a second each way with a long tail from radio-layer retransmission, a couple of megabits of capacity, about one percent loss
lossy-edgeA lossy access edge: bursty loss from a Gilbert-Elliott chain, wide uniform jitter, reordering and the occasional corrupted bit
bufferbloatA short hop in front of a queue deep enough to hold seconds of traffic at the rate that drains it, so the delay a bulk sender sees is the backlog it built rather than anything the delay model imposed
datacentre-cleanThe control: no loss, no jitter, no reordering, no duplication, no corruption — only a fixed sub-millisecond delay and a gigabit bucket, so a run against it isolates what the code under test does on its own

Or a profile written out in full, with the models named per direction:

"network": { "profile": { "downlink": {}, "uplink": {}, "peers": "all" } }

A scenario naming a network puts both legs on an impaired socket, and one proxy holds that for one client at a time. A scenario without one is unaffected, and the run header says which of the two you have.

A rule is a matcher and an action. The matcher picks out units by track alias, group, subgroup, object ID, publisher priority, stream kind or direction; ranges are written { "start": 0, "end": 0 }, and several may be listed.

"rules": [
{
"name": "lose-the-first-object-of-every-group",
"matcher": { "object_id": [{ "start": 0, "end": 0 }] },
"action": "drop"
}
]

The actions are pass, drop, delay, replace-payload and — for a control message or a datagram — replace. The ones that take arguments are written as an object rather than a bare string:

"action": { "replace-payload": { "payload_hex": "deadbeef" } }

payload_hex is lowercase hex of even length, and the engine requires the replacement to be exactly as long as the payload it replaces — the example above therefore only ever fires against a four-byte object. A length mismatch is refused at the rule rather than allowed to mis-frame the stream, so a replace-payload aimed at objects of varying size will refuse most of them.

name is not decoration. JSON has no comments, so it is the only place inside the file where a rule can say what it is for.

A dropped object is elided rather than blanked, so every object that survives keeps the ID it arrived with.

Named token buckets, classes that aim a matcher at a bucket, a bounded queue, and what becomes of a unit that overflows the queue or outlives its hold.

"shaping": {
"buckets": [{ "name": "video", "rate_bps": 500000, "burst_bytes": 65536 }],
"classes": [{ "name": "video", "bucket": "video", "matcher": {} }],
"queue": {
"depth_bytes": 1048576,
"depth_objects": 64,
"max_hold": { "secs": 2, "nanos": 0 },
"overflow": "drop",
"on_expiry": "drop"
},
"discipline": "fifo"
}

Control streams are never shaped, whatever the classes say. Pacing a SUBSCRIBE behind a video bucket breaks a session rather than testing one.

QUIC transport parameters, per leg. The connection your client makes and the connection moqtap makes to the relay are configured separately, so you can starve one side of streams or flow-control credit and leave the other alone.

"transport": {
"client": { "max_concurrent_uni_streams": 1 },
"upstream": { "initial_rtt": { "secs": 0, "nanos": 40000000 } }
}

A connection takes its transport configuration once, at setup, so a timeline step that changes this reaches connections that have not been made yet.

Steps that apply a new network, transport or shaping at an offset into the run. A run is a script, not a constant — and recovery is a behaviour that only shows up when conditions move.

"timeline": [
{ "at": { "secs": 30, "nanos": 0 }, "network": { "preset": "satellite" } }
]

version is checked before the rest of the document is interpreted, and an unknown key anywhere is fatal.

Both are deliberate, and they catch different mistakes. A misspelled shaping would otherwise be a run that reports itself applied and does nothing. A field this release has removed or retyped is the quiet one — the file still parses and every key is still known, but the value means something other than what its author meant — and only the version number catches that.

The refusal names what it found, so a file written against a later release reports the release rather than complaining about a key it has never heard of.

Everything a network draws at random comes from a single seed. --seed defaults to a fresh value on every run, and the run header prints the number it drew:

scenario bad-wifi.json: network, 1 rule(s), 1 timeline step(s) [impaired sockets: one client at a time]
seed 1430453990272362564 (pass --seed 1430453990272362564 to run it again)

Passing that number back reproduces the run exactly, which is what makes a bad session something you can hand to a colleague, a bug report or CI.

The seed is deliberately not part of the file. A scenario is meant to be shared, and a shared scenario carrying a fixed seed gives every reader the same “random” run — a deterministic scenario that merely looks noisy, hiding exactly the bugs it was written to find.