CUECUE Developers 2D games
CUE 2D

Make a 2D game

We're expecting most creators to take a 2D approach with their games - cards do kind of lend themselves to it. So @cue/2d puts your game on a canvas where cards can fly, flip, land, burst into pieces, etc. Have fun!"

The idea

You lay out a 'board', then say what should happen and when.

// The whole surface at a glance
import { createCueClient } from '@cue/sdk';
import { createStage } from '@cue/2d';

const cue = createCueClient({ gameId: 'my-game' });
await cue.init();

const stage = await createStage({ cue, canvas, width: 720, height: 1180 });
const card = stage.addCard('EAG001', { x: 360, y: 500, faceDown: true });
stage.addText('Tap the card', { x: 360, y: 120, size: 30 });
stage.start();

card.onTap(async () => {
  await card.flip();                                   // turns over, and waits
  stage.burst({ x: card.x, y: card.y, kind: 'sparkle' });  // particles
});

Both @cue/sdk and @cue/2d are allowed imports. You do not install them, and you never import a drawing library yourself.

You will see await a lot on this page. It means "wait for this to finish before going on", and here it is usually waiting for an animation. await card.flip() carries on once the card has finished turning, which is how a run of movements stays in the right order without you tracking any timers. Capisce?

Should you use this, or plain HTML?

It's a good question (that you probably weren't asking). Here's a guide:

Use HTML and <cue-card> Use @cue/2d
Lists, grids, buttons, forms, scrolling Cards that do fun animated stuff
Text you want the browser to lay out Particles, glows, screen shake - i.e. VFX
A simple quiz or a deck builder Anything where feel/atmosphere is the point
Whatever CSS already does well Holograms, cards that burn, sparks
A screenful of neat cards Dragging, spritesheets, a camera, a board bigger than the screen

CSS is genuinely good at layout, at text, and at simple shadows and blurs, and a game that is mostly a screenful of neat cards may not need any of The fancy stuff.

Set up the page

Your index.html needs a <canvas> and loads your main.js:

<link rel="stylesheet" href="style.css" />
<canvas id="scene"></canvas>
<script type="module" src="./main.js"></script>

Make it fill the screen in style.css:

html, body { margin: 0; height: 100%; overflow: hidden; }
#scene { display: block; width: 100%; height: 100%; }

Start a stage

createStage({ cue, canvas, width?, height?, background? }): Promise<Stage>

Creates the stage on your canvas. Pass the cue client you already made and the canvas element. width and height are your design size, the made-up units you lay everything out in (default 1280 by 720). This is async, so await it.

const stage = await createStage({
  cue,
  canvas: document.querySelector('#scene'),
  width: 720,
  height: 1180,     // an upright board, good for a phone
  background: '#0b0e14',
});

Place things at whatever coordinates suit you and the stage should (!) scale itself to fit the screen, centred, keeping its shape. A player on a phone and a player on a laptop should (!!) both see your whole layout, so you never write resize code. Pick a size that matches the Screen setting on your game's Details tab (portrait for Mobile, landscape for Desktop).

Two things to remember: Positions are the centre of a thing, not its top left. Nothing moves until you call stage.start().

Cards

stage.addCard(cardId, { x, y, w?, faceDown?, back? }): Card

Puts a real CUE card on the stage. Give it any card id (from cue.cards.query() or cue.player.collection()) and the art loads itself. w is the width in your units (default 140); the height follows at the real card shape, so a card can never come out stretched. faceDown: true starts it turned over.

stage.addCards(cardIds, opts?): Promise<Card[]>

The same thing for a whole list, resolving once every card's art is ready. Use it to build a board and reveal it in one go.

const pool = await cue.cards.query({ album: 'Space' });
const cards = await stage.addCards(pool.slice(0, 12).map((c) => c.id), { w: 132, faceDown: true });

Cards without the stat furniture

By default a card's printed face shows its power, energy and ability text. You may not need that, or may find it's just noise at a certain size, or your game might be drawing its own abilities and numbers. Use this to ask the portal to leave parts off:

createStage({ imageOmit: ['power', 'energy', 'ability'] })

NB This applies to every card on the stage. A single card can override it with the same option on addCard. You can leave off any combination of 'power', 'energy' and 'ability'. The frame, the name and the rarity always stay, for consistency.

// A board of tiles: art and name only, with your own HUD showing the numbers.
const stage = await createStage({ cue, canvas, width: 720, height: 1180, imageOmit: ['power', 'energy', 'ability'] });

// ...and one big card in a reveal, printed in full.
const hero = stage.addCard(id, { w: 320, imageOmit: [] });

Cards asking for different parts are fetched separately, so mixing them costs an extra request.

Each card knows its own id as card.id, and once its data has arrived, card.dto is the full card (name, rank, power, rarity, and the rest) exactly as cue.cards.get() gives it to you. It is null for the brief moment before the data lands, so check it before you read from it:

label.setText(card.dto ? card.dto.name : '');

Adding cards is cheap even in bulk. Every card you add in one run of your code is fetched in a single request, so a 40-card board costs one round trip, not forty. You do not have to batch anything yourself.

How a card looks

A card has five looks you can switch on and off (the same five the HTML <cue-card> element has) so a card reads identically either way.

card.set({ faceDown?, foil?, selected?, matchable?, disabled? })

card.is('selected') reads one back.

State Looks like
faceDown The card back instead of the face
foil A glint that slides across the art (default on foil cards, obvs)
selected Lifted slightly, violet highlight
matchable A green highlight
disabled Dimmed and desaturated
card.set({ selected: true });      // the player picked it
card.set({ matchable: true });     // it is a legal move
card.set({ disabled: true });      // it is not

Moving a card

card.moveTo({ x?, y?, ms?, ease?, delay? }): Promise<void>

Slides the card to a spot. By default it overshoots a touch and settles, to make it look placed rather than teleported.

card.flip({ ms? }): Promise<void>

Turns the card over. But you already figured that out.

card.shake({ ms?, power? }): Promise<void>

Use this for a wrong guess or a move that is not allowed. (The card stays in situ as you'd expect.)

Because each one 'resolves' when it finishes, a sequence is just a list:

await card.moveTo({ x: 360, y: 300 });
await card.flip();
await stage.wait(400);
await card.shake();

delay is the neat trick for dealing a row. Every card starts at once and each waits its turn, with no timers in your code:

await Promise.all(
  cards.map((card, i) => card.moveTo({ x: seat(i).x, y: seat(i).y, delay: i * 55 })),
);

Looks only a canvas can do

These work on the card's own pixels, so they follow the artwork rather than the box around it. NB holo and dissolve, have no CSS equivalent.

Everything in this section except holo, dissolve, shine, sparks and trail also works on anything else you add (see Looks), so you can glow a button or grade a background the same way.

card.glow({ color?, strength? })

A coloured halo OUTSIDE the card's edges. Pass null to clear it. Unlike a CSS shadow, it is shaped by the art, so it looks like light is coming off the card rather than a rectangle behind it. Good for marking a rare card, or ones you can play.

card.holo({ strength?, period? })

Shifting refraction across the artwork, like a holographic physical card. null clears it. (Different from the foil state, which is the sheen effect.)

card.dissolve({ ms? }): Promise<void>

Burns the card away by its own pixels and resolves once it has gone. It leaves the card invisible rather than removed, so you can deal it again.

card.shine({ ms? }): Promise<void>

A highlight sweeping across the card, the way light crosses a real one as it turns. Pair it with flip() for the moment the face catches the light.

card.sparks(spec?) / card.trail(spec?)

sparks emit along the card's OUTLINE. trail leaves a wake that follows it while it moves. Both run until you stop the handle they return, and take the same spec fields as any other effect, so you can recolour them, apply to your own art, etc.

card.blur(strength)

Blurs the card, and 0 clears it. Use it on the whole board when a dialog comes up, for example.

These all chain, so a rare card can be one line:

card.glow({ color: '#ffd166', strength: 2 }).holo({ strength: 1.2 });

// A card leaving play, and its exit
const sparks = card.sparks();
await card.moveTo({ x: 900, ms: 600 });
sparks.stop();
await card.dissolve();

Effects cost more than plain cards. A glow or a holo on a handful of cards is fine, but a whole board of them is probably not. It might seem obvious, but you want to use FX to 'mean' something, not just for visual interest.

Shapes, text and pictures

stage.addRect({ x, y, w, h, color?, radius?, outline?, depth? }): Thing

A rectangle (a slot, a panel, a bar, etc) where radius rounds the corners and outline (a thickness) draws just the edge instead of filling it.

stage.addText(text, { x, y, size?, color?, align?, bold?, italic?, font?, stroke?, strokeWidth?, shadow?, wrap?, lineHeight? }): Thing

Text. Change it any time with thing.setText('...').

stroke is your friend: light text over busy card art is hard to read without an outline. shadow takes { x, y, color, blur }. font is any family name, including a web font you loaded in your own CSS, which is a quick way to stop your game looking like everyone else's (Google has SOOO many fonts now for free, many of which look awesome). wrap is a width to wrap at.

stage.addSprite(url, { x, y, w, h, color?, sheet? }): Thing

One of your own images, uploaded in the Assets panel. color tints it. Pass sheet and it can animate (see Sprites and animation).

stage.addBackground(imageOrColor): Thing

Fills the whole stage, behind everything else. Give it a colour or an image name.

stage.addCircle({ x, y, radius, color?, outline?, colors? })
stage.addLine({ points, color?, thickness?, closed? })
stage.addPolygon({ points, color?, outline?, colors? })

points are [x, y] pairs measured from the 'thing's' own position, so two make a straight line and more make a path. Passing colors with two entries fills with a gradient instead of a flat colour.

Everything you add, cards included, can be moved (thing.x, thing.y), turned (thing.angle, in degrees), faded (thing.alpha), scaled (thing.scale), hidden (thing.visible), stacked (thing.depth, higher is nearer the front), resized (thing.setSize(w, h)), recoloured (thing.setColor()) and removed (thing.remove()).

// A timer bar that drains. Two things matter here. setSize, not scale: the WIDTH is the
// information, and scaling would stretch the rounded ends with it. And setOrigin(0, 0.5),
// which pins the LEFT edge, so it shrinks towards the right instead of closing in from both sides.
const bar = stage
  .addRect({ x: 24, y: 158, w: 672, h: 8, color: '#64e0a0', radius: 4 })
  .setOrigin(0, 0.5);
bar.setSize(672 * fractionLeft);

Looks

Every card, sprite, rectangle and line can carry 'looks'. They are per-pixel work on the thing itself, so a glow is shaped by the artwork rather than by the box. Clear a look by passing null or 0.

thing.glow({ color?, strength? }) / thing.shadow({ x?, y?, color?, strength? })

These give a halo OUTSIDE the edges, or a drop shadow.

thing.grade(name)

Think Instagram circa 2010: 'sepia', 'night', 'vintage', 'technicolor', 'polaroid', 'kodachrome', 'brown', 'negative', 'monochrome', 'psychedelic'.

thing.blur(strength) / thing.pixelate(size)

Soften a 'thing', or go for the retro gamer look.

thing.vignette({ strength?, radius?, color? }) / thing.grayscale(amount) / thing.wipe(progress)

Darken the edges inward, drain the colour, or reveal from 0 to 1. Drive wipe from a countdown or a loop for a reveal.

thing.clearLooks()

Removes all of them at once.

They chain, so a styled panel can be achieved in one statement:

const panel = stage.addRect({ x: 360, y: 500, w: 460, h: 300, color: '#141a26', radius: 20 });
panel.shadow({ y: 10 }).glow({ color: '#8b7bff', strength: 1.5 });

// Grade the whole board while a dialog is up, then put it back.
board.forEach((c) => c.blur(1.2).grade('night'));

Looks cost more than plain drawing, so don't overdo it.

Effects

This is what a canvas is for. Start with a ready-made effect, change any part of it, or describe your own from scratch.

stage.burst({ x, y, kind?, power?, ...spec })

Throws particles. kind picks a ready-made effect, power scales its speed and size together so power: 2 is a big showy version of the same thing, and anything else you pass overrides that effect field by field (see the spec below).

Kind What it is Good for
sparkle Bright stars thrown out, slowing and fading Cards matching or a point scored
confetti Spinning squares falling under gravity Winning :)
ring One shockwave expanding outward A card landing or other impact
smoke Soft puffs that rise and swell A miss, or something vanishing
embers Slow motes drifting upward, continuous Atmosphere
shards Hard fast splinters Something breaking
dust A low scatter kicked sideways A card slapping down
glitter A dense fine shimmer that hangs Rarity, a reward

Make it yours

The eight above are a starting point. You can avoid effects looking 'stock' by using your your own picture, eg:

// Your uploaded art, thrown like a sparkle.
stage.burst({ x, y, kind: 'sparkle', texture: 'my-star.png' });

// Or describe one from nothing.
stage.effect({
  texture: 'petal.png',
  count: 30,
  from: { shape: 'rect', w: 200, h: 20 },  // born across a strip
  speed: [40, 140],
  angle: [-100, -80],                      // upward
  gravity: 120,
  drag: 1.5,
  spin: [-180, 180],
  life: [1, 2.2],
  size: { from: 18, to: 6 },               // ramps over its life
  fade: { from: 1, to: 0, ease: 'in' },
  colors: ['#ffd166', '#ff8fab'],          // walks the list as it ages
  blend: 'add',
}, x, y);
stage.effect(spec, x, y): EffectHandle

Runs any effect you describe. Give it a duration (in milliseconds, or Infinity) and it keeps emitting until you call handle.stop(). No duration = a single burst.

The fields, all optional:

Field What it does
texture Your uploaded image, or an engine shape: 'dot', 'spark', 'square', 'ring', 'smoke'
count How many in one burst
duration / rate Emit over time instead, this many per second
from Where they are born: {shape:'point'}, {shape:'rect',w,h}, {shape:'circle',radius}, or {shape:'edge',w,h}
speed, angle How fast, and which way in degrees. [min, max] for a range
gravity, drag Pull downward (negative floats up), and how fast they slow
spin Degrees per second
life Seconds each one lasts
size, fade A number, or {from, to, ease} to change over its life
colors, colorEase One colour, or list several to cycle through as a particle ages
blend 'normal', 'add' (glowing) or 'screen'
stage.trail(target, spec): EffectHandle

A continuous effect that follows a card or thing as it moves. Stop it with the handle.

stage.shake({ ms?, power? })

Shakes the whole view. Use sparingly!

stage.tween(thing, { x?, y?, angle?, alpha?, scale?, scaleX?, scaleY?, w?, h?, color?, ms?, ease?, delay?, repeat?, yoyo? }): Promise<void>

Animates anything you have added, and resolves when it gets there. ease is 'linear', 'in', 'out', 'inOut', 'back' (overshoots and settles), 'elastic' (wobbles to rest) or 'bounce'.

w/h animate the SHAPE (a bar growing), color animates the fill or ink, and repeat: 'forever' with yoyo: true is how something pulses or breathes without you writing a timer:

stage.tween(button, { scale: 1.08, ms: 700, repeat: 'forever', yoyo: true });
stage.tweenValue(from, to, { ms?, ease?, onUpdate })

Animates a plain number through steps - useful for things like a score ticker, a countdown, etc.

await stage.tweenValue(0, score, { ms: 900, onUpdate: (v) => label.setText(String(Math.round(v))) });
stage.wait(ms): Promise<void>

Pauses inside a sequence. It runs on the stage's clock, so it stops when the game does instead of firing into a finished round.

Put together, a celebration can be a handful of lines:

async function celebrate(a, b) {
  stage.burst({ x: a.x, y: a.y, kind: 'sparkle', power: 1.4 });
  stage.burst({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, kind: 'ring' });
  stage.shake({ ms: 140 });
  await Promise.all([stage.tween(a, { scale: 1.12, ms: 130 }), stage.tween(b, { scale: 1.12, ms: 130 })]);
  await Promise.all([
    stage.tween(a, { y: a.y - 40, scale: 0.4, alpha: 0, ms: 260, ease: 'in' }),
    stage.tween(b, { y: b.y - 40, scale: 0.4, alpha: 0, ms: 260, ease: 'in' }),
  ]);
}

Input

A tap is not the only thing a player does, and for a card game dragging is the main one.

thing.onTap(cb) / card.onTap(cb)

Fires when the player taps it. The front-most thing is what receives the tap (so that 'z-index' matters here). A tap means a press and a release on the same thing, so sliding off and then letting go does not count.

for (const card of cards) {
  card.onTap(() => {
    pick(card);          // your own function
  });
}
thing.drag({ onStart?, onMove?, onDrop? })

Lets the player pick it up and move it. The engine does the moving, and your handlers react. onDrop is where you decide whether it landed somewhere that counts. thing.noDrag() turns the capability off, e.g. for a card that has been played and should stay put.

card.drag({
  onDrop: () => {
    const overSlot = Math.abs(card.x - slot.x) < 60;
    if (overSlot) card.moveTo({ x: slot.x, y: slot.y });
    else card.moveTo({ x: home.x, y: home.y });   // send it back
  },
});
thing.onPress(cb) / thing.onRelease(cb) / thing.onHover(over, out?)

The moment a finger goes down, the moment it comes up, and moving onto or off something. Each callback gets { x, y, dx, dy }, where dx and dy are how far a drag has moved so far.

stage.on('tap', ({ x, y }) => ...)

Every tap that landed on nothing, in your design units. Use it for a tap-anywhere screen, and it will not fire when the player meant to tap a card.

stage.on('key', ({ key, down }) => ...) / stage.keyDown(key)

Keys by the name your browser uses: 'ArrowLeft', 'a', ' ' for space. Listen for the moment one changes, or ask whether it is held down right now from inside a frame handler.

A thing's tap area survives being rotated, so (for example) a hand of cards fanned by a few degrees has no awkward dead spots.

Groups

stage.addGroup({ x, y }): Group / group.add(thing)

A group carries all of its children (awww). Move, rotate, fade or hide the group and they all follow, which is ideal for working with a hand of cards.

const hand = stage.addGroup({ x: 360, y: 900 });
cards.forEach((card, i) => {
  card.x = (i - 2) * 40;      // positions are now relative to the group
  card.setParent(hand);
});
await stage.tween(hand, { y: 700, angle: 4 });   // the whole hand rises and tilts

Tap targets follow the group too, so a card inside a moved group is hit where you can see it.

The camera

Your board can be bigger than the screen.

stage.camera.scrollTo(x, y) / setZoom(z) / panTo(x, y, ms) / follow(thing, catchUp?)

stage.camera.x, .y and .zoom read back where it is.

panTo and the fades resolve when they finish. follow takes a catch-up amount from 0 to 1: lower numbers let the camera drift behind its target and settle, which typically looks better than a camera glued rigidly to it. (0.1 is a good start.)

stage.camera.fadeOut(ms?, color?) / fadeIn(...) / flash(ms?, color?)

Fades resolve when done, so a scene change is await fadeOut(); rebuild(); await fadeIn();.

thing.setScrollFactor(factor)

How much the camera moves it. 1 follows the camera, 0 pins it to the screen (that is your HUD), and values in between give parallax.

Sprites and animation

stage.addSprite(url, { sheet: { w, h } }) then sprite.play({ frames, fps?, repeat?, yoyo? })

Tell it your image is a grid of equally sized frames and it can animate. frames is [first, last] or an explicit list. play resolves when it finishes, whereas a looping one never does. sprite.stop() freezes it.

const coin = stage.addSprite('coin.png', { w: 48, h: 48, sheet: { w: 32, h: 32 } });
coin.play({ frames: [0, 7], fps: 14 });          // spins forever
await explosion.play({ frames: [0, 15], fps: 24, repeat: 0 });   // once, then carry on

Layout and blending

thing.setOrigin(x, y?)

Determined what defines a thing's position (0 to 1 on each axis). The default is the centre. setOrigin(0, 0.5) pins the left edge, eg for a bar draining from the right:

const bar = stage.addRect({ x: 40, y: 60, w: 600, h: 12, color: '#64e0a0' }).setOrigin(0, 0.5);
bar.setSize(600 * fractionLeft);     // shrinks from the right, left edge stays put
thing.setBlend(mode)

How it is drawn over what is already there: 'normal', 'add' (glowing light), 'multiply' (i.e. darkening), 'screen', 'overlay', 'darken', 'lighten', 'difference', 'erase'.

An additive circle over a board:

stage.addCircle({ x: 360, y: 400, radius: 120, color: '#ffb457' }).setBlend('add');
thing.maskWith(shape)

Draw it only where shape is. Pass null to clear.

A clock, or anything per frame

stage.on('frame', ({ dt, elapsed }) => ...)

Runs every frame. dt is the seconds since the last one, elapsed the seconds since start(). Use it for a countdown or meter, or for sorting your own anims.

dt never comes back bigger than a tenth of a second, even if the player left your game in a background tab for a minute. That is on purpose: a clock built by adding dt up stays in step with elapsed and with your animations, instead of a single long gap eating a whole round. The trade is that time spent away does not count, which for a game is the friendlier of the two.

It runs on the stage's own loop, so it stops when you stop the game:

const clock = stage.addText('60', { x: 360, y: 1100, size: 34, bold: true });
let timeLeft = 60_000;   // milliseconds
let running = true;

stage.on('frame', ({ dt }) => {
  if (!running) return;
  timeLeft = Math.max(0, timeLeft - dt * 1000);
  clock.setText(String(Math.ceil(timeLeft / 1000)));
  if (timeLeft === 0) {
    running = false;
    clock.setColor('#f87171');
  }
});

Sound

stage.loadSound(name, url): Promise<boolean> / stage.playSound(name, { volume? })

Load a sound you uploaded in the Assets panel, then play it by the name you gave it.

await stage.loadSound('flip', 'flip.mp3');
stage.playSound('flip', { volume: 0.6 });

Browsers will not play sound until the player has touched the page, which usually means on the first tap. Nothing to do here, just do not expect noise before then.

Run it

stage.start() / stage.stop()

start() begins the loop. Call it once, after you have built your board. stop() freezes it and doubles as pause, since start() picks up again.

Nothing moves and nothing is drawn until you call start(), so an animation you await before calling start would wait for ever and your game would look frozen on a blank screen. Build the board, call start(), then animate.

Call stop() when a game has finished. A results screen left running keeps a full loop going for as long as the player leaves it open.

There is also stage.dispose(), which throws the whole stage away but you rarely need it as the portal automatically tidies up when a player closes your game.

Keeping it smooth

Keep an eye on the number of cards you're using at once as each downloads its own art. (The engine shrinks that art to the size you actually draw it, so a small card does cost much less memory than a large one.)

Cheap Costs you
Rectangles, text, anything that sits still Cards, because each loads a picture
A burst now and then Bursts every frame
One frame listener doing arithmetic Heavy work in a frame listener

A board of a few dozen cards with effects is fine. Hundreds of cards on screen at once is not, so it's best to select a pool.

Putting it together

Here's a tiny - but complete - game: turn two cards, keep them if they match. So, Snap basically. (But not that Snap. :))

import { createCueClient } from '@cue/sdk';
import { createStage } from '@cue/2d';

const cue = createCueClient({ gameId: 'my-pairs' });
await cue.init();

const stage = await createStage({ cue, canvas: document.querySelector('#scene'), width: 720, height: 1180 });
const score = stage.addText('0', { x: 360, y: 80, size: 40, bold: true });

const pool = await cue.cards.query({ album: 'Space' });
const six = pool.slice(0, 6).map((c) => c.id);
const seats = shuffle([...six, ...six]);

// Cards are off the top of the screen to start with, so they can drop into place.
const cards = await stage.addCards(seats, { w: 132, faceDown: true, y: -200 });
cards.forEach((card, i) => {
  card.x = 130 + (i % 4) * 166;
  card.onTap(() => {
    pick(card);
  });
});

// The board is built so we can 'start'. Do this BEFORE the deal below: an animation needs
// the loop running in order to work.
stage.start();

await Promise.all(
  cards.map((card, i) => card.moveTo({ y: 320 + Math.floor(i / 4) * 210, delay: i * 55 })),
);

let picked = [];
let busy = false;
let points = 0;

async function pick(card) {
  if (busy || !card.faceDown) return;
  busy = true;
  await card.flip();
  picked.push(card);

  if (picked.length === 2) {
    const [a, b] = picked;
    picked = [];
    if (a.id === b.id) {
      points += 100;
      score.setText(String(points));
      a.set({ matchable: true });
      b.set({ matchable: true });
      stage.burst({ x: a.x, y: a.y, kind: 'sparkle' });
      stage.burst({ x: b.x, y: b.y, kind: 'sparkle' });
      await Promise.all([stage.tween(a, { alpha: 0, ms: 300 }), stage.tween(b, { alpha: 0, ms: 300 })]);
      a.visible = false;
      b.visible = false;
    } else {
      await stage.wait(400);
      await Promise.all([a.shake(), b.shake()]);
      await Promise.all([a.flip(), b.flip()]);
    }
  }
  busy = false;
}

// A fair shuffle. Do not be tempted by `list.sort(() => Math.random() - 0.5)`: it looks
// too neat and it does not shuffle evenly.
function shuffle(list) {
  const out = [...list];
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [out[i], out[j]] = [out[j], out[i]];
  }
  return out;
}

The busy flag is worth noting here. Without it a fast player can tap a third card while two are still being judged, and your game ends up counting cards that weren't actually displayed!

Why it works this way

Your game runs in its own protected box, separate from the portal, and asks the portal for Info (a card's art, who is playing) via the SDK. @cue/2d is a library that runs inside the box with the game. Like every CUE game it only runs inside the portal.