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
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
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.
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:
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.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 notMoving a card
Slides the card to a spot. By default it overshoots a touch and settles, to make it look placed rather than teleported.
Turns the card over. But you already figured that out.
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.
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.
Shifting refraction across the artwork, like a holographic physical card. null clears it. (Different from the foil state, which is the sheen effect.)
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.
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.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.
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
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.
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.
One of your own images, uploaded in the Assets panel. color tints it. Pass sheet and it
can animate (see Sprites and animation).
Fills the whole stage, behind everything else. Give it a colour or an image name.
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.shadow({ x?, y?, color?, strength? })These give a halo OUTSIDE the edges, or a drop shadow.
Think Instagram circa 2010: 'sepia', 'night', 'vintage', 'technicolor', 'polaroid',
'kodachrome', 'brown', 'negative', 'monochrome', 'psychedelic'.
thing.pixelate(size)Soften a 'thing', or go for the retro gamer look.
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.
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.
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);
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' |
A continuous effect that follows a card or thing as it moves. Stop it with the handle.
Shakes the whole view. Use sparingly!
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 });
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))) });
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.
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
});
}
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.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.
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.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
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 tiltsTap 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.
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.)
fadeIn(...) / flash(ms?, color?)Fades resolve when done, so a scene change is await fadeOut(); rebuild(); await fadeIn();.
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
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 onLayout and blending
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
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');
Draw it only where shape is. Pass null to clear.
A clock, or anything per frame
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.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.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.
