User Tools

Site Tools


programming:pico8:recipes:coroutine

Coroutines

Coroutines are functions that can be paused, when run with the coresume() function. They do so with the yield() function, which returns control to whatever called coresume. It's important to remember: when yield()ing from a coroutine, it ends that function for the rest of the frame!

Why is that important? For starters, once you get a bunch of coroutines started in your cartridge, you may have trouble understanding the flow of the program.

Execution Model

Coroutines are simple in theory, but difficult to use in practice. The primary rule of coroutines is: try to yield in, at most, ONE place within your coroutine. Most coroutines are loops, so placing the yield() at the end of the loop is a good practice. This way, you don't end up with effects or animations taking too long for inexplicable reasons. (Tip: It's usually yield()ing too much when that happens. =))

Each time a coroutine is executed (with coresume()), it runs "on the side" within PICO-8, meaning it can pause execution and pass it back to whatever called coresume(), allowing us to orchestrate coroutines to get parallel-ish computing. This gives us functions that last longer than a frame. If that coroutine calls a function that then yields, it pauses the ENTIRE coroutine environment, not just passing from the inner function to the outer coroutine. To do something like that reliably, the outer coroutine needs to either create a new coroutine and add it to a global list, or execute the coroutine itself, gaining the advantages of yield() for the inner function.

There is a caveat for this, however: The inner coroutine runs will block the execution of the outer coroutine, so crafting nested coroutines is not suggested.

Consider each thing you want to get achieved as one unit of work. Each thing needs its own execution space to yield() from, if we want full flexibility. Ideally, one calls a function every frame, which then executes each coroutine in a globally-accessible list of coroutines. Then, adding animations or multi-frame logic at all is as easy!

Here's a text diagram that illustrates:

COROUTINE EXECUTION MODEL

coroutine updater, executed every frame
  └─> running coresume() on all coroutines in global list
        ├─> coroutine1
        │     └─> while condition do
        │           foo()
        │           yield()
        │         done -- a normally yielding coroutine
        ├─> coroutine2
        │     ├─> foo()
        │     │     └─> yield() -- this pauses coroutine2, NOT foo
        │     ├─> bar() -- this won't happen until frame 2
        │     └─> yield()
        │
        ├─> coroutine3
        │     ├─> func1()
        │     ├─> yield() -- this is the same result as above, just outside func1
        │     ├─> func2()
        │     └─> yield()
        └─> coroutine4
              └─> ...

Code Structure

In a PICO-8 cartridge, you'd use code like this to get started:

coroutines.lua.p8
function update_cors()
  if #corlist > 0 then
    for cor in all(corlist) do
      if costatus(cor) != 'dead' then
        coresume(cor)
      else
        del(corlist, cor)
      end
    end
  end
end
 
function _init()
  corlist = {}
  objlist = {}
end
 
function _update60()
  if #objlist > 0 then
    for o in all(objlist) do
      o:update()
    end
  end
  update_cors()
end
 
function _draw()
  cls()
  map()
  if #objlist > 0 then
    for o in all(objlist) do
      o:draw()
    end
  end
end

The above code doesn't account for coroutines that may accept arguments, which will need to differentiate between a coroutine entry in corlist that is a table (i.e. it's accepting arguments) or a function (just a plain coroutine). This requires modifying update_cors considerably:

-- update_cors, version 2!
function update_cors()
  local s
  local t
  if #corlist > 0 then
    for c in all(corlist) do
      if type(c) == 'table' then
        s = costatus(c[1])
        t = c[1]
      else
        s = costatus(c)
        t = c
      end
      if s != 'dead' then
        if type(c) == 'table' then
          coresume(t, unpack(c, 2))
        else
          coresume(t)
        end
      else
        del(corlist, c)
      end
    end
  end
end

The above version of update_cors can deal with entries in corlist that are either flat coroutines (the return value of cocreate() or tables containing a coroutine as the first member:

corlist = {
  cocreate(foobar),
  {cocreate(barbaz), "some", "args", "to", "use"}
}
-- this will create a table with two entries:
-- one containing a function, the other a table.

With this in place, we have a pretty robust coroutine wrangler! We can still improve it one more time – with stacktrace capabilities – so we can debug our coroutines more easily.

-- update_cors, version 3!
function update_cors()
  local s
  local t
  if #corlist > 0 then
    for c in all(corlist) do
      if type(c) == 'table' then
        t = c[1]
      else
        t = c
      end
      s = costatus(t)
      if s != 'dead' then
        if type(c) == 'table' then
          active, exception = coresume(t, unpack(c, 2))
        else
          active, exception = coresume(t)
        end
        if exception then
          printh(trace(t, exception))
          stop(trace(t, exception))
        end
      else
        del(corlist, c)
      end
    end
  end
end

At this point, update_cors is as good as it can be! Now you need to put some objects into the objlist, write a few easy coroutines, and hook them up to the corlist!

Writing Coroutines

As mentioned a little earlier on this page, yield() is used to pause execution for the remainder of the frame, for that coroutine. We also typically only want to yield in *one* place inside the function, where possible, so we don't end up with multiple-yield, which slows the coroutine down by spreading its work across more frames. Sometimes, this is what we want, with something like wait():

function wait(f)
  for _=1,f do
    yield()
  end
end

wait here simply calls yield() for the number of frames you tell it to. It's an easy way to add a delay to an animation, and is often the first coroutine one learns to use.

Another easy coroutine to learn is toggle(), which toggles some value back and forth. This is often combined with wait to create a "throbber". Throbbers are interface elements like what you'd find at the end of a dialog box's output. It usually oscillates or toggles through graphics to convey that it's waiting for input.

We can do that pretty easily, provided we know a little more about how we're going to handle the graphical units on the screen. For a throbber, let's say it's a simple table with x, y, and sid members:

throbber = {x = 60, y = 60, sid = 1}

sid is what we'll manipulate in the toggle coroutine. Let's say it's meant to increase the sid by one, then wait some time, and toggle it back. We'll use the wait coroutine we wrote earlier, showing how to build and use coroutines together. Remember, a coroutine is just a function that can be paused with yield()!

function toggle(o)
  while true do
    local os = o.sid
    wait(30)
    if o.sid == os then
      o.sid += 1
    else
      o.sid -= 1
    end
  end
end

Okay, now we have a snazzy coroutine, but it needs an argument! How do we get it on the corlist?

add(corlist, {cocreate(toggle), throbber})

Note that we added a table to the corlist, containing the coroutine we're going to run (toggle), and the arguments we want sent to it. That's it!

…Or is it? This coroutine will continue, even if the object doesn't exist anymore or isn't on any lists used by the engine! Let's try that again:

-- we need this to test against tables
function has(t, v)
  local found=false
  for a in all(t) do
    if a == v then
      found=true
    end
  end
  return found
end
 
function toggle(o)
  while true do
    if not has(objlist, o) then
      -- exit coroutine if source object is not being dealt with
      return
    end
    local os = o.sid
    wait(30)
    if o.sid == os then
      o.sid += 1
    else
      o.sid -= 1
    end
  end
end

The code's getting longer and a little more complicated, but the result – and goal – is smart behavior in the background that you don't have to think about, so you can focus on the actual coroutines and other mechanics. In the above, I added a check against objlist, the global table containing objects that need to be updated by the engine! Odds are, if the object is in objlist, you want it to be accessible to coroutines and other things. If you wanted to be fancier, you could use a drawlist and then only draw certain things. Lists are handy, but need maintenance. They also cost tokens and add complexity. Use only as needed. :)

Example

FIXME TODO: Produce a spartan coroutine-based cartridge showcasing how to use engine.

Credits

Thanks to @merwok for feedback on the Lexaloffle BBS thread

programming/pico8/recipes/coroutine.txt · Last modified: 2022-02-21 00:40 by zlg