Contents

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
end

Give a group of steps a name so it can be used again.

Group code: class and module

module Compiler
  class Parser
  end
end

A module groups related names; a class describes an object that can hold data and actions.

Keep a value: @name

@program = program

The @ keeps a value with one object for later use.

Say what kind of value: : Int32

def add(n : Int32) : Int32

Int32 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
end

Run 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 = :trace

A 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
end

These 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.receive

One 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

Commit 69c75a5

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.

Project skeleton

Commit 866c92a

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.

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

Commit 9206c10

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.

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

Commit 27a3ab6

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.

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

Commit 0b60f75

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.

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

Commit 613e895

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.

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

Commit 2ae5e05

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.

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

Commit 2033e63

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.

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

Commit 6a15aea

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.

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

Commit abf7f9b

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.

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

Commit 7fbce2e

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.

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

Commit 2631ebd

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.

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

Commit 4f5d440

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.

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

Commit f0e79e1

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.

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

Commit 2b0d9c4

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.

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

Commit 2038dc7

Bookkeeping: the plan notes that Phase A is complete.

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

Commit 5729f6b

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

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

Commit 1c95a0f

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.

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

Commit e52306b

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.

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

Commit a4d9013

+= -= *= %=. 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 =.

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

Commit 610e3b5

&&, || 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.

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

Commit 3cd6dd9

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.

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

Commit 1f6dd1e

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.

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

Commit 3f8858c

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.

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

Commit d4d74d5

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.

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

Commit 6b46d73

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.

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

Commit cd97689

“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

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

Commit 171214a

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.

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

Commit 7c63ba6

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.

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

Commit b979503

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.

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

Commit 2048be0

“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

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

Commit 5cf1180

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.

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

Commit aed79db

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.

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

Commit 000319b

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.

Phase U.3: versioned robots/*.md

Commit f59b866

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.

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

Commit c08bf52

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

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

Commit a9d012a

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.

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

Commit 92df198

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.

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

Commit eb9c3e0

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.

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

Commit 0d188c3

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.

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

Commit b8ada00

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.

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

Commit 8dc0e82

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.

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

Commit 82cc54d

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.

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

Commit 181bf8c

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.

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::Field takes robots as {name, source} pairs and runs them on fibers itself; the separate Match class 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

Commit 630175a

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

Commit ec9d70a

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.

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

Commit 45c3650

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.

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

Commit b56793b

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.

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

Commit 13db130

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.

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

Commit 3222b06

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.

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

Commit 9a342ee

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.

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

Commit 16883da

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.

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.

Pages: a robot manual and the shared site stylesheet

Commit ae8856b

A game for beginners needs a manual before it needs anything clever. This one starts with a short robot explained line by line and the language as the parser actually accepts it, then every robot command. The command list isn't typed into the page: it's rendered from cheat-sheet.json, which is generated from the same table the code checks every builtin against, so the manual can't list a command that doesn't exist. A spec goes further and pulls every complete example out of the page and runs it through the parser and checker, so a code sample in the manual that stops compiling fails the build. The same commit adds site.css, one small stylesheet with light and dark colors taken from the battlefield's robot palette, which every page after it shares.

Try it

git checkout ae8856b
crystal spec spec/manual_spec.cr -v
crystal run ci.cr -- --with-wasm32
crystal run ci.cr -- --with-wasmer

The manual spec checks its examples with the real parser. Look for four passing examples.

What you'll see

4 examples, 0 failures, 0 errors, 0 pending
121 examples, 0 failures, 0 errors, 10 pending
== ci.cr: all green (with wasm32)
121 examples, 0 failures, 0 errors, 7 pending
== ci.cr: all green (with wasmer)

Browser: a whole tournament inside crystal-robots.wasm

Commit 673ccad

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.

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

Commit 93bf542

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.

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

Commit 74c7a63

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.

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

Commit 51e2338

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.

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

Commit b9a3816

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.

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

Commit 33d67dd

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.

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

Commit f499967

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.

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

Commit c257075

And the smallest commit in the story: a robot from the arena, turret aimed and a missile in flight, as the site's icon.

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

Keep going: the manual has the full rules and every robot command, and the repository has everything else.