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