Pico8 0.1.6 added coroutines!
coroutines are really handy if you want to trigger an action once and then have it continue to do stuff for a while in the "background".
actions = {} function press_mysterious_button() local c = cocreate(function() for i=1,10 do launch_special_fx() yield() yield() yield() yield() end end) add(actions,c) function _update() for c in all(actions) do if costatus(c) then coresume(c) else del(actions,c) end end end |
the above code when the mysterious button is pressed will create a new coroutine (cocreate) which will launch special fx every 4 frames, 10 times and then it will be completed.
each loop of _update it'll run any actions up to the next "yield" and then return. when the action is complete, we remove the dead actions from the table.
hope that makes sense and is helpful!
Oh, nice. One of my favorite features of Lua. Nice to see them supported in Pico. They are similar to things you can do with interrupts, so there good precedent for them being "retro" if people need it justified. ;)
"they are kind of like promises, in a way"
Sort of, but they are a bit more general than that. With promises, you have to keep creating new function closures to keep running a computation. Coroutines are more similar to threads, where you can stop them in the middle of what they are doing, and then continue it later.
One example is animations, imagine the following sequence:
1) Set sprite to standing frame.
2) Wait 3 seconds
3) Do a frame by frame animation walking to another position.
4) Wait 4 more seconds.
5) Etc.
You can't write that as a simple function because your game wouldn't be able to do anything else while the animation runs! Promises can get you the same effect with a bit more work. An example of something that promises can't do easily would be breaking up an expensive long running computation.
For instance, say you have an expensive function to process like pathfinding or an AI sequence. You can at any point you'd like yield the coroutine and jump right back into the middle of the computation the next frame. That might be in the middle of a while loop that has to run an unknown number of times to finish or in a deep function call hierarchy.
This is a nice usage for them!
Maybe some of you will be interested in this article I wrote a few weeks ago: Using Lua Coroutines to Create an RPG Dialogue System
It would totally be possible to build a similar system in PICO-8, if anybody was wanting to make a dialogue-heavy RPG, I imagine this would be a very elegant and space-efficient way to do it.
What's missing though is an equivalent to coroutine.running(), which gives a reference to the current coroutine. It's really useful to implement stuff like WaitForSeconds().
Edit: nevermind, found an easier way.
This is very cool, thanks for sharing. Feels more like a timeout or interval kind of thing to me.
So the yield() is basically a "wait a frame" type of delay, yes?
This could replace having to make one-off counter for a certain object or routine then...? Right now if I want something to happen every 4 frames, I have to make a timer, check it, zero it and increment it within the loop.
Or am I thinking about them the wrong way?
To run a coroutine, you call coresume(). Inside of the coroutine, you can call yield() to pause the coroutine and return from the coresume() call. Then when you call coresume() with that coroutine again, it will continue from the last yield statement called. So it's not based on frames, but if you resumed the coroutine every frame, that would be an easy way to use them.
I'm writing an article on animated RPG dialog for the zine, and now I want to mix in geckojsc's and impbox's ideas. Thanks, both!
A few notes:
The yield() can be called by an inner function called by the coroutine function. As long as it's OK to yield all the way out to the coresume(), you can package up sequences as separate functions that yield, and combine them in a single coroutine without having to maintain inner coroutines. For example, in the example at the top of this thread, you can replace the four yield() calls with:
function delay(t) for x=1,t do yield() end end function press_mysterious_button() local c = cocreate(function() for i=1,10 do launch_special_fx() delay(4) end end) add(actions,c) end |
It appears yield() cannot yield a return value for coresume(), nor can the coroutine return a value at any point. coresume() only ever returns a bool. (Arguments to yield() and the coroutine function's return value are ignored.)
coresume() returns true if the coroutine yielded, false if it returned. So the example can be shortened:
function _update() for c in all(actions) do if (not coresume(c)) del(actions,c) end end |
Whether you consider that simpler depends on how comfortable you are with the semantics. But it's fewer tokens.
A small correction and bug report:
In Pico-8 0.1.6, costatus() doesn't appear to work at all. AFAICT, it always returns true when passed a coroutine (and is a runtime error when passed anything else). The correct behavior (based on Lua's coroutine.status()) is to return a distinction between "suspended" and "dead" coroutines, presumably true and false in Pico-8 (though Lua makes a finer distinction).
coresume() actually returns true on the call that causes the coroutine to exit. It only returns false when passed an already "dead" coroutine. This matches Lua 5.3.1, but it's a caveat when relying on the return value of coresume() to tell the main routine to stop calling. This may or may not matter depending on what the main routine expects: if it's waiting for the coroutine to finish before doing something, it will wait one "extra" cycle.
In Lua it appears that calling coroutine.resume() with a dead coroutine is actually intended to be an error, and the main routine should be checking coroutine.status() before calling coroutine.resume(). So this won't be an issue for Pico-8 if costatus() gets fixed with matching behavior, e.g. returning false when given a dead coroutine.
It is, however, possible to pass extra arguments to coresume():
function foo(a, b, c) print(a) print(b) print(c) a, _, c = yield() print(a) print(b) print(c) end local thread = cocreate(foo) coresume(thread, 1, 2, 3) coresume(thread, 4, 5, 6) |
prints:
1 2 3 4 2 5 |
Which allows for some pretty powerful constructs with OOP.
Here, I made a small example to demonstrate some simple entity scripting concepts:
full code:
The relevant parts of the code:
-- a wait() function to stop your entity for a while function wait( time ) local timer = make_timer( time ) while not timer:done() do yield() end end |
-- a go() function to move your entity somewhere function self:go(x, y) local distance = sqrt((x - self.x) ^ 2 + (y - self.y) ^ 2) local step_x = self.speed * (x - self.x) / distance local step_y = self.speed * (y - self.y) / distance for i=0,distance/self.speed do self.x += step_x self.y += step_y yield() end end -- the run() function is the main function of your thread function self:run() while true do self:go(flr(rnd(128)), flr(rnd(128))) wait(flr(rnd(30))) end end |
-- to update your entity, simply resume its run() function function self:update() coresume(self.thread, self) end self.thread = cocreate(self.run) |
And voilà, you can now write complex AIs with readable imperative algorithms.
Here's a little test/demo I did with coroutines.
I ended up deciding not to go that direction. But it might be a useful example for someone.
In Pico-8 0.1.6, costatus() doesn't appear to work at all. AFAICT, it always returns true when passed a coroutine (and is a runtime error when passed anything else). The correct behavior (based on Lua's coroutine.status()) is to return a distinction between "suspended" and "dead" coroutines, presumably true and false in Pico-8 (though Lua makes a finer distinction).
I can report that as of 0.1.10 at least, this works now. costatus() will return a string of either "dead" or "suspended". So to make the code in the original post work, you just need to replace
if costatus(c) then |
with
if costatus(c) != "dead" then |
I wrote this article for the next issue of the zine a year ago, but it's been delayed a while so I might as well share a draft of it here:
https://docs.google.com/document/d/14HzJnqKdVtBjN2vN9-rZHLR3rlgwWwDym8ehzhKRqFo/edit?usp=sharing
Feedback welcome. (Comments enabled on the draft.)
dddaaannn: Excellent article, nice one! :D
Coroutines are awesome. In fact, I doubt I'd be able to build my current project without them!
I'm currently making a PICO-8 "inspired" version of the S.C.U.M.M. engine used to make all those classic LucasArts (or Lucasfilm Games - depending on how old you are!) adventure games, such as Monkey Island and Maniac Mansion.
As you can imagine, there's quite often the need to have actions, animation and also... cut-scene(!) sequences happening simultaneously to give the desired effect. Had I not found out about the power of coroutines, I doubt I'd have even attempted this project!
Just a shame that the Zine has been (understandably) delayed, as your article would've really helped me understand them quicker! ;o)
I just came across this - very good stuff! Do you think that we could document these functions in the manual page?
Would also be great if errors in co-routines weren't swallowed (is an absolute nightmare to debug!) ;o)
I would say the hidden errors are a nice additional challeng..NO ITS NOT ITS A NIGHTMARE.
(My whole game logic is wrapped in a coroutine for reasons)
So, pretty please Mr. Zep... :)
The error swallowing is standard behaviour from Lua, but unlike Lua, print doesn't handle multiple return values gracefully -- so you need to explicitly capture multiple return values from the function call to get the error message (multiple assignment or capture values into table/another function before printing). The error actually still exists and is accessible, though, it just doesn't crash the entire program, which can be desirable when using coroutines as threads. The error during resuming the coroutine is captured in the second return value of coresume.
Here's some code that demonstrates this (added some helpers so it runs in both PICO-8 and Lua):
tostring=tostring or function(v) if type(v)=='boolean' then return v and'true'or'false' elseif type(v)=='string' then return v elseif type(v)=='number' then return ''..v else return type(v) end end function dump(t) local s='' for i=1,#t do s=s..tostring(t[i])..(i<#t and '\n' or '') end return s end cocreate=cocreate or coroutine.create coresume=coresume or coroutine.resume print(dump{coresume(cocreate(function() local x = nil + 1 end))}) |
See the stdout for Lua version: http://ideone.com/aaULPS
false prog.lua:19: attempt to perform arithmetic on a nil value |
In PICO-8 it does the same, but the error message string will need to be manually wrapped or scrolled to see the whole thing.
Note normally you won't need all of this to handle the error message. Usually you'll want to do
local status, result = coresume(co) if not status then -- handle error message in result else -- handle all values passed to yield() end |
I didn't see it mentioned yet, but yield() itself can also return values (potentially multiple return values if you pass an argument list):
co=cocreate(function() yield(1) yield(2) yield(3) end) repeat local status, result = coresume(co) if status then print(result) end until not status |
which prints:
1 2 3 nil |
The trailing nil in the log might be surprising. It's because there's an implicit return nil
when reaching the end of the function, and the last return of a function is also returned by coroutine resume before it dies. If we put a return statement after those yield, this would be last result instead.
Various weird things:
- You can also pass arguments to coresume(), which are passed as arguments to the coroutine function when you call it first time. This allows passing data into the function's initial arguments when the coroutine is "started" (resumed the first time).
- yield() will also receive any extra arguments passed to coresume() and return them in its return values. This allows passing new data into yield() every time a yielded coroutine is resumed.
- coresume() returns a status plus extra return values
- When coresume() status returns true, the second, third, fourth, etc, return values are any extra arguments passed to yield() or the return values from last return statement in the function before the coroutine ended.
- When coresume() status returns false, the second return value is the error message. You can use this error message for diagnosing problems, and crash out if necessary.
- Effectively, this allows communication to and from coroutines as they're executing.
- For other tricks read the PIL: https://www.lua.org/pil/9.1.html
- You can also use coroutines to effectively "try/catch" around any normal non-yielding function that can fail: coresume(cocreate(f), args) -- returns true, result, result2, result3, ... or false, errormsg.
Overkill, thanks so much for the detailed post about collecting errors from coroutines, I used them heavily in my first Pico-8 project and resorted to moving away from them in places in order to find bugs---the above outlined approach will be much preferable (I want to keep the benefits of coroutines!)
Forgot about this: coroutines seem to yield automatically if PICO-8 runs out of cycles while running the routine. Not sure if this has been documented anywhere.
Here's a snippet that wraps a coroutine error with carriage returns, prints it out and exits. For some reason I could not get the above examples to work in Pico-8.
--adds carriage returns to string s, wrapping it to width l in characters function wrap(s,l) local ws="" while l<=#s do ws=ws..sub(s,1,l).."\n" s=sub(s,l,#s) end ws=ws..s.."\n" return ws end --creates and resumes a coroutine that throws an error, error gets collected in result. status,result=coresume(cocreate(function() x = nil + 1 end)) cls() if status==false then cls() print(wrap(result,32)) stop() end |
"Forgot about this: coroutines seem to yield automatically if PICO-8 runs out of cycles while running the routine. Not sure if this has been documented anywhere." |
@kometbomb
I've *just* come to this same realisation myself. *sigh*
Previously, I've only ever had short code snippets in a coroutine.
But I'm now doing some more intense work and it's bailing about (auto-yielding?) 10% the way through.
Might have to rethink this one, if I can't do this work while within a coroutine...
@Liquiddream
This is actually great... I am making a boardgame AI using co-routines, and I wanted to know how to dynamically limit the time that the AI spends in the co-routine.
So apparently if I call the coresume as the last thing that I do in a frame, I get exactly the effect that I want.
Is there a way to call some code after _draw? it feels a bit ugly to call "do_ai()" insde my draw call :-P
Well the next frames' update is called just after the last frames' draw. So if you wanted to call something after draw, wouldn't that be the start of your update function?
No, because in that case the co-routine would use the entire frame's worth of processing, and anything I did after that would go on top of that.
What I wanted is somewhere between draw and end-of-frame where I could leave my CPU-sucking AI routines to munch on what's left after drawing.
But that's a minor complaint. Plugging the AI function after drawing is not that bad.
Bumping this thread up as it’s a nice explanation of coroutines with links. People will just have to double-check the latest manual to see how to use coresume and costatus.
(Also the yielding behaviour is explained in detail here: https://www.lexaloffle.com/bbs/?tid=37595)
[Please log in to post a comment]