Date: 2026-02-05
Status: Design Complete - Ready for Technical Specification
Related: On vs When API Refactor
This document defines the API design for time-based execution in LunyScript: Timers (fire-once/repeating triggers) and Coroutines (duration-based execution with elapsed events). These replace Unity’s coroutine yield return new WaitForSeconds() pattern with a more beginner-friendly, declarative API.
| Concept | Purpose | Has Duration | ‘While Running’ Blocks | Elapsed Event |
|---|---|---|---|---|
| Timer | Fire-and-forget time trigger | ✅ | ❌ | ✅ |
| Coroutine | Duration with running state | ✅ | ✅ | ✅ |
A Timer is effectively syntactic sugar for a Coroutine without update handlers.
Timer("once").In(3).Seconds(); // one-shot implied by "In"
Timer("repeat").Every(3).Seconds(); // repeating implied by "Every"
// One-shot timer (fires once after delay)
Timer("alarm").In(5).Seconds().Do(Debug.Log("Time's up!"));
Timer("quick").In(500).Milliseconds().Do(blocks);
Timer("slow").In(2).Minutes().Do(blocks);
// Repeating timer
Timer("clock").Every(1).Seconds().Do(Debug.Log("tick"));
Timer("pulse").Every(100).Milliseconds().Do(blocks);
Timer("think").Every(5).Heartbeats().Do(Debug.Log("hmmm?"));
Timer("periodic").Every(30).Frames().Do(blocks);
Timers are in fact coroutines:
// Coroutine with just OnElapsed is equivalent to a Timer
// Timer.In(..) is an alias for this coroutine pattern:
Coroutine("Timer.In(2).Seconds().Do(blocks)")
.For(2).Seconds()
.Elapsed(blocks); // runs once after duration
// Timer.Every(..) is an alias for this coroutine pattern:
Coroutine("Timer.Every(100).Heartbeats().Do(blocks)")
.For(100).Heartbeats()
.Heartbeat(blocks); // runs for duration every heartbeat
Coroutines can run blocks conditionally on frame updates or fixed steps, or both:
// Coroutines run while condition is true
Coroutine("conditional")
.While(conditions)
.OnUpdate(Debug.Log("processing...")) // only while true
.OnHeartbeat(Debug.Log("stepping...")); // only while true
Coroutine duration and conditions can be combined. Conditions only affect the “every” aspect of the Coroutine, while the OnElapsed() event executes unconditionally.
// Coroutine with update/elapsed handlers, indented for emphasis
Coroutine("countdown")
.For(3).Seconds() // run for 3s total
.Elapsed(blocks) // runs unconditionally after 3s
.While(conditions) // conditions
.OnUpdate(blocks) // only while conditions true
.OnHeartbeat(blocks); // only while conditions true
Coroutines can also execute unconditionally, which is similar to On.Update(..) but coroutines can be paused or stopped at any time (see next paragraph).
// Coroutine with update/elapsed handlers, unconditional
Coroutine("unconditional")
.OnUpdate(blocks) // runs on frame update
.OnHeartbeat(blocks); // runs on fixed step
// elsewhere, in some other block
If(conditions).Then(Coroutine("unconditional").Stop())
Coroutine execution can also be time-sliced with staggered execution (phase shifted) to perform load balancing:
// Time-sliced coroutines adjust frequency of condition evaluation
// Processes the same conditions/blocks but in alternating frames
Every(2)
.Frames()
.Do(sameBlocks); // runs in frames 0, 2, 4, 6, ..
Every(2)
.Frames()
.DelayBy(1) // start with +1 delay (offset)
.Do(sameBlocks); // runs in frames +1, 3, 5, 7, ..
Situation when to use this: Group consumes 12 ms frame time total (conditions: 2 ms; blocks: 10 ms) => too much!
After change:
If spread over 4 frames:
Quite commonly not an issue though.
Example: 100 units start to move instantly in the same frame vs only 25% move instantly, while the remaining three quarters each move delayed by 1, 2 and 3 frames.
Delayed response time easily “simulates” human-like variance in group behaviour and individual situations.
Example: A large group of characters no longer exhibit perfectly synchronized animations. In individual combat, an enemy could exhibit minimal timing variation of attack/defense stances.
// Store reference for later control
var countdown = Coroutine("bomb")
.For(007).Seconds()
.OnHeartbeat(Debug.Log("tic-tic"))
.Elapsed(Debug.Log("BOOM!"));
// Lifecycle control: Coroutines pause while object is disabled,
// and resume when object is re-enabled.
// This overrides resume by restarting the coroutine:
On.Enabled(countdown.Start());
// Runtime control methods
countdown.Start(); // start or restart the coroutine
countdown.Stop(); // stop and reset state
countdown.Pause(); // freeze at current time
countdown.Resume(); // continue from paused state
// Control Events (also available on Timer)
var countdown = Coroutine("..").For(5).Seconds()
.Started(startBlocks) // runs when (re-)started
.Paused(pauseBlocks) // runs when paused (if running)
.Resumed(resumeBlocks) // runs when resumed (if paused)
.Stopped(stopBlocks) // runs when stopped (not: elapsed)
.Elapsed(elapsedBlocks); // runs when elapsed (not: stopped)
Speed control affects when the OnElapsed event runs:
var x = Coroutine("x").For(3).Seconds();
x.TimeScale(0.5f); // runs twice as long
x.TimeScale(2f); // ends in half the time
// Pause() is same as TimeScale(0)
x.TimeScale(0f); // paused, can also Resume()
// Sorry, no going back in time: it would end the universe ...
x.TimeScale(-1f); // Clamped to 0
The TimeScale is exposed through the script execution context and can be used to affect block execution.
When a coroutine is stopped:
When a coroutine is running:
When a coroutine is paused:
// ✅ Required
Timer("myTimer").In(3).Seconds().Do(blocks);
// ❌ Not allowed (no unnamed timers)
Timer().In(3).Seconds().Do(blocks);
Rationale: Names enable debugging, logging, and runtime lookup. Avoids backend complexity of generating unique names.
Timer("x").In(3).Seconds().Do(blocks);
Timer("x").In(5).Seconds().Do(blocks); // ❌ throws at Build() time
Calling .Start() on an already-running timer/coroutine restarts it from the beginning. This is predictable and prevents accidental parallel instances.
// If user needs parallel timers, use unique names:
for (int i = 0; i < 10; i++)
Timer($"spawn_{i}").In(2+i).Seconds().Do(spawn[i]);
Builder pattern with typed returns enforces logical order via IntelliSense:
// ✅ Correct order
Timer("x").In(3).Seconds().Do(blocks);
// ❌ Won't compile (wrong order)
Timer("x").Seconds().In(3).Do(blocks);
.Do() / .OnUpdate() / .OnHeartbeat() / .OnElapsed() Are TerminalThese methods finalize the builder and register the timer/coroutine. They return the reference for optional storage.
// Self-contained (no reference needed)
Timer("fire").In(3).Seconds().Do(blocks);
// Store reference for control
var t = Timer("control").In(3).Seconds().Do(blocks);
// later, in some other block:
t.Stop()
EverySimplified coroutine aliases for executing logic every N frames or heartbeats:
Every(3).Frames().Do(blocks); // every 3rd frame
Every(Even).Frames().Do(blocks); // frames 12, 14, 16...
// fixed timesteps
Every(3).Heartbeats().Do(blocks); // every 3rd step
Every(Even).Heartbeats().Do(blocks); // steps 12, 14, 16, 18...
Every(Odd).Heartbeats().Do(blocks); // steps 11, 13, 15, 17...
Implementation: Even and Odd are constants on LunyScript base class:
protected const Int32 Odd = -1;
protected const Int32 Even = -2;
interface IScriptTimerBlock : IScriptActionBlock
{
void Started();
void Stopped();
void Paused();
void Resumed();
void TimeScale(Single scale);
}
interface IScriptCoroutineBlock : IScriptTimerBlock
{
// Inherits all timer control
// Coroutine can run blocks per frame/heartbeat
}
When.Timer() / When.Coroutine() APIThe self-contained Timer/Coroutine APIs (.Do(), .OnUpdate(), .OnElapsed()) supercede the previously mentioned When.Timer("x").Elapsed() patterns.
The TimeScale() API could support built-in tweening:
// Future expansion for Timer & Coroutines
// Timer runs for 2 seconds
// Time scale accelerates for initial 25% of duration (0.5 seconds)
Timer.In(2).Seconds().TimeScale(Tween.EaseIn(.25));
// Time scale decelerates during last 10% of duration (0.2 seconds)
Timer.In(2).Seconds().TimeScale(Tween.EaseOut(.1));
Tweening guarantees the tween value isn’t unintentionally pausing the timer/coroutine (timescale == 0) by clamping the tweened value away from 0 (small epsilon, configurable).
| Feature | API |
|---|---|
| One-shot timer | Timer("x").In(n).Seconds().Do(..) |
| Repeating timer | Timer("x").Every(n).Seconds().Do(..) |
| Frame Coroutine | Coroutine("x").For(n).Seconds().OnUpdate(..) |
| Step Coroutine | Coroutine("x").For(n).Seconds().OnHeartbeat(..) |
| Control | .Start(), .Stop(), .Pause(), .Resume() |
| Speed | .TimeScale(factor) |
| Time-Slicing | Every(n).Frames().Do(..) |