π WAC scripting basics
WAC is the script language that makes things happen in a Joint Ops map: timed events, weather, messages, AI orders, sounds, effects, win and lose. How a script is laid out, when it runs, what multiplayer players actually see, and the traps that fail without a word.
This page comes from the reverse-engineering of the retail game (the FMJ team and the OpenNova project), checked against real TAC map scripts, and from a live test of every command on a real host with a player joined (13 Oct 2026). The full command list, each with its test result, is on Triggers and Actions.
Tested in a real match (13 Oct 2026)
Every one of the 165 commands was fired on a LAN host (normal retail behaviour, stock map 03TR) with a second player joined, and we recorded what the host and the joining player each saw: object states from both games, plus screenshots from both screens. Results:
| Result | Commands |
|---|---|
| Works, and players see it | 90 |
| Works on the server only (players do not see it) | 16 |
| Does nothing, as the game's code says | 15 |
| Broken | 3 (teleSSN, fov, sound) |
| Not proven either way yet | 41 |
The result for each command is on its card. The things most likely to catch a mapper out:
- Inside PLOOP,
playeris the player's slot number, not 10000/10001. On the test map the host was 88 and the joiner 89. A script that comparesplayerwith 10000 never fires. Usepisteam, or flags set withpsetvarand read withpisvar. SSNdeadgoes false again about 30 seconds after the kill, when the body is cleared away (SSNexiststoo). Killed wildlife even comes back. Catch a death the moment it happens:if ssndead(1704) and never then set(V1, 1) endif.reset(name)only works if the reset line comes after its named IF in the script. Written before the IF, it does nothing, with no error.SSNHP,SSNADDHPandGroupHPchange a soldier's or animal's health on the server only. Players' screens keep the old number, but the server decides damage and death, so the game still plays right. Vehicle health does reach players.sound(...)threw the joining player out of the game with one sound name (SS_PSP_LOST);SS_EXPLO_BASEwas fine. Do not usesoundin multiplayer.fallthrows the host's player about 470 m into the air, which kills with normal health, andtelekeeps the fall speed.fovdoes nothing (tried 20, 40 and 120).winandloseend the round for everyone; on the test map it showed "Draw - No winner!".- Weather that reaches players:
rain,snow,overcast,skyspeed,TOD,fogdist,movefog,quake. Server's screen only:gain,skyheight,floor,ceiling.
Still to prove (the 41): mostly AI aim and distance settings, flashes and lightning (too short to catch in a screenshot so far), sounds (a screenshot cannot hear), fire-button tests, and commands this map has nothing for (doors, location boxes, spawn points). They will be retested and the cards updated.
Files
- Name: the map's script has the same name as its
.bms, with.wacon the end:TD-ShippingPort.BMSusesTD-ShippingPort.WAC. Put it with the.bms. Capitals do not matter. - Two more files run with every map:
game.wacandserver.wac. At map load the game readsgame.wac, thenserver.wac, then the map's own.wac, and joins them into one script. Any of them can be missing: the game skips it without a message. - Line endings must be Windows style (CRLF). The game only counts a carriage return as the end of a line. In a file with Unix (LF-only) endings the first
//comment runs to the end of the file, so the rest of the script quietly does nothing, with no error. - RUN pulls another file in at that point:
RUN extra.wac. Use it at the top level only (not inside an IF or a loop). A RUN file can RUN one more file; a third level is refused.
Who runs it, and when
- Only the host runs the script. On a dedicated server that is the server; on a listen server the hosting player's PC; in single player your PC. Players who join do not run anything: they only see what the host's script does to the game (see multiplayer).
- At map load the script is compiled and run once straight away (pass 0). After that it runs once every 62 game ticks, about once a second.
- Every "seconds" value is really a count of script passes.
past(60)means "the 60th pass or later". The pass counter is the valueticks. - An empty server sleeps. While no human player is in, the world and the script stop, and
past,elapseandontickpause with them. - A compile mistake never stops a map. The script runs anyway, with the broken part treated as 0 (see compiler messages).
How a script is written
- Comments:
//or;to the end of the line. - Capitals do not matter for commands, keywords and names.
- Spaces, tabs and commas all separate words, and brackets are optional. These are the same:
fogdist(200) fogdist 200 ceiling 40 20 20 ceiling(40, 20, 20)
- Text goes in double quotes:
text("Hello"). Up to 63 characters are kept. - Keywords match on their first four letters.
ENDIF,ENDLOOP,ENDDOandENDare all END. - Each command takes a fixed number of values. There are no optional values. Give too few and the command takes the next words of the script as its values, even from the following lines, and can swallow your
ENDIF. Extra values are quietly ignored:wave(x.wav, 600)plays the wave and ignores the 600.
IF blocks
These forms come from the game's own built-in help:
IF triggers THEN actions END IF [name] triggers THEN actions END (a named IF, for reset) IF triggers THEN actions ELSE actions END IF triggers THEN actions ELSEIF triggers THEN actions END IF triggers ENTER actions END (runs once when the triggers become true) IF triggers LEAVE actions END (runs once when they stop being true) PLOOP actions END (once for each human player) GLOOP group actions END (once for each member of a group) DOSEQ actions NEXT actions [NEXT actions ...] END DORND actions NEXT actions [NEXT actions ...] END
- The script runs top to bottom on every pass, and each IF tests its triggers every pass.
- THEN runs on every pass while the triggers are true.
if past(10) then flash endifflashes every second from then on. To run something once, addnever. To repeat it every N seconds, addelapse(N). - ENTER runs only on the pass where the triggers turn true; LEAVE only on the pass where they stop being true.
- ELSE runs when the IF did not fire.
- ELSEIF must be one word.
ELSE IF(two words) opens a second, nested IF that needs its own END, or the script ends with "Missing END". Several retail training scripts ship with this mistake. - IFs can be nested inside THEN and ELSE parts.
- Named IFs:
IF [gate] ...gives the IF a name.reset(gate)clears that IF's "has fired" memory (and that of the IFs inside it), sonever,elapseandchainwork again. The reset line must come after the named IF in the script (proven in the live test: before it, reset does nothing).
A misspelled name in reset gives no error: it resets the first IF in the script instead.
Combining triggers
AND,ORandNOT(also&&,||,!):if ssndead(404) and ssndead(405) and never then.- AND and OR have the same priority and are read left to right.
a OR b AND cmeans(a OR b) AND c. Use brackets whenever you mix them. - Comparisons work in an IF:
if V1 < 12 then,if V1 == 3 and V2 != 0 then(<><=>===!=<>). The commandseq,ne,lt,gt,le,gedo the same job.
Timing words
All count script passes, about one second each.
| Trigger | True when |
|---|---|
never | this IF has not fired yet ("do it once") |
elapse(N) | this IF has never fired, or fired N or more seconds ago ("repeat every N") |
past(N) / before(N) | at least N / fewer than N seconds since the map started |
ontick(N) | exactly pass N (ontick(0) is the load pass) |
previous() | the IF before this one (same level) fired more recently than this one |
chain(N) | as previous, and N seconds have passed since it fired |
elapse is true straight away the first time, so it repeats rather than delays. To delay the first time as well, write past(N) and elapse(N). A row of if chain(5) ... endif blocks makes a timed sequence.
DOSEQ and DORND
Each time the block is reached it runs one section and moves to the next section next time. DORND is not random in Joint Ops: it cycles through its sections in order, exactly like DOSEQ. For randomness use random(N) in an IF.
Loops: PLOOP and GLOOP
PLOOP ... ENDrepeats its contents once for each human player. Inside it,playeris the current player's SSN, and the per-player commands (ptext,pwave,pconsol,onptick,piskills,pisvar,psetvar,pisteam,ppunt,pkillpunt) work on that player.GLOOP G_name ... ENDdoes the same for every member of a script group. Built-in groups:G_humans,G_blueplayers,G_redplayers,G_ai,G_blueai,G_redai,G_emptygroup. Always write theG_. A barehumansis the player count, not the group.- Script groups are not the group numbers you set in MED. Commands that take a number (
kill(6),GtoWP(4, 2),groupdead(3)) use MED group numbers; commands that take a group (Gkill,Gremove,GLOOP) use script groups. - A loop cannot sit inside another loop ("No LOOP Nesting!").
An IF's memory (never, elapse, ENTER/LEAVE) belongs to the IF, not to each player. PLOOP if onptick(20) and never then ... END fires for the first player only. Use onptick on its own, or a player flag set with psetvar and tested with pisvar. (Worked out from the code, not tested in game.)
Values and variables
| You write | Meaning |
|---|---|
12, -3, 2.5 | numbers (whole numbers in the end) |
150 in a distance, 150M, 30F | metres; F = feet |
19:45, or 8 in a time | time of day (hours:minutes, or hours) |
V0..V255 | map variables, cleared at every map load |
G0..G255 | global variables (server tools such as WolfRAT use high G numbers to talk to server.wac) |
VAR name | your own named variable (up to 256, names up to 18 characters). Not cleared between maps: set it yourself at the start. |
| SSN | an object's number from MED/Nile; a player is 10000 + slot; player inside PLOOP |
G_ FX_ FACE_ SS_ TT_ ANIM_ SSN_ AMMO_ | prefixes saying what a name is: group, effect, face, sound set, text key, animation, SSN, ammo |
Named values
| Name | What it is |
|---|---|
ticks | number of script passes so far (about seconds) |
result | the value of the last command or test |
RND | the last random roll |
humans | number of human players in the game |
GameOver, WinVar, LoseVar | round over / won / lost |
CurTOD | time of day in minutes (read only: use TOD to change it) |
night | the night flag of the time-of-day system |
accuracyspread | AI aim spread for every AI (can be written) |
breathtime, fallmps | breath time under water, and a falling setting (can be written) |
bluekills, greenkills | enemies killed by the player's side: single player only |
Setting values: use set(V1, 5), add(V1, 2), sub, inc(V1), dec(V1). The game also accepts V1 = 5, but arithmetic in assignments is unreliable: V1 = 1 + 2 * 3 stores 1, not 7. Keep to one operation per line, or use the commands.
Never divide by a value that can be 0. In the retail game that is a processor fault, which would crash the host.
Limits
| Limit | Value |
|---|---|
| IF blocks (including ELSEIF) | 1,024 ("Out of IF space") |
| DOSEQ / DORND blocks | 1,024 ("Out of DO space") |
| VAR names | 256 ("Over Variable Buffersize") |
| different numbers in the script | 512 ("Out of num space") |
| all quoted text together | 4,096 bytes ("Out of string space") |
| compiled script | about 18 KB ("Over Compile Buffersize": the rest of the file is dropped) |
| brackets inside each other | 16 |
| one quoted text | 63 characters |
| V and G variables | 0-255 each |
player flags (psetvar / pisvar) | 0-16 |
| areas a script can test | the first 128 in the map |
| RUN depth | 2 levels |
Compiler messages
The game keeps only the first message, shown as file (line) message on the game's script debug display. Whatever the message, the script still runs: the broken value becomes 0, so a typo usually means "that line quietly does nothing", or worse, "acts on object 0 or IF 0".
| Message | What went wrong |
|---|---|
Unknown 'WORD' | not a command, keyword, variable or number. A "quoted text" on its own, without text, gives this. |
Unknown SSN | no object in the map has that SSN |
Unknown FX / SOUNDSET / AMMO / ANIM / FACE / Group | the name is not in the game's lists |
Wrong Parameter | the wrong kind of value in a slot (a time where a distance belongs) |
Missing END | a block is not closed (often ELSE IF written as two words) |
Unexpected END / ELSE / NEXT ... | a word in the wrong place |
THEN without IF, ELSE without THEN, NEXT without DO | a block broken in the middle |
No LOOP Nesting! | a PLOOP/GLOOP inside another |
IF Name already used, Variable Name already used | the same name twice |
Can't run files inside blocks, Unable to run file | RUN problems |
Out of ... space, Over ... Buffersize | see limits |
Traps with no message at all: LF line endings; a misspelled name in reset; a text key that does not exist; a missing G_ before a group name; extra values after a command.
Multiplayer: what other players see
The script runs only on the host, so what matters is how each command's effect reaches the other players. The game marks every command:
- Host actions on objects (kill, remove, health, AI orders, doors, teleport, spawn counts) change the game on the host, and players see the result through the normal game updates.
- Sent commands (
text,consol,wave,SSNwave,hideSSN,quake,flash, effects ...) are passed on and run on every player's PC.ptext,pwaveandpconsolgo to one player (the one PLOOP picked). - Weather:
rain,snow,overcast,fogdist,movefog,TODandskyspeedrun on the host, and the game's regular weather update carries them to players.skyheight,fogtypeandgaindo not reach other players. - "Your own player" commands (
area,area3D,location,outside,me...,weaponfired,blockfire,anim,fov,tele,fall,ammorain,fxrain) mean the host's own player. They suit single player; a dedicated server has no such player. For every player in multiplayer use PLOOP withSSNarea(player, N)orSSNonSSN(player, N). - Colours need the FMJ onHook DLL: see WAC colour commands.
- Sound sets (
sound,sound2tgt,SS2SSN) are sent in a way that may not match on every PC. Whether players hear the right sound is not verified, and a bad value may crash a client. Test before relying on them in multiplayer.
Commands that do nothing, or not what they say
teleSSNdoes not move the object (a retail bug, confirmed in the live test). Useteleport(group, target), which moved its soldier exactly onto the target on both screens.fovchanges nothing (live test: 20, 40 and 120).soundthrew a joining player out of the game in the live test (withSS_PSP_LOST). Do not use it in multiplayer.music,sunfade,squadevent(always false),pisgold(always false),squadclear,ssnfaceandface(Joint Ops has no face files) have no visible effect.DORNDis not random.random(N): from the code it is true about 1 time in 2N (random(4)about 1 in 8), not 1 in N as Nile's text says. Not measured in game.lose(team)accepts only 0 or 1, and by the code always ends with Red winning. In the live test on 03TR,winandloseboth ended the round for everyone with "Draw - No winner!".crashis an old copy ofskyfogcolor: useskyfogcolor.quake(N)lasts about N tenths of a second, not N seconds.fall,ammorainandfxrainmay crash a dedicated server, because there is no host player (not tested on a dedicated server). On a listen host,fallthrew the host's player about 470 m up.
Names in Nile's action list that are not real commands
wind(kph, seconds): added by the FMJ onHook DLL only. On a normal install it compiles to nothing and gives no error.ssnblind,ssnclimb: not in the game's table, so they give "Unknown".fov(SSN, N): the realfovtakes one number and sets the host's own camera.SSNLeadSSN2SSNis offered with 3 values; the game needs 4.
Example scripts
The SSNs, areas and file names belong to their maps: use your own.
A. Map start, a repair pad and a crane disaster
From TAC's AS-KutuTowerAssault.WAC, shortened.
//map start: runs once on the first pass if never then hidessn(3188) //fallen beam, shown later killssn(4125) movefog(200, 0) fogtype(3) tod(9:00) //the original wrote set(tod, 09:00), which does nothing overcast(45, 0) ssn2ssn(4138, 4139) //put a soldier on a gun ssnuse(4138) endif //heli pad repair: every 30 s while the helo sits on the pad if SSNonSSN(4137, 4136) and elapse(30) then SSNHP(4137, 8000) endif //crane beam drop: a timed sequence, each step after the one above if ssnride(3405) and never then ssnwave(2265, "m1rtrev2.wav", 250) endif if chain(5) and never then killssn(3404) ssnwave(3403, "alarm9.wav", 50) endif if chain(5) and never then ammo2tgt(ARTYRND, 2) hidessn(2648) unhidessn(3188) endif if chain(4) and never then removessn(3405) endif
B. A storm rising after 15 minutes
From TAC's AS-VillaValleyTAC.WAC, shortened (its wind(...) lines are left out: they need the FMJ DLL).
if never() and (OnTick(0)) then //set the start weather
tod(8)
fogdist(1024)
endif
if Elapse(900) and Past(5) then
farflash()
movefog(20, 50)
overcast(100, 40)
rain(100, 40)
consol("TYPHOON RISING - TAKE CARE!")
endif
if chain(10) then
farflash()
endif
if chain(5) then
flash()
quake(10)
movefog(60, 5)
endif
if chain(5) then //clear again
overcast(0, 10)
rain(0, 10)
movefog(1024, 60)
consol("Fog Clear")
endif
C. Seats for every player, and a welcome
The seat part is from TAC's TK-AlQurnahBasin.WAC (made in Nile, fixed by O.M.).
// a player who steps on the pad (133031) is put in the chair (133035)
PLOOP
if ssnonssn(player, 133031) then
ssn2ssn(player, 133035)
ssnuse(player)
endif
END
// let people out of the chair again
if ssnride(133035) then
PLOOP
if ssnonssn(player, 133035) then
ssnrelease(player)
endif
END
endif
// only the player who has just joined sees this
PLOOP
if onptick(20) then
ptext("Welcome to the server!")
endif
END