DEMIENGINEDOCUMENTATION Everything is free
Lua API reference · Create

Async loads, frame time, and scheduling.

Assets, time & timers

Three small modules cover the everyday plumbing of a game loop: loading files without freezing the frame (demi.assets), reading the clock (demi.time), and scheduling work for later (demi.timer).

What an asset load is

An asset is a file the game needs at runtime — a Skyforge texture, a music track, a font. Loading is non-blocking: you ask, the engine works through stages in the background, and you poll until the asset is ready. That keeps a frame budget intact while a level fills in.

local Assets = require("demi.assets")

local request, err = Assets.load("asset://music/skyforge_theme")
if not request or request == 0 then error(err) end

Assets.load(uri) takes an asset:// URI (or an asset-group:// batch) and starts loading. It returns the integer request ID plus an error string (empty on success). Check the ID before polling; every poll below takes it.

Load stages and readiness

Every request moves through named stages: resolve → read → decode → upload → ready. It can also end in failed or cancelled.

local Assets = require("demi.assets")
local Audio = require("demi.audio")
local Game = {}

local request, err = Assets.load("asset://music/skyforge_theme")
if not request or request == 0 then error(err) end
local started = false

function Game:on_update()
    if Assets.is_ready(request) then
        if not started then
            Audio.play("asset://music/skyforge_theme", { bus = "music", loop = true })
            started = true
        end
    else
        local p = Assets.progress(request)
        -- p.stage: the named stage above; p.fraction: number 0 … 1
        -- p.completed_assets / p.total_assets for batches
    end
end

return Game
  • Assets.is_ready(request) → boolean, true when the asset is resident and usable.
  • Assets.progress(request) → progress table: stage, fraction (0…1), completed_assets, total_assets, byte totals, and an error string.

Poll is_ready from on_update and drive a loading bar from progress. Never call gameplay code that needs the asset before is_ready returns true.

Managing loaded assets

local Assets = require("demi.assets")

Assets.cancel(request)                          -- abort a pending request
Assets.unload("asset://music/skyforge_theme")   -- free memory when done
Assets.reload("asset://music/skyforge_theme")   -- pick up a changed source

local body, err = Assets.text("asset://data/credits_roll")
-- body: cached text contents, or nil; err explains why

local report = Assets.memory_report()
-- report.pending_bytes / decoded_bytes / resident_bytes
-- report.assets: { asset_id, backend, resident_bytes, owners }
CallTakesReturns
Assets.cancel(request)Request IDtrue when cancelled — in-flight work ends at stage cancelled
Assets.unload(uri)URItrue on success, plus an error string; later use needs another load
Assets.reload(asset_id)Asset IDtrue on success, plus an error string — re-resolves and re-uploads the asset
Assets.text(asset_id)Asset ID of a loaded text assetCached string (never rereads the file), or nil, plus an error string
Assets.memory_report()NothingReport table with pending/decoded/resident byte totals and per-asset entries

Assets.text returns nil and an error string for a missing URI, wrong kind, or unloaded text asset. An empty string means “loaded and blank”. For batched level streaming see Asset groups & streaming.

What the clock is

demi.time is a table of plain fields plus a few calls. All values are in seconds. Read the fields directly; there are no getters for them.

local Time = require("demi.time")

function Game:on_update()
    local dt = Time.delta_time            -- seconds since last frame (scaled)
    local udt = Time.unscaled_delta_time  -- seconds since last frame, ignoring time scale
    print(Time.time)                      -- total elapsed seconds since start
    print(Time.fixed_time)                -- total simulated seconds at fixed step
    print(Time.frame_count)               -- integer frames since start
    print(Time.time_scale)                -- current scale multiplier (1 = normal)
    print(Time.paused)                    -- true when the clock is paused
end
Field or callMeaning
Time.delta_timeNumber, seconds since the previous frame, affected by time scale
Time.unscaled_delta_timeNumber, seconds since the previous frame, ignoring pause and scale (for UI)
Time.timeNumber, total elapsed scaled seconds
Time.fixed_timeNumber, total seconds simulated at the fixed step
Time.frame_countInteger, frames since start
Time.time_scaleNumber, current scale multiplier
Time.pausedBoolean, true while paused
Time.set_paused(on) / Time.is_paused()Pause control; the call returns nothing, the query returns a boolean
Time.set_scale(s) / Time.get_scale()Set/read the multiplier; get_scale returns a number

Movement code should use Time.delta_time so speed is per-second and survives frame-rate changes. Pause menus and fade-outs that must keep moving use Time.unscaled_delta_time.

local Time = require("demi.time")

Time.set_paused(true)        -- freeze gameplay
Time.set_scale(0.3)          -- slow motion at 30%
print(Time.get_scale())      -- 0.3

Timers

Timers run a function later or repeatedly. Each scheduling call returns an integer timer ID you can cancel. Keep the ID if you may want to stop the timer; pass it back to Timer.cancel, do not read fields off it.

local Timer = require("demi.timer")
local Debug = require("demi.debug")

Timer.delay(1.5, function()
    Debug.log("Skyforge: intro beat")
end)

local later = Timer.after(3, function()
    Debug.log("Skyforge: ambush")
end)

local beat = Timer.every(0.5, function()
    Debug.log("tick")
end)

Timer.cancel(later)   -- stop a scheduled call before it fires
Timer.cancel(beat)
  • Timer.delay(secs, fn) — runs fn once after secs. Returns an integer timer ID.
  • Timer.after(secs, fn) — one-shot alias of delay with intent-revealing naming. Returns an integer timer ID. (To bind a timer to a script's lifetime, use Script.after.)
  • Timer.every(secs, fn) — runs fn every secs until cancelled. Returns an integer timer ID.
  • Timer.cancel(timer_id) — stops a pending timer. Returns true when a pending timer was cancelled; unknown or already-fired IDs return false.

Timers use game time, so pausing the game pauses them. For one-shot effects prefer after (cancellable) over delay (fire-and-forget). Event payloads and subscribe/unsubscribe patterns are covered in Events & timers.

Next steps