User Tools

Site Tools


prpl:prpltutorial

<-PF Home <-PRPL Home

PRPL A-Z Tutorial

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 :)

Navigation:

Making your first script

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.)

Create a script and add it to a core

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.

Adding commands into the script

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.

Setting up the text editor

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 recognize 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.

Make sure you download only from the official Notepad++ website to avoid malware. https://notepad-plus-plus.org/

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.

Homework

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.

Solution:

Click to display ⇲

Click to hide ⇱

once
    "Hello world!" Trace
    "It's a good day to learn PRPL today." Trace
endonce

<span id="the_fundamentals"></span>

How does PRPL work - the fundamentals

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.

Basic terms - command, value and stack

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:

  • string: any text, for example: “abc”, “PRPL is fun” or “” (an empty string)
  • int: a whole number (integer), for example: 1, 5, 42, -86, 0
  • float: a real number, for example: 3.5, -8.6, 5.0 (a float 5.0 is techically different from an int 5)
  • list: a list of several values. We will cover lists much later in this tutorial.

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.

Code snippets with visual representation

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|   | |
===   ===   ===   ===   ===   ===   ===

The command notation

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.

Homework

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.

Solution:

Click to display ⇲

Click to hide ⇱

once
    1 2 add 3 add 4 add 5 add Trace
endonce

First usefull unit - a floodgate

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:

  • Delay [ int - ]: Halts the script execution for X frames (1 second = 30 frames)
  • GetCorrentCoords [ - int int ]: Puts the coordinates of the current script on top of the stack (y on top of x)
  • SetLand [ int int int - ]: Sets land height at the target coordinates

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.

Homework

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.

Solution:

Click to display ⇲

Click to hide ⇱

CurrentCoords swap 2 sub swap 1 SetLand
CurrentCoords swap 1 sub swap 1 SetLand
CurrentCoords 1 SetLand
CurrentCoords swap 1 add swap 1 SetLand
CurrentCoords swap 2 add swap 1 SetLand
 
90 Delay #Sleep for 3 second
 
#destroy the land the land
CurrentCoords swap 2 sub swap 0 SetLand
CurrentCoords swap 1 sub swap 0 SetLand
CurrentCoords 0 SetLand
CurrentCoords swap 1 add swap 0 SetLand
CurrentCoords swap 2 add swap 0 SetLand
 
90 Delay #Sleep for 3 second
 
#                              #sets land at (x-2, y) to 1
CurrentCoords  swap   2    sub    swap     1    SetLand
| |          | |  | |   |2|   |   |  |   |   | 1 |   | | 
| |          |y|  |x|   |x|   |x-2|  | y |   | y |   | |
| |          |x|  |y|   |y|   | y |  |x-2|   |x-2|   | |
===          ===  ===   ===   =====  =====   =====   ===

<span id="variables"></span>

Variables

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.

Example:

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.

Other variable operation

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.

Global variables

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.

Homework

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.

Solution:

Click to display ⇲

Click to hide ⇱

once
    0 ->timesExecuted
endonce
<-timesExecuted 1 add ->timesExecuted
"Times executed: " <-timesExecuted Trace2 # Trace2: write the top 2 values from stack

Flow control commands

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

if - else - endif

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 trueor false. Example:

#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 if and 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.

while - repeat - endwhile

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 while and repeat is a condition that will determine whether or not the loop keeps repeating, and between repeat and endwhile goes the actual code of the loop. The logic goes like this:

  1. Execute commands between while and repeat
  2. If there is true on top of the stack, continue to 3), otherwise go to 5)
  3. Execute commands between repeat and endwhile
  4. Go to 1)
  5. Terminate the loop execution by continuing after the endwhile

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.

do - loop

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

The 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.

Nesting loops into one another

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.

Homework

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.

Solution:

Click to display ⇲

Click to hide ⇱

once
    #easily change directions by commenting / uncommenting
    "vertical" ->direction
    #"horizonal" ->direction
endonce
 
<-direction "vertical" eq if
 
    3 -2 do 
        CurrentCoords I add 1 SetLand #add land
    loop
    90 Delay #Sleep for 3 second
 
    3 -2 do 
        CurrentCoords I add 0 SetLand #remove land
    loop
    90 Delay #Sleep for 3 second
 
endif
 
<-direction "horizontal" eq if
 
    3 -2 do 
        CurrentCoords swap I add swap 1 SetLand #add land
    loop
    90 Delay #Sleep for 3 second
 
    3 -2 do 
        CurrentCoords swap I add swap 0 SetLand #remove land
    loop
    90 Delay #Sleep for 3 second
 
endif

<span id="functions"></span>

Functions

Apart from build-in commands like add or 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

Use :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 return command.

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.

Homework

Make a function that will take 2 numbers and return the bigger one multiplied by their difference.

Solution:

Click to display ⇲

Click to hide ⇱

:myFunction # [ number number - number ]
    ->b ->a
 
    <-a <-b max ->bigger
    <-a <-b sub abs ->difference
 
    <-bigger <-difference mul #leave the result on stack

<span id="lists"></span>

Lists

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.

Homework

Create a list with some numbers and then find out how many of them are bigger than 10.

Solution:

Click to display ⇲

Click to hide ⇱

CreateList ->list
<-list 4 AppendToList
<-list 12 AppendToList
<-list 7 AppendToList
<-list 4 AppendToList
<-list 10 AppendToList
<-list 20 AppendToList
<-list -6 AppendToList
<-list 13 AppendToList
 
0 ->count
<-list GetListCount 0 do
    <-list[I] 10 gt if
        <-count 1 add ->count
    endif
loop
<-count Trace
prpl/prpltutorial.txt · Last modified: 2023/10/18 15:53 by Bob