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,truewhen the asset is resident and usable.Assets.progress(request)→ progress table:stage,fraction(0…1),completed_assets,total_assets, byte totals, and anerrorstring.
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 }
| Call | Takes | Returns |
|---|---|---|
Assets.cancel(request) | Request ID | true when cancelled — in-flight work ends at stage cancelled |
Assets.unload(uri) | URI | true on success, plus an error string; later use needs another load |
Assets.reload(asset_id) | Asset ID | true on success, plus an error string — re-resolves and re-uploads the asset |
Assets.text(asset_id) | Asset ID of a loaded text asset | Cached string (never rereads the file), or nil, plus an error string |
Assets.memory_report() | Nothing | Report 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 call | Meaning |
|---|---|
Time.delta_time | Number, seconds since the previous frame, affected by time scale |
Time.unscaled_delta_time | Number, seconds since the previous frame, ignoring pause and scale (for UI) |
Time.time | Number, total elapsed scaled seconds |
Time.fixed_time | Number, total seconds simulated at the fixed step |
Time.frame_count | Integer, frames since start |
Time.time_scale | Number, current scale multiplier |
Time.paused | Boolean, 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)— runsfnonce aftersecs. Returns an integer timer ID.Timer.after(secs, fn)— one-shot alias ofdelaywith intent-revealing naming. Returns an integer timer ID. (To bind a timer to a script's lifetime, useScript.after.)Timer.every(secs, fn)— runsfneverysecsuntil cancelled. Returns an integer timer ID.Timer.cancel(timer_id)— stops a pending timer. Returnstruewhen a pending timer was cancelled; unknown or already-fired IDs returnfalse.
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
- Asset groups & streaming — loading a whole level under a memory budget.
- Importers — getting a source file into an
asset://in the first place. - Script lifecycle — which hook receives each frame's
dt.