Skip to content

(Ab)using channels to implement a 3D pipe game

While browsing through A Tour of Go, I came across the following description of channels:

Channels are a typed conduit through which you can send and receive values

Conduit? Sounds like the perfect data structure for implementing a 3D rotating pipe game!



Since there are many different kinds of pipe games, let's set some ground rules for our variant:

  • Pipe segments are laid out in a N x N x N grid.
  • Each pipe segment in the grid is a cube, with exactly two faces connected by a pipe.
  • Pipe segments are undirected.
  • Each pipe segment (except the starting and ending blocks) can be rotated, but not translated (moved).
  • Water enters the grid at the top face of the block at (0, 0, 0) and should exit from the bottom face of (N-1, N-1, N-1).

In our implementation, we'll try to use channels wherever practical, while keeping it (relatively) simple.

If we do everything right, checking if the puzzle is solved should be as easy as:

go
blocks[0][0][0].Top() <- "solved"
msg := <-blocks[n-1][n-1][n-1].Bottom()
fmt.Println(msg)

Note on coordinates

Coordinates are zero-indexed.

(0, 1, 2) refers to the block at

  • the first layer (sheet) from the front,
  • the second row from the top, and
  • the third column from the left.

Channels as pipes

Let's start with something easy: linking two channels. This represents a connection between two pipes.

Since the pipes in our game are undirected, we can use select to receive from either channel, then send the result into the other channel:

go
func LinkChannels(c1 chan string, c2 chan string) {
	go func() {
		select {
		case v := <-c1:
			c2 <- v
		case v := <-c2:
			c1 <- v
		}
	}()
}

Channels as arrays

Each pipe segment in the game is implemented using a Block struct. This struct stores 6 channels, one for each face of the cube. These channels will be stored in two groups (Front/Right/Back/Left and Top/Bottom) for reasons that will be explained later.

We will use a channel to store each group of channels. We could use an array, but

  1. That's boring
  2. Using chan chan string makes rotations intuitive(ish) (citation needed)
go
type Block struct {
	Channels  chan chan string // Front/Right/Back/Left 
	Channels2 chan chan string // Top/Bottom
}

func NewBlock() *Block {
	b := Block{}
	b.Channels = make(chan chan string, 4)
	b.Channels2 = make(chan chan string, 3) // One extra channel for reasons
	for i := range 6 {
		if i < 4 {
			b.Channels <- make(chan string)
		} else {
			b.Channels2 <- make(chan string)
		}
	}
	return &b
}

Rotations

To solve the puzzle, the player must rotate blocks. There are two types of rotations: horizontal (left/right) and vertical (up/down). Since each "horizontal" face of the cube is stored in Channels, a left rotation can be implemented by simply receiving the first item in the channel and sending it to the back.

go
// [Front, Right, Back, Left] -> [Right, Back, Left, Front]
func (b *Block) LeftRotate() chan string {
	v := <-b.Channels
	b.Channels <- v
	return v
}

This wouldn't work if we included the "vertical" faces in the same channel-array.

We'll also return the "head" of Channels so that the Front, Right, Back and Left methods can be implemented:

go
func (b *Block) Front() (res chan string) {
	res = b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Right() (res chan string) {
	b.LeftRotate()
	res = b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Back() (res chan string) {
	b.LeftRotate()
	b.LeftRotate()
	res = b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Left() (res chan string) {
	b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	res = b.LeftRotate()
	return
}

A right rotation is just three left rotations. Rotation along the vertical axis is a bit more complicated, as it involves both "horizontal" and "vertical" faces of the block:

go
func (b *Block) UpRotate() {
    // The front face goes to the top
    // (this is why Channels2 has capacity 3)
    b.Channels2 <- <-b.Channels
    // Extract the old top
	top := <-b.Channels2
    // Extract the old bottom
	bottom := <-b.Channels2 
    // The bottom face goes to the front
	b.Channels <- bottom 
    // Skip the right face
	b.Channels <- <-b.Channels 
    // The back face goes to the bottom
	b.Channels2 <- <-b.Channels 
    // The top face goes to the back
	b.Channels <- top 
	// Rotate the front face back in front
	b.Channels <- <-b.Channels
}

With that beautiful and perfectly readable piece of code out of the way, the game is almost complete.

In addition to the connections within a block, we also need to link channels where the faces of two adjacent blocks meet.

For example, the right face of the block at (0, 0, 0) should be connected to the left face of (0, 0, 1).

Repeat this across all three dimensions, and we've got ourselves the Link function:

go
func Link(blocks [][][]*Block) {
	safeGet := func(d int, r int, c int) *Block {
		if d < 0 || d >= n {
			return nil
		}
		if r < 0 || r >= n {
			return nil
		}
		if c < 0 || c >= n {
			return nil
		}
		return blocks[d][r][c]
	}

	for sheet := range n {
		for row := range n {
			for col := range n {
				block := blocks[sheet][row][col]

				above := safeGet(sheet, row-1, col)
				if above != nil {
					LinkChannels(above.Bottom(), block.Top())
				}

				left := safeGet(sheet, row, col-1)
				if left != nil {
					LinkChannels(left.Right(), block.Left())
				}

				front := safeGet(sheet-1, row, col)
				if front != nil {
					LinkChannels(front.Back(), block.Front())
				}
			}
		}
	}
}

Since the channel corresponding to the "right face" of a block might change when the block is rotated, inter-block links can only be established after the player has completed all block rotations.

Putting it together

We now have all the pieces needed for a functional game!

Our program will need to:

  1. Initialize the NxNxN grid
  2. Define intra-block links. These links between faces of the same block set the puzzle's initial state.
  3. Prompt the player for block rotations
  4. Perform the requested rotations
  5. Build inter-block links
  6. Send a message into the input channel
  7. Receive from the output channel

If we are able to receive from the output channel, a valid path of pipes from the input to the output exists, and the puzzle is solved.

However, if the message has not reached the output channel, and the Go runtime detects that all goroutines are blocked on a receive, the program crashes with

go : fatal error: all goroutines are asleep - deadlock!

which signals to the player that they messed up somewhere.

Conclusion

By defining the puzzle using links between channels, we've completely eliminated the need for a Solve function or any pathfinding algorithms!

Instead, by using a "declarative" approach, all that work has been offloaded to select, goroutines, and the Go runtime.

Surprisingly, apart from rotations (which would be complicated anyway), the rest of the game is fairly straightforward to implement and (hopefully) to understand.

I hope you've enjoyed this unconventional use of Go's channels.

A variant of this program was submitted as a reverse engineering challenge to GreyCTF Finals 2025.

If you'd like to try it out, the full code for the game (including a 4x4x4 puzzle) can be found below:

go
package main

import (
	"fmt"
	"slices"
)

const n = 4

type Block struct {
	Channels  chan chan string
	Channels2 chan chan string
}

func NewBlock() *Block {
	b := Block{}
	b.Channels = make(chan chan string, 4)
	b.Channels2 = make(chan chan string, 3)
	for i := range 6 {
		if i < 4 {
			b.Channels <- make(chan string)
		} else {
			b.Channels2 <- make(chan string)
		}
	}
	return &b
}

func (b *Block) LeftRotate() chan string {
	v := <-b.Channels
	b.Channels <- v
	return v
}

func (b *Block) RightRotate() {
	for range 3 {
		b.LeftRotate()
	}
}

func (b *Block) UpRotate() {
	b.Channels2 <- <-b.Channels
	top := <-b.Channels2
	bottom := <-b.Channels2
	b.Channels <- bottom
	b.Channels <- <-b.Channels
	b.Channels2 <- <-b.Channels
	b.Channels <- top
	b.Channels <- <-b.Channels
}

func (b *Block) DownRotate() {
	for range 3 {
		b.UpRotate()
	}
}

func (b *Block) Front() (res chan string) {
	res = b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Top() (res chan string) {
	res = <-b.Channels2
	b.Channels2 <- res
	b.Channels2 <- <-b.Channels2
	return
}

func (b *Block) Bottom() (res chan string) {
	b.Channels2 <- <-b.Channels2
	res = <-b.Channels2
	b.Channels2 <- res
	return
}

func (b *Block) Right() (res chan string) {
	b.LeftRotate()
	res = b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Back() (res chan string) {
	b.LeftRotate()
	b.LeftRotate()
	res = b.LeftRotate()
	b.LeftRotate()
	return
}

func (b *Block) Left() (res chan string) {
	b.LeftRotate()
	b.LeftRotate()
	b.LeftRotate()
	res = b.LeftRotate()
	return
}

func LinkChannels(c1 chan string, c2 chan string) {
	go func() {
		select {
		case v := <-c1:
			c2 <- v
		case v := <-c2:
			c1 <- v
		}
	}()
}

func Link(blocks [][][]*Block) {
	safeGet := func(d int, r int, c int) *Block {
		if d < 0 || d >= n {
			return nil
		}
		if r < 0 || r >= n {
			return nil
		}
		if c < 0 || c >= n {
			return nil
		}
		return blocks[d][r][c]
	}

	for sheet := range n {
		for row := range n {
			for col := range n {
				block := blocks[sheet][row][col]

				above := safeGet(sheet, row-1, col)
				if above != nil {
					LinkChannels(above.Bottom(), block.Top())
				}

				left := safeGet(sheet, row, col-1)
				if left != nil {
					LinkChannels(left.Right(), block.Left())
				}

				front := safeGet(sheet-1, row, col)
				if front != nil {
					LinkChannels(front.Back(), block.Front())
				}
			}
		}
	}
}

func MakeBoard(n int) [][][]*Block {
	out := make([][][]*Block, n)
	for i := range n {
		out[i] = make([][]*Block, n)
		for j := range n {
			out[i][j] = make([]*Block, n)
			for k := range n {
				out[i][j][k] = NewBlock()
			}
		}
	}
	return out
}

func main() {
	blocks := MakeBoard(n)
	LinkChannels(blocks[0][0][0].Back(), blocks[0][0][0].Top())
	LinkChannels(blocks[0][0][1].Front(), blocks[0][0][1].Top())
	LinkChannels(blocks[0][0][2].Bottom(), blocks[0][0][2].Left())
	LinkChannels(blocks[0][0][3].Back(), blocks[0][0][3].Bottom())
	LinkChannels(blocks[0][1][0].Front(), blocks[0][1][0].Top())
	LinkChannels(blocks[0][1][1].Front(), blocks[0][1][1].Left())
	LinkChannels(blocks[0][1][2].Right(), blocks[0][1][2].Top())
	LinkChannels(blocks[0][1][3].Bottom(), blocks[0][1][3].Top())
	LinkChannels(blocks[0][2][0].Right(), blocks[0][2][0].Right())
	LinkChannels(blocks[0][2][1].Bottom(), blocks[0][2][1].Left())
	LinkChannels(blocks[0][2][2].Bottom(), blocks[0][2][2].Right())
	LinkChannels(blocks[0][2][3].Left(), blocks[0][2][3].Top())
	LinkChannels(blocks[0][3][0].Back(), blocks[0][3][0].Front())
	LinkChannels(blocks[0][3][1].Bottom(), blocks[0][3][1].Left())
	LinkChannels(blocks[0][3][2].Back(), blocks[0][3][2].Top())
	LinkChannels(blocks[0][3][3].Left(), blocks[0][3][3].Top())
	LinkChannels(blocks[1][0][0].Right(), blocks[1][0][0].Top())
	LinkChannels(blocks[1][0][1].Back(), blocks[1][0][1].Left())
	LinkChannels(blocks[1][0][2].Back(), blocks[1][0][2].Bottom())
	LinkChannels(blocks[1][0][3].Back(), blocks[1][0][3].Front())
	LinkChannels(blocks[1][1][0].Left(), blocks[1][1][0].Left())
	LinkChannels(blocks[1][1][1].Front(), blocks[1][1][1].Left())
	LinkChannels(blocks[1][1][2].Right(), blocks[1][1][2].Top())
	LinkChannels(blocks[1][1][3].Back(), blocks[1][1][3].Bottom())
	LinkChannels(blocks[1][2][0].Back(), blocks[1][2][0].Left())
	LinkChannels(blocks[1][2][1].Back(), blocks[1][2][1].Top())
	LinkChannels(blocks[1][2][2].Back(), blocks[1][2][2].Bottom())
	LinkChannels(blocks[1][2][3].Back(), blocks[1][2][3].Top())
	LinkChannels(blocks[1][3][0].Bottom(), blocks[1][3][0].Bottom())
	LinkChannels(blocks[1][3][1].Left(), blocks[1][3][1].Top())
	LinkChannels(blocks[1][3][2].Front(), blocks[1][3][2].Top())
	LinkChannels(blocks[1][3][3].Front(), blocks[1][3][3].Front())
	LinkChannels(blocks[2][0][0].Back(), blocks[2][0][0].Bottom())
	LinkChannels(blocks[2][0][1].Back(), blocks[2][0][1].Front())
	LinkChannels(blocks[2][0][2].Back(), blocks[2][0][2].Front())
	LinkChannels(blocks[2][0][3].Bottom(), blocks[2][0][3].Front())
	LinkChannels(blocks[2][1][0].Right(), blocks[2][1][0].Top())
	LinkChannels(blocks[2][1][1].Back(), blocks[2][1][1].Left())
	LinkChannels(blocks[2][1][2].Bottom(), blocks[2][1][2].Right())
	LinkChannels(blocks[2][1][3].Back(), blocks[2][1][3].Top())
	LinkChannels(blocks[2][2][0].Bottom(), blocks[2][2][0].Right())
	LinkChannels(blocks[2][2][1].Back(), blocks[2][2][1].Bottom())
	LinkChannels(blocks[2][2][2].Back(), blocks[2][2][2].Front())
	LinkChannels(blocks[2][2][3].Back(), blocks[2][2][3].Front())
	LinkChannels(blocks[2][3][0].Back(), blocks[2][3][0].Right())
	LinkChannels(blocks[2][3][1].Left(), blocks[2][3][1].Top())
	LinkChannels(blocks[2][3][2].Back(), blocks[2][3][2].Right())
	LinkChannels(blocks[2][3][3].Back(), blocks[2][3][3].Left())
	LinkChannels(blocks[3][0][0].Bottom(), blocks[3][0][0].Front())
	LinkChannels(blocks[3][0][1].Front(), blocks[3][0][1].Right())
	LinkChannels(blocks[3][0][2].Left(), blocks[3][0][2].Top())
	LinkChannels(blocks[3][0][3].Front(), blocks[3][0][3].Right())
	LinkChannels(blocks[3][1][0].Bottom(), blocks[3][1][0].Top())
	LinkChannels(blocks[3][1][1].Front(), blocks[3][1][1].Right())
	LinkChannels(blocks[3][1][2].Left(), blocks[3][1][2].Right())
	LinkChannels(blocks[3][1][3].Left(), blocks[3][1][3].Top())
	LinkChannels(blocks[3][2][0].Back(), blocks[3][2][0].Right())
	LinkChannels(blocks[3][2][1].Front(), blocks[3][2][1].Left())
	LinkChannels(blocks[3][2][2].Front(), blocks[3][2][2].Right())
	LinkChannels(blocks[3][2][3].Front(), blocks[3][2][3].Left())
	LinkChannels(blocks[3][3][0].Front(), blocks[3][3][0].Right())
	LinkChannels(blocks[3][3][1].Left(), blocks[3][3][1].Right())
	LinkChannels(blocks[3][3][2].Front(), blocks[3][3][2].Left())
	LinkChannels(blocks[3][3][3].Bottom(), blocks[3][3][3].Front())

	nMoves := 0
	fmt.Print("Enter number of moves: ")
	fmt.Scan(&nMoves)
	for range nMoves {
		x := 0
		y := 0
		z := 0
		fmt.Print("X: ")
		fmt.Scan(&x)
		fmt.Print("Y: ")
		fmt.Scan(&y)
		fmt.Print("Z: ")
		fmt.Scan(&z)
		if x == y && y == z && (x == 0 || x == n) {
			panic("Illegal")
		}
		m := 0
		fmt.Print("M: ")
		fmt.Scan(&m)
		b := blocks[x][y][z]
		if m == 0 {
			b.UpRotate()
		} else if m == 1 {
			b.DownRotate()
		} else if m == 2 {
			b.LeftRotate()
		} else if m == 3 {
			b.RightRotate()
		} else {
			panic("Illegal")
		}
	}

	Link(blocks)

	blocks[0][0][0].Top() <- "solved"
	msg := <-blocks[n-1][n-1][n-1].Bottom()
	fmt.Println(msg)
}