This morning, [Bill] Tozier posted an interesting problem:

In one of the best puzzle collections I know, Peter Winkler’s ​Mathematical Mind Benders​, is a little quickie that seems like it’d be interesting to un-quicken. The original is something like: “Three spiders and an ant are walking on a framework in the shape of the edges of a cube. The spiders want to eat the ant, and they all walk at last 1/3 as fast as the ant walks. Prove that some spider can catch the ant.”

We were meeting today at the Brighton Agile Roundtable, and we decided to play with the ant problem in codea. It turned out to be fun, and a good way to think about the problem from a programming viewpoint if not from a formal mathematical one.

We began by drawing a projection of a cube:

function draw()
    background(140, 140, 150)
    strokeWidth(5)
    drawCubes()
end

function drawCubes()
    rectMode(CENTER)
    fill(0,0,0,0)
    lineCapMode(SQUARE)
    rect(center.x, center.y, 400, 400)
    rect(center.x, center.y, 800, 800)
    translate(center:unpack())
    line(200,200, 400, 400)
    line(-200,-200, -400, -400)
    line(-200,200, -400, 400)
    line(200,-200, 400, -400)
end

Then we wrote a little table for our ant, so we could draw him at any fraction of the way along a line. We went through a few iterations of this and quickly wound up with a table of corners, a table of edges, and this code:


corners = { 
    vec2(-400, -400), vec2(-400,400 ), vec2(400, 400), vec2(400, -400),
    vec2(-200, -200), vec2(-200,200 ), vec2(200, 200), vec2(200, -200)
}

cube = { {2,4,5}, {1,3,6}, {2,4,7}, {1,3,8}, {1,6,8}, {2,5,7}, {3,6,8}, {4,5,7} }

ant = {}
ant.start = 1
ant.stop = 2
ant.frac = math.random()
ant.speed = speed*3
ant.nextGoal = nextGoal

function draw()
    background(140, 140, 150)
    strokeWidth(5)
    drawCubes()
end

function drawCubes()
    rectMode(CENTER)
    fill(0,0,0,0)
    lineCapMode(SQUARE)
    rect(center.x, center.y, 400, 400)
    rect(center.x, center.y, 800, 800)
    translate(center:unpack())
    line(200,200, 400, 400)
    line(-200,-200, -400, -400)
    line(-200,200, -400, 400)
    line(200,-200, 400, -400)
    progress()
end

function progress()
    moveBug(ant)
    drawBug(ant, color(0,255,0))
end

function moveBug(aBug)
    aBug.frac = aBug.frac + aBug.speed
    if aBug.frac > 1 then
        aBug.start = aBug.stop
        aBug.stop = aBug.nextGoal(aBug.start)
        aBug.frac = 0
    end
    aBug.pos = (1-aBug.frac)*corners[aBug.start] + aBug.frac*corners[aBug.stop]
end

function drawBug(aBug, col)
    pushMatrix()
    pushStyle()
    translate(aBug.pos.x, aBug.pos.y)
    stroke(col)
    fill(col)
    ellipse(0,0,20,20)
    popStyle()
    popMatrix()
end

We had a rudimentary verson of the nextGoal function, which we used for an interim version that just drove the ant around the screen. This code just selects one of the three lines leading away from the corner we just arrived at:

function nextGoal(index)
    local choice = math.random(3)
    local choices = cube[index]
    return choices[choice]
end

The result was that the ant runs around the cube randomly, as desired:

Note: If video stalls, click the YouTube button in the embedded one and then refresh when you get to YouTube. I have no idea why it’s doing that.

It’s worth remarking on our approach to the ant. Instead of making him an object with a class, he’s just a table with some values:

ant = {}
ant.start = 1
ant.stop = 2
ant.frac = math.random()
ant.speed = speed*3
ant.nextGoal = nextGoal

All these are fairly straightforward: we start him on corner 1, heading toward corner 2. At first we started him at zero but when we implemented the spiders, we had everyone starting at zero and that was boring, because everyone arrived at the corners at the same time, so we started them all at a random fraction 0-1 along the line.

But what about that line

ant.nextGoal = nextGoal

Well, that second nextGoal refers to the nextGoal function up above. We see it being called here:

function drawCubes()
    rectMode(CENTER)
    fill(0,0,0,0)
    lineCapMode(SQUARE)
    rect(center.x, center.y, 400, 400)
    rect(center.x, center.y, 800, 800)
    translate(center:unpack())
    line(200,200, 400, 400)
    line(-200,-200, -400, -400)
    line(-200,200, -400, 400)
    line(200,-200, 400, -400)
    progress()
end

function progress()
    moveBug(ant)
    drawBug(ant, color(0,255,0))
end

function moveBug(aBug)
    aBug.frac = aBug.frac + aBug.speed
    if aBug.frac > 1 then
        aBug.start = aBug.stop
        aBug.stop = aBug.nextGoal(aBug.start)
        aBug.frac = 0
    end
    aBug.pos = (1-aBug.frac)*corners[aBug.start] + aBug.frac*corners[aBug.stop]
end

function drawBug(aBug, col)
    pushMatrix()
    pushStyle()
    translate(aBug.pos.x, aBug.pos.y)
    stroke(col)
    fill(col)
    ellipse(0,0,20,20)
    popStyle()
    popMatrix()
end

All this comes down to moving the bug along his line, incrementing the fraction by the speed value, and when the fraction exceeds 1, set his start to his previous stop, pick a new goal, and keep on keeping on.

The drawing is simple, we just translate to where the bug is (somewhere along his line), and draw a circle.

We went through a phase where we created three spiders and made them work the same way, except at slower speed:

s1 = {}
s1.start = 2
s1.stop = 6
s1.frac = math.random()
s1.speed = speed
s1.nextGoal = nextGoal

s2 = {}
s2.start = 7
s2.stop = 3
s2.frac = math.random()
s2.speed = speed
s2.nextGoal = nextGoal

s3 = {}
s3.start = 4
s3.stop = 1
s3.frac = math.random()
s3.speed = speed
s3.nextGoal = nextGoal

We extended the progress function to move and draw all four bugs:

function progress()
    moveBug(ant)
    moveBug(s1)
    moveBug(s2)
    moveBug(s3)
    drawBug(s1, color(255,0,0))
    drawBug(s2, color(255,0,0))
    drawBug(s3, color(255,0,0))
    drawBug(ant, color(0,255,0))
end

This gave us three red spiders and one green ant, running around randomly. They’d run right over each other so we decided to make the game stop when the ant collides with a spider:

local dead = false

function progress()
    if ( not dead ) then
        moveBug(ant)
        moveBug(s1)
        moveBug(s2)
        moveBug(s3)
    end
    dead = isAntDead()
    drawBug(s1, color(255,0,0))
    drawBug(s2, color(255,0,0))
    drawBug(s3, color(255,0,0))
    drawBug(ant, color(0,255,0))
end

function isAntDead()
    local radius = 10
    local pos = ant.pos
    if pos:dist(s1.pos) < radius then return true end
    if pos:dist(s2.pos) < radius then return true end
    if pos:dist(s3.pos) < radius then return true end
    return false
end

The isAntDead function just checks to see if the ant is within ten pixels!pixels of any spider and if it is, we stop moving (but keep drawing, so the screen doesn’t go blank).

This worked fine and results in the halt you see in the final video below.

But we noticed one more thing. Because the ant selects its edge randomly, it has a good chance of selecting an edge that has a spider coming the other way, with tragic results for our poor ant. So we changed things to give the ant its own way of selecting an edge. That turned out to be a bit tricky but not bad:

ant = {}
ant.start = 1
ant.stop = 2
ant.frac = math.random()
ant.speed = speed*3
ant.nextGoal = nextAntGoal

function nextAntGoal(index)
    local choice
    local choices = shuffle(cube[index])
    local try = 1
    while try < 3 do
        choice = choices[try]
        if safeEdge(index, choice) then 
            return choice 
        end
        try = try + 1
    end
    return choice
end

We give the ant its own way of selecting the next goal, as shown above. We create a shuffled selection of edge choices, because we’re going to go through them to find a safe one. Then we try the first two to see if they are safe. If they are, we choose them. If they are not safe, we take the third choice. (You could imagine a more clever ant. For example, it might choose the path where the spider is furthest away, then wait a while and double back. Or it might just check to see if the spider is moving away on any of those edge, and go down that one, slowing down. And so on. For now, the ant isn’t that smart.)

To shuffle the edges we made a list of the integers 1,2,3, selected one at random, picked that edge, repeated, and took the last:

function shuffle(tab)
    local dex = {1,2,3}
    local res = {}
    local next
    next = math.random(1,3)
    table.insert(res, tab[dex[next]])
    table.remove(dex, next)
    next = math.random(1,2)
    table.insert(res, tab[dex[next]])
    table.remove(dex, next)
    table.insert(res, tab[dex[1]])
    return res
end
function safeEdge(start, stop)
    return not spiderOn(start,stop)
end

function spiderOn(start, stop)
    return thisSpiderOn(s1, start, stop) or thisSpiderOn(s2, start, stop) or thisSpiderOn(s3,start,stop)
end

function thisSpiderOn(spider, start, stop)
    return (spider.start == start and spider.stop == stop) or (spider.stop == start and spider.start == stop)
end

The final run. Will the ant elude the spiders? The answer may surprise you!

“Final” program:

-- Ant and Spiders

corners = { 
    vec2(-400, -400), vec2(-400,400 ), vec2(400, 400), vec2(400, -400),
    vec2(-200, -200), vec2(-200,200 ), vec2(200, 200), vec2(200, -200)
}

cube = { {2,4,5}, {1,3,6}, {2,4,7}, {1,3,8}, {1,6,8}, {2,5,7}, {3,6,8}, {4,5,7} }

local speed = 0.005
local dead = false

function nextGoal(index)
    local choice = math.random(3)
    local choices = cube[index]
    return choices[choice]
end

function nextAntGoal(index)
    local choice
    local choices = shuffle(cube[index])
    --print("choices " .. choices[1] .. ", " .. choices[2] .. ", " .. choices[3])
    local try = 1
    while try < 3 do
        choice = choices[try]
        if safeEdge(index, choice) then 
            --print("safe " .. choice)
            return choice 
        end
        try = try + 1
    end
    print("forced ".. choice)
    return choice
end

function shuffle(tab)
    local dex = {1,2,3}
    local res = {}
    local next
    next = math.random(1,3)
    table.insert(res, tab[dex[next]])
    table.remove(dex, next)
    next = math.random(1,2)
    table.insert(res, tab[dex[next]])
    table.remove(dex, next)
    table.insert(res, tab[dex[1]])
    return res
end

function safeEdge(start, stop)
    return not spiderOn(start,stop)
end

function spiderOn(start, stop)
    return thisSpiderOn(s1, start, stop) or thisSpiderOn(s2, start, stop) or thisSpiderOn(s3,start,stop)
end

function thisSpiderOn(spider, start, stop)
    return (spider.start == start and spider.stop == stop) or (spider.stop == start and spider.start == stop)
end

ant = {}
ant.start = 1
ant.stop = 2
ant.frac = math.random()
ant.speed = speed*3
ant.nextGoal = nextAntGoal

s1 = {}
s1.start = 2
s1.stop = 6
s1.frac = math.random()
s1.speed = speed
s1.nextGoal = nextGoal

s2 = {}
s2.start = 7
s2.stop = 3
s2.frac = math.random()
s2.speed = speed
s2.nextGoal = nextGoal

s3 = {}
s3.start = 4
s3.stop = 1
s3.frac = math.random()
s3.speed = speed
s3.nextGoal = nextGoal

function setup()
    center = vec2(WIDTH/2, HEIGHT/2)
end

function draw()
    background(140, 140, 150)
    strokeWidth(5)
    drawCubes()
end

function drawCubes()
    rectMode(CENTER)
    fill(0,0,0,0)
    lineCapMode(SQUARE)
    rect(center.x, center.y, 400, 400)
    rect(center.x, center.y, 800, 800)
    translate(center:unpack())
    line(200,200, 400, 400)
    line(-200,-200, -400, -400)
    line(-200,200, -400, 400)
    line(200,-200, 400, -400)
    progress()
end

function progress()
    if ( not dead ) then
        moveBug(ant)
        moveBug(s1)
        moveBug(s2)
        moveBug(s3)
    end
    dead = isAntDead()
    drawBug(s1, color(255,0,0))
    drawBug(s2, color(255,0,0))
    drawBug(s3, color(255,0,0))
    drawBug(ant, color(0,255,0))
    --drawSpiderLines()
end

function drawSpiderLines()
    pushStyle()
    stroke(255,0,0)
    vline(corners[s1.start], corners[s1.stop])
    vline(corners[s2.start], corners[s2.stop])
    vline(corners[s3.start], corners[s3.stop])
    popStyle()
end

function vline(p,q)
    line(p.x, p.y, q.x, q.y)
end

function isAntDead()
    local radius = 10
    local pos = ant.pos
    if pos:dist(s1.pos) < radius then return true end
    if pos:dist(s2.pos) < radius then return true end
    if pos:dist(s3.pos) < radius then return true end
    return false
end

function moveBug(aBug)
    aBug.frac = aBug.frac + aBug.speed
    if aBug.frac > 1 then
        aBug.start = aBug.stop
        aBug.stop = aBug.nextGoal(aBug.start)
        aBug.frac = 0
    end
    aBug.pos = (1-aBug.frac)*corners[aBug.start] + aBug.frac*corners[aBug.stop]
end

function nextGoal(index)
    local choice = math.random(3)
    local choices = cube[index]
    return choices[choice]
end

function drawBug(aBug, col)
    pushMatrix()
    pushStyle()
    translate(aBug.pos.x, aBug.pos.y)
    stroke(col)
    fill(col)
    ellipse(0,0,20,20)
    popStyle()
    popMatrix()
end

function bline(x1, y1,x2, y2)
    pushMatrix()
    pushStyle()
    fill(0,0,0)
    translate(x1, y1)
    local t = x1 .. " " .. y1
    text(t, 0, 0)
    translate(-x1, -y1)
    translate(x2, y2)
    t = x2 .. " " .. y2
    text(t, 0,0)
    popStyle()
    popMatrix()
    
    line(x1, y1, x2, y2)
end