The spinning cube on this site's front page is about a hundred lines of plain JavaScript: no canvas, no WebGL, no libraries. It writes characters into a <pre> tag, thirty times a second. This post builds it from nothing, one step at a time. Every step is a complete, standalone HTML file you can paste into a text editor and open in a browser, and every step has a live result rendered right here on the page.

  1. A raw square — point cloud, projection, z-buffer, shading
  2. A rotation — the three rotation matrices
  3. Continuous rotation — a timer and an ever-growing angle
  4. Drag to rotate — sliders wired straight into the angles
  5. Choose the spin axis — Rodrigues' formula and a moving axle

Step 1 — a raw square

A cube is six flat faces. We do not need triangles or edges: we simply sample each face on a 100×100 grid and get about 60,000 points on the surface. For every point we keep two things — its position and the outward normal of the face it belongs to.

Each point then goes through the same tiny pipeline:

  • Back-face cull. The viewer sits on the negative z side looking toward +z. If the normal points away from the viewer (nz > 0) the face is on the far side of the cube and we skip it.
  • Project. Map x ∈ [−0.5, 0.5] to a column on an 80×25 character screen. Terminal cells are roughly twice as tall as they are wide, so we stretch x by 20 but y by only 10 to keep the cube square.
  • Depth test. A z-buffer stores 1 / (z + 2) for every cell; a point only wins the cell if it is nearer than what is already there.
  • Shade. Dot the normal with a light coming from above and in front, (0, −1, −1)/√2, and pick one of twelve characters .,-~:;=!*#$@ from dark to bright.
step1.html
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25;                       // screen size in characters
var SHADES = ".,-~:;=!*#$@";              // dark ... bright
var N = 100;                              // samples per face edge

// six faces: [normal, tangent u, tangent v]
var FACES = [
  [[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
  [[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
  [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];

function render() {
  var buf = [], zbuf = [];
  for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }

  for (var f = 0; f < 6; f++) {
    var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
    for (var a = 0; a <= N; a++) {
      for (var b = 0; b <= N; b++) {
        var s = a / N - 0.5, t = b / N - 0.5;
        // point on the face (cube side = 1, centred on the origin)
        var px = 0.5 * n[0] + s * u[0] + t * v[0],
            py = 0.5 * n[1] + s * u[1] + t * v[1],
            pz = 0.5 * n[2] + s * u[2] + t * v[2];
        // outward normal of that face
        var nx = n[0], ny = n[1], nz = n[2];

        if (nz > 0) continue;              // facing away from the viewer

        var x = 0 | (40 + 20 * px),        // project to a screen cell
            y = 0 | (12 + 10 * py),
            D = 1 / (pz + 2),              // "nearness" for the z-buffer
            o = x + W * y;
        var L = 0 | (11 * (-ny - nz) / Math.SQRT2);   // -11 ... 11

        if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
          zbuf[o] = D;
          buf[o] = SHADES[L > 0 ? L : 0];
        }
      }
    }
  }

  var rows = [];
  for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
  document.getElementById("screen").textContent = rows.join("\n");
}

render();
</script>

With no rotation, the only face whose normal has nz < 0 is the front one, so all we see is a flat, uniformly lit square:


It is not much to look at, but every part of the renderer is already there. Everything that follows only changes where the points are before they hit the projection.

Step 2 — add a rotation

Rotating a point around an axis is a 2×2 rotation of the other two coordinates. Around x the y/z pair turns, around y the z/x pair, around z the x/y pair:

$$R_x(\theta)=\begin{pmatrix}1&0&0\\0&\cos\theta&-\sin\theta\\0&\sin\theta&\cos\theta\end{pmatrix}$$ $$R_y(\theta)=\begin{pmatrix}\cos\theta&0&\sin\theta\\0&1&0\\-\sin\theta&0&\cos\theta\end{pmatrix}$$ $$R_z(\theta)=\begin{pmatrix}\cos\theta&-\sin\theta&0\\\sin\theta&\cos\theta&0\\0&0&1\end{pmatrix}$$

Two rules make it work: the rotation must be applied to both the position and the normal (otherwise the shading and the culling describe a cube that is no longer there), and the order matters — rotate(X) followed by rotate(Y) is not the same pose as the reverse. We settle on X, then Y, then Z.

The full file, with the new rotate() helper and the two lines that call it highlighted by comments:

step2.html
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;

var FACES = [
  [[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
  [[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
  [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];

// NEW: rotate vector p = [x, y, z] in place by ax, ay, az degrees
function rotate(p, ax, ay, az) {
  var r = Math.PI / 180, c, s, t;
  c = Math.cos(ax * r); s = Math.sin(ax * r);          // --- X axis
  t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
  c = Math.cos(ay * r); s = Math.sin(ay * r);          // --- Y axis
  t = p[0]; p[0] =  t * c + p[2] * s; p[2] = -t * s + p[2] * c;
  c = Math.cos(az * r); s = Math.sin(az * r);          // --- Z axis
  t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}

var ROT_X = 30, ROT_Y = 45, ROT_Z = 0;                 // NEW: the pose

function render() {
  var buf = [], zbuf = [];
  for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }

  for (var f = 0; f < 6; f++) {
    var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
    for (var a = 0; a <= N; a++) {
      for (var b = 0; b <= N; b++) {
        var s = a / N - 0.5, t = b / N - 0.5;
        var p = [0.5 * n[0] + s * u[0] + t * v[0],
                 0.5 * n[1] + s * u[1] + t * v[1],
                 0.5 * n[2] + s * u[2] + t * v[2]];
        var m = [n[0], n[1], n[2]];

        rotate(p, ROT_X, ROT_Y, ROT_Z);               // NEW: position
        rotate(m, ROT_X, ROT_Y, ROT_Z);               // NEW: normal

        if (m[2] > 0) continue;

        var x = 0 | (40 + 20 * p[0]),
            y = 0 | (12 + 10 * p[1]),
            D = 1 / (p[2] + 2),
            o = x + W * y;
        var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);

        if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
          zbuf[o] = D;
          buf[o] = SHADES[L > 0 ? L : 0];
        }
      }
    }
  }

  var rows = [];
  for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
  document.getElementById("screen").textContent = rows.join("\n");
}

render();
</script>

Tilt 30° around x and 45° around y and three faces come into view, each with its own brightness because each normal now makes a different angle with the light:


Step 3 — continuous rotation

Animation is the cheapest step of all. Keep a global angle, bump it by a small amount every frame, and call render() from a timer. We fix a tilt of 45° around x and 45° around z so the cube sits on a corner, then spin it around the vertical y axis.

Note the order in the code: the tilt is applied first, the spin second. That way the spin happens around the world's up-axis and the tilted cube turns like a top, rather than the tilt being spun around with it.

step3.html
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;

var FACES = [
  [[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
  [[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
  [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];

function rotate(p, ax, ay, az) {
  var r = Math.PI / 180, c, s, t;
  c = Math.cos(ax * r); s = Math.sin(ax * r);
  t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
  c = Math.cos(ay * r); s = Math.sin(ay * r);
  t = p[0]; p[0] =  t * c + p[2] * s; p[2] = -t * s + p[2] * c;
  c = Math.cos(az * r); s = Math.sin(az * r);
  t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}

var TILT_X = 45, TILT_Z = 45;      // fixed pose
var spin = 0;                      // NEW: grows every frame (degrees)

function render() {
  var buf = [], zbuf = [];
  for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }

  for (var f = 0; f < 6; f++) {
    var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
    for (var a = 0; a <= N; a++) {
      for (var b = 0; b <= N; b++) {
        var s = a / N - 0.5, t = b / N - 0.5;
        var p = [0.5 * n[0] + s * u[0] + t * v[0],
                 0.5 * n[1] + s * u[1] + t * v[1],
                 0.5 * n[2] + s * u[2] + t * v[2]];
        var m = [n[0], n[1], n[2]];

        rotate(p, TILT_X, 0, TILT_Z);  rotate(p, 0, spin, 0);   // tilt, then spin
        rotate(m, TILT_X, 0, TILT_Z);  rotate(m, 0, spin, 0);

        if (m[2] > 0) continue;

        var x = 0 | (40 + 20 * p[0]),
            y = 0 | (12 + 10 * p[1]),
            D = 1 / (p[2] + 2),
            o = x + W * y;
        var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);

        if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
          zbuf[o] = D;
          buf[o] = SHADES[L > 0 ? L : 0];
        }
      }
    }
  }

  var rows = [];
  for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
  document.getElementById("screen").textContent = rows.join("\n");
}

setInterval(function () {          // NEW: ~33 frames per second
  spin += 4;                       // 4 degrees per frame
  render();
}, 30);
</script>

Watch the faces: the brightness of each one glides through the twelve shades as its normal swings toward and away from the light. That is the whole "lighting model", and it falls out of one dot product.

Step 4 — drag to rotate

The renderer already takes three angles; all we need is three <input type="range"> sliders and an input event that reads them and re-renders. Because a frame is a few milliseconds the cube follows the thumb with no perceptible lag. No timer this time — the cube only redraws when you move something.

step4.html
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<!-- NEW: the control board -->
<label>X <input type="range" id="rx" min="0" max="360" value="30"> <span id="rx-v">30</span>°</label><br>
<label>Y <input type="range" id="ry" min="0" max="360" value="45"> <span id="ry-v">45</span>°</label><br>
<label>Z <input type="range" id="rz" min="0" max="360" value="0">  <span id="rz-v">0</span>°</label>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;

var FACES = [
  [[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
  [[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
  [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];

function rotate(p, ax, ay, az) {
  var r = Math.PI / 180, c, s, t;
  c = Math.cos(ax * r); s = Math.sin(ax * r);
  t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
  c = Math.cos(ay * r); s = Math.sin(ay * r);
  t = p[0]; p[0] =  t * c + p[2] * s; p[2] = -t * s + p[2] * c;
  c = Math.cos(az * r); s = Math.sin(az * r);
  t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}

function render(ax, ay, az) {                          // NEW: angles are arguments
  var buf = [], zbuf = [];
  for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }

  for (var f = 0; f < 6; f++) {
    var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
    for (var a = 0; a <= N; a++) {
      for (var b = 0; b <= N; b++) {
        var s = a / N - 0.5, t = b / N - 0.5;
        var p = [0.5 * n[0] + s * u[0] + t * v[0],
                 0.5 * n[1] + s * u[1] + t * v[1],
                 0.5 * n[2] + s * u[2] + t * v[2]];
        var m = [n[0], n[1], n[2]];

        rotate(p, ax, ay, az);
        rotate(m, ax, ay, az);

        if (m[2] > 0) continue;

        var x = 0 | (40 + 20 * p[0]),
            y = 0 | (12 + 10 * p[1]),
            D = 1 / (p[2] + 2),
            o = x + W * y;
        var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);

        if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
          zbuf[o] = D;
          buf[o] = SHADES[L > 0 ? L : 0];
        }
      }
    }
  }

  var rows = [];
  for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
  document.getElementById("screen").textContent = rows.join("\n");
}

// NEW: read the sliders and redraw whenever one of them moves
function fromSliders() {
  var ax = +document.getElementById("rx").value,
      ay = +document.getElementById("ry").value,
      az = +document.getElementById("rz").value;
  document.getElementById("rx-v").textContent = ax;
  document.getElementById("ry-v").textContent = ay;
  document.getElementById("rz-v").textContent = az;
  render(ax, ay, az);
}
["rx", "ry", "rz"].forEach(function (id) {
  document.getElementById(id).addEventListener("input", fromSliders);
});
fromSliders();
</script>

  

Try dragging Y from 0 to 360 with the other two at zero: you will see the cube turn through exactly one revolution and notice that with the front face parallel to the screen (0°, 90°, 180°…) we are back to the square of step 1.

Step 5 — choose the spin axis

Step 3 could only spin around y. The real question is: can the reader pick any axis and have the cube spin around it? Yes, and it takes one more formula. Given a unit axis k and an angle θ, Rodrigues' rotation formula rotates any vector v:

$$ \mathbf{v}' = \mathbf{v}\cos\theta + (\mathbf{k}\times\mathbf{v})\sin\theta + \mathbf{k}\,(\mathbf{k}\cdot\mathbf{v})(1-\cos\theta) $$

The three matrices of step 2 are just special cases of this with k = (1,0,0), (0,1,0), (0,0,1). To let the user aim the axle we describe it with two sliders, an azimuth (around the vertical) and an elevation (up or down), and convert to a unit vector with spherical coordinates. A third slider sets the speed, and three more keep the tilt from step 4. As a bonus we draw the axle itself as a line of + characters passing through the z-buffer, so it disappears inside the cube and pokes out on both sides.

step5.html
<!doctype html>
<pre id="screen" style="font: 12px/1.1 monospace"></pre>
<label>tilt X <input type="range" id="rx" min="0" max="360" value="45"></label><br>
<label>tilt Y <input type="range" id="ry" min="0" max="360" value="0"></label><br>
<label>tilt Z <input type="range" id="rz" min="0" max="360" value="45"></label><br>
<label>axis azimuth   <input type="range" id="az" min="0" max="360" value="0"></label><br>
<label>axis elevation <input type="range" id="el" min="-90" max="90" value="90"></label><br>
<label>speed °/frame  <input type="range" id="sp" min="0" max="12" value="4"></label>
<script>
var W = 80, H = 25;
var SHADES = ".,-~:;=!*#$@";
var N = 100;

var FACES = [
  [[ 1,0,0],[0,1,0],[0,0,1]], [[-1,0,0],[0,1,0],[0,0,1]],
  [[0, 1,0],[1,0,0],[0,0,1]], [[0,-1,0],[1,0,0],[0,0,1]],
  [[0,0, 1],[1,0,0],[0,1,0]], [[0,0,-1],[1,0,0],[0,1,0]]
];

function rotate(p, ax, ay, az) {
  var r = Math.PI / 180, c, s, t;
  c = Math.cos(ax * r); s = Math.sin(ax * r);
  t = p[1]; p[1] = t * c - p[2] * s; p[2] = t * s + p[2] * c;
  c = Math.cos(ay * r); s = Math.sin(ay * r);
  t = p[0]; p[0] =  t * c + p[2] * s; p[2] = -t * s + p[2] * c;
  c = Math.cos(az * r); s = Math.sin(az * r);
  t = p[0]; p[0] = t * c - p[1] * s; p[1] = t * s + p[1] * c;
}

// NEW: rotate p around unit axis k by deg degrees (Rodrigues)
function rotateAxis(p, k, deg) {
  var th = deg * Math.PI / 180, c = Math.cos(th), s = Math.sin(th);
  var dot = k[0] * p[0] + k[1] * p[1] + k[2] * p[2];
  var cx = k[1] * p[2] - k[2] * p[1],          // k x p
      cy = k[2] * p[0] - k[0] * p[2],
      cz = k[0] * p[1] - k[1] * p[0];
  var q = 1 - c;
  var x = p[0] * c + cx * s + k[0] * dot * q,
      y = p[1] * c + cy * s + k[1] * dot * q,
      z = p[2] * c + cz * s + k[2] * dot * q;
  p[0] = x; p[1] = y; p[2] = z;
}

var spin = 0;

function val(id) { return +document.getElementById(id).value; }

function render() {
  var ax = val("rx"), ay = val("ry"), az = val("rz");
  var azm = val("az") * Math.PI / 180, elv = val("el") * Math.PI / 180;
  // NEW: spherical -> unit vector (elevation 90 = straight up = the y axis)
  var k = [Math.cos(elv) * Math.sin(azm), -Math.sin(elv), Math.cos(elv) * Math.cos(azm)];

  var buf = [], zbuf = [];
  for (var i = 0; i < W * H; i++) { buf[i] = " "; zbuf[i] = 0; }

  for (var f = 0; f < 6; f++) {
    var n = FACES[f][0], u = FACES[f][1], v = FACES[f][2];
    for (var a = 0; a <= N; a++) {
      for (var b = 0; b <= N; b++) {
        var s = a / N - 0.5, t = b / N - 0.5;
        var p = [0.5 * n[0] + s * u[0] + t * v[0],
                 0.5 * n[1] + s * u[1] + t * v[1],
                 0.5 * n[2] + s * u[2] + t * v[2]];
        var m = [n[0], n[1], n[2]];

        rotate(p, ax, ay, az);  rotateAxis(p, k, spin);   // tilt, then spin about k
        rotate(m, ax, ay, az);  rotateAxis(m, k, spin);

        if (m[2] > 0) continue;

        var x = 0 | (40 + 20 * p[0]),
            y = 0 | (12 + 10 * p[1]),
            D = 1 / (p[2] + 2),
            o = x + W * y;
        var L = 0 | (11 * (-m[1] - m[2]) / Math.SQRT2);

        if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) {
          zbuf[o] = D;
          buf[o] = SHADES[L > 0 ? L : 0];
        }
      }
    }
  }

  // NEW: draw the axle as a line through the z-buffer
  for (var d = -1.1; d <= 1.1; d += 0.01) {
    var qx = k[0] * d, qy = k[1] * d, qz = k[2] * d;
    var x = 0 | (40 + 20 * qx), y = 0 | (12 + 10 * qy), D = 1 / (qz + 2), o = x + W * y;
    if (x >= 0 && x < W && y >= 0 && y < H && D > zbuf[o]) { zbuf[o] = D; buf[o] = "+"; }
  }

  var rows = [];
  for (var r = 0; r < H; r++) rows.push(buf.slice(r * W, (r + 1) * W).join(""));
  document.getElementById("screen").textContent = rows.join("\n");
}

setInterval(function () { spin += val("sp"); render(); }, 30);
</script>

  

Things to try: set elevation to 0 and sweep the azimuth — the axle lies flat and the cube tumbles end over end. Set the speed to 0 and the sliders behave exactly like step 4, only with the axle drawn in. Or drag the elevation slowly from 90 down to −90 while it spins: the rotation never stutters, because every frame is computed from scratch — there is no accumulated state except the single number spin.

Where to go next

  • Smoother shading. Twelve characters means a face jumps visibly between levels. A 70-character ramp sorted by ink density fixes that.
  • Perspective. Multiply the projected x and y by D (the value we already compute for the z-buffer) and the far side of the cube shrinks.
  • Other shapes. The pipeline never cared that this was a cube. Replace FACES with any parametric surface — the original version of this code drew a donut.
The complete cube on the front page of this site is the step 3 file plus a rotating ring of words. Everything else is CSS.