Chris Collins

Lab · Playground

The Mandelbrot set as ASCII art

Nested loops and floating-point arithmetic in eighteen languages — plain loops, complex numbers, whole-array versions in R and BQN, and a recursive query in SQL — all drawing the same 66 × 23 picture, character for character.

Code blocks on this page are editable — change them and press Run.

The Mandelbrot set is the set of points c in the complex plane for which repeating z ← z² + c, starting from z = 0, never runs off to infinity. Run off it does, the moment |z| passes 2, so the program counts how many steps each point survives (up to 30) and picks a character for it from a ten-step ramp, ` .:-=+*#%@. Points that survive all 30 are in (or very near) the set and get `@.

It is a good second program: two nested loops over a grid, an inner loop with an early exit, and nothing but floating-point arithmetic. That last part is the interesting one here. Every block draws the same 66 × 23 picture, and they agree character for character — which only happens because every language does the same IEEE 754 double-precision operations in the same order. The grid is fixed: column x is at −2.1 + 0.04 x and row y at 1.1 − 0.1 y.

Things to try: zoom in by changing the starts and steps (−0.8 and 0.003 for the real axis, 0.25 and 0.008 for the imaginary one), raise the 30, or swap the ramp for your own.

Loops

JavaScript
const RAMP = ' .:-=+*#%@', MAX = 30;

function escapeTime(cx, cy) {
  let zx = 0, zy = 0, n = 0;
  while (n < MAX && zx * zx + zy * zy <= 4) {
    const t = zx * zx - zy * zy + cx;
    zy = 2 * zx * zy + cy;
    zx = t;
    n++;
  }
  return n;
}

for (let y = 0; y < 23; y++) {
  let line = '';
  for (let x = 0; x < 66; x++) {
    const n = escapeTime(-2.1 + x * 0.04, 1.1 - y * 0.1);
    line += RAMP[Math.floor(n * 9 / MAX)];
  }
  console.log(line);
}

The reference version. JavaScript has one number type, a 64-bit double, so there is nothing to choose; Math.floor turns the ramp position back into an index.

C
#include <stdio.h>

#define MAX 30
static const char ramp[] = " .:-=+*#%@";

static int escape_time(double cx, double cy) {
    double zx = 0, zy = 0;
    int n = 0;
    while (n < MAX && zx * zx + zy * zy <= 4) {
        double t = zx * zx - zy * zy + cx;
        zy = 2 * zx * zy + cy;
        zx = t;
        n++;
    }
    return n;
}

int main(void) {
    for (int y = 0; y < 23; y++) {
        char line[67];
        for (int x = 0; x < 66; x++)
            line[x] = ramp[escape_time(-2.1 + x * 0.04, 1.1 - y * 0.1) * 9 / MAX];
        line[66] = '\0';
        puts(line);
    }
    return 0;
}

The same loops with types written down. n * 9 / MAX is integer division, so no floor is needed, and the line is built in a fixed buffer. (Compilers may fuse a * b + c into one fused multiply-add on hardware that has it, which rounds once instead of twice and would change a character or two at the edge of the set; WebAssembly has no such instruction, so this matches.)

Rust
const RAMP: &[u8] = b" .:-=+*#%@";
const MAX: usize = 30;

fn escape_time(cx: f64, cy: f64) -> usize {
    let (mut zx, mut zy) = (0.0_f64, 0.0_f64);
    let mut n = 0;
    while n < MAX && zx * zx + zy * zy <= 4.0 {
        let t = zx * zx - zy * zy + cx;
        zy = 2.0 * zx * zy + cy;
        zx = t;
        n += 1;
    }
    n
}

fn main() {
    for y in 0..23 {
        let line: String = (0..66)
            .map(|x| {
                let n = escape_time(-2.1 + x as f64 * 0.04, 1.1 - y as f64 * 0.1);
                RAMP[n * 9 / MAX] as char
            })
            .collect();
        println!("{line}");
    }
}

Rust will not mix integers and floats silently: x as f64 is required, and 4.0 must be written as a float. Each row is an iterator collect-ed into a String.

Go
package main

import (
	"fmt"
	"strings"
)

const ramp = " .:-=+*#%@"
const max = 30

func escapeTime(cx, cy float64) int {
	zx, zy, n := 0.0, 0.0, 0
	for n < max && zx*zx+zy*zy <= 4 {
		zx, zy = float64(zx*zx)-float64(zy*zy)+cx, float64(2*zx*zy)+cy
		n++
	}
	return n
}

func main() {
	for y := 0; y < 23; y++ {
		var line strings.Builder
		for x := 0; x < 66; x++ {
			n := escapeTime(-2.1+float64(x)*0.04, 1.1-float64(y)*0.1)
			line.WriteByte(ramp[n*9/max])
		}
		fmt.Println(line.String())
	}
}

Go’s parallel assignment updates zx and zy together, so no temporary. The float64(…) conversions look redundant but are not: the Go spec lets the compiler fuse x*y + z into one multiply-add (and it does, on ARM), and an explicit conversion is the documented way to force the product to be rounded first. They keep this block’s picture identical to the others wherever the playground’s server happens to run.

Kotlin
const val RAMP = " .:-=+*#%@"
const val MAX = 30

fun escapeTime(cx: Double, cy: Double): Int {
    var zx = 0.0
    var zy = 0.0
    var n = 0
    while (n < MAX && zx * zx + zy * zy <= 4) {
        val t = zx * zx - zy * zy + cx
        zy = 2 * zx * zy + cy
        zx = t
        n++
    }
    return n
}

fun main() {
    for (y in 0 until 23) {
        println(buildString {
            for (x in 0 until 66) append(RAMP[escapeTime(-2.1 + x * 0.04, 1.1 - y * 0.1) * 9 / MAX])
        })
    }
}

buildString gives the block a StringBuilder as its receiver, so append needs no object in front of it. The JVM is strict about floating point (no fused operations unless you call Math.fma), which is why Java and Kotlin results are the same everywhere.

Lua
local RAMP, MAX = " .:-=+*#%@", 30

local function escape_time(cx, cy)
  local zx, zy, n = 0.0, 0.0, 0
  while n < MAX and zx * zx + zy * zy <= 4 do
    zx, zy = zx * zx - zy * zy + cx, 2 * zx * zy + cy
    n = n + 1
  end
  return n
end

for y = 0, 22 do
  local row = {}
  for x = 0, 65 do
    local k = escape_time(-2.1 + x * 0.04, 1.1 - y * 0.1) * 9 // MAX
    row[#row + 1] = RAMP:sub(k + 1, k + 1)
  end
  print(table.concat(row))
end

Lua 5.4 has separate integer and float subtypes and a floor-division operator //. Strings are indexed from 1, hence k + 1. Building a table and table.concat-ing it is the Lua way to make a string piece by piece.

Ruby
RAMP = " .:-=+*#%@"
MAX = 30

def escape_time(cx, cy)
  zx = zy = 0.0
  n = 0
  while n < MAX && zx * zx + zy * zy <= 4
    zx, zy = zx * zx - zy * zy + cx, 2 * zx * zy + cy
    n += 1
  end
  n
end

23.times do |y|
  puts (0...66).map { |x| RAMP[escape_time(-2.1 + x * 0.04, 1.1 - y * 0.1) * 9 / MAX] }.join
end

23.times and (0…​66).map — three dots excludes the end. Integer / floors, as in C.

PHP
<?php
const RAMP = ' .:-=+*#%@';
const MAX = 30;

function escape_time(float $cx, float $cy): int {
    $zx = $zy = 0.0;
    $n = 0;
    while ($n < MAX && $zx * $zx + $zy * $zy <= 4) {
        [$zx, $zy] = [$zx * $zx - $zy * $zy + $cx, 2 * $zx * $zy + $cy];
        $n++;
    }
    return $n;
}

for ($y = 0; $y < 23; $y++) {
    $line = '';
    for ($x = 0; $x < 66; $x++) {
        $line .= RAMP[intdiv(escape_time(-2.1 + $x * 0.04, 1.1 - $y * 0.1) * 9, MAX)];
    }
    echo $line, "\n";
}

PHP’s / always gives a float when it does not divide exactly, so intdiv it is. Array destructuring ([$a, $b] = […]) does the simultaneous update.

Perl
use strict;
use warnings;

my $ramp = ' .:-=+*#%@';
my $max  = 30;

sub escape_time {
    my ($cx, $cy) = @_;
    my ($zx, $zy, $n) = (0, 0, 0);
    while ($n < $max && $zx * $zx + $zy * $zy <= 4) {
        ($zx, $zy) = ($zx * $zx - $zy * $zy + $cx, 2 * $zx * $zy + $cy);
        $n++;
    }
    return $n;
}

for my $y (0 .. 22) {
    print join('', map {
        substr($ramp, int(escape_time(-2.1 + $_ * 0.04, 1.1 - $y * 0.1) * 9 / $max), 1)
    } 0 .. 65), "\n";
}

map with $_ builds each row in one expression, and list assignment does the simultaneous update. Perl numbers are doubles unless they happen to be integers, so int(… / $max) floors.

Complex numbers

Python
RAMP, MAX = " .:-=+*#%@", 30

def escape_time(c):
    z, n = 0j, 0
    while n < MAX and z.real * z.real + z.imag * z.imag <= 4:
        z = z * z + c
        n += 1
    return n

for y in range(23):
    print("".join(RAMP[escape_time(complex(-2.1 + x * 0.04, 1.1 - y * 0.1)) * 9 // MAX]
                  for x in range(66)))

Python has complex numbers built in (0j is zero), so the iteration is the formula itself. The escape test squares the parts by hand rather than using abs(z) > 2, because abs computes a square root — mathematically the same test, but it rounds differently and would move a character or two on the boundary.

C++
#include <complex>
#include <iostream>
#include <string>

constexpr int MAX = 30;
const std::string RAMP = " .:-=+*#%@";

int escape_time(std::complex<double> c) {
    std::complex<double> z = 0;
    int n = 0;
    while (n < MAX && std::norm(z) <= 4) {
        z = z * z + c;
        ++n;
    }
    return n;
}

int main() {
    for (int y = 0; y < 23; ++y) {
        std::string line;
        for (int x = 0; x < 66; ++x)
            line += RAMP[escape_time({-2.1 + x * 0.04, 1.1 - y * 0.1}) * 9 / MAX];
        std::cout << line << '\n';
    }
}

std::complex and std::norm (the squared magnitude — no square root, despite the name) say exactly what the maths says, and the compiler turns it into the same multiplications as the C version.

Fortran
program mandelbrot
  implicit none
  character(len=*), parameter :: ramp = ' .:-=+*#%@'
  integer, parameter :: max_iter = 30
  character(len=66) :: line
  integer :: x, y, n, k
  complex(8) :: c, z

  do y = 0, 22
    do x = 0, 65
      c = cmplx(-2.1d0 + x * 0.04d0, 1.1d0 - y * 0.1d0, kind=8)
      z = (0d0, 0d0)
      n = 0
      do while (n < max_iter .and. real(z)**2 + aimag(z)**2 <= 4d0)
        z = z * z + c
        n = n + 1
      end do
      k = n * 9 / max_iter
      line(x+1:x+1) = ramp(k+1:k+1)
    end do
    write (*, '(a)') trim(line)
  end do
end program mandelbrot

Fortran has had a built-in complex type for longer than most languages have existed. Literals need the d0 suffix to be double precision — without it, 0.05 is a single-precision number and the picture changes. x**2 with an integer power compiles to a multiplication.

R
ramp <- strsplit(" .:-=+*#%@", "")[[1]]
max_iter <- 30
re <- -2.1 + (0:65) * 0.04
im <- 1.1 - (0:22) * 0.1
c <- outer(im, re, function(i, r) complex(real = r, imaginary = i))
z <- complex(length(c))
n <- array(0L, dim(c))
for (step in seq_len(max_iter)) {
  alive <- Re(z)^2 + Im(z)^2 <= 4
  z[alive] <- z[alive]^2 + c[alive]
  n[alive] <- n[alive] + 1L
}
pic <- matrix(ramp[(n * 9) %/% max_iter + 1], nrow = 23)
cat(apply(pic, 1, paste, collapse = ""), sep = "\n")

No per-point loop: c is a 23 × 66 matrix of complex numbers and every step updates all the points that have not escaped yet, chosen by a logical mask. The loop runs over time instead of over space — the shape of a GPU shader, and of NumPy code. Note the brackets in (n * 9) %/% max_iter: R’s %…% operators bind more tightly than *, and without them every point comes out blank.

BQN
ramp ← " .:-=+*#%@"
max ← 30
cy ← (1.1 - 0.1 × ↕23) ⊣⌜ ↕66      # 23×66: the imaginary part of each point
cx ← (↕23) ⊢⌜ ¯2.1 + 0.04 × ↕66    # 23×66: the real part
Step ← {
  zx‿zy‿n ← 𝕩
  alive ← 4 ≥ (zx × zx) + zy × zy
  tx ← cx + (zx × zx) - zy × zy
  ty ← cy + 2 × zx × zy
  ⟨alive ⊑¨ zx ⋈¨ tx, alive ⊑¨ zy ⋈¨ ty, n + alive⟩
}
n ← 2 ⊑ Step⍟max ⟨0 × cx, 0 × cx, 0 × cx⟩
•Out¨ <˘ ramp ⊏˜ ⌊ 9 × n ÷ max
+´ ⥊ n = max

The same whole-array idea as R, in a language built for it. The state is three 23 × 66 arrays, Step⍟max applies the step function 30 times, and alive ⊑¨ old ⋈¨ new picks, point by point, whether to keep the old value or take the new one. <˘ splits the final character matrix into rows to print. BQN prints a program’s last value, so the last line counts the points that never escaped.

Recursion and queries

OCaml
let ramp = " .:-=+*#%@"
let max_iter = 30

let escape_time cx cy =
  let rec go zx zy n =
    if n >= max_iter || zx *. zx +. zy *. zy > 4. then n
    else go (zx *. zx -. zy *. zy +. cx) (2. *. zx *. zy +. cy) (n + 1)
  in
  go 0. 0. 0

let () =
  for y = 0 to 22 do
    print_endline
      (String.init 66 (fun x ->
           let cx = -2.1 +. float_of_int x *. 0.04 and cy = 1.1 -. float_of_int y *. 0.1 in
           ramp.[escape_time cx cy * 9 / max_iter]))
  done

The inner loop is a tail-recursive function carrying its state in its arguments — no mutable variables at all. OCaml keeps integer and float arithmetic visibly apart: *., +. and -. are the float operators, and mixing them up is a type error.

Clojure
(def ramp " .:-=+*#%@")
(def max-iter 30)

(defn escape-time [cx cy]
  (loop [zx 0.0 zy 0.0 n 0]
    (if (or (>= n max-iter) (> (+ (* zx zx) (* zy zy)) 4))
      n
      (recur (+ (- (* zx zx) (* zy zy)) cx)
             (+ (* 2 zx zy) cy)
             (inc n)))))

(doseq [y (range 23)]
  (println (apply str (for [x (range 66)]
                        (nth ramp (quot (* 9 (escape-time (+ -2.1 (* x 0.04))
                                                          (- 1.1 (* y 0.1))))
                                        max-iter))))))

loop/recur is Clojure’s explicit tail call: the same shape as the OCaml, with the compiler checking that recur really is in tail position. for is a list comprehension, not a loop.

SQL (SQLite)
WITH RECURSIVE
  xs(x) AS (SELECT 0 UNION ALL SELECT x + 1 FROM xs WHERE x < 65),
  ys(y) AS (SELECT 0 UNION ALL SELECT y + 1 FROM ys WHERE y < 22),
  it(x, y, cx, cy, zx, zy, n) AS (
    SELECT x, y, -2.1 + x * 0.04, 1.1 - y * 0.1, 0.0, 0.0, 0 FROM xs, ys
    UNION ALL
    SELECT x, y, cx, cy, zx * zx - zy * zy + cx, 2 * zx * zy + cy, n + 1
    FROM it
    WHERE n < 30 AND zx * zx + zy * zy <= 4
  ),
  escaped(x, y, n) AS (SELECT x, y, max(n) FROM it GROUP BY x, y)
SELECT group_concat(substr(' .:-=+*#%@', n * 9 / 30 + 1, 1), '' ORDER BY x) AS picture
FROM escaped
GROUP BY y
ORDER BY y;

The iteration becomes a recursive query: each row of it is one point at one step, and a point stops producing rows when it escapes. The escape time is then the largest step each point reached, and group_concat(… ORDER BY x) glues each row of the picture together. About 18 000 rows are generated and thrown away, and SQLite does not mind.

Classic BASIC
10 R$ = " .:-=+*#%@"
20 FOR Y = 0 TO 22
30   L$ = ""
40   FOR X = 0 TO 65
50     CX = -2.1 + X * 0.04: CY = 1.1 - Y * 0.1
60     ZX = 0: ZY = 0: N = 0
70     IF N >= 30 OR ZX * ZX + ZY * ZY > 4 THEN 110
80     T = ZX * ZX - ZY * ZY + CX: ZY = 2 * ZX * ZY + CY: ZX = T
90     N = N + 1
100    GOTO 70
110    L$ = L$ + MID$(R$, INT(N * 9 / 30) + 1, 1)
120  NEXT X
130  PRINT L$
140 NEXT Y

A Mandelbrot in BASIC was the classic way to make a home computer sweat; on an 8-bit machine a picture like this took minutes. The GOTO 70 loop is a while loop the hard way. (Real 8-bit BASICs used 40-bit floats, and their pictures differ at the edges; this one uses doubles.)

What it shows

Style Languages

Nested loops, mutable variables

JavaScript, C, Rust, Go, Kotlin, Lua, Ruby, PHP, Perl, BASIC

A complex-number type

Python, C++, Fortran, R

Whole arrays at once, looping over time not space

R, BQN

Tail recursion, no mutation

OCaml, Clojure

A recursive query

SQL

All eighteen produce the same 23 lines, and that is the point worth taking away: floating-point results are deterministic when every language does the same IEEE 754 operations in the same order. They stop being the same the moment something changes the order or the rounding — a fused multiply-add (Go’s float64(…) conversions are there to prevent one), an abs(z) > 2 with its square root instead of zx² + zy² > 4, or Fortran’s single-precision 0.05 instead of 0.05d0. Try one of those and look at the edge of the set: a few characters move, and nothing else does, because the edge is exactly where a rounding error decides whether a point escapes on step 29 or step 30.