It's a fight to the death!
Once my dad told me a story from his time as a young software engineer. To pass the time, he and his friends would have programming deathmatches. One of them would write a compiler, then the others would write programs. All the programs would be put in the same block of memory, and the last program still running wins.
It sounded like fun, so for a birthday party (yes, I am very cool) I wrote this compiler and challenged my friends to a tournament.
This programming language is not intended to be practical; it explicitly lacks a number of convenience features, like instructions for loading small constants directly into registers, to make useful programs harder (but more fun) to write.
Install uv, then sync the project:
uv sync
Compile a program:
uv run blenc --input program.bln --output program.blc
Run a tournament:
uv run blen-rumble jess.blc blaine.blc
File logging is opt-in: set HORSE_LOG_DIR to an existing directory and each
machine writes its execution trace to <HORSE_LOG_DIR>/<machine-name>.log.
Common tasks are wrapped by make:
make help # list targets
make test # run pytest
make lint # ruff + mypy (no writes)
make format # ruff format + autofix in place
make verify # lint + test (pre-commit gate)
make tox # full matrix py3.10-3.13
make cover # coverage profile + htmlcov/
blen is an assembly language for the imaginary Blen machine.
The Blen machine is composed of registers and memory.
A register is a box that contains a number. When a number is in a register,
the machine can use it to do maths. The Blen machine only has 16 registers,
labelled R0 to R15.
The memory is a mapping from addresses to numbers. The machine can't do maths on numbers when they're in the memory, but it can load numbers from the memory into registers and store numbers from registers into the memory to use them later. The memory also contains the program instructions.
The Blen machine runs using the following loop:
- read a number from the memory at the address in a special register called
the
PROGRAM_COUNTER. - parse that number as an instruction.
- execute that instruction by reading some numbers from the registers, doing some maths on them and writing the result back to a register.
- add one to the
PROGRAM_COUNTER.
The Blen machine represents numbers using 16 bits. When it parses instructions, it groups those 16 bits into four 4-bit numbers (nibbles). For example, this is the way that the Blen machine parses the number 38611 (1001011011010011):
| opcode | operand0 | operand1 | result | meaning |
|---|---|---|---|---|
| 1001 | 0110 | 1101 | 0011 | add R6 to R13 and store the result in R3 |
| 9 | 6 | 13 | 3 |
All binary mathematical operations are parsed this way: the first nibble is an opcode that tells the machine which function to call, the next two nibbles tell the machine which two registers to use as arguments for the function, and the last nibble tells the machine which register to store the result in.
There are some instructions that require fewer than two arguments, which are parsed slightly differently. For example, here is how the number 1233 (0000010011010001) is parsed:
| zero | opcode | a | b | meaning |
|---|---|---|---|---|
| 0000 | 0100 | 1101 | 0001 | load a number from memory using the address in R6 and store it in R1 |
| 0 | 4 | 6 | 1 |
These are called "non-binary operations". They all have opcode 0 when parsed as binary operations.
The Blen machine gives some registers special meaning:
R0is theZERO_REGISTER. TheZERO_REGISTERalways contains 0. Writes to theZERO_REGISTERdo nothing.R1is thePROGRAM_COUNTER. At the start of each iteration of the core program loop, the machine reads an address from thePROGRAM_COUNTERand loads from memory the instruction with that address. At the end of each iteration, thePROGRAM_COUNTERis increased by 1. You can read and write from thePROGRAM_COUNTERas normal, and you can use this to jump between sections of the program.
Any number that is non-zero is treated as TRUE by instructions that expect
a boolean value. Instructions that return a boolean value return TRUE or
FALSE.
TRUE = 65535 (1111111111111111 in binary)
FALSE = 0 (0000000000000000 in binary)
Most binary operations treat their arguments as two's complement signed integers.
The numbers in this list indicate the opcode of the relevant instruction:
copy_if test source target: If thetestregister is non-zero, copy the number in thesourceregister to thetargetregister.test_equal a b result: If the number in registerais equal to the number in registerb, writeTRUEto theresultregister. Otherwise, writeFALSEto theresultregister.test_greater_than a b result: If the number in registerais greater than the number in registerb, writeTRUEto theresultregister. Otherwise, writeFALSEto theresultregister.bitwise_and a b result: compute the bitwise and ofaandband write the result to theresultregister.bitwise_or a b result: compute the bitwise or ofaandband write the result to theresultregister.bitwise_xor a b result: compute the bitwise exclusive or ofaandband write the result to theresultregister.add a b result: addatoband write the result toresult.subtract a b result: subtractbfromaand write the result toresult.multiply a b result: multiplyaandband write the result toresult.floor_divide a b result: divideabyb, discard the remainder and write the result toresult.modulus a b result: divideabyband write the remainder toresult.left_shift a b result: move each bit ofato the left bybbits and write the result toresult.right_shift a b result: move each bit ofato the right bybbits and write the result toresult.
The numbers in this list indicate the opcode of the relevant instruction:
no_op: Does nothing.halt: Stops the program.load address target: Load a number from the memory using the address in theaddressregister and write it to thetargetregister.store address source: Store the number in thesourceregister in the memory using the address in thetargetregister.increment a result: Add one to the number in registeraand store the result in theresultregister.decrement a result: Subtract one from the number in registeraand store the result in theresultregister.convert_to_bool a result: If the number in registerais not zero, writeTRUEto theresultregister. Otherwise, writeFALSEto theresultregister.bitwise_not a result: Compute the bitwise not ofaand store the result in theresultregister.negate a result: Treating the number inaas a signed integer, write-ato theresultregister.posit a result: Treating the number inaas a signed integer, write the absolute value ofato theresultregister.
constant i: insert the constant i into the source code of the program.
; comment is a comment; anything from ; to end-of-line will be ignored by
the compiler.
Two ready-to-fight competitors live in examples/:
runner.bln- a passive wanderer that drifts through memory hoping nobody has dropped a halt in its path.bomber.bln- builds the 16-bit halt encoding (256) in a register, picks two address counters far from its own body, and tight-loops storing halts across memory.
Compile and rumble:
uv run blenc --input examples/runner.bln --output runner.blc
uv run blenc --input examples/bomber.bln --output bomber.blc
uv run blen-rumble --seed 0 --max-steps 1000000 --no-logs runner.blc bomber.blc
bomber won!
Run many seeds in parallel for aggregate stats:
uv run blen-rumble --n-seeds 20 --max-steps 1000000 --seed 0 runner.blc bomber.blc
bomber wins: 6/20 (30.0%)
runner wins: 9/20 (45.0%)
draws: 0/20 (0.0%)
no winner: 5/20 (25.0%)
Programs share one block of memory at randomly chosen offsets. Bomber lives at
some bomber_offset; runner lives at some runner_offset. When runner's
program counter walks past the end of its noop slide, it keeps drifting
through real memory one cell at a time (most of which is zero, i.e. noop) and
eventually wanders straight into bomber's instruction sequence.
At that point, runner starts executing bomber's code - but with runner's own
register state and runner's own offset. Runner faithfully rebuilds the halt
encoding in R2, the address counters in R3 / R4, and falls into bomber's
tight write loop. Runner is now an inadvertent bomber, scattering halts across
memory at addresses computed against runner_offset instead of
bomber_offset.
A few thousand iterations later, runner's R3 has incremented far enough that
the next halt-store lands inside bomber's actual loop body in real memory.
Bomber's next read of its own loop returns halt. Bomber halts. Runner, still
quietly drifting (or, depending on where its PC is at that moment, still
running stolen code) is the sole survivor.
bomber doesn't lose because its strategy is wrong - bomber loses because its strategy is contagious.
Move the value in RX to RY: add R0 RX RY
Jump to the instruction at the address in RX: add R0 RX PROGRAM_COUNTER