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.
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 └─> ...
In a PICO-8 cartridge, you'd use code like this to get started:
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
!
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. :)
TODO: Produce a spartan coroutine-based cartridge showcasing how to use engine.
Thanks to @merwok for feedback on the Lexaloffle BBS thread