Joachim Ford About
6.11.2022

Snakes And Ladders Talk Through: Side Effects

This article runs through how the enemies of Snakes And Ladders are animated and rendered. Each simplified example has its source code underneath, so please to have a look if you need help. Pay particular attention to the code inside the enemy() function, which handles the drawing and animation.

Please keep in mind that not all the enemies are showcased on this page - you'll have to play the game to find that out! The main purpose for this article is to help people understand the logic behind the creatures.

Nearly every enemy uses a very similar walking pattern - one that you may recognise from Anthrophobia. In case you haven't already seen it, this "walking pattern" is based on a circle with a limit at its base.

It's quite hard to see, but even the first enemy uses this circular walking pattern. Notice how the joints move. It's a small change, but it's vital to achieve the illusion of moving forward.

const FOOT_LIFT = .35
const FOOT_SLIDE = .3
const segments = []

const generate = () => {
    const parts = 6
    let number = 0

    for (let i = 0; i < parts; i ++) {
        number += parts * i * i
        segments.push(number)
    }
}
generate()

const step = (i, str) => {
    const range = 1 / segments.length * FOOT_SLIDE
    const length = range > .1 ? range : .1

    let X = 0
    let Y = 0

    if (str == 'x')
        X = (i * width) / (segments.length - 1) + Math.cos(WALK + segments[i]) * length
    else
        Y = Math.sin(WALK + segments[i]) * FOOT_LIFT

    Y = Y > 0 ? height : height + Y

    return X ? x + X : y + Y
}

ctx.fillStyle = 'rgb(50, 0, 0)'
for (let i = 0; i < segments.length; i ++) {
    line([
        step(i - 1, 'x'),
        step(i - 1, 'y'),
        step(i, 'x'),
        step(i, 'y')
    ], .06)
}

The Cubic Side Effect

I agree that this enemy looks a little basic, but surprisingly it turned out to be the hardest one to get working. Overall, this enemy has to go through four main procedures to give you the final result.

∙ Rotate in the direction it's moving (easy)

∙ Close and open its eyes before and after rolling (not so easy)

∙ Bump up and down so that it looks like its rolling along the ground (rather tricky)

∙ Make sure it only stops rolling when resting on an edge (tricky)

Take a look at the source code if you need to understand it better.

time += .04
const EYE_SIZE = .3 * (width + height) / 2
const EYE = {
    from_center: .03,
    from_top: .14
}
const PUPIL_SIZE = .05

const COLOR = 'rgb(160, 50, 0)'

const dist_x = width / 2
const dist_y = height / 2
const diagonal_dist = Math.sqrt((dist_x * dist_x) + (dist_y * dist_y)) / 2
const bump_dist = diagonal_dist / 2

const bump = Math.abs(Math.sin(WALK * 2) * bump_dist)

const Y = Math.sin(WALK) * dist_y
const X = Math.cos(WALK) * dist_x

ctx.fillStyle = COLOR
line([-X, -Y - bump, X, Y - bump], width)

const features = () => {
    // EYES
    ctx.fillStyle = '#eef'
    const eye1 = {
        x: x + width / 2 - EYE.from_center,
        y: y + EYE.from_top
    }
    const eye2 = {
        x: x + width / 2 + EYE.from_center,
        y: y + EYE.from_top
    }

    fillRect(eye1.x - EYE_SIZE, eye1.y, EYE_SIZE, EYE_SIZE)
    fillRect(eye2.x, eye2.y, EYE_SIZE, EYE_SIZE)
    
    // PUPILS
    const LIMIT = EYE_SIZE / 2 - PUPIL_SIZE / 2
    const center = {
        x_1: eye1.x - EYE_SIZE / 2 - PUPIL_SIZE / 2,
        y_1: eye1.y + EYE_SIZE / 2 - PUPIL_SIZE / 2,
        x_2: eye2.x + EYE_SIZE / 2 - PUPIL_SIZE / 2,
        y_2: eye2.y + EYE_SIZE / 2 - PUPIL_SIZE / 2
    }
    const rot = Math.sin(time) * .1 + Math.random() * .2 - .4

    ctx.fillStyle = '#900'
    fillRect(
        center.x_1 + Math.cos(rot + .1) * LIMIT,
        center.y_1 + Math.sin(rot + .1) * LIMIT,
        PUPIL_SIZE, PUPIL_SIZE
    )
    fillRect(
        center.x_2 + Math.cos(rot) * LIMIT,
        center.y_2 + Math.sin(rot) * LIMIT,
        PUPIL_SIZE, PUPIL_SIZE
    )

    // BROWS
    ctx.fillStyle = '#000'
    line([
        eye1.x - EYE_SIZE,
        eye1.y,
        eye1.x + (eye2.x + EYE_SIZE - eye1.x - EYE_SIZE) / 2,
        eye1.y + EYE_SIZE / 4,
        eye2.x + EYE_SIZE,
        eye2.y,
    ], .04)

    const speed = .05

    // open eyes after rolling
    if (closed) {
        eyelid -= speed

        if (eyelid <= 0) {
            eyelid = 0
            closed = false
        }
    }

    // close eyes before rolling
    const shrink = roll_timer * speed
    if (shrink < EYE_SIZE) eyelid = EYE_SIZE - shrink

    // Draw eyelid
    const pad = 1 / scale
    ctx.fillStyle = COLOR
    fillRect(x, y + EYE.from_top - pad, width, eyelid)
}

if (!dir) features()

The Crab

Getting the legs to move like this was really hard. In the end, it turned out to be helpful to make a small drawing animation of it first before writing it as code. I know, creating "drawing animations" of your creatures might sound a bit pointless, but it can actually save a lot of time if your creature is already difficult to visualise in your head.

const offset = Math.sin(WALK * 2) * .1
const fake_height = 1.2

const LEG_WIDTH = .2
const LEG_PIVOT = .4
const FOOT_MOVE = .4
const FOOT_LIFT = .3
const KNEE_HEIGHT = .5
const ARM_WIDTH = .2
const EYE_DOWN = .3
const EYE_SIZE = .6
const PUPIL_SIZE = .15
const ARM_LENGTH = 1.4

const half = width / 2
const center = x + half
// LEG
ctx.fillStyle = 'rgb(34,170,136)'
const leg = val => {
    const dir = Math.sign(val)
    const walk = WALK + Math.sin(val * val + dir * 9) * 5

    const step = Math.cos(walk) < 0 && Math.cos(walk)
    const foot = {
        x: Math.sin(-walk) * FOOT_MOVE,
        y: step * FOOT_LIFT
    }
    const knee = {
        x: Math.sin(-walk) * FOOT_MOVE / 2,
        y: step * FOOT_LIFT
    }

    const x = center + val * half
    line([
        x,
        y + offset + fake_height - LEG_WIDTH / 2,
        x + knee.x + LEG_PIVOT * dir,
        y + height - KNEE_HEIGHT + knee.y,
        x + foot.x + LEG_PIVOT * dir,
        y + height + foot.y
    ], LEG_WIDTH)
}

leg(-.9)
leg(-.6)
leg(-.3)
leg(.3)
leg(.6)
leg(.9)

// ARM
const wobble = Math.abs(Math.sin(WALK / 5)) / 10
const _arm_ang = Math.sin(WALK) * .5 - Math.PI / 2
const clw_ang = Math.sin(WALK) * .2 - Math.PI / 2

for (let i = 0; i < 2; i ++) {
    const val = i ? 1 : -1

    const __arm_ang = Math.PI / 7 * val
    const arm_ang = _arm_ang + __arm_ang * 1.5

    const x1 = center + val * (half - ARM_WIDTH / 2)
    const y1 = y + offset + fake_height / 2
    const x2 = x1 + Math.cos(arm_ang) * ARM_LENGTH
    const y2 = y1 + Math.sin(arm_ang) * ARM_LENGTH

    const snap = Math.sin(WALK / 6 + Math.cos(WALK / 2) + val) * 2 + 1.8
    const snap_rot = snap > .5 ? .5 : snap > 0 && snap

    // ARMS
    ctx.fillStyle = 'rgb(34,170,136)'
    line([x1, y1, x2, y2], ARM_WIDTH)

    // CLAWS
    ctx.fillStyle = 'rgb(221,85,34)'
    const real = realPos(x2, y2)
    ctx.save()
    ctx.translate(real.x, real.y)
    ctx.save()
    ctx.rotate(clw_ang)
    rotFillRect(0, 0, .9, .5 * val)
    ctx.restore()
    ctx.save()
    ctx.rotate(clw_ang - snap_rot * val)
    rotFillRect(0, 0, .8, -.2 * val)
    ctx.restore()
    ctx.restore()
}

// BODY
ctx.fillStyle = 'rgb(34,153,119)'
fillRect(x, y + offset, width, fake_height)

// EYES
const eye = val => {
    const dir = Math.sign(val)
    const eye_x = center + half * val
    const eye_y = y + EYE_DOWN + offset

    ctx.fillStyle = 'rgb(255,255,255)'
    fillRect(eye_x, eye_y, EYE_SIZE * dir, EYE_SIZE)

    const ang = Math.sin(WALK) * .3

    const sum = EYE_SIZE / 2 - PUPIL_SIZE / 2
    const X = Math.cos(ang) * sum
    const Y = Math.sin(ang) * sum

    ctx.fillStyle = 'rgb(0,0,0)'
    fillRect(eye_x + sum * dir + X, eye_y + sum + Y, PUPIL_SIZE * dir, PUPIL_SIZE)

    ctx.fillStyle = 'rgb(51,0,0)'
    line([
        eye_x + EYE_SIZE * dir, eye_y,
        eye_x, eye_y + .1
    ], .1)
}
eye(-.3)
eye(.3)

The ...?

Well, I was planning on putting the X-Ray Orb here... but I decided to leave it out so as not to spoil the game for you. You'll just have to find out when you get there, if you haven't already!

Anyway, thanks for reading to the end, and feel free to let me know if you have any more questions!

© Copyright joachimford.uk