TIS-100 — what a fake assembly grid taught me about pipelines and backpressure

TIS-100 is a puzzle game dressed up as restoring a fictional distributed computer from the 1980s. Each level gives you a grid of tiny nodes. Every node runs its own program, in parallel with the others, and the only way nodes talk is by moving integers through UP / DOWN / LEFT / RIGHT ports.

I picked it up as a break from normal work, then kept going because it hits the same nerves as low-level systems stuff: you are not writing one thread that does everything. You are placing computation on a mesh, routing data, and living with the fact that a MOV can stall when the neighbor is not ready to accept a value.

I have been transcribing my solutions into a small archive: github.com/dimgatz98/TIS-100-solutions. Nineteen puzzles so far, each with the problem text, per-node code, and the original screenshot. This post is the "why bother" version for anyone skimming a portfolio — not a leaderboard brag.

What the machine actually is

Strip away the retro UI and each compute node is roughly:

  • one accumulator (ACC)
  • one saved register (SAV), swapped with ACC via SWP
  • a handful of instructions: MOV, ADD, SUB, NEG, compares, branches
  • four directional ports; MOV UP, DOWN is both a load and a store, and it blocks if the port is not ready

Some tiles are stack memory (push/pop semantics on a fixed depth) or dead communication failure cells you have to route around. There is no shared RAM, no OS, no malloc. If you want to remember the previous sample, you keep it in SAV, on a stack, or in a loop that deliberately lags one step behind the stream.

That is much closer to thinking about DMA engines, hardware pipelines, or GPU warps than to solving the same logic in Python on one core — except the feedback loop is immediate. When you mis-time a pipeline, the simulator just sits there with ports blocked, and you stare at which node is waiting on whom.

Early puzzles: routing before algorithms

The first real task is almost insultingly simple on paper — copy IN.X to OUT.X and IN.A to OUT.A — but the grid already has holes:

MOV UP, DOWN

(on the ingress nodes) and a corner turn:

MOV UP, LEFT

The lesson is not the instruction. It is that throughput is a layout problem. You are choosing where each stream gets a dedicated lane so two independent flows do not try to use the same port in incompatible phases. That shows up again any time you have multiple IRQ lines, multiple NIC queues, or two goroutines that must not share one unbuffered channel in the wrong order.

When one node is not enough: differential outputs

Differential Converter asks for OUT.P = IN.A - IN.B and OUT.N = IN.B - IN.A (equivalently the negated pair). One node can subtract; another can negate:

MOV UP, ACC
SUB UP
MOV ACC, DOWN

and on a downstream node:

MOV UP, ACC
MOV ACC, DOWN
MOV ACC, RIGHT
MOV LEFT, ACC
NEG
MOV ACC, DOWN

Nothing clever — just splitting producer and consumer roles so each node’s program stays short. In hardware terms this is the same instinct as not cramming an entire pipeline stage into one piece of logic because you are short on registers; you serialize into a second stage instead.

Branches and synchronized inputs: signal multiplexer

Signal Multiplexer is the first place I had to treat the select line as part of the data plane. Node 11 reads IN.S and dispatches:

MOV UP, ACC
JEZ SUM
JGZ B
JLZ A
SUM:MOV LEFT, ACC
ADD RIGHT
MOV ACC, DOWN
JMP END
B: MOV RIGHT,DOWN
MOV LEFT, NIL
JMP END
A: MOV LEFT, DOWN
MOV RIGHT, NIL
END:

The detail that stuck with me is MOV ..., NIL: when you take only branch A or only branch B, you still have to discard the value waiting on the unused input. Otherwise the next selection reads the wrong pairing. That is backpressure and bookkeeping in one instruction — if you skip the NIL, you do not have a clean stream boundary for the next tuple.

Stateful streaming: edge detector

Signal Edge Detector compares each new sample to the previous one and emits 1 when the absolute change is at least 10 (with the first output forced to 0). Node 6 keeps the previous value in the saved register while the stream moves:

MOV UP, ACC
SAV
MOV UP, NIL
MOV 0, RIGHT
LOOP:
MOV UP, ACC
SWP
SUB UP
MOV ACC, RIGHT
SWP
SAV
JMP LOOP

Node 7 turns the signed difference into a thresholded flag:

MOV LEFT, ACC
JGZ POS
NEG
POS:
SUB 10
JLZ ZERO
MOV 1, DOWN
JMP END
ZERO:
MOV 0, DOWN
END:

This is a tiny two-stage pipeline: stage one computes current - previous and passes it sideways; stage two is a branchy comparator. The SAV / SWP dance is register pressure in miniature — you only get one extra slot, so “remember last sample” competes with “hold partial result”. I have written the same pattern in C with a static prev and in Verilog with a registered delay line; the game just makes the tradeoff visible.

Stack nodes and protocols: sequence reverser

Sequence Reverser uses a stack memory tile and a counter-based handshake. Node 6 pushes the whole zero-terminated sequence while counting elements; node 7 shuttles between stack and output; node 11 drains exactly that many values:

START: MOV 0, ACC
SAV
LOOP: MOV UP, ACC
JEZ WRITE
SWP
ADD 1
SWP
MOV ACC, DOWN
JMP LOOP
WRITE: SWP
MOV ACC, RIGHT
MOV RIGHT, NIL
JMP START
START: MOV UP, ACC
LOOP:SUB 1
JLZ RESET
MOV LEFT,DOWN
JMP LOOP
RESET: MOV 0, DOWN
MOV 0, UP
JMP START

There is no CALL instruction. The “function” that knows how many items to pop is agreed by protocol (count passed on RIGHT, sentinel 0 on the stream). That mirrors how you design packet formats or RPC frames in real systems: the receiver only works if the sender uses the same length discipline.

Division without a divide instruction

Signal Divider was the puzzle that felt most like microcode homework. The game gives you add/sub/move, not hardware divide. My approach is repeated subtraction with nodes passing partial remainders and quotients. One subtract loop on node 6:

START:MOV UP, ACC
LOOP:SUB RIGHT
MOV ACC, LEFT
JEZ R
JLZ R
MOV 0, RIGHT
JMP LOOP
R: MOV -1, RIGHT

and supporting logic on nodes 7 and 9 that shuffle ACC / stack (SWP) and increment a quotient counter. It is slow and inelegant compared to a barrel divider, but it forces you to ask what division even means operationally: repeated reduction with borrow conditions, not a single opcode.

What I think this is good for

If you are interviewing for something closer to the metal — embedded, drivers, performance-sensitive backend work, GPU kernels — TIS-100 is a cheap way to practice skills that do not show up in LeetCode:

  • Parallelism by default. Every node runs every cycle unless blocked. You reason about many programs at once.
  • Backpressure is explicit. A blocked port is a stall you can see; there is no hidden async runtime buffering your mistakes.
  • Register pressure is real. One saved register and one accumulator; spilling “state” means stack nodes or slower pipelines.
  • Correctness is tied to timing. Getting the math right on paper is not enough if values arrive at the wrong cycle.

It is still a game. The instruction set is made up, the grids are tiny, and the scoring UI cares about cycle counts in ways I did not optimize for. My archive screenshots are idle program views, not completion screens — so I am not claiming verified cycle scores or optimal solutions in the repo. The value for me was the documentation pass: typing each node from the screenshot forced me to notice labels, dead nodes, and NIL drains I had previously muscle-memorized.

The archive itself

Each entry in the repo follows the same shape: problem statement transcribed from the game, ASCII grid with our own position numbering (including failure and stack tiles), one code block per node, embedded PNG, short explanation with an honest confidence note.

I use separate commits per puzzle so diffs stay readable when I fix a transcription. The archive is structural documentation, not a verified test suite — I'm checking that files and links exist, not that the programs still pass the puzzles.

If you want primary sources, start with 001 Self-Test Diagnostic for routing, 008 Signal Edge Detector for pipeline state, and 019 Signal Divider for the arithmetic endgame.

I still have more puzzles in the game than in the repo. When those screenshots exist, they will get the same treatment — transcribe first, explain second, and resist the urge to “clean up” code the game never showed.