Hey there! Over the past couple days I've been playing around with PICO-8's GPIO stuff and put together a simple Twitter client. There seemed to be a decent amount of interest in how I got this to work, so I thought I'd share a quick write-up to give others a potential starting point for using GPIO.
This is more of a proof-of-concept than an actual attempt at making a good Twitter client, but hopefully it's good enough for educational purposes!
// PICO-8 // print the value of the third pin and set the first pin's value to 2 print(peek(0x5f80+2)) poke(0x5f80, 2);
128 bytes is a pretty big chunk of data for a PICO-8 app, so we could probably get by using GPIO as-is without issues in a simple app. However, I wanted Twitter integration: even if I only used one-way communication and stripped out all the metadata, 128 bytes isn't enough to fit a 140 character tweet! To get around this, I had to come up with a more formalized structure for communications. (I've never been much of a networking guy, so feel free to point out better or easier ways to handle this stuff.)
My communication structure uses a simple frame-packet analogy. We start with a frame: a single big block of data (e.g. a tweet) which we then convert into smaller, more manageable chunks of data (packets). Each packet is split into two sections: a header which describes the packet content (length, id, etc.) and a body which contains a portion of the original data.
On the browser's side, this means taking the result of a Twitter search, parsing it into a single string of text, splitting the string into an array of strings where each item has at most X characters, where X is the length of a packet body, and then sending this one-by-one through the GPIO.
On the cart's side, this means reading the header pins and using the values to help us parse and interpret the body pins. It also means concatenating data from multiple packets in order to form a full frame.
The trick to getting this working is to make sure that the browser and the cart are always in agreement on who's doing what. If both were just arbitrarily accessing the GPIO, we'd have no idea what was actually in the shared memory at any point. My solution for this was fairly simple:
-the first header pin always stores either a 1 or a 0
-if the browser sees a 1, it updates and sets the pin to 0
-if the browser sees a 0, it skips the update
-if the cart sees a 1, it skips the update
-if the cart sees a 0, it updates and sets the pin to 1
Initially, I had the browser updating on a setInterval with a fairly large delay, but after some experimentation I found that there wasn't really much benefit to this and put it on a requestAnimationFrame loop instead. Writing/reading to the GPIO doesn't seem to be a particularly expensive operation, so this allows for near real-time communication.
Once we've got a reliable order for our operations set up, concatenating the packet data into a frame is pretty easy too: we use one of the header pins to tell us how many packets are in the current data transfer, and another to tell us what the current packet's ID is. We create a new buffer for frame data when we receive the first packet, and append packet data until the packet ID matches the number of packets in the frame.
My focus was on sending data from the browser to PICO-8, but this system also supports limited two-way communication: after we've read the current packet data in PICO-8, we can replace it with a new packet, and the browser can similarly read it back before it sends the next one. In P8T, the state of the buttons are sent from PICO-8 to the browser in this way. If you've looked at the code for an exported PICO-8 app you'll probably notice that this is a bit redundant: the web player already has some separate variables you can use to query button state, but if you were to use GPIO to create a multiplayer game in PICO-8, you're probably going to need to send more than just button presses and the principle is the same regardless of the data.
One of the hurdles in developing P8T was that I was originally sending text data to PICO-8 in ASCII, but PICO-8 has no built-in functionality for converting an ASCII character code into the character string. My workaround for this was to create a string with all the characters I needed and use sub() to grab individual characters based on their position:
// PICO-8 //ignores control characters except \n //includes symbols (128-153) chars="\n\32\33\34\35...\153" function char(k) return sub(chars,k+1,k+1) end
What could've been done better
-Currently, only one frame can be sent from the browser at a time. A fairly simple extension would be to place "send" commands in a queue for a more asynchronous experience.
-The browser can send an arbitrary amount of data, but the cart implementation is limited to a single packet. It wouldn't be too hard to recycle the frame/packet structure used on the browser's end to allow for unlimited communication in both directions, but the order of operations would get a bit more complicated (do you send packets in both directions at the same time knowing the frames won't be in sync, or do you wait for full frames knowing that they take multiple frames to send?)
-The twitter integration in general is pretty sloppy since I focused on just getting simple functionality working. Tweets are pulled one at a time and only on user-request; it would make a lot more sense to grab a dozen or so all at once, and then pull in new batches as the user browses through the ones you've stored locally. With a bit more work, you could even add a login and let the user interact with the tweets!
This is awesome, thanks for sharing it. This opens up a whole new world, if you ask me. I know it's only good for the web player version but that's probably how much people will play our games/apps. I'm sure we'll see some pretty clever uses as this essentially gives P8 games cloud-like data ability.
Very cool, man. Props.
Here's a thought, assuming I understand the GPIO pins correctly...
Instead of using 0/1 on your control pin, you could use this finite state machine on that pin:
If cart needs to send [more] data, it fills the other pins with the next N=1..127 bytes of it, then sets this pin to N.
If cart has no more to send, it sets this pin to 128.
Browser must not read or write other pins.
Browser reads N bytes from the other pins and then must set this pin back to 0.
Cart must not read or write other pins.
If browser needs to send [more] data, it fills the other pins with the next N=1..127 bytes of it, then sets this pin to N+128.
If browser has no more to send, it sets this pin to 0.
Cart must not read or write other pins.
Cart reads N bytes from the other pins and then must set this pin back to 128.
Browser must not read or write other pins.
This allows you to send larger packets without going back-and-forth between fragments.
One option would be to skip my post-reading "must set this pin back to 0/128" rules and allow the receiving end to fill the pins and set a "N bytes to read" flag for the other end. I set that rule up because I think it keeps code simple if every fragment of a packet must be read before switching directions. I don't think the total time required to send data in both directions will be different if you send all of one end's fragments in one direction and then all of the other end's fragments in the opposite direction, versus interleaving the parts back and forth along the way. I just think allowing the interleaving will produce more code complexity. But, I could be wrong about that.
BTW, I did this algorithm in my head. Check it for sanity, because it came out of a place which does not guarantee sanity. :)
That sounds like a pretty good implementation of two-way communication! I don't see anything in there that looks like it wouldn't work.
One downside to combining the length and control pins though is that you'd be losing cycles in-between turns.
- Startup: pin = 0
- Cart starts 200B transfer: pin = 127, 73B left
- Browser reads 127B: pin = 0
- Cart sends rest of transfer: pin = 73, 0B left
- Browser reads 73B: pin = 0
- Cart has no data left: pin = 128
- Browser's turn
The transfer is done at step 4, but the turn doesn't switch to the browser until step 7. By keeping the length of the message separate from the control states, you could accomplish the same thing in 5 steps:
- Startup: control = CART_WRITE, length = 0
- Cart starts 200B transfer: control = BROWSER_READ, length = 126, 74B left
- Browser reads 126B: control = CART_WRITE
- Cart sends rest of transfer: control = BROWSER_READ & BROWSER_WRITE, length = 74, 0B left
- Browser reads 74B, starts its turn
In this case, instead of a binary flag like in P8T, the control pin would use an enum which includes (at minimum) write/read states for both the cart and the browser.
Another possible solution would be to store the total number of bytes for the data transfer in header pins and compare the amount of data received against this to indicate when a turn is over. This is a little bit trickier than it sounds though, since each pin is only a byte. If you're sending messages > 255B, you'd have to use multiple pins (e.g. store a 16-bit int in 2 pins and convert back-and-forth from integer and binary representation).
Another possible solution would be to reserve some character code as a terminating character (iirc this is how strings work in C). You could stop reading and swap turns when the character first appears in the body of a packet. If the character isn't in the body of a packet, then you know that transfer isn't done and wait for the next one.
In regards to the interleaving vs. taking turns, the latter is definitely easier to implement and explain, but it's probably best decided on a case-by-case basis. It makes sense to take turns in P8T for example, since most of the data is going in one direction and getting full tweets faster is more important than registering extra button presses. If you were to do a synchronous multiplayer game though, it might make more sense to interleave to reduce latency.
Note that I don't think interleaving would actually be "faster", but you could ensure that each packet always includes certain things like player position and send less time-sensitive data in the remaining bytes over multiple packets.
This is making me realize the code in P8T is waaay sloppier than I thought, but hey, it's a learning experience!
NICE. Think this can be partially made into a Pico8 social game? Maybe tap into a following list, where tweets are used as data for NPC dialogue that you can actually reply to? Or post high scores and/or challenges to games/minigames with?
You know, you could put two buffers in the pins. One with its own control pin and N bytes to be sent in one direction, and another with its own control pin and 126-N bytes to be sent in the opposite direction. That would get you full-duplex transfers at the cost of having twice as many fragments for large packets.
You could tune N such that the side that sends very little only gets a small number of pins. Like, if the PICO-8 side only ever sent a single player's worth of pad+mouse inputs to a game hosting server, N could be as small as 4 bytes, while the web side gets 122 bytes, which might be useful for the server to bulk-transfer replay data to the client during kills or end-of-game. It might even be worth using yet another pin to configure the two buffers on the fly, based on the current needs.
Also, since the buffers would never switch directions, you could make them lock-free FIFOs (or perhaps just one double-buffer per side if you want to keep it simple) instead of using control pins to block reading during writes and writes during reads. That might produce more efficient transfers, where new data is constantly being put in the fifo while the other end is draining the oldest data, rather than the sender having to wait until the current fragment is totally read before they write more. It depends on how frequently you're willing to poll and how much overhead that adds.
I really like that duplex idea; very neat! I'd like to do another project which revisits all of this GPIO work, and I'm really looking forward to trying out some of your suggestions.
I'm not sure if you'd be able to do both the variable N and the lock-free FIFO improvements though, since the buffers would start overlapping as N changes. Even if you can't, they're both still cool ideas on their own.
It's a cool idea, but unfortunately PICO-8 doesn't have (as far as I know) any way of executing code stored in a string.
I was thinking about it earlier, and I'm not sure if that would ever work properly as a primarily PICO-8 thing even if there was an "exec" function. In the interest of meeting the character limit, a lot of the tweetjam stuff uses infinite loops, gotos, calls to "run", etc which would get in the way of any browsing functionality you made. You'd be able to load up a tweetjam cartridge, but you probably wouldn't be able to get out of it once you did.
If you did the search/browse portion of it entirely in HTML/JS though, you could make a cart which just replaces itself with whatever code you pass in at startup and trigger a page reload for each new tweetcart.
[Please log in to post a comment]