Skip to content

Gameplay

Five recipes that turn a level into a game: something that reacts to where you are, something you can do to the world, and a round with a goal, a clock and a result.

Trigger zones and doors

A zone is a box in space, and "is the player in it?" is six comparisons. Remember whether they were in it last frame, and you know when they step in and when they step out. Here the green square opens a door; stepping off shuts it two seconds later.

A green pad in front of a closed wooden door

zone_door.lua
-- zone_door.lua: a trigger zone. Stand on the green square and the door
-- opens; step off and it shuts two seconds later. Zones are just "is the
-- player inside this box?", checked every frame. (docs/cookbook/gameplay.md)

local ZONE_MIN, ZONE_MAX = Vec(-1, -0.5, 1), Vec(1, 2.5, 3)   -- a box: two corners
local DOOR_AT = Vec(0, 1.25, -2)

physics.box { pos = Vec(0, 0.01, 2), size = Vec(2, 0.02, 2), color = Vec(0.3, 0.9, 0.4), static = true }
-- The wall with a gap for the door.
physics.box { pos = Vec(-2.25, 1.25, -2), size = Vec(2.5, 2.5, 0.3), color = Vec(0.5, 0.5, 0.55), static = true }
physics.box { pos = Vec(2.25, 1.25, -2), size = Vec(2.5, 2.5, 0.3), color = Vec(0.5, 0.5, 0.55), static = true }

local door = nil
local function setDoor(closed)
  if closed and not door then
    door = physics.box { pos = DOOR_AT, size = Vec(2, 2.5, 0.2), color = Vec(0.6, 0.4, 0.25), static = true }
  elseif not closed and door then
    physics.remove(door)
    door = nil
  end
end
setDoor(true)

local function inside(p, lo, hi)
  return p.x >= lo.x and p.x <= hi.x and p.y >= lo.y and p.y <= hi.y and p.z >= lo.z and p.z <= hi.z
end

local wasInside = false
hook.Add("Think", "zone.check", function()
  local now = inside(player.position(), ZONE_MIN, ZONE_MAX)
  if now and not wasInside then            -- just stepped in
    timer.Remove("zone.close")             -- cancel a pending close
    setDoor(false)
    print("Door open")
  elseif wasInside and not now then        -- just stepped out
    timer.Create("zone.close", 2, 1, function() setDoor(true) end)
  end
  wasInside = now
end)

Download zone_door.lua

The same shape works for checkpoints (save where they are), traps (start a timer), shops and cutscenes (switch the camera). timer.Create with a name is what makes "shut it later, unless they come back" work: stepping back in removes the pending timer by its name.

Shooting with rays

A ray is a line tested against the world: where it hits, what it hits, and the surface's direction there. physics.raycast(from, direction, distance) from the camera along camera.forward() is "what am I looking at?". Click (the standard fire action) to knock crates off the wall.

A wall of crates

shooter.lua
-- shooter.lua: click to shoot where the camera looks. A ray finds what
-- it hits; a hit crate gets knocked back, and a spark marks the spot.
-- "fire" is one of the engine's standard actions (left mouse, right
-- trigger). (docs/cookbook/gameplay.md)

-- A wall of crates to shoot at.
for row = 0, 3 do
  for col = 0, 5 do
    physics.box { pos = Vec(-2.5 + col * 0.62, 0.3 + row * 0.62, -1), size = Vec(0.6, 0.6, 0.6), density = 100,
                  color = Vec(0.5 + row * 0.1, 0.35, 0.2) }
  end
end

local sparks = {}

local function spark(at)
  local id = physics.sphere { pos = at, radius = 0.06, color = Vec(1, 0.9, 0.3), static = true }
  table.insert(sparks, id)
  timer.Simple(0.3, function() physics.remove(id) end)
end

hook.Add("Think", "shooter.fire", function()
  if not input.pressed("fire") then return end
  local from = camera.position()
  local dir = camera.forward()
  -- raycast(from, direction, max distance) -> what it hit, or nil.
  local hit = physics.raycast(from, dir, 60)
  if not hit then return end
  spark(hit.pos)
  -- Push what was hit, at the point it was hit (so it spins too). Only
  -- script bodies can be pushed; pcall skips the rest (walls, the floor).
  pcall(physics.impulse, hit.body, dir * 4, hit.pos)
end)

Download shooter.lua

  • physics.impulse(id, push, point) pushes at a point, so a crate hit at the corner spins. Its size is momentum (mass × m/s), so heavy things move less.
  • The hit also tells you hit.normal (which way the surface faces, for bullet holes and ricochets), hit.distance and hit.material (for the right sound).
  • Rays are cheap; line-of-sight checks for enemies are the same call from their eyes to the player.

Explosions

An explosion is a loop over what's near: push each thing away from the centre, harder the closer it is. B blows up the heap; a new one comes three seconds later.

Crates and balls flying apart

explosion.lua
-- explosion.lua: press B to blow up the heap. Every body nearby is
-- pushed away from the centre, harder the closer it is; there's a sound,
-- and the camera shakes.
-- (docs/cookbook/gameplay.md)

input.define("boom", "Explosion", "B")
local RADIUS = 5
local FORCE = 12   -- m/s at the centre

-- Things to blow up: a heap of crates and balls, rebuilt every time.
local things = {}
local function heap(at)
  for _, id in ipairs(things) do physics.remove(id) end
  things = {}
  for i = 1, 30 do
    local p = at + Vec(math.random() * 2 - 1, 0.3 + i * 0.1, math.random() * 2 - 1)
    local id
    if i % 3 == 0 then
      id = physics.sphere { pos = p, radius = 0.2, color = Vec(0.3, 0.7, 1) }
    else
      id = physics.box { pos = p, size = Vec(0.4, 0.4, 0.4), density = 150, color = Vec(0.8, 0.6, 0.3) }
    end
    table.insert(things, id)
  end
end

local function explode(centre)
  for _, id in ipairs(things) do
    local offset = physics.position(id) - centre
    local distance = offset:length()
    if distance < RADIUS then
      local falloff = 1 - distance / RADIUS                 -- 1 at the centre, 0 at the edge
      local away = (offset + Vec(0, 0.5, 0)):normalized()    -- a little upward: things lift
      physics.setVelocity(id, physics.velocity(id) + away * (FORCE * falloff))
    end
  end
  if audio then audio.impact(centre, "Stone", 1.0) end
  camera.shake(0.8, 0.6)   -- strength 0..1, seconds
end

local HEAP = Vec(0, 0, 2)
heap(HEAP)
hook.Add("Think", "explosion.keys", function()
  if input.pressed("boom") then
    explode(HEAP + Vec(0, 0.2, 0))
    timer.Simple(3, function() heap(HEAP) end)   -- a new heap to blow up
  end
end)

Download explosion.lua

if view then ... end is how a script uses something only some games have: tables exist only when the game has the module that provides them (there's no audio without an AudioModule, no view outside the cookbook game), and a missing table is nil.

Lamps

A lamp is a point light a script places with light.point; it lights what is within its range and fades out towards the edge; up to 15 lamps besides the sun, the first 4 that ask can cast shadows; here a warm lamp lights a pillar and eight coloured ones circle it, L switches the ring.

Coloured lamps circling a white pillar

lamps.lua
-- lamps.lua: eight coloured point lights in a ring plus one warm lamp above
-- a pillar, with smooth range falloff and optional shadows. (docs/cookbook/gameplay.md)

input.define("lamps", "Lamps on/off", "L")

-- A white pillar for the lamps to light.
physics.box { pos = Vec(0, 1.5, 6), size = Vec(0.6, 3, 0.6), color = Vec(0.9, 0.9, 0.9), static = true }

local sun, why = light.point { pos = Vec(0, 4.5, 6), color = Vec(1, 0.85, 0.6), intensity = 1.5, range = 9, shadows = true }
if not sun then kke.log("warm lamp: " .. why) end

local ring = {}
local colours = { Vec(1, 0, 0), Vec(1, 0.5, 0), Vec(1, 1, 0), Vec(0, 1, 0), Vec(0, 1, 1), Vec(0, 0, 1), Vec(0.8, 0, 1), Vec(1, 0.2, 0.8) }
for i = 1, 8 do
  local c = colours[i]
  local id, why = light.point { pos = Vec(3, 0.6, 6), color = c, intensity = 2, range = 3 }
  if not id then kke.log("ring lamp " .. i .. ": " .. why) break end
  ring[i] = id
end

local on = true

hook.Add("Think", "lamps.spin", function()
  local t = kke.time()
  for i, id in ipairs(ring) do
    local a = t * 0.5 + i * math.pi / 4
    light.move(id, Vec(math.cos(a) * 3, 0.6, 6 + math.sin(a) * 3))
  end
  if input.pressed("lamps") then
    on = not on
    for _, id in ipairs(ring) do
      light.set(id, { on = on })
    end
  end
end)

Download lamps.lua

light.set changes any of colour, intensity, range and shadows later, on = false switches a lamp off but keeps it, and everything a script placed goes away when the script is reloaded or stops.

Effects

Fire, smoke, sparks, explosions, rain and about twenty more are ready by name (fx.names() lists them, the particle effects page shows each). fx.play fires one once, fx.start keeps one going until fx.stop and fx.move takes it along, and fx.define adds your own from a Lua table; here a campfire burns on a stone, F throws fireworks, and a puff of your own drifts in the wind.

A campfire burning on a stone and a grey puff drifting beside it

effects.lua
-- effects.lua: particle effects by name. A campfire burns on a stone, F
-- throws fireworks from it, and a puff written in Lua drifts in the wind.
-- (docs/cookbook/gameplay.md)

input.define("fireworks", "Fireworks", "F")

-- A flat stone for the fire to sit on.
physics.box { pos = Vec(-1.5, 0.15, 3), size = Vec(1.4, 0.3, 1.4), color = Vec(0.45, 0.43, 0.4), static = true }

-- A stream keeps going until fx.stop; it follows fx.move.
local fire, why = fx.start("campfire", Vec(-1.5, 0.3, 3))
if not fire then kke.log(why) end

-- Your own effect: the same fields as the YAML in docs/PARTICLE_EFFECTS.md.
local ok, err = fx.define("lazy_puff", {
  description = "slow grey puffs that drift with the wind",
  layers = { { kind = "smoke", rate = 3, speed = { 0.2, 0.5 }, spread = 20, up = true, colors = { { 0.6, 0.6, 0.62 } },
               radius = { 0.2, 0.3 }, growth = 0.4, opacity = 0.4, life = { 3, 4 } } },
})
if not ok then kke.log(err) end
local puff = fx.start("lazy_puff", Vec(1.5, 0.5, 3))

hook.Add("Think", "effects.play", function()
  local t = kke.time()
  fx.wind(Vec(math.sin(t * 0.3) * 1.5, 0, 0)) -- smoke leans with it
  if puff then fx.move(puff, Vec(1.5, 0.5 + math.sin(t) * 0.3, 3)) end
  if input.pressed("fireworks") then
    fx.play("fireworks", Vec(-1.5, 1, 3), { scale = 1.5 })
  end
end)

Download effects.lua

Options for play, start and move: normal (which way a burst sprays), velocity (of what it comes off), scale (2 = twice as big) and floor (the ground's height: sparks bounce there). A script's effects stop emitting when it is reloaded or stops; what is already in the air finishes.

A whole round

Everything together: a goal (knock the pillars over), a clock, a HUD, a win and a lose, a best time that's still there tomorrow, and a button to play again.

Red pillars, with the time and score in the corner

round.lua
-- round.lua: a whole game round. Knock all the pillars over before the
-- clock runs out; a HUD shows the time and what's left, you win or lose,
-- the best time is saved, and a button starts again.
-- (docs/cookbook/gameplay.md)

local TIME = 45
local SPOTS = { Vec(-3, 0, -1), Vec(0, 0, -2), Vec(3, 0, -1), Vec(-1.5, 0, 1), Vec(1.5, 0, 1) }

local hud = ui and ui.open([[
<rml><head><style>
  body { width: 100%; height: 100%; font-family: Noto Sans; color: #ffffff; pointer-events: none; }
  #panel { position: absolute; left: 20dp; top: 20dp; padding: 8dp 16dp; background-color: #10131ecc;
           border-radius: 8dp; font-size: 20dp; }
  #time { color: #ffd166; }
  #result { position: absolute; top: 40%; width: 100%; text-align: center; font-size: 40dp; }
  #again { position: absolute; top: 55%; left: 50%; margin-left: -80dp; width: 160dp; padding: 8dp;
           text-align: center; background-color: #ef8354; border-radius: 8dp; pointer-events: auto; }
  #again:hover { background-color: #f4a261; }
  .hidden { display: none; }
</style></head>
<body>
  <div id="panel">Time <span id="time"></span> · Standing <span id="left"></span> · Best <span id="best"></span></div>
  <div id="result"></div>
  <div id="again" class="hidden">Play again</div>
</body></rml>
]])

local pillars, timeLeft, playing = {}, 0, false

local function show(id, text) if hud then ui.text(hud, id, text) end end

local function standing()
  local n = 0
  for _, p in ipairs(pillars) do
    -- Still standing = its middle is still up high and near where it was.
    local at = physics.position(p.id)
    if at.y > 0.9 and (Vec(at.x, 0, at.z) - p.home):length() < 0.5 then n = n + 1 end
  end
  return n
end

local function start()
  for _, p in ipairs(pillars) do physics.remove(p.id) end
  pillars = {}
  for _, home in ipairs(SPOTS) do
    local id = physics.box { pos = home + Vec(0, 1, 0), size = Vec(0.4, 2, 0.4), density = 60, color = Vec(0.9, 0.35, 0.35) }
    table.insert(pillars, { id = id, home = home })
  end
  timeLeft, playing = TIME, true
  show("result", "")
  if hud then ui.class(hud, "again", "hidden", true) end
  local best = store.load("round.best")          -- nil until someone wins
  show("best", best and string.format("%.1f s", best) or "-")
end

local function finish(won)
  playing = false
  if won then
    local took = TIME - timeLeft
    show("result", string.format("You did it in %.1f seconds!", took))
    local best = store.load("round.best")
    if not best or took < best then
      store.save("round.best", took)
      show("best", string.format("%.1f s", took))
    end
  else
    show("result", "Out of time!")
  end
  if hud then ui.class(hud, "again", "hidden", false) end
end

hook.Add("Think", "round.tick", function(dt)
  if not playing then return end
  timeLeft = math.max(0, timeLeft - dt)
  local left = standing()
  show("time", string.format("%.0f", math.ceil(timeLeft)))
  show("left", tostring(left))
  if left == 0 then finish(true)
  elseif timeLeft == 0 then finish(false) end
end)

if hud then ui.onClick(hud, "again", start) end
start()

Download round.lua

  • The HUD is an RmlUi document: HTML-like markup with CSS-like styles. ui.text(doc, id, text) changes what an element says, ui.class adds or removes a class (here hidden, to show the button), and ui.onClick(doc, id, fn) runs fn when it's clicked. pointer-events: none on the body lets clicks go through to the game, except on the button. Press Esc to free the mouse and click it.
  • Saving: store.save(name, value) and store.load(name) keep numbers, text and tables between sessions, in a small database next to the game (Saving).
  • Win or lose is checked once per frame, and playing stops the round from ending twice.

Tutorial 3 builds a coin pickup with a score HUD step by step, and games/first_lua_game/ is a complete small game in one Lua file (its walkthrough).

Next: algorithms.