?Rgba optionals and anytype let us decouple the algorithm from the surface it draws on, so the same Bresenham serves a framebuffer, a counter, or an SVG writer;Framebuffer, Rgba pixel and setPixel/getPixel from episode 156 -- we draw straight into that buffer today;anytype comptime-duck-typing trick we have leaned on since episode 13;Learn Zig Series):Last episode we built a framebuffer -- a flat slice of pixels pretending to be a 2D grid, with setPixel, clear and fillRect. We can paint rectangles all day, but a picture is not made of rectangles. It is made of lines -- the edges of shapes, the strokes of a font, the wires of a 3D model projected flat. And drawing a line between two arbitrary points turns out to be one of those problems that looks trivial for about ten seconds and then bites you. Today we solve it properly, the way it has been solved since 1962, with an algorithm so lean it runs on a microcontroller with no floating-point unit at all. Here we go!
Before we draw a single line, here are the solutions to last episode's three framebuffer exercises.
Exercise 1 -- horizontal and vertical lines. The horizontal one is one @memset over a contiguous row; the vertical one steps by width. Both clip to the canvas. Note the + 1 that makes the endpoint inclusive:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
fn hLine(self: *Framebuffer, x0: usize, x1: usize, y: usize, color: Rgba) void {
if (y >= self.height) return; // whole row off-canvas
const lo = @min(x0, x1);
if (lo >= self.width) return;
const hi = @min(@max(x0, x1) + 1, self.width); // clip right edge, inclusive endpoint
const start = y * self.width + lo;
const end = y * self.width + hi;
@memset(self.pixels[start..end], color); // one contiguous fill
}
fn vLine(self: *Framebuffer, x: usize, y0: usize, y1: usize, color: Rgba) void {
if (x >= self.width) return;
const lo = @min(y0, y1);
const hi = @min(@max(y0, y1) + 1, self.height);
var y = lo;
while (y < hi) : (y += 1) {
self.pixels[y * self.width + x] = color; // step down by a whole row each time
}
}
};
test "hLine and vLine clip to the canvas" {
var pixels: [16]Rgba = undefined;
var fb = Framebuffer{ .pixels = &pixels, .width = 4, .height = 4 };
@memset(fb.pixels, .{ .r = 0, .g = 0, .b = 0 });
const white = Rgba{ .r = 255, .g = 255, .b = 255 };
fb.hLine(1, 99, 2, white); // runs off the right edge
try std.testing.expectEqual(@as(u8, 255), fb.pixels[2 * 4 + 3].r); // last on-canvas pixel painted
try std.testing.expectEqual(@as(u8, 0), fb.pixels[2 * 4 + 0].r); // x=0 left untouched
fb.vLine(0, 0, 99, white); // runs off the bottom
try std.testing.expectEqual(@as(u8, 255), fb.pixels[3 * 4 + 0].r);
}
The key insight: because a horizontal line is contiguous in memory, it collapses to a single @memset, while a vertical line -- the same number of pixels -- costs a whole loop with a stride jump on every step. Same picture, very different memory access. Hold that thought; it is the whole reason Bresenham is written the way it is.
Exercise 2 -- a blit that copies one framebuffer into another, clipping. Copy row by row with one @memcpy per visible row, and stop when you run off any edge of the destination:
const std = @import("std");
const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
};
fn blit(dst: *Framebuffer, src: Framebuffer, dst_x: usize, dst_y: usize) void {
var row: usize = 0;
while (row < src.height) : (row += 1) {
const dy = dst_y + row;
if (dy >= dst.height) break; // below the canvas: no rows left
const copy_w = if (dst_x >= dst.width) 0 else @min(src.width, dst.width - dst_x);
if (copy_w == 0) continue; // this row lands entirely off the right edge
const s_start = row * src.width;
const d_start = dy * dst.width + dst_x;
@memcpy(dst.pixels[d_start .. d_start + copy_w], src.pixels[s_start .. s_start + copy_w]);
}
}
test "blit clips the part that falls off the destination" {
var dpix: [16]Rgba = undefined;
var dst = Framebuffer{ .pixels = &dpix, .width = 4, .height = 4 };
@memset(dst.pixels, .{ .r = 0, .g = 0, .b = 0 });
var spix: [4]Rgba = undefined;
const src = Framebuffer{ .pixels = &spix, .width = 2, .height = 2 };
@memset(spix[0..], .{ .r = 9, .g = 9, .b = 9 });
blit(&dst, src, 3, 3); // only its top-left pixel fits in the corner
try std.testing.expectEqual(@as(u8, 9), dst.pixels[3 * 4 + 3].r); // corner copied
try std.testing.expectEqual(@as(u8, 0), dst.pixels[0].r); // rest untouched
}
Exercise 3 -- a checkerboard exported to PGM. PGM is PPM's grayscale sibling: the magic bytes P5, then width, height, max value, then one byte per pixel. The pattern is a parity check on which cell each pixel falls into:
const std = @import("std");
fn checkerboard(pixels: []u8, width: usize, height: usize, cell: usize) void {
var y: usize = 0;
while (y < height) : (y += 1) {
var x: usize = 0;
while (x < width) : (x += 1) {
const on = ((x / cell) + (y / cell)) % 2 == 0;
pixels[y * width + x] = if (on) 255 else 0;
}
}
}
fn toPgm(allocator: std.mem.Allocator, pixels: []const u8, width: usize, height: usize) ![]u8 {
const header = try std.fmt.allocPrint(allocator, "P5\n{d} {d}\n255\n", .{ width, height });
defer allocator.free(header);
const out = try allocator.alloc(u8, header.len + pixels.len);
@memcpy(out[0..header.len], header);
@memcpy(out[header.len..], pixels);
return out;
}
test "checkerboard pattern and PGM export" {
const w: usize = 16;
const h: usize = 16;
const buf = try std.testing.allocator.alloc(u8, w * h);
defer std.testing.allocator.free(buf);
checkerboard(buf, w, h, 8);
try std.testing.expectEqual(@as(u8, 255), buf[0]); // (0,0) is an "on" cell -> white
try std.testing.expectEqual(@as(u8, 0), buf[8]); // (8,0) flips -> black
const pgm = try toPgm(std.testing.allocator, buf, w, h);
defer std.testing.allocator.free(pgm);
try std.testing.expect(std.mem.startsWith(u8, pgm, "P5\n16 16\n255\n"));
try std.testing.expectEqual("P5\n16 16\n255\n".len + w * h, pgm.len);
}
Right -- framebuffer refreshed, exercises squared away. Now to the real subject.
Here is the trap. You have two points, (x0, y0) and (x1, y1), and you want to light up the pixels between them. Your first instinct, and mine too the first time, is high-school algebra: the line is y = m*x + b where m is the slope. So loop x from x0 to x1, compute y = round(m*x + b), plot. Done, right?
It works -- for gentle slopes. But it has three real problems. First, it uses floating-point multiplication and rounding per pixel, which is slow and, on a chip with no FPU, not even available. Second, if the line is steeper than 45 degrees, stepping x by one skips whole rows of y, leaving a dashed, gappy line -- you would have to detect that case and loop over y instead. Third, floating-point rounding drifts: accumulate enough tiny errors and the line wobbles off its true path. We can do better with only integers, and that is Bresenham's gift.
Before the elegant solution, let us write the honest, naive one -- the DDA (Digital Differential Analyzer). It fixes the gap problem by stepping along whichever axis is longer, but it still leans on floats. We draw into the episode-156 framebuffer, which I have widened to accept signed coordinates -- crucial, because a line can start off the left edge at a negative x and we want to clip, not crash:
const std = @import("std");
pub const Rgba = packed struct { r: u8, g: u8, b: u8, a: u8 = 255 };
pub const Framebuffer = struct {
pixels: []Rgba,
width: usize,
height: usize,
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, width: usize, height: usize) !Framebuffer {
return .{
.pixels = try allocator.alloc(Rgba, width * height),
.width = width,
.height = height,
.allocator = allocator,
};
}
pub fn deinit(self: *Framebuffer) void {
self.allocator.free(self.pixels);
}
pub fn clear(self: *Framebuffer, color: Rgba) void {
@memset(self.pixels, color);
}
// signed coordinates: anything off-canvas is silently clipped, never a crash
pub fn setPixel(self: *Framebuffer, x: i64, y: i64, color: Rgba) void {
if (x < 0 or y < 0) return;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return;
self.pixels[uy * self.width + ux] = color;
}
pub fn getPixel(self: Framebuffer, x: i64, y: i64) ?Rgba {
if (x < 0 or y < 0) return null;
const ux: usize = @intCast(x);
const uy: usize = @intCast(y);
if (ux >= self.width or uy >= self.height) return null;
return self.pixels[uy * self.width + ux];
}
};
Widening the coordinate to i64 is a small decision with big consequences. In episode 156 our pixels were addressed with usize, which is unsigned -- perfect for a buffer index that can never be negative. But a line lives in a coordinate space where negatives are completely normal (a shape half off the screen), so the drawing API speaks i64, and setPixel is the one place that converts, clips and guarantees the final index is a valid usize. The boundary between "geometry, which can be negative" and "memory, which cannot" lives in exactly one function. That is the same encapsulation discipline as last episode's private index helper.
Now the DDA itself:
pub fn drawLineFloat(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
const dx: f64 = @floatFromInt(x1 - x0);
const dy: f64 = @floatFromInt(y1 - y0);
const steps: usize = @intFromFloat(@max(@abs(dx), @abs(dy))); // walk the LONGER axis
if (steps == 0) {
fb.setPixel(x0, y0, color); // start == end: a single dot
return;
}
const x_inc = dx / @as(f64, @floatFromInt(steps));
const y_inc = dy / @as(f64, @floatFromInt(steps));
var x: f64 = @floatFromInt(x0);
var y: f64 = @floatFromInt(y0);
var i: usize = 0;
while (i <= steps) : (i += 1) {
fb.setPixel(@intFromFloat(@round(x)), @intFromFloat(@round(y)), color);
x += x_inc;
y += y_inc;
}
}
This draws correct lines. Stepping along the longer axis kills the gap problem. But look at the loop body: a float add, then a @round, then a float-to-int conversion, on every single pixel. And the accumulating x += x_inc is where drift creeps in over a very long line. It offends me a little that we are doing analog arithmetic to place discrete dots. Bresenham asks: what if we never left the integers at all?
Here is the idea that makes it click. Take a shallow line, going right and slightly up, slope between 0 and 1. We step x by one every iteration -- that is forced, since x is the long axis. The only question at each step is: does y stay the same, or tick up by one? The true line passes somewhere through the column, and we want the pixel row closest to it.
Bresenham tracks a single integer, the error term, which measures how far the true line has drifted from the pixel row we last chose. Each x step adds the slope's rise to the error; when the error crosses the halfway mark, we bump y and subtract back. Multiply everything through by 2*dx and every fraction vanishes -- it is all integer add and compare. Here is the clean single-octant version, for a line heading right and down with slope in [0, 1], which shows the decision variable naked:
// single octant only (x1 >= x0, and 0 <= slope <= 1) -- for teaching the error idea
pub fn drawLineShallow(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
const dx = x1 - x0;
const dy = y1 - y0;
var d = 2 * dy - dx; // the decision variable, pre-scaled by 2*dx so it stays integer
var y = y0;
var x = x0;
while (x <= x1) : (x += 1) {
fb.setPixel(x, y, color);
if (d > 0) { // the true line has crossed into the next row
y += 1;
d -= 2 * dx;
}
d += 2 * dy; // every x step adds the rise
}
}
Read the loop as a running tally. d starts at 2*dy - dx. Every column we add 2*dy (the doubled rise). The moment d goes positive, the ideal line has drifted more than half a pixel above our current row, so we step y up and pay back 2*dx. No division, no floats, no rounding -- just three integer operations per pixel, and the error can never drift because it is exact. That is the whole trick, and it is genuinely beautiful once it lands.
The shallow version only handles one of the eight directions a line can go. The production form handles them all with two sign variables (sx, sy) for the step direction and a symmetric error term that lets either axis be the long one. This is the version to memorise -- I have typed it from memory more times than any other graphics routine:
pub fn drawLine(fb: *Framebuffer, x0: i64, y0: i64, x1: i64, y1: i64, color: Rgba) void {
var x = x0;
var y = y0;
const dx: i64 = @intCast(@abs(x1 - x0));
const dy: i64 = -@as(i64, @intCast(@abs(y1 - y0))); // note: dy is negative here
const sx: i64 = if (x0 < x1) 1 else -1; // step left or right
const sy: i64 = if (y0 < y1) 1 else -1; // step up or down
var err = dx + dy;
while (true) {
fb.setPixel(x, y, color);
if (x == x1 and y == y1) break; // reached the far endpoint
const e2 = 2 * err;
if (e2 >= dy) { // time to step along x
err += dy;
x += sx;
}
if (e2 <= dx) { // time to step along y
err += dx;
y += sy;
}
}
}
What makes this version so pleasant is its total generality. sx and sy absorb direction, so a line going up-left draws exactly as happily as one going down-right. The error starts at dx + dy (remember dy is stored negative), and the two if blocks decide the step: e2 >= dy advances x, e2 <= dx advances y. They are two separate ifs, not else if -- on a 45-degree diagonal both fire in the same iteration, stepping both axes at once. That symmetry is the whole reason one loop covers all eight octants with no special-casing. Read it a few times; the negative dy and the doubled error feel like a magic trick until they suddenly do not.
anytypeNotice that Bresenham does not actually care that it is drawing into a framebuffer. It produces a sequence of integer points; what you do with each point is your business. In episode 13 we met interfaces via type erasure, and in episode 14 comptime generics. Here the lightest possible tool fits: take an anytype context with a plot method and call it. The same line generator can fill pixels, count them, or stream them to an SVG file -- no allocation, no vtable, resolved at compile time:
pub const Point = struct { x: i64, y: i64 };
pub fn bresenham(a: Point, b: Point, ctx: anytype) void {
var x = a.x;
var y = a.y;
const dx: i64 = @intCast(@abs(b.x - a.x));
const dy: i64 = -@as(i64, @intCast(@abs(b.y - a.y)));
const sx: i64 = if (a.x < b.x) 1 else -1;
const sy: i64 = if (a.y < b.y) 1 else -1;
var err = dx + dy;
while (true) {
ctx.plot(x, y); // whatever the caller wants done with this pixel
if (x == b.x and y == b.y) break;
const e2 = 2 * err;
if (e2 >= dy) {
err += dy;
x += sx;
}
if (e2 <= dx) {
err += dx; // the CORRECT form: dx here
y += sy;
}
}
}
const Counter = struct {
n: usize = 0,
fn plot(self: *Counter, x: i64, y: i64) void {
_ = x;
_ = y;
self.n += 1;
}
};
test "bresenham plots the expected number of pixels on a diagonal" {
var c = Counter{};
bresenham(.{ .x = 0, .y = 0 }, .{ .x = 5, .y = 5 }, &c);
try std.testing.expectEqual(@as(usize, 6), c.n); // 45-degree line touches 6 pixels
}
This is the same "pass a thing that quacks the right way" pattern Zig uses everywhere, and because ctx is anytype, Counter.plot is inlined with zero indirection -- the counting version compiles down to essentially a loop incrementing a register. A FramebufferSink with a plot that calls setPixel would be just as cheap. Separating generate the points from consume the points is exactly the "encoder returns bytes, caller does the I/O" separation from last episode, applied to geometry.
How do you test a drawing routine? The same way we tested the framebuffer and the regex matcher before it: perform the operation, read the pixels back, assert. The two properties that catch almost every Bresenham bug are (1) both endpoints are always lit, and (2) the line clips cleanly when it runs off the canvas. Let us pin both down:
test "drawLine lights both endpoints" {
var fb = try Framebuffer.init(std.testing.allocator, 8, 8);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 255, .g = 255, .b = 255 };
drawLine(&fb, 1, 1, 6, 4, c);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(1, 1).?.r); // start
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(6, 4).?.r); // end
}
test "drawLine clips coordinates that leave the canvas" {
var fb = try Framebuffer.init(std.testing.allocator, 4, 4);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 1, .g = 2, .b = 3 };
drawLine(&fb, -5, 2, 20, 2, c); // spans far past both edges on row y=2
try std.testing.expectEqual(@as(u8, 1), fb.getPixel(0, 2).?.r); // left edge painted
try std.testing.expectEqual(@as(u8, 1), fb.getPixel(3, 2).?.r); // right edge painted
try std.testing.expectEqual(@as(u8, 0), fb.getPixel(0, 0).?.r); // other rows clean
}
test "single-octant version hits its endpoints too" {
var fb = try Framebuffer.init(std.testing.allocator, 10, 10);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 255, .g = 255, .b = 255 };
drawLineShallow(&fb, 0, 0, 6, 2, c);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(0, 0).?.r);
try std.testing.expectEqual(@as(u8, 255), fb.getPixel(6, 2).?.r);
}
The clip test is the one I lean on hardest, because negative and out-of-range coordinates are precisely where a careless line routine turns into a buffer overflow. Because clipping lives inside setPixel, the line loop stays gloriously simple and still cannot scribble outside the buffer -- the test proves it by drawing a line that runs five pixels off the left edge and twelve off the right, then checking the corners of the canvas are untouched.
The DDA and Bresenham draw the identical set of pixels, so why prefer Bresenham? Count the work per pixel. The DDA does two float adds, two @round calls and two float-to-int casts. Bresenham does one comparison and one or two integer adds. On a modern desktop with a fat FPU the gap is smaller than it used to be, but it is never zero, and on the embedded targets Zig loves -- a microcontroller driving a little OLED -- there may be no hardware floats at all, so the DDA either traps into slow software emulation or simply will not run. Bresenham needs nothing but an adder.
There is a second, subtler win, and it is the same lesson as last episode: draw with the grain of memory. A near-horizontal line walks along contiguous pixels, one cache line feeding many writes; a near-vertical line jumps width pixels every step, touching a fresh cache line each time. You cannot change a vertical line's access pattern -- the geometry demands it -- but you can make sure your per-pixel cost is a single integer op so the cache stall is the only thing you pay. And if you are hammering a hot inner loop, the setPixel bounds check becomes the bottleneck: once your tests prove the endpoints are in range, a specialised unclipped inner routine that writes self.pixels[y * width + x] directly (skipping the four comparisons) is a legitimate optimisation for ReleaseFast builds -- but only after the clipped version has proven correct.
This tiny routine is everywhere. Every wireframe 3D renderer projects triangles to 2D and draws their edges with exactly this. Every charting library plots a data series as Bresenham segments. UI toolkits draw borders, underlines and focus rings with it. And the moment you have drawLine, whole shapes fall out for free -- a triangle outline is just three lines:
pub fn drawTriangle(fb: *Framebuffer, a: Point, b: Point, c: Point, color: Rgba) void {
drawLine(fb, a.x, a.y, b.x, b.y, color);
drawLine(fb, b.x, b.y, c.x, c.y, color);
drawLine(fb, c.x, c.y, a.x, a.y, color);
}
test "triangle outline paints its three corners" {
var fb = try Framebuffer.init(std.testing.allocator, 16, 16);
defer fb.deinit();
fb.clear(.{ .r = 0, .g = 0, .b = 0 });
const c = Rgba{ .r = 200, .g = 100, .b = 50 };
drawTriangle(&fb, .{ .x = 2, .y = 2 }, .{ .x = 12, .y = 4 }, .{ .x = 6, .y = 13 }, c);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(2, 2).?.r);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(12, 4).?.r);
try std.testing.expectEqual(@as(u8, 200), fb.getPixel(6, 13).?.r);
}
When Bresenham is not the right tool: when you want smooth, anti-aliased edges. Bresenham gives a hard, aliased "staircase" line -- every pixel is fully on or fully off. For soft edges you reach for Xiaolin Wu's algorithm, which shades boundary pixels by fractional coverage (and, ironically, brings the floats back). Bresenham is the choice when you want crisp, fast and exact; Wu when you want pretty. Most of the time, for shape edges and UI, crisp and fast wins.
Because the algorithm is pure integer arithmetic, it ports almost character-for-character across languages -- what changes is the safety story around the pixel write. In C, it is a raw pointer and the bounds check is your problem, exactly the danger Zig's setPixel closes:
// C: the classic all-octant Bresenham, raw pointer, manual clipping
void draw_line(uint32_t *buf, int w, int h, int x0, int y0, int x1, int y1, uint32_t color) {
int dx = abs(x1 - x0), sx = x0 < x1 ? 1 : -1;
int dy = -abs(y1 - y0), sy = y0 < y1 ? 1 : -1;
int err = dx + dy;
for (;;) {
if (x0 >= 0 && x0 < w && y0 >= 0 && y0 < h) // you clip by hand, or you corrupt memory
buf[y0 * w + x0] = color;
if (x0 == x1 && y0 == y1) break;
int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
Notice it is line-for-line our Zig, because the algorithm is language-neutral -- only the pixel store differs. Rust would write the loop identically over i32, but the pixel write goes through buf.get_mut(y*w + x) returning an Option<&mut u32> (our ?Rgba again) or a bounds-checked buf[i] that panics -- the same safe-by-default posture as Zig. The image and tiny-skia crates give you draw_line out of the box. Go leans on its standard image package plus golang.org/x/image/vector for anything fancy, with slice accesses bounds-checked at runtime and a panic (not corruption) if you overrun -- again landing in Zig's safety neighbourhood, trading a little speed for not trashing memory. The through-line: everyone runs the identical 1962 integer loop; they differ only in what happens the instant your index escapes the buffer. Zig hands you the raw-C speed and the clip-or-crash safety in one language, and lets you drop the check per build once you have earned it with tests.
Break it on purpose, then prove the fix. Copy drawLine and change the y-branch to add dy instead of the correct dx. Now write a test that draws a steep line -- say (0,0) to (2,8), where y is the long axis -- and asserts a midpoint pixel like (1,4) is lit. Watch the broken version fail (or loop), then restore the dx and watch it pass. The lesson: the "hits both endpoints" test alone is not enough, because a shallow line barely exercises the y-branch -- you need a steep case to catch this class of bug.
Thick lines. Write drawThickLine(fb, x0, y0, x1, y1, width, color) that draws a line width pixels thick. The simplest correct approach: run Bresenham, and at each plotted point stamp a small filled square (or, better, a short perpendicular run) of the given width. Test that a horizontal thick line of width 3 lights three rows, and that the thickness clips at the canvas edge.
Extend the error term to a curve. Bresenham's decision-variable idea is not limited to lines. Using nothing but integer add and compare, implement drawCircle(fb, cx, cy, radius, color) for a circle centred at (cx, cy): track an integer error term as you step one octant from the top, and mirror each plotted point into the other seven octants by symmetry. Test that the four cardinal points (cx +/- radius, cy +/- radius) are lit. This is the exact same "integer decision variable" machinery, pointed at a rounder target.
sx/sy sign steps and two (non-exclusive) if branches so one loop draws every direction, including exact 45-degree diagonals;i64 and clipping inside setPixel puts the "geometry can be negative, memory cannot" boundary in exactly one place, so the line loop stays simple and still cannot overflow the buffer;anytype plot context decouples generating the pixels from consuming them, with zero runtime cost -- the same line drives a framebuffer, a counter, or a file;We can draw any line now, at any angle, without a single floating-point operation -- and the error-term trick we just learned is not really about lines at all. It is about walking a discrete grid to approximate a continuous shape using only integers, and that idea generalises to rounder things than lines, as exercise 3 hints. That is exactly where we go next. Keep this drawLine handy -- like the framebuffer before it, everything from here builds on top of it.
Bedankt en tot de volgende keer! ;-)