Building crystal-robots
A robot battle game, a compiler to WebAssembly, and the robots that fight in your browser, built one commit at a time.
Open the play page and a battle is already running: four robots, each a few dozen lines of code, scanning, driving and firing at each other on a thousand-meter square. Click one and you can peel it apart: its source, the steps the compiler took to understand it, and the WebAssembly it became. Or go to the challenge and write your own, one level at a time, until it is good enough to enter a tournament.
This post is the story of how that was built, one commit at a time, and how you can rebuild it yourself.
Where this comes from
In 1985 Tom Poindexter released CROBOTS, “a programming game, for programmers (or aspiring programmers.)”. You wrote a robot in a small subset of C; CROBOTS compiled it for its own little virtual machine and set it loose on a battlefield against up to three others. The robots had a scanner, a cannon and a drive, and everything else was up to you. Its rules, its physics and even its arithmetic quirks are the rules here.
In 2019 Colin Eberhardt wrote a blog post and a small language called chasm: a compiler to WebAssembly built from nothing, one step at a time, each step linked to a commit you could check out. That post is the model for this one. crystal-robots's WebAssembly encoder started as a port of chasm's.
And Crystal, a language that reads like Ruby but compiles to fast native code, is what it is written in. A Ruby-like robot language implemented in a Ruby-like systems language turns out to be a good fit: the robot code a beginner writes and the compiler code that reads it look like relatives.
Following along
A terminal is a window where you type a command and press Enter to run it. The lines below are commands: type one, press Enter, then go to the next.
Every section below names a commit. Clone the repository, check out that commit, and run the commands under “Try it”. You'll need Crystal 1.18; some steps also use wasmer (installed for you by the CI command from Phase U onward), Node, or the WebAssembly linker from LLVM, and each step says which.
git clone https://github.com/jadonk/crystal-robots.git
cd crystal-robots
git checkout <commit>
crystal spec
git clone copies the project from GitHub into a new folder.
cd moves into that folder so the next commands use its files.
git checkout opens a saved step of the project; replace <commit> with the short code shown beside a step below.
crystal spec runs the tests in spec/. A good run ends with 0 failures, 0 errors.
Every commit on this line builds and passes its tests; that was the rule for each one, and the output under “What you'll see” is real: copied from the run recorded in that commit's message, or, for the few commits that recorded none, produced by running the commands for this post. It is lightly trimmed (“...” marks a cut) and never edited. Commit hashes link to GitHub.
Reading Crystal
Crystal is the language used to build this project. These small shapes are enough to follow the changes below. A spec is a test: a small program that checks an expected result.
Bring in a file: require
require "./compiler/parser"Use code written in another file.
Name an action: def … end
def double(n)
n + n
endGive a group of steps a name so it can be used again.
Group code: class and module
module Compiler
class Parser
end
endA module groups related names; a class describes an object that can hold data and actions.
Keep a value: @name
@program = programThe @ keeps a value with one object for later use.
Say what kind of value: : Int32
def add(n : Int32) : Int32Int32 means a whole number stored in 32 bits; the marks say what kind comes in and goes out.
Repeat steps for each item: do |x| … end
numbers.each do |x|
puts x
endRun the indented steps once for each item, calling the current item x.
Put a value in text: #{x}
puts "robot #{name}"Crystal replaces #{name} with the current value of name.
Use a label: :name
mode = :traceA symbol is a short, fixed label used to choose a case or name a rule.
Check a result: describe, it, should
describe "addition" do
it "adds" do
(2 + 3).should eq(5)
end
endThese group a test, name what it checks, and say what answer is expected.
Write bytes: Bytes[…]
Bytes[0x00, 0x61]Bytes are small numbers written directly into a file; 0x marks a number written in base 16.
Pass a turn: Channel
ready.send(nil)
ready.receiveOne task sends a signal and another waits to receive it before continuing.
Reading JavaScript
The browser pages use JavaScript to connect buttons, robot code and WebAssembly. These are the shapes used in the changes below.
import { run } from "./wasi-shim.js";
export const ready = async (source) => {
const button = document.querySelector("#run");
const result = await run(source);
button.textContent = result.status;
};import brings in a named action; export lets another file use one. const gives a value a name, and => names the input to a short function. async allows await to wait for work such as loading a module. document.querySelector finds an element on the page; textContent changes the words it shows.
Words you’ll see
- Compiler
- A program that turns a robot’s code into instructions another machine can run.
- Parser
- The part of the compiler that groups the robot’s words and symbols into meaningful pieces.
- Emitter
- The part of the compiler that writes runnable instructions from those pieces.
- WebAssembly
- A portable format for instructions that a browser or another runner can execute.
- Instruction
- One small action, such as adding numbers or calling a function, that a runner understands.
- The stack
- A pile of working values where instructions take numbers from the top and put answers back.
- Host
- The code outside a robot program that provides arena actions such as scanning and driving.
- Interpreter
- A program that follows a robot’s steps directly instead of writing a WebAssembly file first.
- Test/spec
- A small program that checks whether another part of the project gives an expected answer.
- Commit
- A saved step in the project’s history that you can open and try.
- Cycle
- One measured unit of work a robot spends while it takes its turn.
- Battlefield physics
- The rules for how robots speed up, turn, move and collide.
- Seed
- A starting number for random choices; using it again repeats the same match setup.
- Channel
- A one-place mailbox that lets one running task wait for another.
- Tournament pool
- A small group in which each robot fights every other robot.
- Bracket
- Rounds where winners advance and other robots leave the tournament.
- CI
- Continuous integration: one repeatable command that builds and checks a project.
- Browser
- The program that opens web pages and runs their JavaScript, such as Chrome or Firefox.
- Web page
- A document a browser opens; its buttons and code can let you write and run robots.
- WASI
- A small set of operating-system services a WebAssembly program can ask its host to provide.
- Worker
- JavaScript that runs separately so a long battle does not freeze the page.
- GitHub Pages
- A service that publishes the project’s web pages from files on GitHub.
- Workflow
- A list of build and publishing steps GitHub runs after a chosen event.
- Frame
- One recorded picture of a battle at a particular cycle.
- Resumable
- A program that can stop after one turn, remember its place and continue on the next.
Chapter 1 · Phase A: a compiler to WebAssembly
Phase A follows chasm's own path, step by step: an empty module, a hand-written function, then a real compiler that grows one language feature per commit, plus the two things a robot language needs that chasm does not: host builtins for sensing and acting, and a checker that explains mistakes. By the end, a robot program can be traced, checked, interpreted and compiled from the command line, and two independent engines agree on what it does.
Initial commit
The repository starts the way most do: a README and an intention. It names the three things this whole project leans on, CROBOTS, the Crystal language and chasm, and says what it is for: “a great way to learn about programming with a simple Ruby-like syntax. Write programs to battle it out with other programs on a virtual battlefield. Then, learn about how programming languages turn your code into instructions a machine can understand.” Everything below is an attempt to keep that second promise as carefully as the first.
Selected change: See the whole change on GitHub.
Project skeleton
A Crystal project is a shard: a shard.yml that names it, a src/ directory, a spec/ directory for tests, and a bin/ that shards build fills. This commit is that and almost nothing else. The program it builds knows exactly one thing, its own version.
It is a squash of an earlier, messier start. The goal from here on is that every commit is small enough to read in one sitting, builds, and passes its own tests, so the skeleton is the one place where history was folded down to get a clean starting line.
Selected change: See the whole change on GitHub.
Try it
git checkout 866c92a
shards build
bin/crystal-robots --version
shards build creates the command in bin/; --version asks it for its version. Look for crystal-robots 0.0.1 after the build lines.
What you'll see
Dependencies are satisfied
Building: crystal-robots
crystal-robots 0.0.1
Phase A.2: emit the minimal WASM module
Colin Eberhardt's chasm post starts where every WebAssembly compiler has to start: with the smallest file a WebAssembly runtime will accept. A WebAssembly module is the unit a browser or runtime loads, the way a .exe or a shared library is on a desktop. The smallest valid one is eight bytes: the four-byte “magic number” \0asm that says what kind of file this is, then a four-byte version number, 1.
No functions, no imports, nothing to run. But it is real, and a spec checks the exact bytes, which is the habit this whole history keeps: say what the output must be, then make it so.
Selected change: See the whole change on GitHub.
Try it
git checkout 9206c10
crystal spec
shards build
bin/crystal-robots
Run the tests, then build and run the still-empty command. Look for the eight-byte module test to pass and for the command to say it has nothing to do yet.
What you'll see
..
Finished in 1.06 seconds
2 examples, 0 failures, 0 errors, 0 pending
Dependencies are satisfied
Building: crystal-robots
crystal-robots 0.0.1: nothing to do yet
Phase A.3: a hand-assembled add function
Before writing a compiler, it helps to write by hand what the compiler will eventually write for you. This commit assembles a module containing one function, add, which takes two 32-bit integers and returns their sum. A module is a list of sections: a type section describes the function's signature (i32, i32) -> i32, a function section declares that function 0 has that type, an export section gives it the name add so the outside world can call it, and a code section holds its body.
That body is four instructions, and it shows the most important idea in WebAssembly: it is a stack machine. local.get 0 pushes the first argument onto a stack, local.get 1 pushes the second, i32.add pops both and pushes their sum, and whatever is left on the stack at end is the return value. Every program this compiler ever produces will be built from that same push-and-pop pattern.
To actually run the module, the specs use wasmer, a WebAssembly runtime with a Crystal binding (pinned at 4.4.0). Not every machine has wasmer's library installed, so the wasmer specs are opt-in: crystal spec -Dwasmer runs them, and plain crystal spec reports them as pending rather than silently skipping them. That pattern repeats for every optional tool from here on.
Selected change: See the whole change on GitHub.
Try it
git checkout 27a3ab6
crystal spec
crystal spec -Dwasmer
-Dwasmer turns on tests that run the WebAssembly output in Wasmer. Look for the new add-function test to pass; the run should end with no failures.
What you'll see
..*
Pending:
CrystalRobots::Compiler::WASM_Emitter runs a hand-assembled add function (run with -Dwasmer)
Finished in 1.04 seconds
3 examples, 0 failures, 0 errors, 1 pending
...
Finished in 1.08 seconds
3 examples, 0 failures, 0 errors, 0 pending
Phase A.4: the multipass tokenizer and a real compiler for puts NUMBER
This is where crystal-robots stops following chasm closely. chasm has a classic compiler front end: a tokenizer that chops source text into words (“tokens”), then a parser that assembles those tokens into a tree. crystal-robots uses a different idea, worked out in docs/PARSER.md: a multipass tokenizer.
Pass 0 turns the source into a string of single-character symbols, called glyphs here: a number becomes №, the word puts becomes ∊, a line break becomes ⏎. Every later pass looks for the first rule in a list (GRAMMAR) that matches somewhere in that string and replaces every match with a single new glyph that stands for the bigger piece. The program is understood when the whole string has collapsed to one glyph, ⏹, the program. Three rules are enough for a program made of puts 42 lines:
The appeal is that you can watch it work. -t prints the string after every pass, so the whole parse is a short list of lines you can read top to bottom. (The maintainer's own note in this commit is worth repeating: the lexing pass and the grammar passes ended up in two different loops, and a single loop would be easier for a new programmer to hold in their head. That tension, simple to explain versus simple to implement, comes back throughout.)
The emitter, the part that writes WebAssembly, now walks the result instead of hand-assembling bytes. For each puts it emits three instructions: push the number, call an import named env.puts (a function the host provides, in this case the spec), and drop the result. puts returns its argument instead of nothing, partly to dodge a crash in the wasmer binding on imports with no result, and partly because later, when robots call builtins like scan for their value, every builtin will return something anyway.
Selected change: See the whole change on GitHub.
Try it
git checkout 0b60f75
crystal spec -Dwasmer
shards build
bin/crystal-robots -t examples/hello.cr
-t asks the command to trace how it parses examples/hello.cr. Look for the puts line becoming a command, then a statement, then a complete program.
What you'll see
.........
Finished in 2.33 seconds
9 examples, 0 failures, 0 errors, 0 pending
Dependencies are satisfied
Building: crystal-robots
0 lex ∊№⏎
1 command1 😑⏎
2 exprstmt ❢
3 program ⏹
Phase A.5: binary expressions
Arithmetic: + - * / // %, parentheses and unary minus. The interesting part is precedence, the rule that 2 + 3 * 4 is 14, not 20. In a pass-based parser, precedence falls out of rule order and a few guards: the multiply rule only fires when it cannot steal an operand from a tighter operator, add only fires after multiply is done, and parentheses reduce before either. Both are left associative, so 10 - 3 - 2 means (10 - 3) - 2.
One shortcut is taken on purpose and written down: for now / and // both truncate toward zero, which is what WebAssembly's i32.div_s does. CROBOTS's own rules (division that rounds down, and dividing by zero gives zero rather than crashing) wait until a robot can actually divide by something that might be zero.
Selected change: See the whole change on GitHub.
Try it
git checkout 613e895
crystal spec -Dwasmer
Look for the arithmetic tests to pass, including a calculation where multiplication happens before addition.
What you'll see
...............
Finished in 2.37 seconds
15 examples, 0 failures, 0 errors, 0 pending
Phase A.6: global variables and assignment
Robots need memory between turns, so the language gets variables. A CROBOTS-style robot declares its long-lived state up front with global(name, initial value); plain assignment with = changes it. In WebAssembly, each one becomes a global, a named slot the module keeps for as long as it runs. Assignment compiles to global.set followed by global.get, so the assigned value is also left on the stack, which is what lets a = b = 3 work later.
The grammar rule for assignment only fires once the right-hand side is a complete expression followed by a line break. That kind of lookahead, “only match if what follows is X”, is how the pass-based parser avoids grabbing half an expression too early.
Selected change: See the whole change on GitHub.
Try it
git checkout 2ae5e05
crystal spec -v -Dwasmer
-v prints each test’s name, so you can see which behavior passed. Look for the new global-variable and assignment cases, then 0 failures.
What you'll see
...
declares a global and reads it back
assigns to a global and keeps mutating it
Finished in 2.27 seconds
19 examples, 0 failures, 0 errors, 0 pending
Phase A.7: comparisons and while/until loops
Comparisons (== != < > <= >=) and the first loops: while, until and break. WebAssembly has no while instruction. It has structured blocks and branches: block marks a place you can jump out of, loop marks a place you can jump back to, and br_if jumps when the top of the stack is non-zero. So a while loop becomes:
Branch targets are counted outward from where you are, which is why the emitter tracks a “depth” and a stack of loops: break has to know how many blocks out its loop's exit is. The number itself is written in LEB128, the variable-length encoding WebAssembly uses for every integer in the file: small numbers take one byte, bigger ones take more.
Selected change: See the whole change on GitHub.
Try it
git checkout 2033e63
crystal spec -v -Dwasmer
Look for named tests about comparisons and while or until loops. The final count should still show no failures.
What you'll see
...
counts up in a while loop
counts down in an until loop
break stops a while loop before its condition would
Finished in 2.31 seconds
25 examples, 0 failures, 0 errors, 0 pending
Phase A.8: if / elsif / else
Branching. In the glyph grammar, an if block only matches once every branch's body has already collapsed to statements, the same “headers first, bodies next, whole blocks last” shape the loops use. The emitter turns a chain of if/elsif/else into nested WebAssembly if/else blocks. The spec that proves it is a fizzbuzz-style counter mixing a loop and every kind of branch.
Selected change: See the whole change on GitHub.
Try it
git checkout 6a15aea
crystal spec -v -Dwasmer
Look for tests in which a robot takes the right if, elsif, or else path.
What you'll see
...
takes the if branch
takes the else branch
takes the matching elsif branch
counts fizzbuzz-style with if/elsif/else inside a while loop
Finished in 2.46 seconds
30 examples, 0 failures, 0 errors, 0 pending
Phase A.9: the CROBOTS builtins as host imports
A robot's whole job is calling a small set of built-in functions: scan to look for enemies, cannon to shoot, drive to move, damage, speed, loc_x and loc_y to sense itself, plus math helpers. None of those can be written in the robot language itself. They belong to whatever is running the robot: a test, an interpreter, eventually a battlefield.
So each builtin becomes a WebAssembly import: the module says “I need a function called env.scan that takes two numbers and returns one”, and the host supplies it. Every builtin is typed by how many numbers it takes (zero, one or two) and returns one. A module only imports the builtins its program actually calls, in a fixed order, so two programs that use the same builtins get identical import sections. There is no battlefield yet, so the spec's stand-in host returns fixed made-up numbers for the sensors and just adds up the arguments of the actions.
Selected change: See the whole change on GitHub.
Try it
git checkout abf7f9b
crystal spec -v -Dwasmer
Look for tests that call robot actions such as scan and drive through the host.
What you'll see
...
calls a zero-argument builtin
calls a two-argument builtin with both arguments in order
calls the math one-argument builtins
only imports the builtins the program actually calls
Finished in 2.45 seconds
35 examples, 0 failures, 0 errors, 0 pending
Phase A.10: user-defined functions
def, calls with arguments, and return. Each def compiles to its own WebAssembly function; parameters become locals, and a name assigned inside a function that is not a global becomes another local.
Writing the recursion test turned up a real ambiguity in the glyph grammar. Inside n * fact(n - 1), the multiply rule saw fact as an ordinary value and grabbed it before the call rule ever got to see the whole fact(n - 1). The fix is one line in the definition of “a value”: a name immediately followed by an opening parenthesis is never a value, because it is always a call waiting to be reduced.
№ is a number, 😑 an already-reduced expression, 𝑥 a name and ⟮ an opening parenthesis; (?!⟮) means “not followed by an opening parenthesis”. The spec now computes fact(5) == 120.
Selected change: See the whole change on GitHub.
Try it
git checkout 7fbce2e
crystal spec -v -Dwasmer
Look for function-call tests, including one where a function calls itself.
What you'll see
...
returns explicitly, short-circuiting the rest of the function
keeps a function's locals separate from a global of the same shape
recurses
Finished in 2.42 seconds
41 examples, 0 failures, 0 errors, 0 pending
Phase A.11: main("Name") do ... end, the entry point
Every robot has a name and a starting point: main("Sniper") do ... end. This adds the one string the language allows (the robot's name) and the rule that the main block runs last, after every global declaration and other top-level statement, wherever it appears in the file. The spec checks the ordering directly: a puts before main, one inside it and one after it print in the order before, after, main.
Selected change: See the whole change on GitHub.
Try it
git checkout 2631ebd
crystal spec -v -Dwasmer
Look for the test showing that main runs after other top-level steps.
What you'll see
...
runs the main block after every other top-level statement
Finished in 2.46 seconds
43 examples, 0 failures, 0 errors, 0 pending
Phase A.12: the checker
Some programs are grammatically fine but mean nothing: using a variable that was never set, calling scan with three numbers, break outside a loop, two functions with the same name. Catching those is the job of a checker, which runs after parsing and before compiling. It collects every problem it finds, each with a line and column, instead of stopping at the first, and it reads all the function names first so a robot can call a function defined further down the file.
For beginners this is the most important part of the compiler, because it is the part that talks to them. Every later surface, from the command line to the browser editor's live underlining, shows these same messages.
Selected change: See the whole change on GitHub.
Try it
git checkout 4f5d440
crystal spec -v -Dwasmer
Look for checker tests that name an undefined variable, a bad call, and a misplaced break. They should all pass.
What you'll see
CrystalRobots::Compiler::Checker
finds nothing wrong with a clean program
flags an undefined variable
flags an undefined function
flags a call with the wrong number of arguments
flags return outside of a function
flags break outside of a loop
flags a duplicate def
...
reports every issue, not just the first
...
Finished in 2.42 seconds
54 examples, 0 failures, 0 errors, 0 pending
Phase A.13: the reference interpreter
An interpreter runs a program directly from its tree, one node at a time, instead of turning it into machine code first. crystal-robots gets one here for a very practical reason: having two independent ways to run the same program lets each check the other.
Every builtin goes through a Host, an interface that something else implements. NullHost is a stand-in with the same made-up behavior the WebAssembly specs' host already has. break and return are implemented with Crystal exceptions inside the interpreter, which is simple and works well natively. (Remember that choice; it matters again in the browser.)
The payoff is spec/differential_spec.cr: three programs covering arithmetic, globals, every loop and branch, functions, recursion and builtins run through both the interpreter and the compiled WebAssembly, and must print exactly the same thing. Two separately written readings of the same tree agreeing is a stronger signal than either one's own tests.
Selected change: See the whole change on GitHub.
Try it
git checkout f0e79e1
crystal spec -v -Dwasmer
Look for “agree on” tests: the interpreter and compiled runner should print the same answers for each robot program.
What you'll see
the interpreter and the WASM emitter
agree on arithmetic and comparisons
agree on globals, while, until, if/elsif/else, break
agree on functions, recursion, builtins and main
...
Finished in 2.36 seconds
62 examples, 0 failures, 0 errors, 0 pending
Phase A.14: CLI wiring and a disassembler debugging aid
Phase A closes by giving people a real tool. bin/crystal-robots grows options: -t traces the parse, -k runs the checker alone, -c -o FILE writes a WebAssembly file, -i runs a program in the interpreter, and -d prints a readable dump of the compiled instructions. A small disassembler (a program that turns machine code back into readable instructions) decodes exactly the shapes this compiler emits, so you can inspect its output without installing other WebAssembly tools. Compiling and interpreting both run the checker first and refuse a program that has problems.
Selected change: See the whole change on GitHub.
Try it
git checkout 2b0d9c4
shards build
bin/crystal-robots --help
--help lists the jobs the command can do. Look for -t to trace, -k to check, -c to compile, and -i to run directly.
What you'll see
Usage: crystal-robots [options] FILE
-v, --version Print the version
-t, --trace Print the parser's derivation, one line per pass
-k, --check Run the checker and print any issues found
-c, --compile Compile to a WebAssembly module (needs -o)
-d, --disassemble Compile and print a readable instruction dump
-i, --interpret Run in the reference interpreter
-o FILE, --output=FILE Where -c writes the WebAssembly module
-h, --help Show this help
docs: mark Phase A done in the tutorial plan
Bookkeeping: the plan notes that Phase A is complete.
Selected change: See the whole change on GitHub.
Chapter 2 · Phase B: the language real robots use
chasm stops at a language that can draw a fractal. A robot language has a harder customer: existing robots, written by people, in the style people actually write. Phase B is a list of small grammar and checker changes, each one forced by something in the five example robots, until all of them parse.
Phase B.1: comments and true/false literals
Phase A built a language. Phase B makes it the language real robots are written in. The target is the five example robots that come with the project (counter, rabbit, rook, sniper and target), and each commit closes one gap between what they use and what the parser accepts.
First, the small things: # starts a comment that runs to the end of the line, and true and false are words for 1 and 0, so while true means what it looks like.
(Phase B's commit messages don't include transcripts; the output shown in this chapter was produced for this post by running the commands at each commit.)
Selected change: See the whole change on GitHub.
Try it
git checkout 5729f6b
crystal spec
shards build
printf '# a comment\nputs true # trailing\nputs false\n' > b1.cr
bin/crystal-robots -i b1.cr
printf writes a tiny robot into b1.cr; > puts that text in the file. Run it with -i and look for 1, then 0.
What you'll see
69 examples, 0 failures, 0 errors, 29 pending
1
0
Phase B.2: parenthesized builtin calls
Every example robot writes scan(angle, res) and drive(angle, 50) with parentheses, so those forms now parse alongside the bare scan angle, res style. The interpreter, the checker and the emitter share the logic for both shapes.
This brings back a known, deliberately accepted quirk. Once spaces are gone, puts (1) + 2 looks exactly like puts(1) + 2, so the parser calls puts with 1 and the + 2 is applied to what puts returns. It prints 1, where Ruby or Crystal would print 3. Crystal itself warns about writing it this way, docs/PARSER.md lists it, and a spec pins the behavior so nobody “fixes” it by accident.
Selected change: See the whole change on GitHub.
Try it
git checkout 1c95a0f
crystal spec
shards build
printf 'puts (1) + 2\nputs sqrt(16)\n' > b2.cr
bin/crystal-robots -i b2.cr
The new program calls sqrt(16) with parentheses. Look for 1 from the accepted puts (1) + 2 quirk, then 4 from the square root.
What you'll see
71 examples, 0 failures, 0 errors, 31 pending
1
4
Phase B.3: implicit global constants and bare zero-argument calls
Two gaps found by reading real robots. sniper.cr and rook.cr declare constants with a plain top-level assignment (C1X = 10) rather than global(...), and several robots call their own zero-argument functions without parentheses (new_corner, run, change). Both now work: a top-level assignment declares a global, and a bare name that isn't a variable but is a known function with no parameters is a call to it.
Selected change: See the whole change on GitHub.
Try it
git checkout e52306b
crystal spec
Look for tests that accept a top-level constant and a bare call to a function with no arguments.
What you'll see
77 examples, 0 failures, 0 errors, 33 pending
Phase B.4: compound assignment
+= -= *= %=. The one subtle part is in the first pass: the scanner has to try the two-character operator before its one-character prefix, or += would be read as + followed by a stray =.
Selected change: See the whole change on GitHub.
Try it
git checkout a4d9013
crystal spec
shards build
printf 'global(x, 10)\nx += 5\nx -= 3\nx *= 4\nx %%= 7\nputs x\n' > b4.cr
bin/crystal-robots -i b4.cr
%% makes printf write one percent sign into the robot file. Look for the final value after all four updates.
What you'll see
80 examples, 0 failures, 0 errors, 34 pending
6
Phase B.5: logical and/or and bitwise xor
&&, || and ^. Following CROBOTS, both sides of && and || are always evaluated (no short-cut when the left side already decides the answer), and the result is 0 or 1. ^ is bitwise exclusive-or, applied to the raw numbers: 6 ^ 3 is 5.
This commit also records a real problem in the test setup. After roughly a hundred wasmer-backed examples, the test process would occasionally crash in the wasmer binding's interaction with Crystal's garbage collector, regardless of which test was running. The workaround (turning the collector off for the wasmer run) is documented in the spec helper, so nobody mistakes it for a grammar bug later.
Selected change: See the whole change on GitHub.
Try it
git checkout 610e3b5
crystal spec
shards build
printf 'puts 3 && 0\nputs 3 || 0\nputs 6 ^ 3\n' > b5.cr
bin/crystal-robots -i b5.cr
Look for 0 and 1 from the two logical tests, then 5 from 6 ^ 3.
What you'll see
83 examples, 0 failures, 0 errors, 35 pending
0
1
5
Phase B.6: assignment inside a condition, bare calls as call arguments
counter.cr scans in a loop like this: while ((range = scan(angle, res)) > 0). That needs an assignment to be an expression that can sit inside parentheses, so the lookahead for assignment and bare calls now accepts a closing parenthesis or a comma as well as a line break.
Writing the spec also caught a checker bug: a name first assigned inside a condition was reported as undefined, even though both engines handled it fine. The checker now adds a name to scope the moment it sees it assigned.
Selected change: See the whole change on GitHub.
Try it
git checkout 3cd6dd9
crystal spec
Look for tests that assign a scan result inside a condition and pass a bare call as an argument.
What you'll see
88 examples, 0 failures, 0 errors, 37 pending
Phase B.7: case / when / else
The last statement shape: case/when/else, which sniper.cr uses to pick a corner. The subject is evaluated once into a temporary, and each when compiles to an equality test inside the same nested if chain that if/elsif already uses.
Selected change: See the whole change on GitHub.
Try it
git checkout 1f6dd1e
crystal spec
shards build
printf 'x = 2\ncase x\nwhen 1\n puts 10\nwhen 2\n puts 20\nelse\n puts 30\nend\n' > b7.cr
bin/crystal-robots -i b7.cr
The new robot chooses the when 2 branch. Look for 20.
What you'll see
92 examples, 0 failures, 0 errors, 38 pending
20
Phase B.8: the six example robots parse -- grammar parity reached
The payoff for Phase B: counter, rabbit, rook, sniper and target come back word for word, and every one parses, passes the checker and compiles to WebAssembly on the first try. Each earlier commit in this phase existed because one of these robots needed it.
None of them is run yet. They all loop forever (while true), because a robot is meant to share a battlefield with others under a cycle limit, not run to completion on its own. That battlefield is the next chapter. The tail of sniper.cr's 40-pass parse shows the whole robot collapsing to one program glyph.
Selected change: See the whole change on GitHub.
Try it
git checkout 3f8858c
crystal spec
shards build
for r in counter rabbit rook sniper target; do printf "%s: " $r; bin/crystal-robots -k examples/$r.cr; done
bin/crystal-robots -t examples/sniper.cr | tail -4
The for command repeats the checker for each named robot; tail -4 shows the last four trace lines. Look for clean checks and the final program glyph.
What you'll see
99 examples, 0 failures, 0 errors, 38 pending
counter: no issues found
rabbit: no issues found
rook: no issues found
sniper: no issues found
target: no issues found
37 case ❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢🅼❢❢❢❢❢❢❢🔙⏎🅳❢❢❢❢❢❢❢❢❢🔙⏎🅳❢❢❢🔙⏎🅳❢❢❢❢❢❢❢🔙⏎
38 def ❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢🅼❢❢❢❢❢❢❢🔙⏎❢❢❢
39 main ❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢❢
40 program ⏹
Chapter 3 · Phase C: the battlefield
A compiler with nothing to drive is a curiosity. Phase C builds the CROBOTS arena: cycle costs that make turns fair, motion, missiles and the scanner ported from the original C source, and a scheduler that lets several robots take turns. It ends with real matches from the command line, and with two honest bug stories.
Phase C.1: cycle charging
In CROBOTS, robots don't run as fast as the computer allows. They take turns, and each turn is a budget of virtual cycles: reading a value costs one, an operator one, a builtin call two, a call to your own function three. That is what makes a clever robot different from a busy one, and it is what makes battles fair.
Interpreter::Costs maps CROBOTS's per-instruction costs onto this interpreter's tree nodes. The WebAssembly side has to agree, or a compiled robot would get more or fewer turns than an interpreted one, so the emitter can now import env.tick and call it with the number of cycles to charge at exactly the places the interpreter charges them. The differential specs gain a check that both engines charge identical totals.
Selected change: See the whole change on GitHub.
Try it
git checkout d4d74d5
crystal spec -Dwasmer
crystal eval 'require "./src/compiler/parser"; require "./src/compiler/interpreter"; include CrystalRobots::Compiler; src = File.read("examples/functions.cr"); host = NullHost.new; cycles = Interpreter.execute(Parser.new(src).program, host); puts "printed #{host.puts_out}, charged #{cycles} cycles"; puts "statements only: #{Interpreter.execute(Parser.new(src).program, NullHost.new, Interpreter::Costs.statements)}"'
crystal eval runs the Crystal code in quotes without making a file. Look for the total cycles charged, then the smaller statements-only total.
What you'll see
105 examples, 0 failures, 0 errors, 0 pending
printed [5, 30, 35], charged 46 cycles
statements only: 10
Phase C.2: robot motion physics
Now the robots need somewhere to be. src/battle/field.cr is the battlefield, ported from the real CROBOTS C source (motion.c and main.c) rather than reconstructed from memory. The field is 1000 by 1000 meters; robots start in separate quarters of it; speed changes gradually toward what the robot asked for; and a robot can only change direction when it is going at half speed or slower. Ask for a turn while going faster and the drive disengages instead, the same trade CROBOTS makes. Hitting a wall or another robot stops you and hurts.
Even the trigonometry is CROBOTS's: sine and cosine scaled up by 100000 and kept as whole numbers, rounding the way the C code rounds. Matching the original's arithmetic exactly is what lets decades-old robot strategies work unchanged.
Selected change: See the whole change on GitHub.
Try it
git checkout 6b46d73
crystal spec spec/battle/field_spec.cr -v
The path names the field tests, and -v prints their names. Look for movement, turning and collision cases.
What you'll see
CrystalRobots::Battle::Field
places each robot in its own quadrant of the field
accelerates toward the desired speed, not instantly
decelerates toward a lower desired speed
changes heading only at or below TURN_SPEED, otherwise cancels the speed change
moves in the direction of its heading once driving
stops and damages a robot that drives into a wall
stops and damages both robots on collision
kills a robot whose damage reaches 100
Finished in 420 microseconds
8 examples, 0 failures, 0 errors, 0 pending
Phase C.3: missiles
“A battlefield where nobody can shoot is just a parking lot.” Firing, flight and explosions, ported from CROBOTS's c_cannon and move_miss. A robot has two missile slots and has to wait for its cannon to reload; a missile flies to the distance it was aimed at and explodes, damaging every robot nearby, more the closer they are.
Porting faithfully means porting the quirks too. In the C original, asking to fire at a negative distance returns “success” without firing anything, because of where an early return sits. crystal-robots keeps that on purpose, with a spec that names it.
src/battle/field.cr
Selected change: See the whole change on GitHub.
Try it
git checkout cd97689
crystal spec spec/battle/field_spec.cr -v
Look for missile tests that cover firing, reload and an explosion near a robot.
What you'll see
...
fires a missile, then refuses while reloading
reloads after RELOAD motion cycles, filling the second slot
reports success without firing on a negative distance, a real CROBOTS quirk
flies a missile toward its target range, then explodes it
damages a robot caught in a direct hit, and ages the explosion back to available
does not damage a robot far outside the blast radius
Finished in 20.24 milliseconds
14 examples, 0 failures, 0 errors, 0 pending
Phase C.4: the scanner and a real Host bound to the battlefield
Until now every builtin a robot called landed in NullHost, which returned made-up numbers. RobotHost replaces it: one robot's view of a real Field. scan is a port of CROBOTS's c_scan, down to working out the compass quarter by hand around a plain arctangent, because that is what the C does and the robots' own math assumes it. The payoff spec runs a real robot's source through the interpreter and watches the field's own robot move.
Selected change: See the whole change on GitHub.
Try it
git checkout 171214a
crystal spec spec/battle/robot_host_spec.cr -v
Look for host tests that read a robot’s position and use the field’s scanner.
What you'll see
CrystalRobots::Battle::RobotHost
...
Finished in 1.85 milliseconds
6 examples, 0 failures, 0 errors, 0 pending
Phase C.5: interleaving robots into real matches
A match is several robot programs sharing one field and taking turns, one step each, with the physics updated every so many steps. Crystal has fibers, lightweight threads you can pause and resume, which looked like exactly the right tool. It wasn't, at least not the obvious way.
A minimal experiment showed that a fiber calling Fiber.yield in a loop ran to completion inside the first resume instead of pausing at each yield. So each robot's interpreter instead gets a pair of channels, one-slot mailboxes: after every step it sends “done” and waits for permission to continue.
src/compiler/interpreter.cr
A second trap showed up while building the match loop: checking whether a robot's fiber was already dead raced the scheduler and hung the tests. The match now decides from the robot's own alive/dead state, which it controls. Because this is concurrency, the transcript runs the match spec five times in a row.
Selected change: See the whole change on GitHub.
Try it
git checkout 7c63ba6
for i in 1 2 3 4 5; do crystal spec spec/battle/match_spec.cr | grep examples; done
The shell loop runs the match spec five times; grep examples keeps each run’s result line. Look for five clean runs.
What you'll see
5 examples, 0 failures, 0 errors, 0 pending
5 examples, 0 failures, 0 errors, 0 pending
5 examples, 0 failures, 0 errors, 0 pending
5 examples, 0 failures, 0 errors, 0 pending
5 examples, 0 failures, 0 errors, 0 pending
Phase C.6: CLI wiring for matches, and a real crash-safety fix
bin/crystal-robots -m 3 robot1.cr robot2.cr ... now runs matches with a fixed seed, CROBOTS style, and prints each robot's damage and the winner. The first real match exposed a real bug: a robot that crashed (dividing by zero, which at this point still raised an error) stopped its fiber before it could say “done”, and the match waited forever for an answer that would never come.
It first looked like a slow debug build. It wasn't: 100,000 rounds take 2.4 seconds in a release build. The fix is in the crashed robot's error handler, which now marks the robot dead and sends the pending “done” itself, so the round in progress finishes and later rounds skip it.
src/battle/match.cr
Nobody wins the matches below: counter is destroyed every time, and sniper and rabbit are still alive at the cycle limit.
Selected change: See the whole change on GitHub.
Try it
git checkout b979503
shards build --release
bin/crystal-robots -m 3 --seed 1 examples/sniper.cr examples/rabbit.cr examples/counter.cr
--release builds a faster command, -m 3 asks for three matches, and --seed 1 repeats the same starting random choices. Look for three match results and a final score.
What you'll see
match 1: 500000 rounds, sniper 10%, rabbit 0%, counter 100% (dead) -- no winner
match 2: 500000 rounds, sniper 2%, rabbit 0%, counter 100% (dead) -- no winner
match 3: 500000 rounds, sniper 9%, rabbit 0%, counter 100% (dead) -- no winner
Chapter 4 · Phase T: the tournament
crystal-robots was always meant for a room: a class, a club, a meetup, where everyone writes a robot and they all fight. Phase T is the tournament engine, in pure Crystal, knowing nothing about battlefields.
Phase T.1: tournament pool play
“Two robots in a match is a fight. A room full of people who each wrote a robot needs a tournament.” The format was designed on the project wiki, in plain words, before any code: pool play first, then a bracket.
Pools hold four robots, or three when the numbers don't divide evenly (nine robots make three pools of three, not 4 + 4 + 1). Everyone in a pool fights everyone else once: +1 for a win, -1 for a loss. A fight with no winner is refought with the next random seed, and after three tries it counts as 0-0 so the tournament always finishes. Ties in the standings are broken by a mini round-robin among just the tied robots.
The engine never runs a battle itself. It is handed a function, “given these robots, this seed and this limit, who won?”, so the specs can script exact outcomes and the real battlefield plugs in later.
src/tournament/tournament.cr
Selected change: See the whole change on GitHub.
Try it
git checkout 2048be0
crystal spec spec/tournament/tournament_spec.cr -v
Look for named pool tests covering grouping, scoring, refights and ranking.
What you'll see
CrystalRobots::Tournament
makes one pool for 5 or fewer entrants
splits larger fields into pools of 4 or 3
groups entrants in the given order, not shuffled
rejects fewer than 3 entrants
scores a win +1 and a loss -1 with no ties
refights a no-winner fight at the next seed
scores 0-0 after 3 no-winner tries, so the tournament can finish
breaks a points tie with a refight among the tied names
takes the top three from a single pool
takes every pool's winner, then every runner-up, in pool order
Finished in 818 microseconds
10 examples, 0 failures, 0 errors, 0 pending
Phase T.2: tournament bracket
The robots that come out of the pools meet in a single-elimination bracket, seeded so the best pool finishers meet last and get the byes when the numbers aren't a power of two. Bracket matches are best of three.
A real bug from testing the format by hand is fixed here the way the wiki records it: refights are capped at three for a whole match, not three per game, or one stubborn match could balloon into a dozen fights. A match still tied after that goes to the higher seed.
The transcript ends with a real tournament: five example robots, on a real field, from a single crystal eval.
Selected change: See the whole change on GitHub.
Try it
git checkout 5cf1180
crystal eval '...' # the full one-liner is in the commit message
crystal eval runs the quoted tournament setup directly. Look for pool standings followed by bracket rounds and a champion.
What you'll see
pool: 1. sniper 1
pool: 2. rabbit 1
pool: 3. target 1
pool: 4. rook 0
pool: 5. counter -3
Semifinal: sniper vs bye -> sniper
Semifinal: rabbit vs target -> rabbit
Final: sniper vs rabbit -> sniper
champion: sniper
Chapter 5 · Phase U: the pieces in between
A short chapter of things a real project needs that make no headlines: knowing which build you're looking at, more robots to fight, a reference for robot writers, and one command that says whether everything works.
Phase U.1: version reports the check-in it was built from
When a binary or a published page misbehaves, the first question is “built from what?” The version line now includes the commit it came from, read at compile time from a small file (manifest.uuid) that the project's version control keeps up to date.
Selected change: See the whole change on GitHub.
Try it
git checkout aed79db
shards build
bin/crystal-robots --version
--version now prints a short check-in identifier beside the version number. Look for both on one line.
What you'll see
crystal-robots 0.0.1 (check-in 492fcf474b)
Phase U.2: crystal and riscv emitter placeholders
Two empty files reserve room for ideas the maintainer jotted down early on: a back end that turns robots into Crystal source, and one that targets RISC-V processors. Nothing uses them yet.
Selected change: See the whole change on GitHub.
Phase U.3: versioned robots/*.md
Five more robots ship with the project, each as a short Markdown page: a description of its strategy and the CROBOTS-era idea behind it, followed by exactly one code block, the robot. hunter, circler, dodger, turret and wallhugger are better opponents than the bare examples, and their prose doubles as tactics notes for new robot writers. A spec checks that each file has exactly one code block and that it parses and passes the checker.
Selected change: See the whole change on GitHub.
Try it
git checkout f59b866
crystal spec spec/robots_spec.cr -v
The path runs only the shipped-robot tests; -v names each one. Look for all five strategies to parse and check cleanly.
What you'll see
robots/*.md
ships a handful of them
circler has one code block that parses and checks clean
dodger has one code block that parses and checks clean
hunter has one code block that parses and checks clean
turret has one code block that parses and checks clean
wallhugger has one code block that parses and checks clean
Finished in 28.55 milliseconds
6 examples, 0 failures, 0 errors, 0 pending
Phase U.4: the robot API reference
“What can my robot call?” is the question every new robot writer asks first. RobotAPI answers it in one table: every builtin with a plain-worded description, what it gives back, its cycle cost and an example, grouped as Sense, Move, Shoot, Math and Output. Because that table is written by hand, a spec compares it against the list of builtins the interpreter really handles, so a builtin can't go undocumented and a documented one can't be made up. The browser editor and the manual later read their reference from this same table.
Selected change: See the whole change on GitHub.
Try it
git checkout c08bf52
crystal spec spec/web/robot_api_spec.cr -v
The path runs the reference tests; -v names each one. Look for the check that its builtin names match the interpreter.
What you'll see
CrystalRobots::Web::RobotAPI
...
renders every group heading and one entry per builtin, with a cost and an example
...
Finished in 3.49 milliseconds
4 examples, 0 failures, 0 errors, 0 pending
Phase U.6: scripts/ci.sh as the one CI command
A project built on a laptop, a CI server and later a web host needs one command that means “this revision is good”. scripts/ci.sh installs dependencies, builds, runs the specs, checks formatting and smoke-tests the command line. With --with-wasmer it also installs wasmer inside the checkout and runs the WebAssembly specs for real. Without it they are reported as pending, which is a visible signal, never a quietly tolerated failure.
Selected change: See the whole change on GitHub.
Try it
git checkout a9d012a
scripts/ci.sh --with-wasmer
--with-wasmer includes the compiled-robot specs in this one check command. Look for no failures or pending specs and the final green line.
What you'll see
== shards install
== bootstrap build
== check manifest.uuid
== shards build
== wasmer 4.4.0
wasmer 4.4.0
== crystal spec -Dwasmer
159 examples, 0 failures, 0 errors, 0 pending
== crystal tool format --check
== CLI smoke
== ci.sh: all green (with wasmer)
Chapter 6 · Phase W: the compiler moves into the browser
chasm compiles in the browser because it is written in TypeScript. crystal-robots is written in Crystal, so putting it in a page means compiling the compiler itself to WebAssembly. Phase W is that experiment, the one surprising fact it uncovered, and the plumbing that publishes the result on every push.
Phase W.0: compile and interpret in one call
The next commits put the compiler itself inside a web page. A page can't reasonably drive the parser, interpreter and emitter one by one, so two wrappers take raw source text: one compiles it to WebAssembly bytes, the other interprets it and reports the cycles charged. A page also can't afford a robot that loops forever, so the interpreter gets a standalone cycle limit.
Selected change: See the whole change on GitHub.
Try it
git checkout 92df198
crystal eval 'require "./src/compiler/compiler"; wasm = CrystalRobots::Compiler.compile_to_wasm(File.read("examples/hello.cr")); puts "#{wasm.size} bytes: #{wasm[0, 8].hexstring}"; puts "cycles: #{CrystalRobots::Compiler.interpret(File.read("examples/functions.cr"))}"'
crystal eval runs the quoted Crystal code without making a file. Look for WebAssembly bytes beginning with its standard header and 46 cycles from the second example.
What you'll see
67 bytes: 0061736d01000000
cycles: 46
Phase W.1: the wasm32 spike
Can the compiler itself run in a browser? Crystal can build programs for wasm32-unknown-wasi: 32-bit WebAssembly with WASI, a small standard set of operating-system functions (write to the console, get random numbers, and so on) that a host provides. src/browser.cr is the experiment: a handful of entry points a page can call, built for that target.
The experiment found the fact that shaped everything after it. Crystal 1.18.2 has no working exception handling on this target. Not “slow”, not “limited”: none. A rescue placed directly around a raise in the same function, nothing in between, still let the module trap (stop dead with an error the page sees, discarding everything). It was confirmed twice, by running the real module, not by reading the source.
Remember the interpreter's break and return, built on exceptions back in A.13? Every one of those decisions now has a price. The rule from here on: anything that can raise lives in its own call, so a trap loses only that call's answer. Compiling and interpreting become two separate calls for exactly that reason.
Selected change: See the whole change on GitHub.
Try it
git checkout eb9c3e0
WASM32_WASI_LIBS=$PWD/.wasm32-wasi-libs sh scripts/install_wasm32_wasi_libs.sh
crystal build --target wasm32-unknown-wasi src/browser.cr -o bin/crystal-robots.wasm \
--link-flags="-L$PWD/.wasm32-wasi-libs/lib/wasm32-wasi" --release --no-debug
ls -l bin/crystal-robots.wasm
WASM32_WASI_LIBS chooses where the support libraries are installed; --target builds for WebAssembly. The size printed by ls -l shows that the browser module was made.
What you'll see
wasm32-wasi-libs 0.0.3 installed: .../.wasm32-wasi-libs/lib/wasm32-wasi
Warning: positional parameter 'filename' corresponds to parameter 'path' of the overridden method ...
A total of 1 warnings were found.
688705 bin/crystal-robots.wasm
Phase W.2: the static page and WASI shim
A WASI program expects an operating system underneath. In a browser there isn't one, so site/wasi-shim.js is a hand-written stand-in implementing exactly the seven WASI functions this build uses, with no framework and nothing loaded from elsewhere. It creates a fresh instance of the module for every call, because after a trap the old instance is unusable, and it reports a trap as an ordinary {trapped, stderr} result instead of an exception.
site/index.html and site/app.js make the first page: a text box, a Run button, and four panels for the parse, the checker's problems, the interpreter's output and the compiled bytes. The transcript drives the real module through the real shim from Node. Note target.cr: it compiles cleanly but has no interpreter output, because its fight loop hits the cycle limit and traps, which is exactly the case the page was built to survive.
Selected change: See the whole change on GitHub.
Try it
git checkout 0d188c3
# build site/crystal-robots.wasm as in W.1, then:
node --input-type=module -e '...' # full command in the commit message
node runs JavaScript outside a browser; --input-type=module allows import in the quoted check. Copy the full check from this commit’s message; look for a trapped result on bad source and a working next call. To open the page itself, run python3 -m http.server 8000 --directory site, then enter http://localhost:8000/ in a browser’s address bar.
What you'll see
examples/hello.cr {"passes":4,"problems":[],"wasm_len":67,"interpreter":{"status":"finished after 4 cycles","puts":[42]}}
examples/functions.cr {"passes":12,"problems":[],"wasm_len":129,"interpreter":{"status":"finished after 46 cycles","puts":[5,30,35]}}
examples/target.cr {"passes":9,"problems":[],"wasm_len":79,"interpreter":null}
bad source: {"trapped":true,"stderr":"EXITING: Attempting to raise:\nUnexpected character '@' (CrystalRobots::Compiler::Parser::Error)"}
Phase W.3: the wasm32 spec and the ci.sh flag
The hand check from W.2 becomes part of the test suite. spec/wasm32_spec.cr loads the already-built module through the same wasi-shim.js the browser loads, in Node, and compares against the native compiler: the same parse, the same checker results, a clean trap on bad input that doesn't affect the next call. ci.sh --with-wasm32 checks that node and the WebAssembly linker are present up front, so a machine missing either fails with a clear message rather than deep in a linker error.
Selected change: See the whole change on GitHub.
Try it
git checkout b8ada00
scripts/ci.sh --with-wasmer --with-wasm32
--with-wasm32 builds the browser module and runs its specs. Look for zero pending specs in the combined run.
What you'll see
...
167 examples, 0 failures, 0 errors, 0 pending
== ci.sh: all green (with wasmer) (with wasm32)
Phase W.4: the GitHub Pages deploy workflow
A compiler in a web page only helps if the page is published. .github/workflows/pages.yml builds and deploys site/ to GitHub Pages on every push to main or dev. It doesn't list its own build steps; it runs the one CI command. A developer who runs that command locally sees exactly what gets published, and a failing test blocks a broken deploy.
Selected change: See the whole change on GitHub.
Try it
git checkout 8dc0e82
grep -nE 'branches|crystal:|lld|ci.sh' .github/workflows/pages.yml
grep -nE prints matching workflow lines with line numbers. Look for the branches, Crystal version, linker and CI command that publish the page.
What you'll see
15: branches: [main, dev]
35: crystal: "1.18.2"
37: - run: sudo apt-get update && sudo apt-get install -y lld
39: - run: scripts/ci.sh --with-wasm32
Phase W.5: pin the Pages workflow with Crystal's own YAML
The deploy workflow runs somewhere nobody watches, so it gets a spec like everything else: Crystal's standard YAML library reads the file and pins its trigger, its steps in order and its Crystal version. Writing it turned up a classic: in YAML 1.1 a bare on: key is read as the boolean true, not the word “on”, so the trigger lives at workflow[true]. That was checked against the file, not assumed.
Selected change: See the whole change on GitHub.
Try it
git checkout 82cc54d
crystal spec spec/pages_workflow_spec.cr -v
-v names each workflow spec as it runs. Look for five examples and no failures.
What you'll see
.github/workflows/pages.yml
...
Finished in 462 microseconds
5 examples, 0 failures, 0 errors, 0 pending
Phase W.6: ci.cr replaces ci.sh
The maintainer's direction: the CI should be Crystal, not shell. ci.cr is a direct port of every step, run as crystal run ci.cr -- [flags], and the shell script is deleted. Verifying the port found a real bug: the first draft told only the wasmer installer where to install, so it installed into the home directory instead of the checkout, and the next step failed. Setting the variable in ci.cr's own process, which every later step inherits, fixed it.
Selected change: See the whole change on GitHub.
Try it
git checkout 181bf8c
crystal run ci.cr -- --with-wasmer --with-wasm32
crystal run ci.cr -- --bogus-flag; echo "exit=$?"
The -- passes flags to ci.cr. The bad flag check should print an error and exit=2 after the successful build.
What you'll see
...
== crystal spec -Dwasmer -Dcrd_wasm32
172 examples, 0 failures, 0 errors, 0 pending
== crystal tool format --check
== CLI smoke
== ci.cr: all green (with wasmer) (with wasm32)
unknown option: --bogus-flag
exit=2
Intermission: where the tutorial meets the shipping code
Here is a confession the commit history makes on its own. The chapters above are a retelling. The project was first built in a hurry, the way projects are, and then rebuilt from scratch, one teachable commit at a time, so that a reader could follow it. Meanwhile the original line kept shipping. By the end of Phase W the two did the same things (the same language, battlefield, tournament, browser compiler and CI command) with different code.
Rather than pretend otherwise, the history swaps the tree for the shipping code in a single commit, at the point where the two reached the same features. The diff is large and teaches nothing line by line; what matters is what changes shape:
Battle::Fieldtakes robots as{name, source}pairs and runs them on fibers itself; the separateMatchclass is gone.- The command line's match output reports cycles and a wins/ties table, and the engine has grown the CROBOTS behaviors the early robots lean on (dividing by zero gives zero, for one), so the same three robots from C.6 now produce a winner every time.
- The test suite is the shipping one: fewer, broader specs (99 here, against 172 at W.6), including building the example robots as native programs.
Catch-up: adopt the shipping implementation at the tutorial's parity point
Every commit after this one is one of the shipping line's own changes, replayed in order.
This step brings in the shipping code built alongside the tutorial.
Try it
git checkout 630175a
shards build
bin/crystal-robots -m 3 --seed 1 examples/sniper.cr examples/rabbit.cr examples/counter.cr
-m 3 asks for three matches and --seed 1 makes their starting choices repeatable. Look for counter’s three wins.
What you'll see
Running with robots: ["examples/sniper.cr", "examples/rabbit.cr", "examples/counter.cr"]
Match 1: seed 1, cycles = 85215
Match 2: seed 2, cycles = 53115
Match 3: seed 3, cycles = 79230
Cumulative score:
sniper: wins=0 ties=0
rabbit: wins=0 ties=0
counter: wins=3 ties=0
Chapter 7 · Battles in the browser
From here the history follows the shipping line commit by commit. This chapter moves the whole battle into the page, gives it an editor and a real-time player, and then does what software does: breaks in production, three times, each for an instructive reason.
Phase 5b-2: battle entirely in the browser, no server
W.1's discovery comes due. If exceptions don't work on wasm32, neither can the battle engine's fibers be trusted once the page is in control. So src/battle/step_robot.cr gives each robot a second way to run with no fibers and no exceptions at all.
The trick is continuation-passing style. Everywhere the interpreter would say “work out this value, then do something with it”, it instead hands over a small function meaning “and here is what to do next”. Charging a cycle is the one place that does not call that function right away: it stores it and returns. The next turn picks up by calling the stored function. No exception, no fiber, just an ordinary return.
The parser also gains try_parse, which reports a syntax error as data instead of raising, so one robot's typo can't take down a whole battle. The page sends 2 to 4 robots and a seed into crystal-robots.wasm and gets back the standings and an animated SVG replay.
Selected change: See the whole change on GitHub.
Try it
git checkout ec9d70a
crystal run ci.cr -- --with-wasm32
node --input-type=module -e '...' # full command in the commit message
The Node check is in this commit’s message. Look for a winner and the length of the SVG replay after the browser build passes.
What you'll see
{"seed":1,"limit":200000,"cycles":101085,"winner":"counter","robots":[{"name":"counter","active":true,"damage":50,...},{"name":"rabbit","active":false,"damage":100,...},{"name":"sniper","active":false,"damage":100,...}]}
svg replay: 48663 chars
Phase 6: the browser robot editor
Watching battles is fun; writing a robot that wins one is the point. site/editor/ is a plain text-box editor: start from any example, change it, and it is checked as you type (half a second after you stop). A Fight button runs your robot against others. The live check is a new entry point that never traps on a half-typed program, built on try_parse. The editor's cheat sheet comes from the same robot API table U.4 introduced, exported as JSON at build time.
Selected change: See the whole change on GitHub.
Try it
git checkout 45c3650
node --input-type=module -e '...' # checks three sources; full command in the commit message
Run the Node check from this commit’s message. Its three answers show a syntax mistake, an unknown name and a valid robot.
What you'll see
{..."error":{"message":"Cannot reduce 🅼 (MainHead) in 🅼∋⟮№,❢🔙⏎ at 1:1","line":1,"col":1},"problems":[]}
{..."error":null,"problems":[{"message":"undefined variable or function speed_up","line":2,"col":13}]}
{..."error":null,"problems":[]}
Fix the editor's line-number gutter drifting on scroll and wrapped lines
Reported from an iPhone: the line numbers drifted away from the lines they labelled. Two bugs. The number column had no fixed height, so it never scrolled with the text. And long lines wrapped onto two rows while the numbers counted one, so every wrapped line pushed the numbers below it out of step. The fix turns wrapping off and keeps the scroll positions in sync, and a new spec drives a real headless Chromium to prove it, using only Node's built-in networking.
Selected change: See the whole change on GitHub.
Try it
git checkout b56793b
crystal spec spec/editor_gutter_spec.cr -v
This spec drives a browser without opening a visible window. Look for one passing example after it scrolls the editor.
What you'll see
site/editor gutter (headless)
...
Finished in 10.1 seconds
1 examples, 0 failures, 0 errors, 0 pending
Phase 6: battle playback at real frame rate, with event-guaranteed frames
The first in-page replay was an SVG animation with a few hundred keyframes: choppy, and likely to skip the moments worth seeing, because a shot fired between two sampled frames simply didn't appear. Now the engine always records a frame when something happens (a shot, a hit, a death), whatever the sampling rate. The page gets a compact binary log of frames instead of a giant animation, and site/battle-playback.js draws it at 60 frames per second with play, pause, speed and a scrubber.
This commit also carries a bug nobody noticed for a while: the log stores positions in CROBOTS clicks (ten per meter), and the new player drew them as meters.
Selected change: See the whole change on GitHub.
Try it
git checkout 13db130
node --input-type=module -e '...' # full command in the commit message
Use the Node check in this commit’s message. Even with a low frame limit, the result has more frames because shots and hits are kept.
What you'll see
max_frames 400: 469 frames in 24396 bytes, 82425 cycles, winner sniper, SVG skeleton 2194 chars
max_frames 20000: 5494 frames in 285696 bytes, 82425 cycles, winner sniper, SVG skeleton 2194 chars
Pages battle: draw robots and missiles in meters, not clicks
Here is that bug. On the published page every robot and missile landed about ten times too far out, off the field entirely: a battle “ran” with nothing visible where it should be. The fix is one division. The test is the interesting part: it decodes a real battle with the real player code and checks every coordinate is inside the field and that some coordinate is above 100, so the opposite mistake, dividing twice, would also fail.
Selected change: See the whole change on GitHub.
Try it
git checkout 3222b06
node --input-type=module -e '...' # full command in the commit message
Use the Node check in this commit’s message. The coordinates should now lie within the thousand-meter field.
What you'll see
frames 102 coordinate range 19.8 to 940.6 meters
first frame: [ [ '841.3', '570.1' ], [ '19.8', '297.4' ] ]
Pages deploy: mark site/*.js as ES modules so the playback spec runs on older Node
The first deploy after that fix failed, and the reason is a nice example of how environments differ. The new test has Node load the site's JavaScript, which uses modern import/export. Recent Node versions detect that on their own; Node 20.18 and older assume the old module style and fail. A one-line site/package.json declaring the files as ES modules settles it for every version.
Selected change: See the whole change on GitHub.
Try it
# without the fix, under Node 20.18
crystal spec spec/battle_playback_spec.cr
# with it
crystal spec spec/battle_playback_spec.cr
These two runs show the same spec before and after the one-line fix. Look for the second run to pass on older Node.
What you'll see
SyntaxError: Named export 'decodeFrames' not found. The requested module '../../site/battle-playback.js' is a CommonJS module, ...
1 examples, 1 failures, 0 errors, 0 pending
v20.18.1
1 examples, 0 failures, 0 errors, 0 pending
Pages deploy: run the headless editor gutter spec with the runner's Google Chrome
Then the build failed again, for a different reason. GitHub's build machines had moved to a newer Ubuntu, where chromium is a launcher that doesn't start reliably in that environment. The browser test waited ten seconds for Chromium, gave up, and said nothing about why. The fix points the test at the build machine's own Google Chrome, waits longer, and, most usefully, puts the browser's own error output into the failure message so the next surprise explains itself. The deploy after this went green.
Selected change: See the whole change on GitHub.
Try it
git checkout 16883da
CHROMIUM_BIN=/nonexistent crystal spec spec/editor_gutter_spec.cr
CHROMIUM_BIN=/tmp/stall-browser crystal spec spec/editor_gutter_spec.cr 2>&1 | grep -E 'Error: |examples,'
The first command shows a missing browser becomes pending; the second uses a deliberately stalled one. Look for a timeout message that names the browser.
What you'll see
1 examples, 0 failures, 0 errors, 1 pending
Error: timed out waiting for chromium devtools port (binary: /tmp/stall-browser; stderr, last 2000 characters:
1 examples, 1 failures, 0 errors, 0 pending
Chapter 8 · The front door
Everything so far lives in a repository and a bare test page. This chapter turns jadonk.github.io/crystal-robots into the project's front door, written for people who have never programmed before. A manual explains the game the way the CROBOTS manual did, with every example checked against the real parser. The landing page opens on a battle already in progress, then lets you peel it back one layer at a time: the robot's code, the parser collapsing it pass by pass, its syntax tree, and the WebAssembly bytes it compiles to. The whole tournament runs inside crystal-robots.wasm, and every fight can be replayed exactly from its recorded seed. A seven-level challenge walks a beginner from “make it move” to entering that tournament, checking each level against real battles. And replays finally show what each robot prints with puts, in step with the action.
Browser: a whole tournament inside crystal-robots.wasm
Chapter 4's tournament engine never needed a server; it just needed someone to call it. Here crystal-robots.wasm gets a crd_tournament_run export that runs pool play and the bracket in one call. Two details matter. First, every fight records its entrants and its seed, so the page can later replay any single fight through the ordinary battle call and get the same winner; a spec replays a pool fight and a bracket game to prove it. Second, everything a robot author can get wrong (too few robots, a duplicate name, a robot that doesn't parse) comes back as data. W.1 established why that matters: on this target any raise stops the whole module, so the tournament refuses to start rather than letting the engine throw.
An early version also hand-validated the request's JSON syntax, about 120 lines of it. It came out again: the page builds every request with JSON.stringify, so malformed JSON could only be the page's own bug, and the battle call had already set the rule of trapping only on that.
Selected change: See the whole change on GitHub.
Try it
git checkout 673ccad
crystal spec spec/browser_tournament_spec.cr -v
crystal spec -Dcrd_wasm32 spec/wasm32_spec.cr -e tournament -v
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
-e tournament selects the browser tournament spec by name. Look for its result to match the native tournament.
What you'll see
5 examples, 0 failures, 0 errors, 0 pending
1 examples, 0 failures, 0 errors, 0 pending
127 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasm32)
127 examples, 0 failures, 0 errors, 8 pending
== ci.cr: all green (with wasmer)
Pages: a landing page that opens on a live battle and peels back to the WebAssembly
The front page now does one thing first: a four-robot battle starts as soon as the module loads. A legend shows each robot's damage live, Rematch picks a new seed, and ?seed=N makes any battle shareable. Click a robot and the page peels it back in five layers: its code; the parser's passes, stepped one at a time until the whole program collapses to a single symbol; the syntax tree, from a new crd_ast export that reports a broken program as data; the WebAssembly bytes, decoded by a small wasm-view.js into sections and instructions next to the bytes they came from; and what the engine did with that robot in this battle.
The old test page moves to /workbench/ unchanged. A headless browser check opens all five layers and fails if the page scrolls sideways at phone width.
Selected change: See the whole change on GitHub.
Try it
git checkout 93bf542
crystal spec -Dcrd_wasm32 spec/landing_spec.cr spec/browser_ast_spec.cr spec/wasm_view_spec.cr -v
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
These specs check the landing page’s layers and its WebAssembly view. Look for four passing examples, then open the published page in a browser to watch the battle.
What you'll see
4 examples, 0 failures, 0 errors, 0 pending
132 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasm32)
132 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasmer)
Pages: a tournament page and a seven-level robot challenge
The tournament page lets you pick three to eight robots, now including the five from robots/*.md, and runs the whole thing in a Web Worker so the page stays responsive. It draws the pools and the bracket, and any fight in either can be replayed on the spot, with the replay's winner shown next to the recorded one.
The challenge is the part aimed squarely at beginners: “Can your robot win the tournament?” Seven levels take you from moving at all, to seeing the target, hitting it, destroying it, beating a moving robot, beating one that fights back, and finally entering the tournament. Each check runs real battles in your browser and says in one sentence why it passed or failed, with the deciding battle there to watch. Your robot and progress are kept in the browser, so the tournament page can enter “Your robot” too.
Selected change: See the whole change on GitHub.
Try it
git checkout 74c7a63
crystal spec -Dcrd_wasm32 spec/challenge_checks_spec.cr spec/challenge_tournament_spec.cr spec/site_robots_spec.cr -v
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
The selected specs check the challenge and its tournament. Look for three passing examples before the full build.
What you'll see
3 examples, 0 failures, 0 errors, 0 pending
135 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasm32)
135 examples, 0 failures, 0 errors, 13 pending
== ci.cr: all green (with wasmer)
Replays show what each robot prints
puts is the oldest debugging tool there is, and until now a robot's printing vanished in the browser. Its lines were collected during a battle but never reached the page, and they had no time attached, so a replay couldn't have shown them at the right moment anyway. Each line now records the battle cycle it was printed on, identically in both engines, and the replay grows a console under the arena that shows only what has been printed up to the current moment. Scrub backward and the lines disappear again. The challenge's hints now suggest printing what your scanner sees.
Selected change: See the whole change on GitHub.
Try it
git checkout 51e2338
crystal run printer_demo.cr -- printer.cr
crystal spec spec/battle_spec.cr:112 spec/browser_spec.cr:40 -v
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
The numbered spec locations run the two output checks. Look for both to pass; a replay should show a line only after its battle cycle.
What you'll see
2 examples, 0 failures, 0 errors, 0 pending
137 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasm32)
137 examples, 0 failures, 0 errors, 13 pending
== ci.cr: all green (with wasmer)
Chapter 9 · The robots start fighting as WebAssembly
For a long time the headline on the landing page, “robots you program, compiled to WebAssembly, fighting in your browser”, was only half true. The site compiled your robot and showed you the bytes, but the battle itself ran your robot through the interpreter. The reason goes back to C.1: the compiled code could count cycles with env.tick, but plain WebAssembly cannot pause in the middle of a function and pick up later, and a robot that can't pause can't take turns.
This chapter makes the breakpoints real, without relying on any browser-specific feature. The emitter learns a resumable mode: each function becomes numbered blocks run by a dispatch loop, and everything a paused robot needs to remember (where it was, its local variables, the half-finished parts of an expression) lives in its own memory instead of on WebAssembly's stack. Calling the module's step() runs exactly one CROBOTS cycle and returns. Then crystal-robots.wasm builds one such module per robot and lets the battlefield step them. The proof is the same idea as A.13's differential spec, scaled up: sixty seeded battles and a full tournament produce byte-for-byte identical results whether the robots are compiled or interpreted, and the compiled ones are faster.
A resumable WebAssembly emitter: compiled robots that pause at every cycle
Here is the problem stated plainly. The battlefield runs four robots in lockstep, one CROBOTS cycle each, round and round. A compiled robot is a WebAssembly function, and a WebAssembly function, once called, runs until it returns. There is no instruction for “stop here and carry on later”. Browsers are adding ways around that, but each one is a newer feature that only some browsers have, and this project wanted plain WebAssembly that runs everywhere, wasmer included.
So the emitter does the pausing itself. In resumable mode every function is cut into numbered blocks at each place a cycle is charged, and run by a loop that jumps to the saved block number. Nothing is left on WebAssembly's own value stack when a block ends: half-finished calculations go into local variables, and every call in progress keeps its block number, locals and temporaries in a 2 KB frame in the robot's own memory. Calling step() runs exactly one cycle, stores where it got to, and returns. Calling it again carries on, even from deep inside nested function calls. Recursion that goes too deep becomes the same robot error the interpreter reports, not a crash.
The proof reuses A.13's idea at full scale. Under wasmer, every bundled robot fights three seeds in two- and four-robot fields, once compiled and once interpreted, and every recorded frame, cycle count, restart, printed line and winner must match exactly. The modules grow about ten to fifteen times larger for it, which still means a few kilobytes.
Selected change: See the whole change on GitHub.
Try it
git checkout b9a3816
WASMER_DIR=$PWD/.wasmer crystal spec -Dwasmer spec/wasm_resumable_spec.cr -v
bin/crystal-robots -c examples/sniper.cr -o /dev/null --resumable
bin/crystal-robots -c examples/sniper.cr | wc -c
bin/crystal-robots -c examples/counter.cr -o /dev/null --resumable
bin/crystal-robots -c examples/counter.cr | wc -c
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
--resumable builds a robot that exports one-cycle steps; wc -c counts ordinary module bytes for comparison. Look for eight passing resume specs.
What you'll see
resumable WASM
pauses before a builtin and resumes a nested call
matches the interpreter after every charged unit
keeps globals across main restarts
keeps top level constants while main restarts
turns deep recursion into a robot runtime error
pauses a loop that calls no builtins
returns zero for division and modulo by zero
matches stepwise battles for every compilable bundled robot
Finished in 16.09 seconds
8 examples, 0 failures, 0 errors, 0 pending
Module size: 16817 bytes
1174
Module size: 10703 bytes
790
...
145 examples, 0 failures, 0 errors, 18 pending
== ci.cr: all green (with wasm32)
...
145 examples, 0 failures, 0 errors, 13 pending
== ci.cr: all green (with wasmer)
Compiled robots fight in the browser
The last step wires it up. crystal-robots.wasm compiles each robot in resumable mode and asks the page for a fresh copy of that robot's module per battle. Each copy's builtins (scan, drive, cannon and the rest) call back into crystal-robots.wasm to act on that robot's own place on the battlefield, and the battlefield steps each robot by calling its step(). Battles run in a Web Worker, where the robot modules are compiled ahead of time. A robot the resumable emitter can't handle falls back to the interpreter on its own, and the legend says which way each robot ran.
One linking detail is worth a sentence. The quick way to let the Crystal code call functions the page provides is a linker flag that turns every unresolved symbol into an import, which would also quietly turn any real linking mistake into a failure at run time. Declaring just those three functions as coming from a named import module (crd_host) keeps the linker strict.
Sixty seeded battles and a full eight-robot tournament now come out byte-for-byte identical compiled and interpreted, and the compiled robots are the faster of the two.
Selected change: See the whole change on GitHub.
Try it
git checkout 33d67dd
crystal spec -Dcrd_wasm32 spec/wasm32_compiled_spec.cr -v
node -e '...WebAssembly.Module.imports(site/crystal-robots.wasm), minus wasi...'
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
The Node command in this commit’s message inspects the browser module’s imports. Look for the compiled battle spec to pass and the compiled timing to be lower.
What you'll see
browser compiled robots
compiled 143 ms; interpreter 201 ms for the 4-robot 60,000-cycle battle
matches interpreted battles and a full tournament, falls back per robot, and stays within the timing budget
1 examples, 0 failures, 0 errors, 0 pending
146 examples, 0 failures, 0 errors, 18 pending
== ci.cr: all green (with wasm32)
145 examples, 0 failures, 0 errors, 13 pending
== ci.cr: all green (with wasmer)
Plain language for first-time programmers, and a CROBOTS-style manual
With the machinery done, the words got another pass. The site is for people who have never programmed, so it now explains the game first and the computer science last, taking its tone from the CROBOTS manual: the battlefield with diagrams, every command with an example, tactics drawn from the bundled robots. Error messages are translated whole into plain sentences that keep the names from your code (“drive needs 2 numbers, but 1 was given”), and anything the page doesn't recognise is shown as it is rather than half-translated. The same commit fixes two test problems that broke builds: a test that assumed Node was installed, and a browser test that failed after passing because Chrome was still writing to its temporary folder.
Selected change: See the whole change on GitHub.
Try it
git checkout f499967
PATH=<everything but node> crystal spec
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer
The altered PATH check runs without Node available. Look for the ordinary specs to pass, then the browser build to finish green.
What you'll see
145 examples, 0 failures, 0 errors, 34 pending
146 examples, 0 failures, 0 errors, 18 pending
== ci.cr: all green (with wasm32)
145 examples, 0 failures, 0 errors, 13 pending
== ci.cr: all green (with wasmer)
Pages: a favicon
And the smallest commit in the story: a robot from the arena, turret aimed and a missile in flight, as the site's icon.
Selected change: See the whole change on GitHub.
What’s next
The language is still a careful subset: whole numbers only, no lists, one robot per file. CROBOTS robots fought on a text screen; these fight in an SVG arena, but the arena could do much more with sound, trails and slow motion. The two placeholder back ends from U.2, Crystal source and RISC-V, are still waiting. And the multipass parser could teach more than it does: the plan has long included drawing the interpreter's walk through a program as a diagram.
The repository also carries a self-hosted web version and a hardware badge interface, outside this story.
Credits
- Tom Poindexter, for CROBOTS, its manual, and the C source this battlefield is ported from.
- Colin Eberhardt, for chasm and the post that showed how to tell a compiler’s story one commit at a time.
- The Crystal team, for a language that targets both native code and WebAssembly.
- Jason Kridner, who started crystal-robots, designed the parser, and steered every phase.
Keep going: the manual has the full rules and every robot command, and the repository has everything else.