Log In  

People have been asking how I managed to fit a multiplayer game into the space of two tweets (560 chars) for TweetTweetJam5, so I thought I'd write a mini tutorial to explain the process!

We'll cover:

  • Stuff you need (such as javascript libraries, and where to get them)
  • How pico-8 talks to the outside world (General Purpose Input Output - GPIO - memory)
  • How to have a conversation between pico-8, the web browser running a pico-8 cart, and a database server
  • How to implement multiplayer that allows users to play against 'replays' or 'ghosts' of previous players (which is rather cool and under-explored territory imo)

We won't cover:

  • Server-side coding such as would be required for concurrent multiplayer gaming like, er, Counterstrike or something (do people still play Counterstrike?)
  • Setting up a specific online database to hold your replay data - I use FatFractal http://fatfractal.com, but I am sure there are loads of options these days. Anything that can be accessed from client-side Javascript will do just fine.
  • How to make games or code pico-8 - this is an advanced-level tutorial I'd say

So let's dive in ...

A quick example or two

I've used the method for a few pico-8(**) games now - if you want to get a feel for what this tutorial will teach you, feel free to play these (and please let me know what you think!) - I'll wait...

Things we need

GPIO: How pico-8 talks to the outside world

GPIO (general purpose input output) is a 128 byte segment of pico-8 memory, starting at 0x5f80. We can access it like any other chunk of pico-8 RAM, using peek, poke, memcpy and memset. Each of the 128 GPIO bytes can store a number in the range 0--0xff (0--255 in decimal). If we want to store larger numbers we can do so using poke2 (0--0xffff) or, for full precision, poke4 (0--0xffff.ffff). For TweetCarts we can make use of handy function aliases @=peek, %=peek2, $=peek4.

GPIO memory is shared between the pico-8 cart, and the host environment (in our case, this is the HTML page running the exported cart Javascript). This means that both sides can read from, and write to, the memory. This allows data to be passed back and forth 128 bytes at a time between pico-8 and the outside world. We need two tricks. The first trick is to set up the 'conversation' so that both sides don't try to 'speak' at the same time (which would result in lost data). The second trick is to encode the game data so it can pass through the small 128 byte buffer. Let's take the second one first.

Passing game data in 128 byte buffer

How to approach this really depends on your specific game. For my games, I want to save the player position as a ghost replay. It is convenient to fit a replay into a single 128-byte chunk, so that the whole thing can be sent to the server in one transaction. Splitting across multiple chunks requires more sophisticated synchronisation and is beyond the scope of this article, but there are other resources on lexaloffle.com that might give some ideas here.

Now, 128 bytes is not much space. If we are storing x,y coordinates at full pico-8 numerical precision, each 'frame' would require 8 bytes meaning we could only store 16 frames, which would cover 1/4 of a second's worth of action at 60fps - not much of a replay! We therefore need to intelligently compress information.

The first compression is to use lower precision numbers, just in the range 0-255, so that each frame can be stored in two bytes. This gives per-pixel accuracy for games where the world doesn't scroll. For scrolling games we can divide by 2, or 4 etc to get per 2-pixel or per-4-pixel accuracy but with the ability to represent numbers out to 512 or 1024, to support bigger game worlds.

The second compression is to only record replay frames every N pico-8 frames, using something like if(t%10==0)add(replay,{x,y}). This snippet appends a replay frame every 10 pico-8 frames.

But won't these compression methods lead to replay sprites jumping around the screen in an unappealing way? Yes! But to get around that we can interpolate between two frames when rendering replays, to smoothly blend replay spite positions from one replay frame to the next.

Oh, and one more thing, we can't use all 128 bytes of GPIO for replay frames - we need at least one byte to manage the "conversation" (more on that next), and perhaps more bytes to hold things like the current level number, the player's name, the score, and the number of replay frames in the packet (if this isn't constant between plays).

Here is a gif from Infinite Zombies with Friends. The player moves over extended ranges, and can take up to a minute. By using interpolation the replayed motion is nice and smooth, even though the replays use hardly any data. (apologies for the poor colour reproduction here - blame GiphyCapture)

Managing the 'conversation' between pico-8 and the host environment

The 'conversation' between the cart, the browser, and the cloud database (the 'communications protocol' if you want the proper term) uses the first byte of the GPIO array to control which party is 'speaking' at any given time. If the cart is sending data to the browser, it memcpy's the data into the GPIO memory, and sets the first byte (let's call it the comms byte) to 1 with poke(0x5f80,1). The browser listens for changes to the GPIO buffer, and if it sees a comms byte of 1, sends the replay data off to the cloud database.

On the other hand, if the browser wants to send data to the cart, it loads the data for a single replay into GPIO and set the comms byte to 2. The cart checks the state of the comms byte once per frame, and if it sees a 2, knows it can load 128 bytes from GPIO to user data, with memcpy(0x4300 + 128*N, 0x5f80, 128) where N is the number of replays it has already loaded. The cart then sets the comms byte to 3, which is the cart's way of signalling the browser that it is ready for more data.

The final matter to resolve is 'who speaks first'. I always have the cart speak first, by setting special comms state to 9. The simple reason being that the browser side is ready before the cart, and may have data ready to send before the cart is ready to receive. By starting with the cart, the conversation can be driven more directly by the needs of the player. For instance, maybe the cart needs data for a specific map - in this case the browser needs the cart to tell it which map that is, before data can be fetched from the cloud.

If this is all rather abstract, hopefully the index.html excerpt below will help things make a bit more sense:

<script src="pico8-gpio-listener.js"></script>

<!-- REPLACE THIS WITH YOUR OWN DATABASE JS API!! -->
<script src="FatFractal.js"></script> 

<script type="text/javascript">

// This array is how we read/write GPIO on the browser side
var pico8_gpio = new Array(128);

// benwiley4000's GPIO library - "other GPIO libraries are also available"
var gpio = getP8Gpio();

// register a callback handler which is triggered whenever the GPIO is 
// changed by EITHER pico-8 or the browser
gpio.subscribe(function(indices) {

      // read current communication state - who is 'speaking'? Cart, or browser?
      var comms_state = gpio[0];

      // these comms states are set by the browser side, and so we don't want 
      // to respond to them (no 'talking to ourself')
      if (comms_state==4) return;
      if (comms_state==0) return;
      if (comms_state==2) return;

      if (comms_state==9)
      {
            // this represents the cart saying 'hello', and triggers us to fetch data 
            // from our cloud database
            loaded_replays = // YOUR DATABASE QUERY HERE
            nrep = loaded_replays.length;

            if (nrep>0)
            {
                        // lock GPIO - this means that as we insert data into the GPIO 
                        // array, we won't keep triggering the browser 
                        // ('no talking to ourself')
                        gpio[0]=4;

                        // pack replay data into GPIO array
                        for(var i = 1; i < 128; i++)
                        {
                                    gpio[i]=loaded_replays[nrep-1].raw_gpio_n[i];

                        }

                        // decrement counter so we can keep track of what's already sent
                        nrep-=1;

                        // Setting the GPIO comms byte to 2 indicates to the cart that 
                        // there is data ready for them to consume
                        gpio[0]=2;
            }
            else
            {
                        // If there is no data to send, we indicate this to the cart by 
                        // setting the GPIO comms byte to 0
                        gpio[0]=0;
            }
      }

      else if (comms_state==1)
      {
            // This comms state represents the cart SENDING data to the browser. All we 
            // have to do here is push it up to the cloud server.
            // We pack the whole GPIO array into a field called raw_gpio, and send it to 
            // our cloud database service (not shown)
            var raw_gpio = new Array(128);
            for(var i = 0; i < 128; i++)
            {
                  raw_gpio[i]=gpio[i];
            }

            // SEND raw_gpio TO YOUR CLOUD DATABASE

            // No need to do anything else with GPIO 
      }

      else if (comms_state==3)
      {
        // In this comms state, the cart is signalling that they have received a data 
        // item, and are ready for more if there is more to send.
        // We either send more (and set comms state back to 2) or if there is nothing
        // to send, set comms state to 0

        if(nrep>0)
        {
            // Lock to avoid 'talking to ourself'
            gpio[0]=4;

            // pack replay into GPIO memory
            for(var i = 1; i < 128; i++)
            {
                    gpio[i]=loaded_replays[nrep-1].raw_gpio_n[i];
            }

            // decrement counter
            nrep-=1;

            // signal cart that the data is ready for them to read from GPIO
            gpio[0]=2;
        }
        else
        {
            // no more data - indicate by setting gpio comms state to 0
            gpio[0]=0;
        }
      }

},
true); // don't forget that 'true'!

Here's a tip: you won't need to export the .html file every time. You can do EXPORT FOO.JS to just export the javascript innards. I append a version number here, and then update the FOO_v?.js reference in index.html. Bonus tip, for your game to work on itch.io, the html file must be called index.html.

Summary

If you've read this far, you already know that Pico-8 is a wonderfully expressive tool. I hope this article has given you some ideas for how you could extend your exploration of Pico-8's creative possibilities. I would be very grateful for any comments, feedback, or questions - I always reply promptly :) Looking forward to seeing what you create with Pico-8 GPIO online functionality!

Complete source code for Massive Multiplayer Moon Lander

The code is fully 'golfed' to fit in minimal size- 560 characters in this case - in no way would I recommend writing code in this style unless it is absolutely necessary!

Unpacking all of this is beyond the scope of this article, but there are loads of great tweetcart resources on lexaloffle.com and elsewhere.

g=0x5f80m=memcpy
u=0x4300s=memset
k=63s(g,9,1)n=1p=poke::❎::d=print
o=128t=0r=1x=o*rnd()y=0q=0v=0z=0.05f=99w=0::_::cls()circfill(k,230,o,6)rect(0,0,1,f,9)
?"❎",32,105,8
if(@g==2)m(u+n*o,g,o)p(g,3)n+=1
b=0a=0j=btn
if(f>0)then
if(j(⬅️))a+=z b-=z d("ˇ",x-3,y+4,9)f-=1
if(j(➡️))a-=z b-=z d("ˇ",x+4,y+4,9)f-=1
end
q+=a
v+=b+z
if pget(x+3,y+5)==8and v<1then
w=1else
x+=q 
y+=v
end
for e=1,n-1do
h=@(u+e*o+r*2)l=@(u+e*o+r*2+1)
if(r>k)h=o
?"웃",h,l,2
end
?"웃",x,y,7
flip()t+=1if(t%3==0and r<k)p(u+r*2,x)p(u+r*2+1,y)r+=1
if(y<o*2and w<1)goto _
p(u,1)m(g,u,o)
if(w<1)goto ❎

Thanks for reading!

Superfluid
@superfluid
itch.io: https://superfluid.itch.io
twitter: @trappyquotes

(**)
And fwiw a couple of iOS games too:

P#84109 2020-11-11 16:00 ( Edited 2020-11-12 10:08)

1

Cart #mmo_moon_tut_ver-0 | 2020-11-12 | Code ▽ | Embed ▽ | No License
1

Here is an 'offline' version of massive multiplayer moon lander; it stores replays locally rather than sending via GPIO - on the plus side, it is playable here on the BBS :)

CONTROLS: left/right = fire boosters. Fire both to slow down. Try and land gently on the X ;)

If you like it, please do let me know here: https://superfluid.itch.io/massive-multiplayer-moon

P#84141 2020-11-12 10:27 ( Edited 2020-11-12 10:30)
1

Yes!! We need more GPIO-JS-Communication tutorials!

This is probably going to make me create one for actual real-time multiplayer.

P#84143 2020-11-12 10:32

Haha thank you :) It was pretty hard to work all this out so I hope by putting it all in one place, people will find something useful.

Will be very keen to see a tut on real-time multiplayer

P#84145 2020-11-12 10:37
1

This is super cool! I want to use it at some point, thank you for making it!

I'd love to make something in real-time too, but I don't know anything about this kind of stuff XP

P#85636 2020-12-21 11:13
1

Hey, did the server get taken down for these games? Would love to try them, but I don't see any other players.

P#136131 2023-10-20 06:44

Hey Steven, and all. Sadly yes, I was using FatFractal as a backend and FatFractal is no more! I’ve been able to do the exact some thing with apple CloudKit but that’s no good for pico8 obviously. I think the new high score submit system in Pico 8 would support the necessary functionality but will only be for Pico 8 games in splore/on the bbs I think (Ie not on itch.io etc). If anyone has suggestion of a lightweight backend solution I’d be keen to hear…. Azure App service maybe? Too heavyweight?

Update: I’m currently working on trappy tomb sequel for iOS, and you can play original TT at the moment as it’s back on the App Store :) cheers

P#136133 2023-10-20 08:39 ( Edited 2023-10-20 08:40)

Thanks for the thorough explanation. Does the browser need to send notification to the cart when it receives data to avoid duplicate writes?

P#136141 2023-10-20 14:23
1

My pleasure :) yes, you are correct, it ideally should. It could do this by setting the comms byte to a special value. I get away without doing so here I think (been a while since I wrote this) because the GPIO listener JavaScript library triggers an event only when a value changes.

P#136147 2023-10-20 23:15

[Please log in to post a comment]

Follow Lexaloffle:          
Generated 2024-04-20 10:10:20 | 0.016s | Q:28