SCUA has two layers of time. The low-level clock gives you raw
milliseconds; the time module turns those
into civil dates you can read, format, and parse.
now()returns the current time as milliseconds since the Unix epoch (anint). It is frozen within a turn — everynow()in the same turn returns the same value — which keeps programs deterministic and replayable.dt()returns the milliseconds elapsed since the previous turn (handy for per-frame updates).- Duration literals are just ints in milliseconds:
5s,200ms,2min,1h. Sonow() + 5sis ordinary arithmetic — no special type.
That's enough for timers and elapsed-time logic. For
calendar work — "what day is this?", "format this for a
report", "parse this date string" — use the time module and
the datetime value.
#The datetime value
A datetime is an absolute instant plus the UTC
offset it's read at. It prints as ISO-8601:
import time
let t = time.now() -- the current instant, at the configured timezone (see below)
print(t) -- e.g. 2026-06-29T14:05:09.123Z
print(type(t)) -- datetime
The instant and the offset are separate ideas: the same moment can be
expressed at different offsets. now() (the int) is
offset-free absolute time; a datetime carries an offset so
it knows what local wall-clock reading it represents.
#Building a datetime
let a = time.now() -- now, at the configured offset
let b = time.at(1_782_741_909_123) -- wrap a specific epoch-ms instant
let c = time.of(2026, 6, 29, 14, 5, 9) -- year, month, day, [hour, minute, second, milli]
let d = time.of(2026, 6, 29, 14, 0, 0, 0, 120) -- last arg = UTC offset in minutes (+02:00)
time.of only requires year/month/day; the rest default
to zero, and the offset defaults to the program's configured timezone.
Out-of-range fields normalize rather than fail —
time.of(2026, 13, 1) is January 2027,
time.of(2026, 1, 32) is February 1 — so date arithmetic is
easy and never crashes.
#Reading the fields
Everything you do to a datetime is a method on
it (dt.year()); the time module is
just the constructors and the duration helpers. Read fields
individually, or grab them all with .parts():
print(`{t.year()}-{t.month()}-{t.day()}`)
print(`{t.hour()}:{t.minute()}:{t.second()}.{t.millisecond()}`)
print(`weekday {t.weekday()}`) -- ISO: 1 = Monday … 7 = Sunday
print(`day {t.year_day()} of the year`)
let p = t.parts()
-- p = { year, month, day, hour, minute, second, millisecond, weekday, year_day, offset_min }
let ms = t.to_ms() -- back to absolute epoch milliseconds
let off = t.offset() -- the offset (minutes) it's expressed at
t.with_offset(minutes) re-expresses the same
instant at a different offset (the wall-clock reading changes;
the moment doesn't):
let utc = t.with_offset(0)
print(utc.to_ms() == t.to_ms()) -- true — same instant
#Formatting
t.iso() gives the canonical ISO-8601 string. For
anything else, t.format(pattern) uses a small token
language — a token is a run of one letter; any other character
is literal:
| Token | Means | Example |
|---|---|---|
YYYY / YY |
year | 2026 / 26 |
M / MM |
month | 6 / 06 |
D / DD |
day | 9 / 09 |
H / HH |
hour (24) | 14 |
h / hh |
hour (12) | 2 / 02 |
mm |
minute | 05 |
ss |
second | 09 |
SSS |
millisecond | 123 |
Z / ZZ |
offset | +0200 / +02:00 (or Z at
UTC) |
print(t.format("YYYY-MM-DD HH:mm:ss")) -- 2026-06-29 14:05:09
print(t.format("D/M/YYYY")) -- 29/6/2026
print(t.iso()) -- 2026-06-29T14:05:09.123+02:00
Month and weekday names (
MMMM,dddd) and AM/PM (A) aren't part ofdt.format()— using one is an error today, so stick to the numeric tokens here. For a locale-aware month name (1 de febrero de 2023), reach for the render layer's@fmtinstead — see Rendering and localization.
#Parsing
time.parse(s) reads an ISO-8601 date or date-time and
returns an outcome — Ok(datetime)
or Error(reason) — so bad input is a value you handle, not
a crash:
match time.parse("2026-06-29T12:00:00Z")
Ok(t) -> print(t.format("YYYY-MM-DD"))
Error(e) -> print(`bad date: {e}`)
end
It accepts 2026-06-29 (date only → midnight), and
2026-06-29T12:00:00 with an optional .123
fraction, T or a space separator, and an optional zone
(Z, +02:00, +0200). If the string
carries a zone it wins; otherwise the reading is taken at the offset you
pass (or the configured timezone).
#Durations
Durations are plain millisecond ints. time.add and
time.diff move between datetimes and millisecond gaps; the
rest format and parse spans:
let later = time.add(t, 90min) -- advance by a duration
let gap = time.diff(later, t) -- 5400000 (ms between two instants)
print(time.humanize(gap)) -- "1h 30m"
let parts = time.duration_parts(gap) -- { days, hours, minutes, seconds, milliseconds }
match time.parse_duration("2h5m") -- "2h5m" → 7500000 ms
Ok(ms) -> print(time.humanize(ms)) -- "2h 5m"
Error(e) -> print(e)
end
time.parse_duration understands ms,
s, m (or min), h,
and d.
#What this layer is not
To stay small and fully deterministic, the time module
is a fixed-offset proleptic-Gregorian calendar. It does
not include:
- Daylight saving time or IANA zones
(
Europe/Berlin). You work with a fixed UTC offset; see Set the locale and timezone. - Leap seconds — a day is exactly 86,400,000 ms.
- Localized text —
dt.format()itself is locale-neutral; for a localized month name and locale-specific ordering, use the render layer's@fmt(Rendering and localization). Localized weekday names are still pending.
See examples/time.scua for a runnable tour, and Set the locale and
timezone for where the default offset comes from and how to override
it for testing.