Table of Contents

Tick Helper

common.tick is a small Lua helper for NPC brains and other coroutine-based scripts that need simple recurring cadences without repeating last_*_ms boilerplate.

It is a good fit for:

  • periodic movement
  • ambient speech
  • idle sounds
  • simple state progression timers

It is not meant for:

  • persistent world timers
  • background work
  • gameplay state that must survive reloads

Why Use It

Without common.tick, Lua brains usually drift toward this pattern:

local MOVE_INTERVAL_MS = 5000
local last_move_ms = 0

local now = time.now_ms()
if now - last_move_ms >= MOVE_INTERVAL_MS then
    npc:move(random.direction())
    last_move_ms = now
end

That works, but once a brain has multiple cadences it becomes noisy quickly.

common.tick keeps the same behavior while moving the timing bookkeeping into one shared helper.

API

local tick = require("common.tick")

tick.state(intervals, start_ms?)

Creates cadence state for one or more named intervals.

local cadence = tick.state({
    move = 5000,
    speech = 2000,
    sound = 3000,
})

tick.ready(state, key, now_ms)

Returns true when the named cadence is ready to fire.

tick.run(state, key, now_ms, action?)

Runs the cadence if ready, updates the internal timestamp, and executes action. Returns true when the cadence fired.

tick.reset(state, key, now_ms, interval_ms?)

Resets a cadence timer and optionally changes its interval. Useful for state machines where the next wait duration depends on the current state.

Use Case: Ambient NPC Brain

orion.lua uses three independent cadences:

local tick = require("common.tick")

local state = {
    cadence = tick.state({
        move = 5000,
        speech = 2000,
        sound = 3000,
    }),
}

function orion.on_think(npc_id)
    while true do
        local npc = mobile.get(npc_id)

        if npc ~= nil then
            local now = time.now_ms()

            tick.run(state.cadence, "move", now, function()
                npc:move(random.direction())
            end)

            tick.run(state.cadence, "speech", now, function()
                local message = random.element(MESSAGES)
                if message ~= nil then
                    npc:say(message)
                end
            end)

            tick.run(state.cadence, "sound", now, function()
                local sound_id = random.element(SOUNDS)
                if sound_id ~= nil then
                    npc:play_sound(sound_id)
                end
            end)
        end

        coroutine.yield(250)
    end
end

This keeps the brain focused on behavior, not timer bookkeeping.

Use Case: State Machine Durations

test_state_brain.lua combines libs.statemachine with common.tick.

Instead of storing entered_at_ms and manually checking each state's elapsed time, it uses one named cadence:

local state = {
    machine = machine,
    cadence = tick.state({
        advance = IDLE_DURATION_MS,
    }, time.now_ms()),
}

local function enter_state(state, duration_ms)
    tick.reset(state.cadence, "advance", time.now_ms(), duration_ms)
end

Then the loop only asks whether the state should advance:

tick.run(state.cadence, "advance", now, function()
    if machine:is("idle") then
        if machine:start_wander() then
            enter_state(state, WANDER_DURATION_MS)
            npc:move(random.direction())
        end

        return
    end

    if machine:is("wander") then
        if machine:start_speak() then
            enter_state(state, SPEAK_DURATION_MS)
            local message = random.element(MESSAGES)
            if message ~= nil then
                npc:say(message)
            end
        end

        return
    end

    if machine:is("speak") and machine:finish_speak() then
        enter_state(state, IDLE_DURATION_MS)
    end
end)

That is still explicit, but it removes repetitive elapsed >= X checks and keeps state duration changes in one place.