Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Installation & Getting Started

Requirements

Steplo is built using Rust, so you must have Rust installed to run the compiler.

Installation

## Clone the repo
git clone https://github.com/tetrogem/steplo.git
cd steplo

## (Optional) Create an output folder, or use an already existing folder
## Note: The local output folder named `out` is .gitignored, so it's safe to create/use!
mkdir out

## Compile a Steplo program
cargo run examples/hel.lo out --target scratch

This will generate a .sb3 file in the out/ directory.

You can now open the .sb3 in the Scratch editor and run your Steplo-compiled project!

Compiler Options

Run the following to see all CLI options:

cargo run -- -h

Next Steps

The rest of the chapters in the book will walk you through all of Steplo's features!

Hello World!

Let's start by learning how to create a simple Hello World program!

Start by creating a new file named helloworld.lo anywhere on your computer.

In Steplo, all programs need to have a single main procedure, located anywhere within the source code. A main procedure can be declared as follows:

main || {

}

All of the code to be executed when the program starts goes in the body of the main procedure, which is surrounded by braces. Let's try calling the built-in out function to print to stdout:

main || {
    out("Hello world!");
}

Now we can compile this program! The compiled Scratch program can be written to any directory on your system, specified by the compile command. In this example, we'll use a directory named out. To compile, we can then call:

cargo run helloworld.lo out --target scratch

This will create helloworld.s.sb3 inside of out, which we can load into Scratch to see our working program!

Compilations Targets

Steplo has two different targets it can compile to: Scratch and TurboWarp.

You can specify which platform you'd like to target with the --target flag:

# compiles targetting Scratch
cargo run helloworld.lo out --target scratch

# compiles targetting TurboWarp
cargo run helloworld.lo out --target turbowarp

How a Steplo project is compiled to a .sb3 file will change based on which platform is being targetted. These changes will be to optimize the program to run most optimially on the targetted platform.

A project compiled targetting Scratch will be outputted with a .s.sb3 extension, and compilations targetting TurboWarp will have the .t.sb3 extension.

Of course, since Scratch and TurboWarp both use .sb3 files, you can run a .t.sb3 on Scratch and a .s.sb3 on TurboWarp, but they will likely have worse performance than running the corresponding extension for that platform.

Stack Variables

Next, let's learn how to create variables on the stack. Stack variables are able to have their values modified, as well as their addresses stored as references. Stack variables live for the duration of the procedure they were declared for; That means one the procedure ends, any references to its stack variables will be invalid! Steplo does not currently perform checks for if references are valid for you, so this is something you'll need to keep in mind while programming.

Let's start by declaring a variable on our main procedure:

main |hello: val| {

}

All stack variables are declared in the two pipes | | at the start of a procedure. The also must have a type! In this case, we've created a variable hello with the type val. val is the supertype of all other primitive types in Steplo.

Now, let's assign a value to our variable:

main |hello: val| {
    hello = "Hello world!";
}

We can then use this value in-place of the literal we printed in the previous chapter, passing it to out as a parameter:

main |hello: val| {
    hello = "Hello world!";
    out(hello);
}

This program should have the same output as the previous "Hello World!" program!

Note: Variables are not initialized with a value automatically; If you use a variable before setting it yourself, the exact value it will contain is undefined.

You can also declare multiple variables in a single procedure:

main |hello: val, world: val| {
    hello = "Hello";
    world = "World!";
    out(hello);
    out(world);
}

Types

Primitive Types

NameSize (Cells)ExtendsDescription
val1The base primitive type of Steplo, equivalent to strings in other languages
num1val64-bit floating-point numbers (equivalent to TypeScript's number type or Rust's f64 type)
int1numA 64-bit floating-point signed integer
uint1intA 64-bit floating-point unsigned integer
bool1valA boolean value, either true or false
main |string: val, float: num, integer: int, index: usize, maybe: bool| {
    string = "hello world!";
    float = 12.34;
    integer = -7;
    index = 10;
    maybe = true;
}

Reference Types

All reference types start with & and can be reference to any other type. They are all 1 cell in size, no matter the size of the type they are pointing to. They are represented in memory by the memory address of the value they are pointing to. No reference types are subtypes of any other reference type.

main |integer: int, int_ref: &int| {
    integer = 10;
    int_ref = &integer;
}

Array Types

Array types are denoted with the following syntax: [<element>; <length>], where <element> is the type of each element in the array, and <length> is a uint denoting how many elements the array will contain. Arrays are all fixed-size, they cannot grow or shrink. All elements in an array must be of the same type. For example, an array of 10 ints would be typed as: [int; 10].

main |nums: [num; 5]| {
    nums = [1, 2, 3, 4, 5];
}

Sometimes you'll have very long arrays, where typing each individual element to set isn't feasible, especially when you'd likely want to set all of the elements to a common "default" value. Luckily, you can use the spread ... operator in an array literal to do this for you:

main |long_arr: [int; 999], short_arr: [int; 3]| {
    long_arr = [0...]; // sets all elements to 0
    long_arr = [1, 2, 3, 4, 0...]; // sets the first 4 elements, then the rest to 0
    short_arr = [1, 2, 0...]; // works on all array lengths!
    short_arr = [1, 2, 3, 0...]; // a spread element can still be declared even if it'd go unused
}

Struct Types

Struct types are denoted with braces surrounding key/value pairs: { <field>: <type> }, where <field> is the name of a struct field, and <type> is the type of that field. Unlike arrays, fields in a struct can be of different types. The order and names of struct fields matter as well; If these mismatch between two types, they are not assignable to eachother.

main |coords: { x: num, y: num }, user: { id: uint, name: val }| {
    coords = { x: 10, y: -5.5 };
    user = { id: 1234, name: "Steplo" };
}

The any Type

Any is a special type that is a supertype of any type that is 1 cell in size. This includes all primitives, references, and arrays with a single 1 cell-sized element.

main |anything: any, integer: int| {
    integer = 10;

    anything = "hello!";
    anything = 10;
    anything = true;
    anything = &integer;
}

Type Aliases

Retyping complex compound types (e.g., arrays, structs) is tedious and error-prone. Instead of retyping an entire type definition each time it is used, a type alias can be declared, allowing for a shorter name to be used in-place of a more complex type:

type Link = {
    name: val,
    redirect: val,
};

type Message = {
    created_time: uint,
    sender_name: val,
    likes: uint,
    content: {
        body: val,
        links: [Link; 2],
    },
};

main |msg: Message| {
    msg = {
        created_time: 123456789,
        sender_name: "Steplo",
        likes: 4,
        content: {
            body: "Hello world!",
            links: [
                { name: "GitHub", redirect: "https://github.com/tetrogem/steplo" },
                { name: "Book", redirect: "https://steplo.tetro.dev/" },
            ],
        },
    };
}

Supertypes, Subtypes, & Type Coercion

Types are able to automatically be coerced into their supertypes.

main |foo: int, bar: uint| {
    bar = 10;
    foo = bar; // `uint` is a subtype of `int`, so it can be coerced to type `int`
}

Array Types

An array type A is a subtype of another array type B if:

  1. The element type of A is a subtype of the element type of B
  2. The two array types are the same length
main |nums: [num; 5], ints: [int; 5]| {
    ints = [1, 2, 3, 4, 5];
    nums = ints;
}

Struct Types

A struct type A is a subtype of another struct type B if:

  1. A & B share the same number of fields, with the same names, in the same order
  2. The type of a given field in A is a subtype of that same field's type in B
main |coords: { x: num, y: num }, strs: { x: val, y: val }| {
    coords = { x: 1.1, y: -2 };
    strs = coords;
}

Reference Types

A reference type &A is a subtype of another reference type &B only if A & B are isomorphic, meaning A is a subtype of B and B is a subtype of A.

Unary Operaters

Unary operators can create expressions that take one other expression as input. An example of a binary operator is the not ! operator:

main || {
    out(!true); // prints false
}

All the currently existing unary operators and the types they evaluate to:

OperatorOperand Type= Evaluated Type
! (not)boolbool

Binary Operaters

Binary operators can create expressions that take two other expressions as input. An example of a binary operator is the add + operator:

main || {
    out((1 + 2)); // prints 3
}

Currently, all binary operator expressions must be wrapped in parentheses to denote the order of evaluation. Operator precedence will be added in the future, allowing for parentheses to be omitted to utilize a more typical default order of operations.

All the currently existing binary operators and the types they evaluate to:

Left TypeOperatorRight Type= Evaluated Type
uint+ (add)uintuint
int+ (add)intint
num+ (add)numnum
int- (subtract)intint
num- (subtract)numnum
uint* (multiply)uintuint
int* (multiply)intint
num* (multiply)numnum
num/ (divide)numnum
num% (mod)numnum
any== (equal to)anybool
any!= (not equal to)anybool
any> (greater than)anybool
any< (less than)anybool
any>= (greater than or equal to)anybool
any<= (less than or equal to)anybool
bool&& (and)boolbool
bool|| (or)boolbool
val~ (join)valval

Functions & References

Functions

You can declare functions to be able to reuse code throughout your program.

main || {
    // prints "Hello world!" 3 times
    hello();
    hello();
    hello();
}

func hello() || {
    out("Hello world!");
}

You can also declare arguments for functions that must be passed to the function to call it:

main || {
    // prints "Hello Steplo!"
    hello("Steplo");
}

func hello(name: val) || {
    out((("Hello " ~ name) ~ "!"));
}

References

You can also pass references into functions in order to mutate values on other procedures' stacks. References are created with the reference & operator. You can then assign to the memory address pointed to by a reference with the dereference * operator. This allows you to create functions that return values:

main |sum: int| {
    add(&sum, 1, 2);
    out(sum); // prints 3
}

func add(return: &int, a: int, b: int) || {
    *return = (a + b);
}

Functions can declare their own stack variables, too. (Remember though, these variables are only valid for the duration of the function's runtime, so don't return references to a function's stack variables to its caller!)

main |sum: int| {
    add(&sum, 1, 2);
    out(sum); // prints 3
}

func add(return: &int, a: int, b: int) |sum: int| {
    sum = (a + b);
    *return = sum;
}

Built-In Functions

The following functions are automatically added to and available in every Steplo program:

func out(value: any)

Prints the given value to stdout.

func in(return: &val)

Prompts the user for text input, and pauses the program's execution until it's received.

random_num(return: &num, min: num, max: num)

Returns a random num between min and max (inclusive). The returned number may always generate with decimals, even if both min and max are integers.

random_int(return: &int, min: int, max: int)

Returns a random int between min and max (inclusive).

random_uint(return: &uint, min: uint, max: uint)

Returns a random uint between min and max (inclusive).

stdout_clear()

Clears stdout.

stdout_read(return: &val, index: uint)

Reads the value of line number index (starting at 0) of stdout.

stdout_write(value: val, index: uint)

Overwrites the value of line number index (starting at 0) of stdout with value.

stdout_len(return: &uint)

Returns the number of lines currently printed to stdout.

wait_s(duration_s: num)

Pauses execution of the program for approximately duration_s seconds. The implementation of this function is subject to change in the future to allow for more accurate waiting durations.

timer_s(return: &num)

Returns the number of seconds since the start of the program.

Control Flow

If Statements

If statements will only execute their body as long as their condition is true. The condition must be a bool, and does not have to be in parentheses (though because a binary operator expression is used below, it looks like it does)

main |x: int| {
    x = 20;

    if (x > 10) {
        out("Greater than 10!"); // condition is met, so this is printed
    }

    if (x > 50) {
        out("Greater than 50!"); // condition is not met, so this is not printed
    }

    out("Done!"); // this always prints
}

Else Statements

main |x: int| {
    x = 5;

    if (x > 10) {
        out("Greater than 10!");
    } else {
        out("Less than (or equal to) 10!"); // condition is not met, so the `else` statement executes and prints
    }

    out("Done!"); // this always prints
}

Else If Statements

main |x: int| {
    x = 10;

    if (x > 10) {
        out("Greater than 10!");
    } else if (x == 10) {
        out("Equal to 10!"); // condition is met, so this `else if` statement executes and prints
    } else {
        out("Less than 10!");
    }

    out("Done!"); // this always prints
}

While Statements

While statements will loop and execute their body as long as their condition is true. Their condition is rechecked to be true after each loop. The condition must be a bool, and does not have to be in parentheses (though because a binary operator expression is used below, it looks like it does)

main |i: int| {
    i = 10;
    while (i > 0) {
        out(i);
        i = (i - 1);
    }

    // this program will output: `10 9 8 7 6 5 4 3 2 1`
}

Type Casting & Transmutation

Casting

Casting is the inverse of type coercion, and must be manually declared and guaranteed by the programmer. For example, if we coerced an int to a val like so:

main |foo: val, bar: int| {
    bar = 10;
    foo = bar; // automatically coerces `int` to `val`
}

We cannot automatically do the opposite to get our int back from the val variable:

main |foo: val, bar: int| {
    bar = 10;
    foo = bar;
    bar = foo; // error: `foo` may be a `val` that is not an `int` (e.g, a `num`, `bool`, or string)
}

Since in this case, we know bar is 10, we can tell the compiler we are sure the value of foo is in fact assignable to an int with a typecast. You can typecast with the cast <type> operator:

main |foo: val, bar: int| {
    bar = 10;
    foo = bar;
    bar = <int>foo; // no error!
}

This is dangerous though; In the future, code could be added that breaks this guarentee, which could lead to runtimes errors the compiler won't warn about:

main |foo: val, bar: int| {
    bar = 10;
    foo = bar;
    foo = "hello world"!;
    bar = <int>foo; // this isn't actually an int anymore, but the compiler gives no warning!
}

When is Casting Allowed?

You can't cast all types to all other types. You can only cast type A to type B if the following conditions are met:

  1. B is a subtype of A OR The in-memory representation of A is a subtype of B
  2. B contains no reference types

For example: Casting type val to int is ok, because:

  1. int is a subtype of val
  2. val is not a reference type

Another example: Casting type &int to val is ok, because:

  1. ✅ The in-memory representation of &int (which is a uint) is a subtype of val
  2. val is not a reference type

Now for an example of a cast that is not allowed: You cannot cast from a &any to a &int, because:

  1. &int is not a subtype of &any
  2. &int is a reference type

You also cannot cast from an any to a &int, because:

  1. &int is a subtype of any
  2. &int is a reference type

This last cast is disallowed as treating an arbitrary address as a &int may break the invariants of the type actually located at that memory address. Casts ensure that they only may possible break the resulting expression being casted, not affecting other values in the program. In order to tell the compiler you'd like to consider a reference to be a different type, you'd need to employ transmuting.

Transmuting

Any type can be transmuted to any other type of the same size. Unlike casting, no additional checks are done to ensure this conversion makes sense. You can transmute with the transmute <<type>> operator:

main |integer: int, int_ref: &int, float_ref: &num| {
    integer = 10;
    int_ref = &integer;
    float_ref = <<&num>>int_ref;
    *float_ref = 12.34;
    out(integer); // integer is now a float without ever directly assigning a float to it with a cast
}