Luawe the Wayfarer Engine's scripting language All pages

Four scripts to learn from

Four whole scripts, each explained. The first three are in the editor under Try an example; the fourth is a level of its own, made with File > New. Each makes everything it needs, so all of them work in an empty level: add one, press F5, then change things and see what happens.

On the beat

A text that counts the song's beats, and a kick of the camera on every fourth one.

-- A text made by the script, a little above the middle of the screen.
local counter = things.new("text", { x = camera.x, y = camera.y - 3, size = 1.2, text = "Beat 0" })

-- level.beat is an event: it happens on every beat of the song, with the beat's number.
level.beat:connect(function(beat)
    counter.text = `Beat {beat}`
    if beat % 4 == 0 then
        camera:effect("pulse", 0.5, 0.4)
        print(`bar {beat // 4 + 1} begins`)
    end
end)

What to notice:

  • The script is over after two statements: it makes a text and connects a function. The function keeps being called for the whole level. See events.
  • beat % 4 == 0 is true on beats 0, 4, 8...: % gives what is left after dividing, and nothing is left on every fourth beat.
  • The text between backticks is a string with a value put into it: {beat} becomes the beat's number.

Try: make it kick on every second beat. Make the text bigger on the kick and glide back with counter:tween({ size = 1.2 }, 0.3).

A button and a door

Touch the yellow square, and the door glides out of the way.

local x, y = camera.x, camera.y

-- A strip of floor, a door across it, and a button before the door.
things.new("floor", { x = x + 4, y = y, w = 12, h = 3 })
local door = things.new("block", { x = x + 5, y = y, w = 1, h = 3 })
local button = things.new("deco", { x = x + 2, y = y, ["color.r"] = 255, ["color.g"] = 214, ["color.b"] = 92 })

-- The script waits on this line until the cube touches the button...
button.touched:wait()
print("the button was pressed")
sound.play("@door")
button.alpha = 0.3

-- ...then the door glides up and out of the way, in one second.
door:tween({ y = door.y - 3 }, 1, "quad", "out"):wait()
print("the door is open")

What to notice:

  • The script reads from top to bottom like a story: make the room, wait for the button, open the door. :wait() on an event is what makes that possible.
  • A block kills the cube, so the door really is in the way until it has moved.
  • A colour's parts have a dot in their names (color.r), so in a table they are written with square brackets and quotes.

Try: close the door again after three seconds. Make a second button that only works once the first was pressed.

Three lives

What would kill the cube takes a life instead, and nothing hurts it for three seconds after.

local lives = 3
local x, y = camera.x, camera.y

things.new("floor", { x = x + 3, y = y, w = 11, h = 5 })
things.new("saw", { x = x + 4, y = y, w = 1.5, h = 1.5 })
local label = things.new("text", { x = x, y = y - 3.5, text = `Lives: {lives}` })

-- player.hurt is an event: it happens in the moment the cube touches something that kills it.
player.hurt:connect(function()
    if lives <= 0 then
        return -- no lives left: this time the run is over
    end
    lives -= 1
    label.text = `Lives: {lives}`
    player:revive()
    camera:effect("flash", 0.4, 0.3)

    -- Nothing hurts the cube for three seconds (it is drawn smaller meanwhile, to show it).
    player.god = true
    player.size = 0.7
    wait(3)
    player.size = 1
    player.god = false
end)

What to notice:

  • player:revive() only works inside player.hurt: it saves the cube in the very moment it would die.
  • Without the three seconds of player.god, the cube would still be on the saw the moment after, and lose its next life at once.
  • The connected function waits three seconds in the middle. That is fine: each call of it is a task of its own. And while god mode is on, player.hurt does not happen at all.
  • When the last life is gone the run fails, the level starts again, and so does the script: lives is 3 again.

Try: give a life back for every coin. Make the cube blink while it is safe, with a loop that switches player.visible and waits a tenth of a second.

A maze in 30 seconds

File > New > A maze in 30 seconds makes a level whose script builds a new maze every time. The level itself holds almost nothing: the pad the cube starts on (named start), a text for the clock (named clock), and a camera that stands still. The script does the rest.

const COLUMNS, ROWS = 9, 5  -- how many rooms the maze has, across and down
const ROOM = 2              -- how wide a room and a corridor are, in tiles
const GAP = 1               -- how much water lies between two corridors
const SECONDS = 30          -- the time to get through

const STEP = ROOM + GAP
local pad = things.find("start") -- the piece of floor the cube starts on: the maze's first room
local startX, startY = pad.centerX, pad.centerY

-- random.new() gives new numbers at every try. random.new(7) would build the same maze every time.
local rng = random.new()

-- A level's scripts make 64 things in one step at most: a breath after every 40.
local made = 0
local function floor(x, y, w, h)
    things.new("floor", { x = x, y = y, w = w, h = h })
    made += 1
    if made % 40 == 0 then
        wait()
    end
end

-- Digging: from a room, go to a neighbour that isn't dug out yet, picked at random; with none left, go back a room.
-- dug[room] is how many rooms away from the start that room is.
local function room(c, r)
    return r * COLUMNS + c
end
local dug = { [room(0, 0)] = 0 }
local trail = { { 0, 0 } }
local farthest, goalC, goalR = 0, 0, 0
const ways = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }

while #trail > 0 do
    local c, r = trail[#trail][1], trail[#trail][2]
    local open = {}
    for _, way in ways do
        local nc, nr = c + way[1], r + way[2]
        if nc >= 0 and nc < COLUMNS and nr >= 0 and nr < ROWS and dug[room(nc, nr)] == nil then
            table.insert(open, way)
        end
    end
    if #open == 0 then
        table.remove(trail)
    else
        local way = rng:pick(open)
        local nc, nr = c + way[1], r + way[2]
        local steps = dug[room(c, r)] + 1
        dug[room(nc, nr)] = steps
        if steps > farthest then
            farthest, goalC, goalR = steps, nc, nr
        end
        -- The new room, and the corridor that leads to it.
        floor(startX + nc * STEP, startY + nr * STEP, ROOM, ROOM)
        floor(startX + (c + way[1] / 2) * STEP, startY + (r + way[2] / 2) * STEP, way[1] ~= 0 and GAP or ROOM, way[2] ~= 0 and GAP or ROOM)
        table.insert(trail, { nc, nr })
    end
end

-- The way out: a yellow square in the room furthest from the start.
local goal = things.new("deco", {
    x = startX + goalC * STEP, y = startY + goalR * STEP, w = 1.2, h = 1.2,
    ["color.r"] = 255, ["color.g"] = 214, ["color.b"] = 92,
})

-- A checkpoint: a try that fails starts again from here, with the same maze and the clock back at 30.
level.checkpoint()

local clock = things.find("clock")
local began = time.now

goal.touched:connect(function()
    print(`out in {math.round(time.now - began, 1)} seconds`)
    sound.play("@coin")
    level.win()
end)

while true do
    local left = SECONDS - (time.now - began)
    if left <= 0 then
        clock.text = "0.0"
        level.fail()
        break
    end
    clock.text = string.format("%.1f", left)
    wait()
end

How it works:

  • The settings are const at the top: change COLUMNS, ROWS or SECONDS and the whole maze follows. (A bigger maze needs the camera zoomed out: Level > Camera.)
  • The digging is a classic way to make a maze. Stand in a room; pick a neighbour that has not been dug yet, at random; break through to it and stand there. When a room has no such neighbour, walk back one room along trail and look again. When the trail is empty, every room has been reached, and there is exactly one way between any two rooms.
  • The maze is made of floor. There are no walls: the cube must stay on the floor, as in every level, and the water does the rest. A room is a square of floor, a corridor a smaller piece between two rooms.
  • made % 40 == 0 spreads the building over a few steps, because a level makes 64 things in one step at most.
  • The goal is put in the room that took the most steps to reach, which the digging remembered on the way (farthest).
  • The checkpoint right after building is what keeps the maze the same when the player fails: the run goes back to that moment, script and all. began is part of what is kept, and so is the time, so the clock is at 30 again.
  • The clock is a loop that waits one step in every round. When it runs out it calls level.fail().

Try: make the maze 12 by 7 and give it 45 seconds. Put a coin in every dead end (a room with only one way in). Let the walls close in: destroy a few pieces of floor every ten seconds.