The language itself: tokens, chaining, conditionals, quoting, scope. This is the one document to read before writing VDJScript, because none of it is discoverable by lookup — the parser accepts almost anything and tells you nothing.
Not here: what an individual verb does or which argument forms it honours. Those are
per-verb facts — just get-verb <name>, just find-verbs <term>.
Provenance is per rule. HTTP = the HTTP control interface
on VirtualDJ 2026, 2026-07-22. Pad = the
Grammar Battery pad run on
v2026-m b9482, 2026-07-14. Where a rule is proven on only one surface, it says so —
backticks are the proof that this
distinction is not pedantic.
Enough to write correct VDJScript. If you are only editing skin layout or XML structure and not writing script, you can stop after this section.
- The runtime will never report a syntax error. Wrong script silently does something else. Assume nothing; test it. (The editor is a different story — it autocompletes real verbs and colours ternary branches by role, so it validates far more than the runtime reports. See the correction below.)
&separates statements, it is not "and".a & bruns both.- Never put cleanup after a ternary. In
cond ? a : b & c, the& cbelongs to the false branch. Put unconditional actions first:c & cond ? a : b. Each branch takes its whole chain, on both sides — see Conditionals. - Ternary branches must be verbs, not literals.
on ? get_version : get_clockworks;on ? 'A' : 'B'errors. Neither branch may be empty. - A verb's value is not its truth.
get_versionreports2026and is false as a condition; no slider verb is ever true. 171 of 652 query verbs are traps — check withjust verb-return-type <name>before using one as a condition. - Quote any argument containing a space.
'Beat Grid'works, bareBeat Griddoes not. Single and double quotes are equivalent; quotes are optional for single tokens. &&never guards an action.cond && do_thingrunsdo_thingwhatevercondis. The only guard is a ternary:cond ? do_thing : nothing.- Store numbers in variables, never strings. A string-valued variable cannot be read
back (
get_varreturns blank) or compared (var_equalreturnsyesagainst everything). Use numeric codes. - Backticks only interpolate in XML attribute contexts, not everywhere.
- There are no comments.
//,#,;,--,/* */all silently discard the rest of the statement. - Variable prefixes are part of the name.
modeand$modeare different variables.$= global,@= persists across restarts, bare = deck-local. - In XML,
&must be written&. - Test any construct in one call:
just vdj-query 'on ? get_version : get_clock'
- The parser never reports an error
- Statements and chaining
- Conditionals
- Boolean composition with
&& - Arguments and quoting
- Variables hold numbers, not strings
- There is no comment syntax
- Chains stop after exactly 255 statements
- Backticks are a surface feature, not a parser feature
- Variable scope prefixes
- Deck and scope wrappers
- XML escaping
- Testing a construct yourself
- Not yet established
This is the single most important fact about the language, and the reason every other section exists. VirtualDJ validates the head verb of a statement and essentially nothing else. Junk in argument position — stray quotes, unbalanced backticks and parens, operator soup, extra arguments, a nonexistent deck — is silently discarded and the verb runs anyway.
HTTP, all returning the normal result of get_version:
get_version & & & & get_version '''''' get_version ```
get_version ) ) ( ( get_version !!!@@@### get_version 1 2 3 4 5 6
deck 99 get_version get_version & zzz_bogus
Only the leading verb is checked: zzz_bogus & get_version errors, while
get_version & zzz_bogus returns 2026 — the bogus second statement is dropped in
silence.
Consequences: a working script is no evidence that its syntax is right, and a typo in a verb
argument produces a no-op rather than a complaint. tools/lint_mappers.py resolves leading
verbs against the verb index precisely because the runtime will not.
The heading above, and the summary line "nothing you write will be reported as a syntax error", were written from HTTP and mapper evidence only — the execution path. They are accurate about that path and every example above still holds. But they were too broad about VirtualDJ as a whole, and the distinction matters:
VirtualDJ validates considerably more than it reports. There are two parsing surfaces.
| Surface | Behavior |
|---|---|
| Runtime (HTTP, mappers, pads, skins) | Lenient by design. Head verb checked; argument junk discarded; the verb runs. Reports almost nothing. |
| Editor (Button Editor / mapper Action box) | Validating. Autocomplete offers only real verbs — all 955 canonical, aliases excluded — and the highlighter colours ternary structure, giving the condition, true branch and false branch distinct colours. |
The editor could not colour branches by role unless a structural parser were running as you type. So the app is not ignorant of malformed script; the failure to tell you is a property of the execution surface, not a limit of VirtualDJ's knowledge. Junk in argument position is recognised and discarded, not unnoticed.
The practical advice is unchanged: test everything, because the runtime will not complain.
"You cannot lint VDJScript by feeding it to VirtualDJ" stands — tested 2026-07-30. This
sentence was briefly withdrawn on the theory that the editor might flag unknown verbs. It does
not. Local test (Button Editor, screenshots), three scripts differing only in the head verb:
get_version ? get_text "A" : get_text "B" <- real verb
zzz_bogus ? get_text "A" : get_text "B" <- nonsense
browser_filter ? get_text "A" : get_text "B" <- proven not a verb (rule 1b)
All three render identically — same head-verb colour, same green true branch, same red false branch. The highlighter parses shape, not vocabulary: it will happily colour a well-formed ternary built entirely from verbs that do not exist.
So the two surfaces divide like this, and neither lints:
| Component | Knows the verb set | Validates structure |
|---|---|---|
| Autocomplete | yes — offers exactly the 955 canonical verbs | n/a |
| Highlighter | no | yes — colours ternary branches by role |
| Runtime | head verb only | no — junk in argument position is discarded |
tools/lint_mappers.py therefore remains necessary, and its reason is now sharper: no surface
in VirtualDJ will tell you a verb is fake once you have typed it. What the highlighter is good
for is mapping grammar — see
VDJScript Syntax Evidence, where branch-role colouring is
being used to settle ternary binding questions that the runtime cannot answer.
Error bodies, when you do get one, are coarse — see the
HTTP doc for the E_FAIL vs E_INVALIDARG split. E_FAIL
does not mean "unknown verb": the official verb nothing returns exactly what a bogus name
returns.
& is a statement separator. Both sides run; it is not a boolean operator.
set '$mode' 1 & load_skin
In a query context the value of the first statement is what comes back: on & off
returns yes, off & on returns no (HTTP). So chaining is for actions; do not chain
to build a condition.
A leading chain splits off before a ternary parses, which is the useful, safe ordering
(Pad, HTTP):
set '$mode' 1 & play ? action_a : action_b
The set runs unconditionally, then the ternary evaluates independently.
query ? true_branch : false_branch
This is the biggest trap in the language and it is invisible from the value. A condition
asks a boolean question. It does not mean "the value is non-zero" or "the value is
non-empty". HTTP, 2026-07-30:
get_version -> 2026
get_version ? get_text 'T' : get_text 'F' -> F # returns 2026, but is FALSE
Swept across all 652 query verbs: only 106 are boolean-true in condition position, and 171 return a value that reads as true while the verb is false. The pattern is systematic:
| Observed return type | boolean-true |
|---|---|
float |
0 / 68 |
percent |
0 / 4 |
text |
2 / 72 |
int |
35 / 145 |
bool |
68 / 334 (the rest are genuinely off at rest) |
By implementation family, no slider verb is ever boolean-true (0 / 71). So volume → 1,
pitch → 0.5, zoom → 0.53, stem_color → white are all false as conditions.
volume ? action_a : action_b # WRONG: volume reports 1, but this always takes action_b
get_bpm ? action_a : action_b # WRONG: same trap
Use a verb that is boolean by design (loaded, is_*, has_*, toggles), or make the
comparison explicit:
param_bigger 'volume' 0.5 ? action_a : action_b
var_equal '$mode' 1 ? action_a : action_b
Check any verb before using it as a condition — just verb-return-type <name> reports
boolean_truth alongside the value, and flags truthiness_trap for the 171.
This follows from the typing model in Plugin SDK: the host exposes a numeric channel and a text channel, and the boolean a conditional consumes is a third question that most value-reporting verbs simply do not answer — defaulting to false.
A trailing chain binds to the false branch (Pad). In cond ? a : b & c, c runs only
when cond is false — observed a=1 b=0 c=0 when true, a=0 b=1 c=1 when false. This is
the most common way to get a script wrong, because it reads like c always runs.
cleanup & cond ? action_a : action_b # correct: cleanup always runs
cond ? action_a : action_b & cleanup # WRONG: cleanup is part of the false branch
Each branch takes its whole chain (HTTP). With chains on both sides, the taken branch
runs all of it and the other runs none — the trailing-chain rule above is specifically
about a chain that follows the last branch, not about chains in general:
on ? set '$a' 1 & set '$b' 1 : set '$c' 1 & set '$d' 1 # -> a=1 b=1, c=0 d=0
off ? set '$a' 1 & set '$b' 1 : set '$c' 1 & set '$d' 1 # -> a=0 b=0, c=1 d=1
Corroborated independently by the Button Editor's parse hint (Local test, 2026-07-30): with
the cursor in each statement of get_version ? get_text "A" & get_text "B" : get_text "C" & get_text "D", the editor reports condition: get_version for A and B, and condition: not get_version for C and D. VirtualDJ's own model is a per-statement guard, negated on the
false side — see VDJScript Syntax Evidence.
Chaining gives you else if (Local test, Button Editor parse hint, 2026-07-30).
a ? b : c ? d : e is right-associative — a ? b : (c ? d : e) — and the guards accumulate
exactly as an else-if ladder should. Reading the editor's per-statement guard for
get_version ? get_text "A" : get_version ? get_text "B" : get_text "C"
gives get_version for A, not get_version & get_version for B, and
not get_version & not get_version for C. So the chain works the way you would hope, and
this is the idiom for multi-way selection:
var_equal '$mode' 1 ? do_one : var_equal '$mode' 2 ? do_two : do_default
Note this is the chained form (a ternary in the false branch). It is distinct from nesting a ternary in the true branch, below, and the two are frequently confused.
Nesting associates the standard way (Pad, HTTP): a ? b ? c : d : e parses as
a ? (b ? c : d) : e, so clamped selection composes safely.
var_equal '$phrase_len' 16 ? phrase_sync 16 : phrase_sync 32
nothing is the correct null branch for actions (HTTP). A ternary is the only
construct that actually guards an action:
var_equal '$mode' 1 ? do_thing : nothing
In query position nothing has no value and errors, so this idiom is action-only.
Branches must be verbs and must not be empty (HTTP):
| Form | Result |
|---|---|
on ? get_version : get_clock |
works |
on ? 'A' : 'B' |
error — literals are not values in branch position |
on ? 1 : 2 |
error |
off ? get_version : |
error:1 — empty branch, taken |
on ? : get_version |
error — empty true branch |
off ? get_version : nothing |
error — nothing has no query value |
An empty branch only errors when execution reaches it: on ? get_version : returns
normally because the false branch is never taken. Do not rely on that.
Corrected 2026-07-30 — && is not a distinct operator, and it does not bind into a
conditional. The previous version of this section said "&& is boolean AND over queries, and
it binds tighter than ?, so a && b ? c : d uses a && b as the condition", and read
off && on ? get_version : get_clock returning no as "the ternary never ran". Both were
wrong. They were inferred from returned values alone — exactly the mistake
Evidence Standards rule 4 warns about — and two independent checks
refute them.
Structure (Button Editor parse hint): in get_version && get_bpm ? get_text "A" : get_text "B" the guard for the true branch is get_bpm — not get_version && get_bpm. Replacing
&& with & gives byte-identical guards. The condition is only the statement immediately
before the ?.
Behaviour (HTTP, action position with independent readback — the ternary does run):
| Script | $a |
$b |
|
|---|---|---|---|
off && on ? set '$a' 1 : set '$b' 1 |
1 | 0 | true branch taken — condition is on |
off & on ? set '$a' 1 : set '$b' 1 |
1 | 0 | identical to && |
on && on ? set '$a' 1 : set '$b' 1 |
1 | 0 | |
off && off ? set '$a' 1 : set '$b' 1 |
0 | 1 | false branch — condition is off |
So a false left operand does not abort anything. off && on ? … takes the true branch,
because the condition is on.
& and && are both statement separators. They differ only in the value a query reports
(HTTP, with on→yes and off→no bare):
| Script | Returns | Rule |
|---|---|---|
on & on ? get_version : get_clock |
yes |
& always reports the first statement |
off & on ? get_version : get_clock |
no |
" |
on && on ? get_version : get_clock |
2026 |
&& with a true left reports the right |
on && off ? get_version : get_clock |
06:48 AM |
" (ternary took its false branch) |
off && on ? get_version : get_clock |
no |
&& with a false left reports the left |
off && off ? get_version : get_clock |
no |
" |
That is short-circuit AND semantics applied to the reported value only — false-left reports the left, true-left reports the right — while both statements execute either way. It is a value-selection rule, not a guard, and it never reaches into a following conditional.
In action position && does not guard anything (HTTP). This is the dangerous half:
| Script | $a after |
|---|---|
off && set '$a' 1 |
1 |
var_equal '$x' 999 && set '$a' 1 (with $x=0, so false) |
1 |
var_equal '$x' 0 && set '$a' 1 (true) |
1 |
var_equal '$x' 999 ? set '$a' 1 : nothing |
0 |
&& in front of an action behaves exactly like &: both sides run regardless. Anyone
writing condition && action expecting a guard gets the action unconditionally, with no
error. Use a ternary with a nothing false branch.
This row was always right, and it is now the general case rather than the exception: &&
never guards anything, in any position. Treat && as & with a different value-reporting
rule, and use a ternary whenever you mean "only if".
Single quotes, double quotes, and no quotes are equivalent for a single-token argument.
Quoting becomes mandatory as soon as the value contains a space (HTTP):
get_effect_title 'Echo' -> Echo - Deck 1
get_effect_title "Echo" -> Echo - Deck 1
get_effect_title Echo -> Echo - Deck 1
get_effect_title 'Beat Grid' -> Beat Grid - Deck 1
get_effect_title "Beat Grid" -> Beat Grid - Deck 1
get_effect_title Beat Grid -> '' <- silently wrong
The unquoted multi-word case is the trap: it does not error, it returns nothing. Quote every string argument as a habit.
That equivalence holds for a string argument being matched (an effect name). It does not
hold for a value being stored, where quoting decides the type — see
Variables hold numbers, not strings. on and off
are real constants that evaluate to yes/no; true and false are not — they store
nothing (HTTP).
Argument matching is a per-verb matter, not grammar — effect names are case-insensitive
but not space-insensitive, some verbs require a signed number, some ignore computed
values. Those live on the verb record: just get-verb <name>.
A numeric or boolean variable round-trips correctly, and quoting distinguishes the types —
5 and '5' are different values (HTTP):
set '$v' 5 -> get_var 'yes'/5 var_equal '$v' 5 -> yes var_equal '$v' '5' -> no
set '$v' on -> get_var yes var_equal '$v' on -> yes
A string variable is effectively write-only. It cannot be read and it cannot be
compared (HTTP, $v set to 'apple'):
| Read attempt | Result |
|---|---|
get_var '$v' |
'' — blank, as it is in pad labels too (Pad) |
var_equal '$v' 'apple' |
yes |
var_equal '$v' 'banana' |
yes ← wrong |
var_equal '$v' banana |
yes ← wrong |
Once a variable holds a string, var_equal returns yes against anything. So a
string-keyed branch is not just unreadable, it takes the true branch every time and looks
like it works.
Store numeric codes and branch on those. set '$mode' 2 with
var_equal '$mode' 2 ? … : …, never set '$mode' 'reverb'.
true and false are not constants — set '$v' true stores nothing. Use on/off or
1/0.
Every common comment marker is accepted and silently discards the rest of the statement
(HTTP — in each case $a was set and $b was not):
set '$a' 1 // set '$b' 1 set '$a' 1 # set '$b' 1 set '$a' 1 ; set '$b' 1
set '$a' 1 -- set '$b' 1 set '$a' 1 /*x*/ set '$b' 1
There is no way to annotate script inline. Put explanation in the surrounding XML comment
(<!-- … -->) or in the file that documents the page.
A chain executes its first 255 statements and silently drops the rest. /execute returns
false when that happens, so the truncation is reported. HTTP over POST, 2026-07-30,
with per-statement readback (set '$cN' 1, then querying each $cN):
| Statements sent | execute |
Statements that ran |
|---|---|---|
| 254 | true |
254 |
| 255 | true |
255 |
| 256 | false |
255 |
| 300 (4689 chars) | false |
255 |
| 400 (6289 chars) | false |
255 |
| 500 (7889 chars) | false |
255 |
255 = 2^8 − 1, and it is a statement count, not a size: three chains of 4689, 6289 and 7889
characters all cut at exactly the same statement. Keep generated chains under 255, and check
execute's return value if you are anywhere near it.
Two separate transport limits sit above this and are easy to mistake for it:
- GET has a URL-length limit around 2650 characters. Past it the connection is reset before VirtualDJ sees the script, so nothing runs — for transport reasons, not language reasons.
- POST bodies fail somewhere above ~9500 characters with a connection reset.
The previous version said a long chain runs nothing at all including the first statement,
that execute reports success anyway, that the boundary was neither a character count
nor a statement count, and that GET and POST behaved identically. All four were artefacts of
testing over GET: past ~2650 characters the request never arrived, which looks exactly like
"nothing ran". Over POST the real behaviour is visible — partial execution, a false return,
and a clean 255-statement cutoff.
A same-day attempt to "sharpen" the ceiling to 172 statements / 2641 characters was the same mistake once more, and is withdrawn: 2641 characters is where the GET URL limit falls for that statement shape, not where VDJScript stops.
Lesson worth keeping: when a channel and a language limit can produce the same symptom, distinguish them before measuring — here, by re-running over a different transport.
`verb` evaluates and substitutes in XML attribute string/colour contexts. It is
not a general argument-evaluation mechanism, and the surface decides whether it works.
Proven not to substitute in HTTP argument position (HTTP), with Echo — a 6-slider
effect — loaded in slot 1:
get_effect_slider_count 'Echo' -> 6
get_effect_slider_count `get_effect_name 1` -> 0 <- no substitution
get_effect_title `get_effect_name 1` -> ''
Proven to work in a pad attribute context (Pad): set '$dst' `get_var '$src'` read
back 42.
So a construct prototyped over HTTP may behave differently once pasted into a skin, and
vice versa. When a computed argument is needed and backticks are unavailable or ignored,
chain parameters instead — this works on both surfaces tested (Pad):
get_var '$src' & param_multiply 2 & set '$dst'
Whether a given verb honours a computed argument is per-verb: loop, beatjump, and
phrase_sync ignore them even where the identical literal works. Check the verb record.
The prefix is part of the variable's identity. MyVar and $MyVar are two different
variables, so a global must be set, toggled, and queried with $ every time.
| Prefix | Scope |
|---|---|
name, #name |
local to the current deck |
%name |
local to a logical deck reference such as deck left |
$name |
global for the session |
@name, @%name, @$name |
persistent across restarts |
deck 1 set 'mode' 1
toggle '$MyVar'
set '@$layout_4deck' 1 & load_skin
For skin-wide or controller-wide state use $ or @$; a bare set 'mode' 1 reads
differently when the same script later runs in another deck context.
A deck wrapper prefixes a statement and applies to the rest of it, including a conditional
(HTTP):
deck 1 get_version -> 2026
deck 2 get_version -> 2026
deck left get_version -> 2026
deck 1 on ? get_version : get_clock -> 2026
deck 99 get_version -> 2026 <- nonexistent deck, accepted silently
deck 99 is accepted, which is the no-error rule
again: an out-of-range deck is not reported. all_decks get_version errors — all_decks
is action-only and has no query value, so it cannot wrap a query.
Inside XML attributes, & must be written &:
<pad action="set '$mode' 1 & load_skin" />This is XML escaping, not VDJScript syntax — it does not apply to the HTTP channel, where
the whole script is URL-encoded instead. < and > need the usual XML entities too.
The fastest way to settle a grammar question is to ask the running app. Queries are read-only:
just vdj-query 'on ? get_version : get_clock'Remember what an answer does and does not prove: a returned value means the construct
evaluated, not that it parsed the way you intended, and no output at all is far more
often a silently-dropped argument than a rejected script. Prefer probes whose two possible
answers are visibly different — that is why the examples above use get_version against
get_clock rather than two effects that might both be empty.
Do not guess in these gaps; test and record.
while_pressedrelease behaviour. It is accepted both trailing and mid-chain, and mid-chain it does not block the rest (set '$a' 1 while_pressed & set '$b' 1set both,HTTP). What happens on release cannot be tested over HTTP — there is no press — so the modifier's actual semantics still need a pad or mapper run.- Backtick boundaries in nested quoting:
param_equal "`get_text 'x'`" "x" ? on : off— and more usefully, which surfaces interpolate backticks at all, since HTTP does not. - The exact chain ceiling and what drives it (parse buffer? execution budget?).
Whether— answered 2026-07-30:&&'s query-position short-circuit is deliberate or a parse artefact&&is not a distinct operator structurally (identical parse to&, and identical action-position behaviour with readback). It only changes which statement's value a query reports. See Boolean composition with&&.- Operator-lookalike names in verb argument position, e.g. a verb whose parameter is
literally
on, where a constant and a value collide.setis settled; other verbs are not.
Recording an answer: put the observation in VDJScript Local Test Tracker with the build and surface, then promote the rule into the matching section above.