Welcome to the Mindbreak tutorial! This document is up-to-date as of Mindbreak v0.1.0-alpha.
Mindbreak is a high-level programming language that transpiles to perhaps the most famous esolang ever, Brainfuck. Development began in 2020, inspired by the CFuck and byfon projects (which I also worked on). Somewhat unlike the previous two projects, Mindbreak is focused on a welcoming, high-level design that abstracts away a majority of Brainfuck's concepts.
The purpose of this tutorial is to introduce all of Mindbreak's core concepts in a simple, easy to follow manner through examples. It'll also act as the language's specification, at least until a reference implementation or formal specification is written. By the end, you'll hopefully be able to write your own Mindbreak programs with confidence.
This tutorial assumes prior experience with programming.
Let's start with everybody's favourite introductory example.
def main() {
println!("Hello, World!");
}
To try the examples from this tutorial yourself, run pip install mindbreak
, then you can use the mbc
transpiler:
$ mbc helloworld.mb
$ # run the program
$ tritium -b helloworld.b
Hello, World!
Any reasonably fast implementation that implements the simplest Brainfuck optimizations (including, importantly, move operations) should be able to run Mindbreak output fast enough, but Ρ‴/tritium is a good choice.
Mindbreak resembles Rust, but it is not Rust. Its semantics are more similar to C's in many aspects.
Similarities with Rust:
- Syntax
- Everything is an expression
- Type system (traits, ADTs)
- Macros
Differences to Rust:
- No lifetimes, borrowing, or ownership (memory management is fully manual)
- No references to objects on the stack
- Mutability by default (no
mut
keyword)
A "Hello, World!" program doesn't actually tell us much about how the language works, so let's take a leaf out of Rust's book (literally) and write a simple guessing game to get started learning. Our program will generate a random integer between 1 and 100, then prompt the player to guess a number. After each incorrect guess, the program will indicate whether the guess is lower or higher than the "target" number generated at the beginning, then prompt the player to guess again. Once the player gets the answer correct, we'll congratulate them and display a score (the number of guesses it took them to find the number).
Since our program involves generating random numbers, we'll need a library that can generate them. Luckily, Mindbreak comes with a rand
module, which we can import
.
We'll start off our program like so:
import rand;
def main() {
// TODO: implement the program
}
The import
keyword will bring the rand
module into scope as a namespace also named rand
.
The rand
module's API requires us to create a rand::Gen
object to create random objects.
There's a rand::Gen::from_seed
function, but because Brainfuck has no support for reading anything from the environment, like the time, that would help us seed the random generator,
we'll have to prompt the user for a seed. Luckily, rand
also has a helper macro for doing this, rand::Gen::from_seed_prompt
, so we can add this to the top of main
:
let gen = rand::Gen::from_seed_prompt!("Enter any string as a seed: ");
The let
keyword declares and optionally initializes a variable. The !
denotes that from_seed_prompt
is a macro being called, and not a function.
Mindbreak is a strictly typed language, so gen
now has the inferred type rand::Gen
, which is the return type of from_seed_prompt
.
Now we can generate the target number with the rand::Gen::range
method:
let target: ui8 = gen.range(1..=100);
1..=100
represents an inclusive range between 1 and 100.
Note the ui8
after target
: this is a type annotation. It says that target
is a ui8
: an unsigned 8-bit integer type with undefined underflow/overflow behaviour.
Since this is a number from 1 to 100, this is all we need, but Mindbreak has an "infinite" number of integer types that are generated dynamically,
so you can use whichever ones you want! They always end with an i
followed by a non-negative integer representing the number of bits,
but you can also prefix them with a u
to make them unsigned or a w
to ensure they wrap on underflow and underflow.
You can also use both with uw
or wu
, so all of these types are valid:
i32
, the default integer typeuwi8
-- could be useful for a Brainfuck interpreter?i7
-- doesn't have to be a power of 2!wi30
. Yes, you're very clever.i0
-- the only value this can store is0
!
Now that we have code to generate our target number, we may as well test it by outputting it before moving on. We'll need to output numbers later anyway to display the player's score, so this is a good time to learn how to do it!
The println
macro from before is based on Rust, but its syntax should look familiar to most programmers. We can use {}
to denote a "placeholder", then pass the
number as an additional argument:
println!("Target number: {}", target);
Our code now looks like this, which is a complete program we can transpile and run:
import rand;
def main() {
let gen = rand::Gen::from_seed_prompt!("Enter any string as a seed: ");
let target: ui8 = gen.range(1..=100);
println!("Target number: {}", target);
}
Now we can go ahead and use it:
$ mbc guessing.mb
$ tritium -b guessing.b
Enter any string as a seed: mindbreak
Target number: 34
Awesome, looks like it works! Let's delete that printing line (lest the game be far too easy) and move on.
To receive the player's guess, the first thing we'll have to do is read a line of input. readln
can do that for us:
let guess_str = readln();
It takes input until a newline or EOF and returns a reference to the string on the heap. Simple enough. If you don't want to use the heap (dereferences can be slow in BF!)
you can use readln_into!
and a str!
(a string on the stack with a maximum size) instead, since we know the maximum size of our input:
let guess_str: str!(3);
let valid = readln_into!(guess_str);
Yes, that's a macro generating a type! Nifty, huh? Meanwhile, readln_into!
returns a boolean to report whether the line inputted fits inside the buffer or not.
If it's more than 3 characters, it's definitely invalid, so we'll store that to check later.
Now we need to parse the string as an integer. .parse()
can do this for us. It infers the type to parse to on its own, thanks to type inference.
let guess: ui8 = guess_str.parse();
Let's go ahead and validate the input. Don't forget to check valid
as well, if you're using readln_into!
.
if !(1..=100).contains(guess) {
println!("Invalid guess.");
continue;
}
I hear you asking, "Hold on... continue
? But we're not in a loop!", and you're right. Let's go ahead and retcon that, and keep track of the guesses as well:
let guesses = 0;
loop {
print!("Enter a guess: ");
let guess_str = readln();
let guess: ui8 = guess_str.parse();
if !(1..=100).contains(guess) {
println!("Invalid guess.");
continue;
}
}
The rest of the logic should be obvious enough by now:
guesses += 1;
if guess < target {
println!("Lower.");
} else if guess > target {
println!("Higher.");
} else {
println!("Wow, you got it right in {} guesses!", guesses);
break;
}
Cool! Now that our program is ready, let's transpile it!
$ mbc guessing.mb
error: mismatched types
guessing.mb:10:26:
|
10 | let guess: ui8 = guess_str.parse();
| ^^^^^^^^^^^^^^^^^ expected ui8, got _ ? _
|
Uh-oh. It turns out parse
doesn't actually return a result directly, but rather an instance of an enum: in this case, its type is ui8 ? IntParseError
.
This is called a result type. It represents either a result or an error. If everything went well, parse
will return an instance of the first type wrapped in
a variant called Ok
. However, if there's an error with parsing (here, it would happen if the string is not an integer), an object representing what went wrong is
returned in Err
. This is the basis of error semantics in Mindbreak, and it saved us here. I forgot to check whether the user's input is invalid: without this compiler
error, that could have been a nasty bug.
In this case, the success value is ui8
, the parsed value as request, and the error type is IntParseError
. IntParseError
is an enum representing possible
failures with parsing integers, but we don't really care about what's wrong with the input, as our message to the user will be same either way.
All we need to do is check whether the value we got is valid and unpack it if it is. Preferably, we could do this all at once without intermediate variables,
but without duplicating the "invalid guess" code: that is, we want the check for validity to be combined with the check that the guess is in range.
This almost seems like a tricky problem, but luckily Mindbreak's unsafety saves us:
let (valid_int, guess): (bool, ui8) = guess_str.parse().separate();
if !valid_int || !(1..=100).contains(guess) {
// ...
}
separate
destructures an option or result into a boolean (representing validity) and a value (representing the result).
"But wait", I hear you ask, "What happens if you try to access guess
if valid
is false?"
Undefined behaviour.
Now that our program is finished (and doesn't have any bugs in it), we can finally go ahead and actually run it:
$ mbc guessing.mb
$ tritium -b guessing.b
Enter any string as a seed: mindbreak
Enter a guess: 34
Wow, you got it right in 1 guesses!
$ # > 1 guesses
$ # whoops
$ tritium -b guessing.b
Enter any string as a seed: kaerbdnim
Enter a guess: 50
Lower.
Enter a guess: 25
Higher.
Enter a guess: 33
Higher.
Enter a guess: 41
Lower.
Enter a guess: 36
Higher.
Enter a guess: 38
Higher.
Enter a guess: 39
Wow, you got it right in 7 guesses!
Cool! Now we can move on to cooler things!