This is a full “A-Z” tutorial on how to write PRPL scripts. PRPL scripts can be attached to PRPL cores in the editor, and they are used to create custom units. This tutorial assumes no prior knowledge in programming or making scripts or even maps for any of the Knuckle Cracker games.
I will try to show pictures where illustration could be welcomed, but will also just explain with text at other places to not clutter the tutorial too much. Also I'll leave a link to the PRPL reference here since I'm sure someone will find that useful.
After each section there is a little homework, that will hopefully help you try something on your own and get the grip on things. I recommend you actually do the homework yourself and not just read the solution, since doing the work yourself will give you more EXP :)
Before we get to what to write into script, we need to know how to make one at all, so our first goal will be a simple script that just writes “Hello world!”. First, let's create a new map in the Mission Editor.
(This may look like a lot of work, but some of it is “set it once and forget it”, like setting up notepad++ as your editor, and other things will become easier to do as you get used to them, so don't be discouraged.)
Next, let's create a new PRPL core, a new script, and attach the new script to the core. In general you can have any number of cores and any number of scripts. Moreover, multiple scripts can be attached to the same core, even the same script multiple times to the same core! For now however, we will just create one core with one script attached.
To recapitulate, in the first screenshot we create a new PRPL core on the map, in the second screenshot we create and compile a new script, and in the third screenshot we add the new scritp to the core.
You can notice the script doesn't do anything yet. Let's fix that. Go back into the scripts tab and hit “Edit” on your script. For now just write
"Hello world!" Trace
into the text file and save it. Finally, go back into Particle Fleet map editor and hit “Compile” again. After you unpause the game, you should see the console at the top beign spammed by the “Hello world!” message.
If you don't want the message to be spammed so much, change the code to
once "Hello world!" Trace endonce
Which will execute the code only once after compilation. Hitting the “Compile” button will reload the script files, which you need to do manually every time you make a change to your script. Also make sure you save your file before you compile. Now let's finish the setup for the best coding experience.
This step is entirely optional, but can make it much easier to write scripts. Essentially, Notepad++ is a common text editor for writing in unusual languages, and there's an easy way to get it to recognise PRPL.
You may have seen a different text editor when editing the script, and almost definitely you didn't see the “Hello world!” text colored blue like that. That is called syntax highlighting and is very useful to quickly determine the type of command by color.
To set it up like this, first download the Notepad++ editor from their website and install it. After that, make sure that Notepad++ is the default editor for .prpl files. Just create an empty file named “abc.prpl” anywhere and right-click it -> Properties -> General -> Opens with … -> Change -> Select Notepad++ -> OK. After that, your prpl scripts should open in Notepad++ when you click “Edit” in the map editor.
Next, let's set up the syntax highlighter, because we like pretty colors. Go here and download the prpl-syntax.xml attachment file anywhere. Next, open your Notepad++ and go to Language -> Define your language -> Import -> (select the downloaded prpl-syntax.xml file) -> Open -> (close the language window). It should then change the highlighting of the file. If not, try restarting Notepad++ or selecting the language manually from the Language menu.
Change the script to write 2 messages instead of one: “Hello world!” and “It's a good day to learn PRPL today.”, then save the file, recompile it and see the difference in the debug window.
As we have seen, you can create scripts which you can in turn add to PRPL cores. The same script can be added to multiple cores, and a single core can have multiple scripts (even the same script multiple times). Every frame (there are 30 frames per second) all scripts in all cores are executed - the game runs the commands of every script in every core.
The 3 most vitals things are commands, values and the stack. Your code is always composed from the sequence of commands. In our first example, we saw 2 commands: “Hello world!” and Trace. Commands are always executed in the order they are written in the file, unless you alter the execution order by adding special flow control commands - for example the once and endonce commands create a block that will be executed only once, and the program will skip the commands in the block on the following executions.
The most common thing commands do is take/put values from/on the stack. There are 4 types of values:
For example, the “Hello world!” command will put the string value “Hello world!” on the stack, whereas the Trace command will take a value from the stack and write it to the debugger console.
There is also an informal data type “bool” (stands for boolean) that is used for yes/no (true/false in computer terminology) values. In reality it is just an int where 1=true and 0=false. However some functions use “bool” instead of “int” to indicate that they require/return a logical true/false value isntead of numerical ones.
Lastly, the stack is a place where you can store values. Imagine the stack as a bunch of values literally stacked on top of each other. New values are always added to the top, and commands always take values from the top as well. The stack can hold an arbitrary amount of values (Well, limited by RAM of course, but it could handle milions of values with ease).
In summary, the programs consists of commands that will be executed in a sequence (= in the same order they are writen in the script file). Each commands can take or put (or both) values on or from top of the stack, as well as do other things.
Let's take a look at some examples with images of how the stack behaves. For example if we write
1 2 3 Trace Trace Trace
We will see 3 printed, then 2 and then 1. Why? Let's take a look:
Throughout the execution, there is only one stack, but I drew the stack 7 times to show the changes in it as the commands are executed. Since 3 was put last on the stack, it will be on top when the the first Trace reads it and prints it into the console, therefore 3 will be printed first.
PRPL of course has normal math functions like add (plus), sub (minus), min, max, etc. The way they work is they take their arguments from the stack, and then put the results back on the stack. Examples with some ASCII art:
5 7 add Trace #prints 12 5 7 add Trace | | | | |7| | | | | | | |5| |5| |12| | | === === === ==== === 8 6 sub 5 mul Trace #prints 10 8 6 sub 5 mul Trace | | | | |6| | | |5| | | | | | | |8| |8| |2| |2| |10| | | === === === === === ==== === 8 9 5 min max Trace #prints 8 8 9 5 min max Trace | | | | | | |5| | | | | | | | | | | |9| |9| |5| | | | | | | |8| |8| |8| |8| |8| | | === === === === === === ===
Since there is a lot of commands, an easy way to quickly coveney what values does the command take from the stack and what values does it put back is to use a special notation. It looks something like this: [ input1 input2 input3 - output1 output2 output3 ]. the individual inputs and outputs are replaced with the data type of the particular input/output.
For example, the “add” command has notation [ number number - number ] since it takes 2 numbers from the stack and puts one number on the stack in return (the addition of the 2 numbers consumed). Trace has notation [ anything - ] since it accepts one value of any time and doesn't put anything on the stack.
Write a program that will sum numbers from 1 to 5. Do it in a way so that there are no more than 2 numbers on the stack at a time.
Let's make our first useful unit that will create and destroy few tiles of land in a row, so that particles can gather behind the land while it is up, and then all be released when the land is destroyed. First, we will need 3 new commands:
You can look the commands up in the PRPL reference for more info, although some commands don't have pages there yet.
So how will our program work again? Simple: we set land at the current coordinates of the PRPL core that is running the script, then we wait for a bit, and then we remove the land again. Code:
CurrentCoords 2 sub 1 SetLand CurrentCoords 1 sub 1 SetLand CurrentCoords 1 SetLand CurrentCoords 1 add 1 SetLand CurrentCoords 2 add 1 SetLand 90 Delay #Sleep for 3 second #destroy the land the land CurrentCoords 2 sub 0 SetLand CurrentCoords 1 sub 0 SetLand CurrentCoords 0 SetLand CurrentCoords 1 add 0 SetLand CurrentCoords 2 add 0 SetLand 90 Delay #Sleep for 3 second # #sets land at (x, y-2) to 1 CurrentCoords 2 sub 1 SetLand | | | | |2| | | | 1 | | | | | |y| |y| |y-2| |y-2| | | | | |x| |x| | x | | x | | | === === === ===== ===== ===
How it looks:
You can barely see the land being created and destroyed because of the image, but hey, at least it's something.
Change the program to make a horizontal wall instead of vertical. Use
swap [ anything anything - anything anything ] to swap the 2 top values on stack.
So far we have seen how to store values on the stack and do operations with them. However, sometimes we need to save a value aside and use it later, and leaving it on the stack is not always possible, since in the meantime we want to use other commands that also manipulate with the stack. In such a case, we use variables.
Variables are used for storing values. Unlike the stack, they work like a table of name-value pairs. Use ->variableName to save a value from the stack into a variable named “variableName” and similarly, use <-variableName to read the value stored in that variable and put in on top of the stack.
5 ->a 7 ->b <-a <-b add Trace #prints 12 <-a 1 add ->a #increases a by 1 <-a <-b add Trace #prints 13
Visual representation of what's going on:
Again, each variable can have only one value at a time, I just drew them multiple times to indicate the changed as the commands are being executed. Variables don't have to be only numbers, you can story any of the 4 types of value in a variable. Note that you cannot put space into the variable name, so separate the words in the name likeThisInstead.
Unlike the stack that is reset between executions when the script finishes, the variables are persistent enough to stay set across multiple executions (frames). However, all local variables are reset upon compilation. The variables are called local, because they are local to the instance of a script in a core. That means if the same script is attached multiple times to possibly multiple core, it has separate variables every time, with possibly different values.
So far, we have seen how to read from and write to variables. Moreover, we can check if the variable exists at all: -?variableName puts 1 on the stack if the variable exists, 0 otherwise, or we can delete the variable by writing –variable. To summarize:
->varname # [ anything - ] Saves a value into a variable <-varname # [ - anything ] Reads the value from a variable -?varname # [ - bool ] Checks if a variable exists --varname # [ - ] Deletes a variable
All 4 operation also have a reference variant, where they take the name of the variable as an argument from the stack, instead of being written in the command itself. Just put ! instead of the variable name:
->! # [ anything string - ] Saves a value into a variable <-! # [ string - anything ] Reads the value from a variable -?! # [ string - bool ] Checks if a variable exists --! # [ string - ] Deletes a variable
They work exactly the same, so
5 ->a is the same as
5 “a” ->!,
<-foo Trace is the same as
“foo” <-! Trace and so on. It's important to note that both of them work with the same table of variables, so you can set a variable by reference and then read it normally. The reference operators can be really useful when you generate the variable name itself by code as well, however in most cases we will just use the normal variant.
Instead of local variables for each script instance, there is also a single table of global variables. They are shared between all scripts, so when one script changes a global variable, another script will be able to read the change. Global variables are stored completely separately from local variables, so they don't affect each other in any way. Just add * to the end of the command to change it to work with global variables:
->*varname # [ anything - ] Saves a value into a global variable <-*varname # [ - anything ] Reads the value from a global variable -?*varname # [ - bool ] Checks if a variable global exists --*varname # [ - ] Deletes a global variable ->!* # [ anything string - ] Saves a value into a global variable <-!* # [ string - anything ] Reads the value from a global variable -?!* # [ string - bool ] Checks if a global variable exists --!* # [ string - ] Deletes a global variable
Unlike local variables, global variables persist (are not deleted) even after a compilation.
Write a variable that will count the number of times the script has been executed. Use the
once - endonce block to initialize it at the start.
So far we have seen commands being executed one by one as we wrote them. But sometimes you need to execute a piece of code only when a certain condition is met, or you need to execute the same code sever times. For that, there are flow control commands that, well, allow you to control the flow of the command execution.
We have already the
once command, that together with
endonce form a code block that will be executed only the first time the code passes through the
once command, and the block will be skipped on all future executions. This is reset by compiling your script. Most scripts usually have a
once block at the start where they initialize variables, set images, etc. Quick example:
once 0 ->invocationCount endonce <-invocationCount 1 add ->invocationCount
Next on the list is
if. Together with
endif it again creates a block that will be executed only if
true (anything other than 0) is on top of the stack. Since the
if needs to read the value, it will be removed from the stack regardless of whether or not it was
#ensure that variable x is at least zero <-x 0 lt if 0 ->x endif
lt (less than) will check if the first argument is less than the second argument. The inside commands
0 ->x will only be executed if
x was less than zero. The same effect could also be achieved by simply calling
<-x 0 max ->x, but this is just a tutorial example.
You can also insert
else between the
endif to create 2 blocks: one that will be executed if the
if succeeds (reads
true from the stack), and the other if the
if will fail. Example:
<-energy 50 gte if "We have enough energy, hooray!" Trace else "Not enough energy :(" Trace endif
gte (greater than or equal) will check if the first argument is greater than or equal to the second argument. This will write different message depending on if
energy is at least 50. List of all comparison commands:
eq [ anything anything - bool ](equals) checks if the 2 values are the same
neq [ anything anything - bool ](not equals) checks if the 2 values are different
lt [ number number - bool ](less than) checks if the first number is less than the second one
lte [ number number - bool ](less than) checks if the first number is less than or equal to the second one
gt [ number number - bool ](greater than) checks if the first number is greater than the second one
gte [ number number - bool ](greater than) checks if the first number is greater or equal to than the
eq0 [ number - bool ](equals 0) checks if the number equals 0
neq0 [ number - bool ](not equals 0) checks if the number is different from 0
Moreover, you can combine conditions using logical operators. For example, if you want to check for 2 conditions being true at once, just put them on stack and then call the
and command that returns
true only if both of it's arguments are true:
<-energy 50 gte <-cannonsReady and eq if "Fire!" Trace 0 ->energy 0 ->cannonsReady endif
Note: we will get to how to accept energy from mines or how to fire actual shots later, but we need to get through the fundamentals first. List of all logical operators:
and [ bool bool - bool ]Tests if both arguments are true
or [ bool bool - bool ]Tests if at least one argument is true
xor [ bool bool - bool ]Tests if exactly one argument is true
not [ bool - bool ]Negates the value
As always, check the PRPL Reference for more information.
Next we take a look at loops - an easy way to execute the code multiple times. Let's start with a simple example: write numbers from 1 to 10. If I asked you to do that on paper, you could easily do it by starting with 1, then 2, then 3, and so on until you reach 10. Let's now write that in PRPL:
1 ->counter while <-counter 10 lte repeat <-counter Trace <-counter 1 add ->counter endwhile
First we initialize the variable counter to 1. Next we use the
while - repeat - endwhile combo: between
repeat is a condition that will determine whether or not the loop keeps repeating, and between
endwhile goes the actual code of the loop. The logic goes like this:
trueon top of the stack, continue to 3), otherwise go to 5)
In other words, the “body” of the loop will keep getting executed as long as the condition is true. Keep in minds that the condition is checked first, so it's possible that the body will not be executed at all if the condition fails on the very first try.
Since counting from one number to another is such a common usage of loops, there are 2 entirely different commands just for that: looping by counting a number from a value to another value. Just specify the upper and lower bound (in that order: first upper, then lower) and you are good to go:
11 1 do I Trace loop
I command will read the internal counter of the loop, so this will do exactly the same as the previous loop: count from 1 to 10. So why is there 11? It's because the internal counter has to be strictly less than the upper bound for the body of the loop to execute. So if we want to count to ten, we need to put 11 there instead.
You can also put multiple loops into one another, both do-loop and while-repeat-endwhile can be nested. In such case, 'I' will read the counter of the innermost do-loop loop, 'J' of the second inner-most, and 'K' of the third inner-most. A simple example of a multiplication table:
11 1 do 11 1 do J " times " I " is " J I mul concat concat concat concat Trace loop loop
concat is a new commands that takes 2 string from the stack, puts them together and returns them as one string. Since we have 5 values on stack in total (J, “ times ”, I, “ is ” and J I mul), we need to contact 4 times to join them all together. After that, we cas simply Trace to see the result.
Add a variable
direction to the floodgate script with 2 possible values: “vertical” and “horizontal”. Then change the script to properly work, depending on how this variable is set. Also rework it to use the
do - loop loop. Tip: the lower bound can be negative and remember that the loop will only execute if the inner counter is strictly less than the upper bound.
Apart from build-in commands like
SetLand, you can define your own functions that you can later use elsewhere. When calling a function, the program will execute all the commands in the function, and then return to where the function was called from. Example:
5 @getMagicNumber add @addMagic Trace #prints 89 (5+42+42) :getMagicNumber # [ - int ] 42 :addMagic # [ int - int ] @getMagicNumber add
:funcName to define a function, and
@funcName to call one. The body of the function is from it's definition to the definition of the next function, or to the end of the file in case it is the last function. The code before the first function definition is the main code of the script that will be executed every frame. You can return from the function early by using the
You can call functions withing other functions, and you can call the same function on multiple places (that's the point, really). However functions are local to a script, so you can't call a function from another script, even if that script is attached to the same core.
Functions can read and write to the same local variables as the rest of the script, meaning that if you write into a variable in one functions, you can see the value in any other function in the same script.
Make a function that will take 2 numbers and return the bigger one multiplied by their difference.
List is a data type that can contain other values. You can add, read, replace and remove values from a list. The 4 basic commands to get started with lists are:
CreateList [ - list ]Creates a new empty list
AppendToList [ list anything - ]Adds a value to the end of the list
GetListCount [ list - int ]Gets the number of values in the list
GetListElement [ list int - anything ]Reads the value at the specified index from the list
See the PRPL reference for more list commands. Example of creating a list and then reading values from it:
#create list CreateList ->list <-list "a" AppendToList <-list "b" AppendToList <-list "c" AppendToList #read it's values <-list GetListCount 0 do <-list I GetListElement Trace # <-list[I] Trace - a shortcut way of writing the same thing as above loop
This will print “a”, “b” and then “c”. The
<-list[I] syntax will do the same thing as
<-list I GetListElement it's just easier to write. Some functions like GetAllUnitsInRange return a list, so it's useful to then iterate over the list values to inspect what units did the command find.
Unlike the 3 other data types, lists are mutable. It means that if you have the same list in 2 variables and you change one, the other one will be changed as well - it is the same list, after all. Think about it like this: let's say that Alice and Bob have the same favourite playground. If you add a seesaw to Alice's favourite playground, Bob's favourite playground will also have that seesaw. A PRPL parallel:
CreateList ->playground1 #model a playground as a list of attractions <-playground1 "swings" AppendToList <-playground1 "carousel" AppendToList <-playground1 "slide" AppendToList # set this playground as both Alice's and Bob's favourite <-playground1 ->alicesFavPlayground <-playground1 ->bobsFavPlayground # add a seesaw to Alice's favourite playground <-alicesFavPlayground "seesaw" AppendToList # the seesaw will be in Bob's favourite playground as well <-bobsFavPlayground Trace
I try to explain this in more detail in the Copy vs DeepCopy vs dup page, but just remember that if the same list is shared in multiple variables/on multiple place on stack, you can modify it on one place and the changes will be visible when reading the list via a different variable as well. However if you just get a list from a function like GetAllUnitsInRange and read it's content, you don't have to care about any of this.
Create a list with some numbers and then find out how many of them are bigger than 10.