Log In  

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!

P#21399 2016-05-26 10:51 ( Edited 2018-06-23 00:40)

1

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. ;)

P#21401 2016-05-26 11:43 ( Edited 2016-05-26 15:43)
1

uh, nice. they are kind of like promises, in a way.

P#21404 2016-05-26 12:36 ( Edited 2016-05-26 16:36)

Wow, need to think how I can make use of these.

P#21406 2016-05-26 14:07 ( Edited 2016-05-26 18:07)

"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.

P#21407 2016-05-26 15:21 ( Edited 2016-05-26 19:22)

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.

P#21408 2016-05-26 15:21 ( Edited 2016-05-26 19:21)

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.

P#21414 2016-05-26 17:55 ( Edited 2016-05-26 22:23)

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?

P#21422 2016-05-26 19:12 ( Edited 2016-05-26 23:12)

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.

P#21424 2016-05-26 19:37 ( Edited 2016-05-26 23:37)

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!

P#21425 2016-05-26 19:40 ( Edited 2016-05-26 23:40)

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.

P#21447 2016-05-27 03:23 ( Edited 2016-05-27 07:23)

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.

P#22690 2016-06-11 16:11 ( Edited 2016-06-11 20:11)

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.

P#23108 2016-06-18 07:58 ( Edited 2016-06-18 11:58)

Here, I made a small example to demonstrate some simple entity scripting concepts:

Cart #23109 | 2016-06-18 | Code ▽ | Embed ▽ | No License

full code:

local time = 0

function make_timer( countdown )
    local self = {
        countdown = countdown
    }

    function self:start()
        self.start = time
    end

    function self:gettime()
        self.stop = time
        return self.stop - self.start
    end

    function self:done()
        return self:gettime() >= self.countdown
    end

    self:start()

    return self
end

function wait( time )
    local timer = make_timer( time )
    while not timer:done() do
        yield()
    end
end

local entities = {}

function makeball( )
    local self = {
        x = flr(rnd(128)),
        y = flr(rnd(128)),
        radius = rnd(3) + 3,
        color = flr(rnd(15)) + 1,
    }

    self.speed = 7 - self.radius

    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

    function self:run()
        while true do
            self:go(flr(rnd(128)), flr(rnd(128)))
            wait(flr(rnd(30)))
        end
    end

    function self:draw()
        circfill(self.x, self.y, self.radius, self.color)
    end

    function self:update()
        coresume(self.thread, self)
    end

    self.thread = cocreate(self.run)
    add(entities, self)
end

function _init( )
    for i=1,32 do
        makeball()
    end
end

function _update( )
    for entity in all(entities) do
        entity:update()
    end

    time += 1   
end

function _draw( )
    cls()
    for entity in all(entities) do
        entity:draw()
    end 
end

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.

P#23111 2016-06-18 08:33 ( Edited 2016-06-18 12:53)

Very cool! Thanks for the example.

P#23115 2016-06-18 10:28 ( Edited 2016-06-18 14:28)
5

Here's a little test/demo I did with coroutines.

Cart #36036 | 2017-01-18 | Code ▽ | Embed ▽ | License: CC4-BY-NC-SA
5

I ended up deciding not to go that direction. But it might be a useful example for someone.

P#36037 2017-01-18 17:35 ( Edited 2017-01-18 22:35)

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
P#37957 2017-03-01 03:14 ( Edited 2017-03-01 08:14)

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.)

P#37987 2017-03-02 01:40 ( Edited 2017-03-02 06:40)
1

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)

P#38004 2017-03-03 00:40 ( Edited 2017-03-03 05:40)

Thanks! Best of luck with your game!

P#38008 2017-03-03 10:41 ( Edited 2017-03-03 15:41)

I just came across this - very good stuff! Do you think that we could document these functions in the manual page?

P#41566 2017-06-12 14:51 ( Edited 2017-06-12 18:51)

Would also be great if errors in co-routines weren't swallowed (is an absolute nightmare to debug!) ;o)

P#41567 2017-06-12 15:33 ( Edited 2017-06-12 19:33)

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... :)

P#41747 2017-06-18 05:51 ( Edited 2017-06-18 09:51)

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.
P#41760 2017-06-18 13:43 ( Edited 2017-06-19 19:52)

Makes sense! Thanks for the info!

P#41769 2017-06-19 02:19 ( Edited 2017-06-19 06:19)

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!)

P#41784 2017-06-19 12:55 ( Edited 2017-06-19 16:55)

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.

P#41789 2017-06-19 14:36 ( Edited 2017-06-19 18:36)

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
P#41806 2017-06-20 13:05 ( Edited 2017-06-20 17:11)

"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...

P#52139 2018-04-29 14:16 ( Edited 2018-04-29 18:16)

@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

P#53689 2018-06-21 21:15 ( Edited 2018-06-22 01:15)

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?

P#53695 2018-06-22 03:50 ( Edited 2018-06-22 07:50)

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.

P#53719 2018-06-22 20:40 ( Edited 2018-06-23 00:40)

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)

P#94882 2021-07-15 02:07

[Please log in to post a comment]

Follow Lexaloffle:          
Generated 2024-03-19 07:48:19 | 0.069s | Q:69