Skip to main content

Using with Pico 8

In this guide, you’ll add buttons and analog control to a pico-8 game instance.

This tutorial will assume some HTML, CSS and JS knowledge, and will be based on this codesandbox, so just fork it and follow along!

What we'll build

The result is a fully working pico-8 template with custom controls!

Prerequisites

Before we start, there are a couple of things you’ll need to do:

Export Your Game from Pico-8

First, you’ll need a Pico-8 game file to work with. Use Pico-8’s built-in export feature to generate the game files we’ll use in this tutorial. Open the Pico-8 terminal (by loading a game and press ESC to type commands) and run this command:

See Pico-8 Web Applications from the Pico-8 manual for more information.

EXPORT my_game.html

This will create a folder on your computer with a few files, including my_game.js.

Locate the Exported File

Open the folder where Pico-8 saved your exported game. Copy the my_game.js file, as we’ll use it in the template later.

You can open the export folder by running the command:

FOLDER

Copy the my_game.js file and add it to the sandbox.

Create A Pico-8 Player

Now that we have the exported game file, we’ll set up something called the PicoPlayer class. This is a piece of JavaScript code that handles:

  • Loading the game and displaying it on the screen.
  • Adding support for game controllers.
  • Handling touch controls and audio.

The PicoPlayer class will give us control over how the game interacts with players. For example, it will let us customize the layout and connect physical or on-screen buttons to game actions.

To add this, take the code below and add it to the html just before the closing </body> tag:

<script>
window.pico8_audio_context = window.pico8_audio_context;
window.pico8_buttons = window.pico8_buttons;
window.Module = window.Module;

const pico8ButtonValues = {
o: 0x10,
x: 0x20,
left: 0x1,
right: 0x2,
up: 0x4,
down: 0x8,
menu: 0x40,
};

const gamepadButtonMap = {
0: "o",
1: "x",
2: "o",
3: "x",
4: "o",
5: "x",
6: "o",
7: "x",
8: "menu",
9: "menu",
12: "up",
13: "down",
14: "left",
15: "right",
};

class PicoPlayer {
_canvas;
_maxPlayers;
_buttonsCache;
_buttonsRequestAnimationFrame;
_src;

constructor({ canvas, src, maxPlayers = 8 }) {
this._canvas = canvas;
this._maxPlayers = maxPlayers;
this._buttonsCache = Array.from({ length: maxPlayers }, () => 0);

this._src = src;

if (!canvas || !src) {
console.error(
`Missing ${
!canvas ? "canvas element" : "src path to pico-8 .js export"
}`
);
return;
}

window.addEventListener("gamepaddisconnected", (event) => {
const playerIndex = event.gamepad.index;

if (playerIndex < this._maxPlayers) {
this._buttonsCache[playerIndex] = 0;
}
});
}

init() {
if (!this._canvas) return;

window.pico8_audio_context = undefined;
window.pico8_buttons = this._buttonsCache;
window.Module = { canvas: this._canvas };

this._injectGameScript();
this._requestAudio();
this._startButtonsUpdates();
}

setButton(button, pressed, player = 0) {
if (player >= this._maxPlayers) return;

const code = pico8ButtonValues[button];

this._buttonsCache[player] = pressed
? this._buttonsCache[player] | code
: this._buttonsCache[player] & ~code;
}

_injectGameScript() {
const existingScript = document.getElementById("_pico_8_js");
if (existingScript) return;

const script = document.createElement("script");
script.id = "_pico_8_js";
script.src = this._src;
document.head.appendChild(script);
}

_updateButtons() {
window.pico8_buttons = [...this._buttonsCache];
}

_requestAudio() {
if (!window.pico8_audio_context) {
window.pico8_audio_context = new AudioContext();
document.addEventListener("pointerdown", () =>
window.pico8_audio_context?.resume()
);
}

window.pico8_audio_context.resume();
}

_updateButtonsFromGamepad() {
const threshold = 0.3;
const gamepads = navigator.getGamepads();

if (!gamepads?.some(Boolean)) return;

gamepads.slice(0, this._maxPlayers).forEach((gamepad, i) => {
if (!gamepad) return;

this._buttonsCache[i] = 0;
const tempButtonStates = {};

Object.values(gamepadButtonMap).forEach((mappedButton) => {
if (mappedButton) {
tempButtonStates[mappedButton] = false;
}
});

const { axes, buttons } = gamepad;
const threshold = 0.3;

if (axes) {
tempButtonStates["left"] = axes[0] < -threshold;
tempButtonStates["right"] = axes[0] > threshold;
tempButtonStates["up"] = axes[1] < -threshold;
tempButtonStates["down"] = axes[1] > threshold;
}

buttons.forEach((button, j) => {
const isPressed = button.value > 0 || button.pressed;
const mappedButton = gamepadButtonMap[j];

if (mappedButton) {
tempButtonStates[mappedButton] =
tempButtonStates[mappedButton] || isPressed;
}
});

Object.keys(tempButtonStates).forEach((buttonName) => {
this.setButton(buttonName, tempButtonStates[buttonName], i);
});
});
}

_startButtonsUpdates() {
this._buttonsRequestAnimationFrame = requestAnimationFrame(() => {
this._updateButtonsFromGamepad();
this._updateButtons();
this._startButtonsUpdates();
});
}
}
</script>

Load Your Game into PicoPlayer

After setting up the PicoPlayer class, we need to tell it to load your exported Pico-8 game. To do this, we’ll create an instance of the PicoPlayer class and point it to your game file (my_game.js).

Here’s how it works:

  • The PicoPlayer class takes two main inputs:
    • A canvas element, which is where the game will be displayed.
    • The path to your game file (my_game.js).

Once you tell PicoPlayer where to find these, it will load your game and set everything up automatically.

<body>
<canvas></canvas>
<script>
// Rest of the script content with class PicoPlayer

const player = new PicoPlayer({
src: "my_game.js",
canvas: document.querySelector("canvas"),
});

// Call init to start the game
player.init();
</script>
</body>

Add Touch Controls

Now let’s add some controls for your game. The PicoPlayer class makes it easy to simulate button presses, which means we can connect touch buttons to your Pico-8 game.

To set up the touch controls:

  • Import the xyba-elements library to make the elements available
  • Update your HTML to include the touch button and analog stick elements. These elements send input to the PicoPlayer class when pressed or moved.
  • Add event listeners to connect the touch controls to the game.

Import Xyba Elements

First we need to import the xyba elements library, the quickest way to get started is to load the library from a CDN.

Add the <script> tag to load the library just before the closing </body> tag:

    <!-- Rest of html -->
<script type="module" src="https://cdn.jsdelivr.net/npm/xyba-elements/dist/cdn/elements/index.js"></script>
</body>

Update the HTML with Touch Controls

Pico-8 is controlled with only 4 direction's, x and o buttons and a menu button.

To mimic these controls, we'll use:

  • Analog: An input element mimicking a joystick type input.
  • Button: A button allowing simultaneous presses and touch pointers.

In the <body> tag add the following HTML:

<body>
<canvas></canvas>
<div class="virtual-controller">
<xyba-analog data-analog="direction" class="virtual-controller__analog-direction" adapt></xyba-analog>
<xyba-button data-button="x" class="virtual-controller__button-x">x</xyba-button>
<xyba-button data-button="o" class="virtual-controller__button-o">o</xyba-button>
<xyba-button data-button="menu" class="virtual-controller__button-menu">...</xyba-button>
</div>
<!-- Rest of html, including scripts -->
</body>

On the elements we added classes we will use for styling, and data- attributes we will use for later when listening for input events to decide how to handle the events.

Listen to Input Events

The last thing we need to do for us to have a functionally working Pico-8 player with touch controls is to listen for the change event from the elements and bind the element values up to our picoPlayer instance.

In the <script> tag where we instantiate the picoPlayer add:

<!-- Rest of the html -->
<script>
// Rest of the script
document.addEventListener("change", (e) => {
const target = e.target;

if (!target) return;

if (target.dataset.analog === "direction") {
for (dir in target.directions) {
player.setButton(dir, target.directions[dir] || false);
}
}

if (target.dataset.button) {
player.setButton(target.dataset.button, !!target.value);
}
});
</script>
</body>

Style the Virtual Controller

Keep the controls visible and position them for easy access by adding a few basic styles to a <style> tag in the <head> tag:

<style>
:not(:defined) {
visibility: hidden;
}

html,
body {
margin: 0;
background: black;
color: white;
}

canvas {
image-rendering: pixelated;
object-fit: contain;
width: 100%;
max-height: 100vh;
max-height: 100dvh;
}

.virtual-controller {
position: absolute;
bottom: 0;
width: 100%;
height: 300px;

@media (orientation: landscape) {
height: calc(50% + 72px);
}
}

.virtual-controller__analog-direction {
position: absolute;
left: max(env(safe-area-inset-left), 24px);
width: 144px;
}

.virtual-controller__button-o {
position: absolute;
top: 0;
right: max(env(safe-area-inset-left), 24px);
width: 72px;
}

.virtual-controller__button-x {
position: absolute;
top: 72px;
right: calc(max(max(env(safe-area-inset-left), 24px)) + 72px);
width: 72px;
}

.virtual-controller__button-menu {
position: absolute;
width: 40px;
bottom: 24px;
left: calc(50% - 20px);

@media (orientation: landscape) {
position: fixed;
bottom: unset;
top: 24px;
right: 24px;
left: unset;
}
}
</style>

Now you should be able to play your game on touch devices!

What’s Next?

Congratulations! You’ve built a fully customizable Pico-8 game template that supports game controllers and touch controls. Here are some ideas to take it further:

  • Customize the layout and style to match your game.
  • Experiment with different options for Analog, Button and Capture for different behaviors.
  • Add vibration feedback for a more immersive experience using the Vibration API on Android devices.
  • Show / hide touch controls based on keyboard or touch inputs.
  • Use Pico-8 magic strings (##js_file##) to replace my_game.js path and use as export template in Pico-8 following the Pico-8 manual!