Make a 3D game
Want a blocky little character to run around grabbing CUE cards? Who doesn't, right? Well @cue/3d lets you build that. You place blocks, cards, and zones, and it handles the character, the camera, the physics, and the drawing for you.
The idea
You build a world out of a few simple pieces and react to what happens in it. You do not write a camera, a physics engine, or a character (those come for free, for better or for worse). Your job is to place things and say what happens when the player touches them.
// The whole surface at a glance
import { createCueClient } from '@cue/sdk';
import { createWorld } from '@cue/3d';
const cue = createCueClient({ gameId: 'my-game' });
await cue.init();
const world = await createWorld({ cue, canvas }); // needs a <canvas> on your page
world.addPart({ size: [10, 1, 10], position: [0, 0, 0] }); // a block to stand on
world.addCard('EAG001', { position: [2, 2, 0] }); // a real card you can carry
const zone = world.addZone({ position: [0, -5, 0], size: [50, 1, 50], kills: true });
const me = world.spawnPlayer(); // your character, with a camera
me.on('death', () => me.respawn());
world.start(); // goBoth @cue/sdk and @cue/3d are allowed imports. You do not install them, and you never
import three.js or a physics library yourself. Just these two.
Set up the page
Your index.html needs a <canvas> for the 3D view and loads your main.js:
<link rel="stylesheet" href="style.css" />
<canvas id="scene"></canvas>
<script type="module" src="./main.js"></script>Make the canvas fill the screen in style.css:
html, body { margin: 0; height: 100%; overflow: hidden; }
#scene { width: 100vw; height: 100vh; display: block; }Start a world
Creates the world and its camera on your canvas. Pass the cue client you already made,
and the canvas element. gravity is optional and defaults to -20 (bigger negative =
heavier). This is async, so await it.
const world = await createWorld({
cue,
canvas: document.querySelector('#scene'),
gravity: -22,
});Positions and sizes are three numbers, [x, y, z], in blocks. y is up. A part's
position is its centre, so a floor 10 tall sitting at y: -0.5 has its top surface at
y: 0. Nothing moves until you call world.start().
Blocks
A box. size is [width, height, depth]. anchored is true by default, meaning it
never moves (floors, walls, platforms). Set anchored: false for a box that falls and gets
pushed around. color is any CSS colour. Set carriable: true to make a box the player can
pick up and carry, exactly the way they carry a card.
world.addPart({ size: [16, 1, 16], position: [0, -0.5, 0], color: '#3f6212' }); // ground
world.addPart({ size: [3, 1, 3], position: [0, -0.5, -8], color: '#57534e' }); // a platform to jump to
Change any part's colour while the game runs, for example turning a slot from orange to green when it's filled. Works on anything you added (parts, zones, cards).
Moving platforms
This is a block that moves over time. at(seconds) returns where it is, [x, y, z], for a given number of seconds since the game started. Three more optional functions rotate it, each
returning radians for that same time: yaw(seconds) spins it about the up axis,
pitch(seconds) tips it forward and back (about its own x axis), and roll(seconds) tips
it side to side (about its own z axis). Keep them all pure functions of the time you get
(work only from that time value, don't read anything else that changes), so the motion is
the same every run. The part starts wherever the functions put it at time 0. If you combine
rotations, yaw turns the part first, then pitch and roll tip it about its turned axes.
A player who stands on a moving part rides it: they stay locked to it and can still walk and
jump normally, and they keep its speed when they jump off. A tilting top behaves like a
slope: walkable up to the player's slopeLimitDeg (default 50 degrees, about 0.9 radians),
and steeper than that they slide off, which is the whole fun of a seesaw. The same one call
covers every kind of mover:
// A platform that slides side to side (a stepping stone across a gap).
world.addMovingPart({ size: [3, 1, 3], color: '#57534e', at: (t) => [Math.sin(t) * 3, 0, -6] });
// A lift that goes up and down (riders stay glued to it, no sinking).
world.addMovingPart({ size: [4, 1, 4], color: '#78716c', at: (t) => [0, 2 + Math.sin(t) * 2, 0] });
// A conveyor that runs one way forever.
world.addMovingPart({ size: [12, 1, 3], color: '#57534e', at: (t) => [t * 2, 0, 0] });
// A turntable that spins in place: riders orbit the centre and turn with it.
world.addMovingPart({ size: [6, 1, 6], color: '#57534e', at: () => [0, 0, 0], yaw: (t) => t * 0.8 });
// A seesaw plank that tips. Push the swing past slopeLimitDeg to dump whoever is on it.
world.addMovingPart({ size: [4, 0.6, 10], color: '#b45309', at: () => [0, 1, -20], pitch: (t) => 0.35 * Math.sin(t) });
// A windmill: a long blade sweeping the vertical plane. Stand clear or be knocked aside.
world.addMovingPart({ size: [9, 0.8, 0.8], color: '#dc2626', at: () => [0, 5, -30], roll: (t) => t * 1.2 });
// A rolling log: spin plus drift.
world.addMovingPart({ size: [1.2, 1.2, 8], color: '#92400e', at: (t) => [Math.sin(t * 0.5) * 4, 0.6, -40], roll: (t) => t * 2 });Moving parts are solid: one that moves or turns into a player shoves them out of its way, so sliding walls, pistons and swinging bars all make real hazards. Pair them with kill zones and you have an obstacle course. One rule to know: a mover cannot squash a player through a wall or the floor. A player trapped between a mover and something solid holds their ground and the mover slips past them, so where a crusher should be deadly, aim the squeeze at a kill zone instead.
Keep a platform's motion horizontal if a rider must never be carried into a kill zone: a mover carries its rider up and down too, tilting included. Loose objects resting on a moving part (a dropped card) are not carried, only the player is.
Zones
A zone is an invisible box that tells you when something enters or leaves it. Use zones for danger (a river, lava, a pit) and for goals (a drop-off pad).
zone.on('exit', thing => ...)If kills is true, any player who enters is killed automatically (you do not have to
check for it). on('enter') gives you the thing that crossed in, so you can react.
const river = world.addZone({ position: [0, -4, 0], size: [50, 2, 50], kills: true });
const goal = world.addZone({ position: [10, 0, 0], size: [3, 3, 3] });
goal.on('enter', (thing) => {
console.log('something reached the goal');
});Cards
Drops a real CUE card into the world as a solid object the player can pick up and carry.
Give it any card id (from cue.cards.query() or cue.player.collection()); the art loads
onto the card for you. You never handle the picture yourself. yaw (radians) is which way
the front faces; 0 faces +z. In a two-sided level, alternate it so both players can read
the cards. back (optional) puts art on the card's back face too; pass a player's
cardBackUrl from cue.player.me() (or a seat's cardBacks entry in a multiplayer
match, below) so cards on their side wear their chosen back.
const catalog = await cue.cards.query({});
catalog.slice(0, 5).forEach((c, i) => world.addCard(c.id, { position: [i * 2 - 4, 2, 5] }));The player picks up the nearest card (or any carriable block) with E, and drops it
with E again. A carried card rides on the avatar's back, facing outward, so from the
chase camera behind the player you see its front. It turns with the avatar. A carried thing
still collides with the world and other players, just not with the person carrying it.
Lock a card at a fixed spot, for example when it's delivered into a slot. It snaps to
position, stops being pickable, and no longer reacts to physics (its card.placed
becomes true). yaw (radians) sets which way it faces; 0 faces the same way a fresh
card does.
A common pattern: a delivery pad is a visible part plus an invisible trigger zone, and you deliver by running over it while carrying a card.
const pad = world.addPart({ size: [1.5, 0.1, 1.5], position: [x, 0.05, z], color: '#a16207' });
const slot = world.addZone({ position: [x, 0.9, z], size: [1.6, 1.8, 1.6] });
slot.on('enter', (who) => {
if (who !== me) return; // only react to the player
const card = me.carryingCard; // are they carrying one?
if (!card) return;
pad.setColor('#16a34a'); // orange -> green
world.placeCard(card, { position: [x, 0.6, z] }); // lock it centered in the pad
});
Events are typed: on('death', ...) gives a no-argument callback, on('pickup'/'drop', card => ...)
and on('enter'/'exit', entity => ...) give the thing involved. A mistyped event name is a
compile error, and on() returns a function that removes the listener.
Floating labels
label.setColor(color)Floating text that always turns to face the camera, for a score over a base or a sign
over a door. size is the text height in blocks (default 1), so make it bigger to be
readable from far away. Change it any time with setText.
Pass readable: true and the label never gets too small to read: it shrinks with
distance as normal up close, but from about 10 blocks away it holds its apparent size
however far the player goes. A readable label grows upward from its position, so place
it at the top of the thing it sits over rather than centred on it.
const board = world.addLabel({ text: 'Ada · 0', position: [0, 5, 25], size: 1.6, readable: true });
// later, when the score changes:
board.setText(`Ada · ${score}`);The player
Adds the character you control: a blocky avatar with a follow camera, already wired to the
keyboard and mouse. Pass the player's own avatar so it looks like them:
spawnPlayer({ avatar: player.avatar3d }) (you get player back from cue.init()).
spawn is where they start, default [0, 2, 0]. facing is which way they (and the
camera) start facing, in radians: 0 looks along -z, Math.PI along +z, so a player who
spawns on the far side of your level can start looking at the action.
view picks the camera: 'third' (the default) watches from behind them, 'first' puts the
camera at their eyes. See First person below.
You tune how the character moves, you never rewrite it: moveSpeed (units per second, default
6), jumpSpeed (higher jumps higher, default 9), stepHeight (the tallest ledge it climbs
without jumping, default 0.5), and slopeLimitDeg (the steepest slope it can walk, default 50;
anything steeper, it slides down).
The controls are fixed and are part of the package:
| Key / action | Does |
|---|---|
| W A S D | Move (relative to the camera) |
| Space | Jump |
| E | Pick up the nearest card, or drop the one you carry |
| Click the scene | Capture the mouse so you can look around; Esc releases it |
| Mouse | Look around, once captured. In first person you can look (nearly) straight up and down |
Your character emits events you can listen to:
player.on('death', () => ...) fires when they fall in a kill zone or off the world.
player.on('pickup', card => ...) and player.on('drop', card => ...) fire when they pick up or drop a card.
player.respawn(point?) sends them back to their start, or to a point you pass.
const { player } = await cue.init();
const me = world.spawnPlayer({ avatar: player.avatar3d, spawn: [0, 2, 6] });
me.on('death', () => me.respawn());
me.on('drop', (card) => {
// e.g. did they drop it on the goal? check card.position against your goal
});First person
By default the camera watches your player from behind. Switch it to first person and it sits at their eyes instead, so they see the world the way they would really see it.
const me = world.spawnPlayer({ avatar: player.avatar3d, view: 'first' });
Swaps the camera over at any time, even mid-jump. world.view tells you which one is in use.
Let players choose, if you like. Nothing in the engine claims a key for it, so pick your own:
addEventListener('keydown', (e) => {
if (e.code === 'KeyV') world.setView(world.view === 'first' ? 'third' : 'first');
});Three things change when you go first person:
- You can look nearly straight up and down. The chase camera only tilts a little, because it swings around the player.
- Their body turns to face wherever they are looking. Walking sideways now looks like walking sideways to everyone else in the game, instead of like turning to walk that way.
- Picking things up needs you to be looking at them.
Estill takes the nearest thing in reach, but only from the ones roughly in front of you. Standing next to a card with your back to it will not pick it up, which is what a player expects when they can see where they aim.
There is no crosshair unless you draw one.
First person only works while the mouse is captured, because that is the only way the browser lets you keep looking in one direction.
Your player cannot see their own carried card in first person, because it rides on their back
where everyone else can see it. If it matters which card they are holding, put it in your HUD
using the pickup and drop events.
me.on('pickup', (card) => { held.textContent = `Holding ${card.id}`; });
me.on('drop', () => { held.textContent = ''; });Pointing at things
Often you want to know what the player is aiming at: the block under their crosshair, the card they are about to grab, whether a door is in front of them. Ask.
The first thing in the player's line of sight, or null if there is nothing that close. You get
back { entity, point, distance, normal }: entity is the block, card or player you hit,
point is where on it the line landed, distance is how many blocks away that was, and
normal is which way that surface faces (handy for placing something flat against it).
It measures from the player's eyes, so it works in both cameras.
// Highlight whatever they are looking at, within reach
setInterval(() => {
const hit = world.lookingAt(4);
prompt.textContent = hit ? `Press E to take it` : '';
}, 100);
The same question from anywhere, not just the player's eyes. from is [x, y, z] and
direction is which way to look, as [x, y, z]. It does not have to be a neat length-one
direction; maxDistance always means blocks. Pass ignore to skip one thing, which you want
when the line starts inside it.
Use it for a turret's line of fire, or to check whether one thing can see another:
const canSee = (a, b) => {
const dir = [b.position[0] - a.position[0], b.position[1] - a.position[1], b.position[2] - a.position[2]];
return world.raycast(a.position, dir, 100, { ignore: a })?.entity === b;
};
Zones and labels are never in the way. They are not solid things, so a line passes straight through them and reports the real object behind.
Run it
world.stop()start() begins the loop that steps the physics and draws each frame. Call it once, after
you have built your level.
Keeping it smooth
The engine is pretty happy with a big world - so roughly a thousand static blocks is fine, because anything that never moves costs almost nothing once it is placed. What you pay for is things that move or load:
| Cheap | Costs you |
|---|---|
| Blocks, zones and labels that stay put | Moving parts (each one is worked out every frame) |
| More seats in the lobby | Cards (each one downloads its own picture) |
So: build as much scenery as you like, but keep the moving parts and the cards to the ones your game actually uses, and size the world to the number of players rather than to the maximum your game allows.
If you overload a frame, the world shouldn't stutter, but it will likely go into very slow motion. It can looks exactly like a bad connection, but isn't, so if things feel sluggish then check the size of your world first. The give-away is that it is slow for everyone, including whoever has the best internet. :)
A HUD over the top
A HUD (heads-up display) is the score, timer or menu you lay over the game. The 3D view is a normal <canvas>, so you can put ordinary HTML on top of it for a score,
a timer, or a menu. Position it over the canvas and it just works. A tip: while the mouse is
captured for looking, buttons are not clickable, so show menus when the game is not in
look mode.
Playing together
You can make a game that several people share, from two players up to the Max players you
set on your game's Details tab (up to 32). Use cue.match.lobby() (see the Multiplayer section
of the API reference) to show a ready-made lobby and get a room once a
match starts, then hand that room to the world:
Connects a match room and keeps everyone's worlds agreeing. Every other player's character
shows up in your world and moves in real time, and cards are shared: when one player picks
up, drops, or delivers a card, everyone sees it happen. If two people grab the same card at the
same moment, one of them wins it and the other's game hears a normal drop event, so a
race for the best card just works.
The handle it returns has started, a promise that resolves with { seat, roster, seed }
when the match begins: seat is your seat number (0 for the host) and roster is the name in
each seat. Use the seat to pick your side of the level. It rejects if the room closes before the
match starts, so put the await in a try/catch. close() disconnects.
roster.length is not your player count
This one catches people out. roster has one entry per SEAT, not one per player. A game
that allows up to 32 gets a 32-long roster every time, with null wherever nobody is sitting:
// Three people playing a game that allows 32:
roster = ["Ellie", "Sam", "Ada", null, null, ... ] // length 32, three real namesBuild your level from roster.length and you make a world for 32 when three turned up. It looks
fine and it runs horribly. Count the names instead:
const { seat, roster, seed } = await net.started;
const players = roster.filter(Boolean).length; // <- the real number
buildMyLevel(players, seed);Keep your own per-player things keyed by seat number, though, because that is what messages
carry (m.from) and what roster is keyed by.
When someone leaves
People quit, and their internet drops. Yeah it's disappointing, but here's how you deal with it:
- One player goes, the rest play on. You get
room.on('seat-left', m => ...)with their seat number. Their character disappears from the world for you automatically. Take them off your scoreboard and carry on. - Too many go and there is no match left. Once the room falls below the Min players you
set on Details, the portal ends the match and everyone gets
room.on('ended', ...). Show your result screen. This is a normal ending, not an error, and it is not the same asroom.on('disconnected', ...), which means your own connection dropped. Gulp.
match.room.on('seat-left', (m) => {
scores.delete(m.seat); // they are gone; the world already removed their character
refreshScoreboard();
});
match.room.on('ended', () => {
world.stop();
showResults('Not enough players left.');
});The match seed, and how everyone looks
seed is the match seed: a random number made by the server, the SAME for every player in
this match and different next match. Every client must build an identical world, so any
randomness in your level (which cards spawn, layouts) should come from this seed through a
seeded random function, never from Math.random():
const { seat, roster, seed } = await net.started;
let s = seed;
const rand = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); // 0..1, same on every client
const pick = catalog[Math.floor(rand() * catalog.length)]; // everyone picks the same cardstarted also tells you how each player likes to look, in seat order: avatarUrls
(their little portrait, nice on a scoreboard), cardBacks and sleeves (art URLs).
A null entry means that player just uses the normal look. The portal fills these in
from each player's own account, so nobody can wear someone else's. Cards in a shared
world don't belong to anyone, so it's your call whether one shows a player's back:
pass it when you create the card, like world.addCard(id, { back: started.cardBacks[seat] ?? undefined }) for cards on that player's side.
const match = await cue.match.lobby(); // the ready-made lobby handles Play, codes, waiting
if (!match) return showMyMenu(); // the player closed the lobby without starting
const net = world.connectNet(match.room);
try {
const { seat, roster, seed } = await net.started; // the match has begun
const players = roster.filter(Boolean).length; // how many actually turned up
buildMyLevel(players, seed); // a world sized to them, the same on every client
const me = world.spawnPlayer({ avatar: player.avatar3d, spawn: spawnPoints[seat] });
world.start();
} catch (err) {
// the match ended before it got going; show your menu again
}Your own messages still work the same way: anything you room.send() reaches every other
player as a peer-action (a score update, a rematch offer). Four rules keep the sharing
working:
- Don't attach your own
room.on('start')listener; usenet.startedinstead. - The send names
stateand anything starting withcard:belong to the engine. Pick other names for your own messages. room.state()belongs to the engine too. That is how it streams everyone's position, and the portal keeps only the newest one per player, so calling it yourself would throw your own character's position away. In a 3D game you never need it: positions are already handled. Useroom.send()for your own messages.- Never
remove()a card in a connected game. Send it home withresetCardor lock it down withplaceCardinstead, so every world keeps the same card list.
Each player's game runs its own copy of the world and trusts the other, which is right for
playing together and friendly competition. It is not built to stop a modified game from
cheating, so keep anything with a prize on the line out of it. Blocks made carriable with
addPart({ carriable: true }) are not shared yet, only cards are.
Why it works this way
Your game runs in its own protected box, separate from the portal, and asks the portal for
anything real (a card's art, who is playing, a reward) through the SDK. @cue/3d is just a
library that runs inside that box with you. It cannot mint cards or currency any more than
your own code can. The one thing to remember: like every CUE game, it only runs inside the
portal (use the Test button), because it needs the portal on the other side to answer.
