A tiny platform game
A character on a meadow, a few stacks of crates and fifteen coins, some of them only reachable by jumping. Collect them all and the game shows your time. This tutorial covers the character controller, standing on objects, pickups and a score drawn in plain HTML over the canvas.
The whole program
import { createApp, createCharacter, createGround, createCoin, createCrate, createTree, CharacterController, spin, bob, createRandom } from "wgpu3d";
const app = await createApp({ canvas: document.querySelector("#coin-demo"), environment: "noon" });
const scoreLabel = document.querySelector("#coin-score");
const random = createRandom(3);
app.add(createGround({ size: 90, tiles: 18, color: "#7cb35b", alternateColor: "#72a853" }));
const coinSpots = [];
for (let tower = 0; tower < 5; tower++) {
const angle = (tower / 5) * Math.PI * 2;
const x = Math.cos(angle) * 9, z = Math.sin(angle) * 9;
const levels = tower % 3 + 1;
for (let level = 0; level < levels; level++) {
app.add(createCrate({ size: 1.4, position: [x + level * 1.4, 0, z], yaw: angle }));
for (let stacked = 1; stacked <= level; stacked++) {
app.add(createCrate({ size: 1.4, position: [x + level * 1.4, stacked * 1.4, z], yaw: angle }));
}
}
coinSpots.push([x + (levels - 1) * 1.4, levels * 1.4 + 0.9, z]);
}
for (let index = 0; index < 10; index++) {
const angle = random() * Math.PI * 2, distance = random.range(3, 16);
coinSpots.push([Math.cos(angle) * distance, 0.9, Math.sin(angle) * distance]);
}
for (let index = 0; index < 18; index++) {
const angle = random() * Math.PI * 2, distance = random.range(22, 40);
app.add(createTree({ seed: index, height: random.range(4, 7), position: [Math.cos(angle) * distance, 0, Math.sin(angle) * distance] }));
}
const coins = coinSpots.map((position) => {
const coin = createCoin({ position });
coin.addBehavior(spin(2.5));
coin.addBehavior(bob({ height: 0.15, speed: 3 }));
return coin;
});
app.add(...coins);
const hero = createCharacter({ shirt: "#e8553d" });
hero.addBehavior(new CharacterController());
app.add(hero);
app.orbit({ subject: hero, distance: 10, pitch: 0.55, minDistance: 4, maxDistance: 20 });
let collected = 0;
let finishTime = null;
app.onUpdate(({ scene, timeSeconds }) => {
for (const coin of scene.touching(hero, { tag: "coin", radius: 0.7 })) {
coin.removeFromParent();
collected += 1;
}
if (collected === coins.length && finishTime === null) finishTime = timeSeconds;
scoreLabel.textContent = finishTime === null
? `Coins ${collected} / ${coins.length}`
: `All ${coins.length} coins in ${finishTime.toFixed(1)} s`;
});
app.start();
Building the level
Five stacks of crates stand in a ring, each a little staircase one, two or three crates high, built with nested loops. A coin floats above the top of every stack, and ten more are scattered on the grass. Their positions are collected in coinSpots first and turned into coins afterwards, which keeps the level layout separate from what lives in it. Trees around the edge frame the play area.
Crates have box colliders, so the character cannot walk through them. More interesting is that the character can stand on them, which is what makes the stacks climbable.
The character controller
createCharacter builds a blocky figure whose arms and legs are separate child objects pivoting at the shoulders and hips. CharacterController is the behavior that plays it:
- Movement is relative to the camera. Pressing forward walks away from the camera wherever it is pointing, which is what players expect in a third-person game.
- Speed eases in and out rather than snapping, and the figure turns smoothly to face where it is going.
- Jumping gives an upward speed that gravity takes away. In the air you keep a third of your steering, enough to correct a jump but not to fly.
- Every frame it looks for the highest box top under its feet. If that top is no more than
stepHeightabove the feet, it becomes the floor. That one rule handles landing on crates, walking off their edges and stepping onto low kerbs. - While walking, legs and arms swing in opposite pairs at a rate tied to ground speed; in the air the legs tuck up.
Pickups
Coins are prefabs tagged "coin" with a sphere collider that does not block movement. Two stacked behaviors, spin and bob, make them easy to spot. The game logic lives in app.onUpdate, which runs once a frame after every behavior:
scene.touching(hero, { tag: "coin", radius: 0.7 })returns every coin whose collider overlaps a circle around the hero and lies within reach vertically, so a coin above a crate is only collected from on top of it.- A collected coin is simply removed from the scene with
removeFromParent(). - When the count reaches the total, the time is stored once and the label changes.
The score itself is an ordinary <output> element positioned over the canvas. HTML is good at text, so there is no need to draw it in 3D.
A following orbit camera
app.orbit({ subject: hero }) orbits the moving character instead of a fixed point. You can drag to look around while you walk, and because movement follows the camera, turning the view is also how you steer. On touch screens one finger moves the character, so the camera keeps to its last angle.
Things to try
- Raise
jumpSpeedto 9 in the controller options and stack the crates higher for a harder course. - Give each coin a sound with the Web Audio API, or tint the hero gold for a moment with
hero.tint. - Put a
VehicleControllercar in the level and let the player switch between walking and driving.