The game loop and tasks

Pick a program shape, end each frame with Sync(), wait on game time, and run several jobs side by side with cooperative tasks.

In one line#

The Engine draws the frames, not your program. A BASIC program does some work, then hands control back so the frame can be drawn. Sync() is that hand-off. Tasks let several pieces of work take turns at those hand-offs.

Three program shapes#

ShapeWhat it looks likeWhen it runsUse it for
Straight-linestatements, top to bottomonce, when the program startssetup, tools, tests
Main loopone DO … LOOP with Sync()the loop body runs once per framea classic AGK or DarkBASIC game
Lifecycle functionsFUNCTION start(), FUNCTION update(dt)…the Engine calls themthe same model as JavaScript scripts

Straight-line#

lives = 3
Console("Lives: " + Str(lives))   ' Lives: 3

The program runs once, top to bottom, and ends.

The main loop#

[statements]          ' run once, before the first frame
DO
  [statements]        ' run every frame
  Sync()
LOOP
[statements]          ' run once, after EXIT leaves the loop
frames = 0
Console("start")                       ' start
DO
  frames = frames + 1
  IF frames = 3 THEN EXIT
  Sync()
LOOP
Console("left after " + Str(frames))   ' left after 3
  • One DO … LOOP per program, at the top level. Not inside IF, WHILE or a FUNCTION.
  • The loop must contain Sync() (or Swap(), or YieldTask()). Without it the compiler stops with "DO/LOOP requires SYNC() so the loop can yield a frame".
  • EXIT leaves the loop. END stops the whole program at once.
DO
  Sync()
LOOP
DO              ' refused: a second main loop
  Sync()
LOOP

In AGK and DarkBASIC, DO … LOOP is just an endless loop you can put anywhere. In aukimi it is the frame loop: one per program. For any other loop, use WHILE … ENDWHILE or REPEAT … UNTIL.

⚠️ Do not write a FUNCTION main() in a program with a DO loop. The loop is compiled into a function of that name, and the two collide. The compiler does not warn you, and the loop never runs.

Lifecycle functions#

GLOBAL elapsed# = 0

FUNCTION start()
  Console("ready")                    ' ready
ENDFUNCTION

FUNCTION update(dt#)
  elapsed# = elapsed# + dt#
  Print("Time: " + Str(elapsed#, 1))
ENDFUNCTION

The Engine calls start once, then update every frame with the frame duration in seconds. There is no loop to write and no Sync() to call: the Engine draws the frame when update returns. The full list of hooks (fixedUpdate, onCollisionEnter, onMessage…) is in Functions.

⚠️ A lifecycle program cannot wait. Wait(), Sleep(), AWAITTASK, AWAITALL and AWAITANY are refused inside a FUNCTION. Worse, one of them anywhere in a program without DO, even inside a task label, turns the whole program into a single main(), and the Engine then no longer finds start or update. Nothing tells you. To time something in a lifecycle program, count frames in a task with YieldTask() (see Tasks, below), or add up dt.

Ending a frame: Sync(), Swap(), YieldTask()#

CallWhat it does
Sync()ends the frame: the Engine draws, and the program resumes on the next frame. It also waits for 3D models still loading, so the frame after LoadObject(...) and Sync() has the model.
Swap()the same thing. Kept for the classic Render2DBack(), Render3D(), Render2DFront(), Swap() sequence: the three Render calls do nothing, Swap() draws the whole frame, and you get the right picture.
YieldTask()the same thing again, under a name that reads well inside a task

All three need parentheses: SYNC alone is refused.

When they really yield#

Sync() yields a frame only where the program can wait: the program has a top-level DO … LOOP, or it has an END statement, or the call is inside a label (a GOSUB subroutine or a task).

Anywhere else, Sync() draws once and returns at once. The program does not pause.

⚠️ The straight-line WHILE trap. This is the classic "wait for a click" loop. In a program with no DO and no END, its Sync() never yields: the loop spins without a break and freezes the browser tab. There is no error.

' No DO and no END in this program: do not run it.
ready = 0
WHILE ready = 0
  IF PointerPressed() THEN ready = 1
  Sync()                  ' draws, but does not wait for the next frame
ENDWHILE

The fix is one line. With an END, the same loop yields every frame:

ready = 0
WHILE ready = 0
  IF PointerPressed() THEN ready = 1
  Sync()                  ' yields: the program has an END
ENDWHILE
Console("clicked")        ' clicked
END
  • A Wait() or a Sleep() elsewhere in the program does not fix it. Only DO … LOOP, END, or a label does.
  • In AGK, Sync() ends the frame wherever it is written. This is the main difference to remember.

⚠️ Never call Sync() inside a FUNCTION. Without a DO loop it does not yield. With one, the program fails to start. Lifecycle functions never need it: the Engine draws when they return.

Before DO and after LOOP#

WhereRunsAllowed
before DOonce, before the first frameanything: loading, Wait(), a WHILE … Sync() loading screen
between DO and LOOPonce per frameanything
after LOOPonce, when EXIT leaves the loopanything but Wait() and Sleep()
Console("loading")                       ' loading
Wait(0.5)                                ' half a second of game time
frames = 0
DO
  frames = frames + 1
  IF frames = 120 THEN EXIT
  Sync()
LOOP
Console("played " + Str(frames) + " frames")   ' played 120 frames
  • END inside the loop skips the code after LOOP.
  • FUNCTION blocks and labels can sit anywhere, before or after the loop. They are not "code after LOOP": they only run when called.
  • Wait() or Sleep() after LOOP is refused: must appear before or inside the top-level DO/LOOP.

Waiting: Wait() and Sleep()#

Wait(<seconds>)
Sleep(<milliseconds>)
Console("3")              ' 3
Wait(1)                   ' one second
Console("2")              ' 2
Sleep(500)                ' half a second
Console("go")             ' go
  • Both wait on game time. They stop while the game is paused, and they follow the time scale. The Engine keeps drawing frames during the wait.
  • Wait counts seconds, Sleep counts milliseconds. Wait is an aukimi addition; Sleep is the AGK command.
  • ⚠️ In DarkBASIC, WAIT counts milliseconds. Here Wait(1000) is a wait of sixteen minutes.
PlaceWait() / Sleep()Sync() / Swap() / YieldTask()
straight-line programyesyields only if the program has an END
before DO or inside the loopyesyes
after LOOPrefusedyes
a label (GOSUB subroutine or task)yesyes
inside a FUNCTION, lifecycle hooks includedrefuseddoes not yield: do not use
FUNCTION Pause()
  Wait(1)            ' refused: a FUNCTION cannot wait
ENDFUNCTION

The error reads Wait(...) is supported in the main BASIC program, not inside FUNCTION. To wait in a reusable piece of code, write a label and reach it with GOSUB, or start it as a task. A GOSUB to a label that waits needs a DO loop or an END in the program, or the compiler refuses it.

CallWhere it goesHow often to call it
Print(...)drawn on the screen, top leftevery frame, inside the loop
PrintC(...)the same, without a line breakevery frame
Console(...)the editor's consolewhen something happens
Debug(...)the same as Console, under PureBasic's namewhen something happens
ConsoleWarning, ConsoleError, ClearConsolethe consolewhen something happens

Print writes into the frame being built. Each new frame that draws anything starts from an empty text layer, so text printed once disappears. Print it on every frame.

Console writes one line, once. Do not call it every frame: the real errors drown. Identical lines in a burst are grouped and counted.

score = 0
DO
  score = score + 1
  Print("Score: " + Str(score))                 ' drawn again every frame
  IF score = 100 THEN Console("100 reached")    ' logged once
  IF score = 200 THEN EXIT
  Sync()
LOOP

Tasks#

A task runs a label alongside the main program. Tasks are cooperative: only one piece of code runs at a time, and they take turns only when the running one yields.

<handle> AS TASKHANDLE              ' or AS JOB; optional
STARTTASK <handle>, <Label>
AWAITTASK <handle>
AWAITALL <handle>, <handle> [, <handle>…]
AWAITANY <handle>, <handle> [, <handle>…]
CANCELTASK <handle>
YieldTask()

<Label>:
  [statements]
RETURN <value>

Start a task and wait for its result#

job AS TASKHANDLE
STARTTASK job, CountDown
Console("started")                           ' started
AWAITTASK job
Console("result " + Str(TaskResult(job)))    ' result 42
END

CountDown:
  FOR i = 3 TO 1 STEP -1
    Console("tick " + Str(i))                ' tick 3, tick 2, tick 1
    Wait(0.5)
  NEXT
RETURN 42
  • STARTTASK does not run the task on the spot. The task begins when the main program next yields: Sync(), Wait(), AWAITTASK… That is why started comes first.
  • A task is a label that ends with RETURN value. The value is what TaskResult gives back. A bare RETURN leaves no usable result.
  • Tasks share the program's variables. A task can update the score; the main loop sees the new value on its next line.
  • A task can start and await other tasks.
  • AWAITTASK, AWAITALL and AWAITANY are refused inside a FUNCTION, like Wait(). STARTTASK and CANCELTASK are allowed there.

⚠️ Inside a label, give each yield a line of its own. The compiler decides whether a label waits by reading its lines. It misses IF … THEN Sync() or IF … THEN Wait(1) on one line, and it misses Sync(), Swap() or YieldTask() followed by a comment. The program then fails to start, with no error at compile time. Write a block IF, and put the comment on the line above, as in the example below.

The handle#

STARTTASK creates the variable if it does not exist, even under OPTION EXPLICIT. Declaring it AS TASKHANDLE (or AS JOB, the same type) gives it a type:

  • it starts as NULL;
  • it refuses anything that is not a task: job = 5 stops the program with BASIC value is not a task handle.

⚠️ The handle is a plain variable. STARTTASK jobs[1], Worker is not supported: start the task into a variable, then copy it into the array.

Read a task's state#

CallGives
TaskDone(h)true once the task returned
TaskFailed(h)true if the task stopped on an error
TaskCancelled(h)true as soon as CANCELTASK was called on it
TaskResult(h)the RETURN value. An error if the task has not finished, or failed.

⚠️ These four answer true or false, not 1 or 0. Test them bare or with NOT: IF TaskDone(h) THEN …. Written IF TaskDone(h) = 1, the test is always false, and Str(TaskDone(h)) prints true.

Polling from the main loop keeps the game drawing while the work goes on:

loader AS TASKHANDLE
STARTTASK loader, LoadLevel
DO
  IF TaskDone(loader)
    Console("rooms: " + Str(TaskResult(loader)))   ' rooms: 3
    EXIT
  ENDIF
  Print("Loading...")
  Sync()
LOOP
Console("play")                                    ' play

LoadLevel:
  FOR room = 1 TO 3
    ' one room per frame
    Sync()
  NEXT
RETURN 3

Errors inside a task#

An error inside a task does not stop the program. The task is marked failed, and the error comes back where you wait for it:

t AS TASKHANDLE
STARTTASK t, Reload
TRY
  AWAITTASK t
CATCH e$
  Console("caught: " + e$)                  ' caught: out of ammo
ENDTRY
IF TaskFailed(t) THEN Console("failed")     ' failed
END

Reload:
  Wait(0.2)
  THROW "out of ammo"
RETURN 0
WaitWhen a task failed
AWAITTASK hraises the task's error
AWAITALL a, braises the first error it meets
AWAITANY a, braises nothing: check TaskFailed afterwards
TaskResult(h)raises the task's error

Errors are covered in Errors.

Cancel a task#

CANCELTASK h asks the task to stop. The task stops at its next yield: Wait(), Sleep(), Sync(), Swap() or YieldTask(). Normally the code after that yield never runs. The exception is a yield inside a TRY: see below.

slow AS TASKHANDLE
fast AS TASKHANDLE
STARTTASK slow, Slow
STARTTASK fast, Fast
AWAITANY slow, fast
IF TaskDone(fast) THEN Console("fast finished first")      ' fast finished first
CANCELTASK slow
AWAITALL slow, fast
IF TaskCancelled(slow) THEN Console("slow cancelled")      ' slow cancelled
END

Slow:
  Wait(10)
  Console("never printed")
RETURN 1

Fast:
  Sync()
RETURN 2
  • TaskCancelled is true at once, and AWAITTASK on a cancelled task returns at once, without an error.
  • TaskResult on a cancelled task is an error: it has no result.
  • CANCELTASK on a task that already finished does nothing.
  • ⚠️ A task cancelled right after STARTTASK still runs up to its first yield, because it had not started yet.
  • ⚠️ A task that never yields cannot be stopped: it runs to its RETURN in one go. AWAITTASK inside a task is not a cancellation point either.
  • ⚠️ A TRY around a yield catches the cancellation. Its CATCH receives the message BASIC task cancelled, and the code after ENDTRY keeps running until the next yield. To let the cancellation through, re-raise it: CATCH e$, then IF e$ = "BASIC task cancelled", then RETHROW.

Why there are no threads#

Threads with shared memory are deliberately left out. Not "not yet": left out.

With tasks, only one piece of code runs at a time, and the switch happens only at a yield. Between two yields, nothing else can touch your variables. You need no lock, and there is no data race to hunt.

PureBasic offers CreateThread with mutexes; AGK and DarkBASIC have no tasks at all. In aukimi, real parallel work goes elsewhere:

  • heavy number crunching goes to the GPU, see GPU compute;
  • CreateWorker starts a Web Worker written in JavaScript. It exchanges JSON messages and shares no memory.

Limits and common snags#

  • Sync() freezes a straight-line WHILE loop. Add an END, or use a DO … LOOP.
  • One DO … LOOP, at the top level. Other loops are WHILE or REPEAT.
  • No waiting inside a FUNCTION. Use a label with GOSUB, or a task.
  • Wait is in seconds, Sleep in milliseconds. Both on game time.
  • Print every frame, Console once.
  • Test task states bare: IF TaskDone(h), never = 1.
  • In a label, each yield on its own line: no IF … THEN Sync(), no comment after Sync().