Your first game
A complete 3D game: physics, a character, shadows, sound, a HUD, and its rules in DriftScript.
Starts examples/first-game in this page, on WebGPU where your browser has it.
Your first game is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /first-game/.
Source
main.ts
examples/first-game/main.ts
/**
* A complete small 3D game: walk an arena, push crates out of the way, and gather every orb
* before the clock runs out.
*
* The manual's "Your first game" chapter is built from this file, one region at a time. It uses
* only what a consumer installs: `@driftengine/core` for the renderer, the loop, input and physics,
* `@driftengine/audio` for one sound that is synthesised, so the game needs no asset at all, and
* DriftScript for the round's rules, which live in `round.drs` and reload while the game runs.
*/
import { AudioGraph, toneBuffer } from '@driftengine/audio';
import {
ActionMap,
BODY_DYNAMIC,
BODY_STATIC,
Camera,
CharacterController,
DEFAULT_TEXT_STYLE,
InputSource,
MeshBuilder,
PhysicsWorld,
SceneNode,
TouchControls,
boxShape,
computeLightMatrix,
createEnvironment,
createRenderer,
damp,
lerp,
mulberry32,
startLoop,
} from '@driftengine/core';
import type { ShadowCasters, Vec3 } from '@driftengine/core';
import { bindModule } from '@driftengine/script';
import { loadModule, patchModule } from 'driftscript';
import * as roundScript from './round.drs';
// #region rules
/** Half the arena's width, in metres. */
const ARENA = 12;
const ORB_COUNT = 8;
/** The same seed lays out the same rounds, in the same order, on every machine. */
const SEED = 2026;
// #endregion
// #region renderer
const canvas = document.querySelector<HTMLCanvasElement>('#stage');
if (canvas === null) throw new Error('the page must carry <canvas id="stage">');
const { renderer, backend } = await createRenderer(canvas, {
maxDevicePixelRatio: 1.75,
directionalShadows: true,
});
const readout = document.querySelector('#backend');
if (readout !== null) readout.textContent = backend;
renderer.resize();
addEventListener('resize', () => renderer.resize());
// #endregion
// #region environment
const SKY: Vec3 = [0.56, 0.68, 0.84];
const ENV = createEnvironment({
directionalDir: [0.35, 0.8, 0.45],
directionalColor: [1, 0.95, 0.86],
ambient: [0.34, 0.38, 0.46],
ambientGround: [0.14, 0.12, 0.1],
// The emissive master switch. At 0 nothing glows, whatever emissiveGain says.
nightFactor: 1,
emissiveGain: 1.6,
fogColor: SKY,
fogDensity: 0.01,
fogHeightFalloff: 0.06,
fogBaseY: 0,
});
// #endregion
// #region level
const world = new PhysicsWorld({ substeps: 4 });
const level = new MeshBuilder();
const footprints: { x: number; z: number; hx: number; hz: number }[] = [];
/** One box, in the picture and in the physics from the same numbers, so the two cannot disagree. */
function solid(
x: number,
y: number,
z: number,
hx: number,
hy: number,
hz: number,
color: Vec3,
): void {
level.addBox([x, y, z], [hx, hy, hz], color);
world.addBody({ type: BODY_STATIC, shape: boxShape(hx, hy, hz), x, y, z, friction: 0.8 });
footprints.push({ x, z, hx, hz });
}
const GRASS: Vec3 = [0.4, 0.5, 0.36];
const WALL: Vec3 = [0.62, 0.6, 0.56];
const STONE: Vec3 = [0.5, 0.52, 0.58];
solid(0, -0.5, 0, ARENA, 0.5, ARENA, GRASS);
for (const side of [-1, 1]) {
solid(side * (ARENA + 0.5), 0.75, 0, 0.5, 0.75, ARENA + 1, WALL);
solid(0, 0.75, side * (ARENA + 0.5), ARENA, 0.75, 0.5, WALL);
}
// Two tiers, each low enough to jump onto, with an orb waiting on top.
solid(-6, 0.4, -6, 2.6, 0.4, 2.6, STONE);
solid(-6, 1.2, -6, 1.4, 0.4, 1.4, STONE);
solid(6, 1.5, -5, 0.6, 1.5, 0.6, STONE);
solid(5, 1.5, 6, 0.6, 1.5, 0.6, STONE);
const levelMesh = renderer.createMesh(level.build());
const PLATFORM_TOP = 1.6;
// #endregion
// #region crates
const CRATE = 0.5;
const crateMesh = renderer.createMesh(
new MeshBuilder().addBox([0, 0, 0], [CRATE, CRATE, CRATE], [0.74, 0.52, 0.3]).build(),
);
const crates: number[] = [];
for (const [x, z] of [
[2, -1],
[3.1, -1.2],
[2.5, 0.1],
[-3, 5],
]) {
crates.push(
world.addBody({
type: BODY_DYNAMIC,
shape: boxShape(CRATE, CRATE, CRATE),
x,
y: CRATE,
z,
friction: 0.6,
}),
);
}
/** Position and rotation per crate, before and after the last step, for drawing between them. */
const crateBefore = new Float32Array(crates.length * 7);
const crateAfter = new Float32Array(crates.length * 7);
function recordCrates(out: Float32Array): void {
const b = world.bodies;
for (let i = 0; i < crates.length; i += 1) {
const body = crates[i];
const o = i * 7;
out[o] = b.posX[body];
out[o + 1] = b.posY[body];
out[o + 2] = b.posZ[body];
out[o + 3] = b.rotX[body];
out[o + 4] = b.rotY[body];
out[o + 5] = b.rotZ[body];
out[o + 6] = b.rotW[body];
}
}
// #endregion
// #region player
const player = new CharacterController({ maxSpeed: 7, jumpSpeed: 7, pushStrength: 2 });
const START: Vec3 = [0, 1.2, 7];
const playerMesh = renderer.createMesh(
new MeshBuilder()
.addCapsule([0, 0, 0], player.radius, player.halfHeight, [0.92, 0.88, 0.82])
.build(),
);
/** Where the player was before the last step, for the same reason the crates keep one. */
const playerBefore = { x: 0, y: 0, z: 0 };
// #endregion
// #region orbs
interface Orb {
x: number;
y: number;
z: number;
taken: boolean;
}
const random = mulberry32(SEED);
const orbs: Orb[] = [];
const orbMesh = renderer.createMesh(
new MeshBuilder().addSphere([0, 0, 0], 0.32, [1, 0.78, 0.36], 1).build(),
);
function blocked(x: number, z: number): boolean {
return footprints.some(
(f) => f.hx < ARENA && Math.abs(x - f.x) < f.hx + 0.8 && Math.abs(z - f.z) < f.hz + 0.8,
);
}
/** Lay out a round. Called between rounds, never per frame, so building the list is fine here. */
function scatter(): void {
orbs.length = 0;
while (orbs.length < ORB_COUNT - 1) {
const x = (random() * 2 - 1) * (ARENA - 1.5);
const z = (random() * 2 - 1) * (ARENA - 1.5);
if (!blocked(x, z)) orbs.push({ x, y: 0.9, z, taken: false });
}
orbs.push({ x: -6, y: PLATFORM_TOP + 0.9, z: -6, taken: false });
}
// #endregion
// #region input
const input = new InputSource(canvas, ['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const actions = new ActionMap(input, {
move: {
stick: 'left',
up: ['KeyW', 'ArrowUp'],
down: ['KeyS', 'ArrowDown'],
left: ['KeyA', 'ArrowLeft'],
right: ['KeyD', 'ArrowRight'],
},
jump: { keys: ['Space'], buttons: ['faceDown'] },
restart: { keys: ['KeyR', 'Enter'], buttons: ['start'] },
pause: { keys: ['KeyP', 'Escape'], buttons: ['select'] },
});
/** A thumb stick on the left of a touch screen, and a tap or hold on the right. */
const touch = new TouchControls(input);
const move = { x: 0, y: 0 };
// #endregion
// #region sound
// A browser lets a page make sound only after a gesture, so the graph is made on the first press.
let sound: AudioGraph | null = null;
let soundAsked = false;
async function startSound(): Promise<void> {
if (soundAsked) return;
soundAsked = true;
const graph = await AudioGraph.create({ stemCount: 0 });
if (graph === null) return;
graph.registry.register('orb', {
urls: [],
synth: (ctx) => toneBuffer(ctx, 0.2, 660, 1320, 3),
});
sound = graph;
await graph.registry.load(graph.context);
}
addEventListener('pointerdown', () => void startSound());
addEventListener('keydown', () => void startSound());
function chime(): void {
sound?.play(sound.registry.get('orb'), 0.45);
}
// #endregion
// #region script
/*
* The round's rules are a DriftScript module. Loading it builds the module, binding it hands it
* the engine capabilities it imports, which here is none, and `createRound` makes the record it
* keeps the round in. The page owns that record and passes it to every rule.
*/
const roundModule = loadModule(roundScript as Record<string, unknown>);
const bound = bindModule(roundModule, {});
if (!bound.bound) throw new Error(bound.reason);
/* A variant reaches the page as its tag: `round.phase.tag` is `'Playing'`, `'Won'` or `'Lost'`. */
interface Round {
remaining: number;
gathered: number;
total: number;
phase: { tag: 'Playing' | 'Won' | 'Lost' };
}
/* Read through the module each call, so a rule patched by a save is the rule that runs. */
const rules = roundModule.exports as unknown as {
createRound(): Round;
start(round: Round, total: number): void;
reaches(dx: number, dy: number, dz: number): boolean;
gather(round: Round): void;
tick(round: Round, dt: number): void;
};
const round = rules.createRound();
/* Saving `round.drs` replaces its functions and keeps `round` as it was: the clock carries on. */
if (import.meta.hot) {
import.meta.hot.accept('./round.drs', (next) => {
if (next !== undefined)
patchModule(roundModule, next as Record<string, unknown>, { Round: [round] });
});
}
// #endregion
// #region round
let paused = false;
function newRound(): void {
scatter();
rules.start(round, orbs.length);
player.teleport(START[0], START[1], START[2]);
playerBefore.x = player.x;
playerBefore.y = player.y;
playerBefore.z = player.z;
}
// #endregion
// #region simulate
function simulate(dt: number): void {
if (round.phase.tag !== 'Playing') {
if (actions.consumePress('restart') || touch.consumePrimaryPress()) newRound();
return;
}
// Keys and the pad follow the stick convention, where up is negative. The touch stick reports
// up as positive, so it is flipped here, once.
actions.vector('move', move);
if (touch.moveX !== 0 || touch.moveY !== 0) {
move.x = touch.moveX;
move.y = -touch.moveY;
}
playerBefore.x = player.x;
playerBefore.y = player.y;
playerBefore.z = player.z;
player.move(world, dt, {
moveX: move.x * player.maxSpeed,
moveZ: move.y * player.maxSpeed,
jump: actions.down('jump') || touch.primaryHeld,
});
recordCrates(crateBefore);
world.step(dt);
recordCrates(crateAfter);
for (let i = 0; i < orbs.length; i += 1) {
const orb = orbs[i];
if (orb.taken) continue;
const dx = orb.x - player.x;
const dy = orb.y - player.y;
const dz = orb.z - player.z;
if (rules.reaches(dx, dy, dz)) {
orb.taken = true;
rules.gather(round);
chime();
}
}
if (player.y < -10) player.teleport(START[0], START[1], START[2]);
rules.tick(round, dt);
}
// #endregion
// #region camera
const camera = new Camera();
camera.fovYDeg = 55;
const eye = { x: START[0], y: START[1] + 6, z: START[2] + 9 };
/** Ease toward a spot behind and above the player. Presentation only, so it runs on frame time. */
function follow(x: number, y: number, z: number, frameDt: number): void {
eye.x = damp(eye.x, x, 5, frameDt);
eye.y = damp(eye.y, y + 6, 5, frameDt);
eye.z = damp(eye.z, z + 9, 5, frameDt);
camera.position[0] = eye.x;
camera.position[1] = eye.y;
camera.position[2] = eye.z;
camera.lookAt(x, y + 0.6, z);
camera.updateMatrices(renderer.cssHeight > 0 ? renderer.cssWidth / renderer.cssHeight : 1);
}
// #endregion
// #region transforms
/** One node per thing that moves, built once, so a frame writes matrices and allocates nothing. */
const still = new SceneNode();
still.updateWorld();
const playerNode = new SceneNode();
const crateNodes = crates.map(() => new SceneNode());
const orbNodes = Array.from({ length: ORB_COUNT }, () => new SceneNode());
let clock = 0;
function placeEverything(alpha: number): void {
playerNode.setPosition(
lerp(playerBefore.x, player.x, alpha),
lerp(playerBefore.y, player.y, alpha),
lerp(playerBefore.z, player.z, alpha),
);
playerNode.updateWorld();
for (let i = 0; i < crates.length; i += 1) placeCrate(crateNodes[i], i, alpha);
for (let i = 0; i < orbs.length; i += 1) {
const orb = orbs[i];
orbNodes[i].setPosition(orb.x, orb.y + Math.sin(clock * 2.4 + i) * 0.12, orb.z);
orbNodes[i].setRotationAxisAngle(0, 1, 0, clock * 1.5 + i);
orbNodes[i].updateWorld();
}
}
/** Position by lerp and rotation by normalised lerp. A step turns a crate very little, so that is enough. */
function placeCrate(node: SceneNode, i: number, alpha: number): void {
const a = crateBefore;
const b = crateAfter;
const o = i * 7;
// q and -q are the same rotation; flip one so the blend goes the short way round.
const dot = a[o + 3] * b[o + 3] + a[o + 4] * b[o + 4] + a[o + 5] * b[o + 5] + a[o + 6] * b[o + 6];
const sign = dot < 0 ? -1 : 1;
const r = node.rotation;
for (let k = 0; k < 4; k += 1) r[k] = lerp(a[o + 3 + k] * sign, b[o + 3 + k], alpha);
const length = Math.hypot(r[0], r[1], r[2], r[3]);
for (let k = 0; k < 4; k += 1) r[k] /= length;
node.position[0] = lerp(a[o], b[o], alpha);
node.position[1] = lerp(a[o + 1], b[o + 1], alpha);
node.position[2] = lerp(a[o + 2], b[o + 2], alpha);
node.markMoved();
node.updateWorld();
}
// #endregion
// #region casters
/**
* Everything in the world, said once. The shadow pass and the colour pass both draw from this,
* so nothing can cast a shadow without being seen, or be seen without casting one.
*/
const casters: ShadowCasters = (sink) => {
sink.mesh(levelMesh, still.worldMatrix);
sink.mesh(playerMesh, playerNode.worldMatrix);
for (let i = 0; i < crateNodes.length; i += 1) sink.mesh(crateMesh, crateNodes[i].worldMatrix);
for (let i = 0; i < orbs.length; i += 1) {
if (!orbs[i].taken) sink.mesh(orbMesh, orbNodes[i].worldMatrix);
}
};
const lightMatrix = new Float32Array(16);
ENV.lightViewProj = lightMatrix;
ENV.shadowStrength = 0.8;
// #endregion
// #region render
function render(alpha: number, frameDt: number): void {
// Input is sampled here, at the edge of the simulation: a step reads what this left behind.
touch.tick(performance.now());
// #region pause
// Read here, not in simulate: while paused, simulate is not called at all.
if (actions.consumePress('pause')) paused = !paused;
// #endregion
if (!paused) clock += frameDt;
placeEverything(alpha);
const at = playerNode.position;
follow(at[0], at[1], at[2], frameDt);
// The shadow map covers 16 metres around the player, which is everything the camera sees.
ENV.shadowDepthSpan = computeLightMatrix(
ENV.directionalDir,
at[0],
at[1],
at[2],
16,
renderer.shadowMapSize,
lightMatrix,
);
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters(casters);
renderer.endShadowPass();
renderer.beginFrame(SKY);
renderer.bindMeshPass(camera, ENV);
renderer.drawSceneCasters(casters);
drawHud();
renderer.endFrame();
}
// #endregion
// #region hud
const hud = renderer.createText();
let hudStyle: typeof DEFAULT_TEXT_STYLE = {
...DEFAULT_TEXT_STYLE,
color: [1, 0.98, 0.92],
glow: 0.7,
};
/** What the text last said, so a new string is built only when a number on it changes. */
const shown = { gathered: -1, seconds: -1, phase: '', paused: false };
function drawHud(): void {
const seconds = Math.ceil(round.remaining);
if (
round.gathered !== shown.gathered ||
seconds !== shown.seconds ||
round.phase.tag !== shown.phase ||
paused !== shown.paused
) {
shown.gathered = round.gathered;
shown.seconds = seconds;
shown.phase = round.phase.tag;
shown.paused = paused;
renderer.setText(
hud,
paused
? 'PAUSED. PRESS P TO CARRY ON'
: round.phase.tag === 'Won'
? 'ALL GATHERED. PRESS R OR TAP TO PLAY AGAIN'
: round.phase.tag === 'Lost'
? 'OUT OF TIME. PRESS R OR TAP TO PLAY AGAIN'
: `ORBS ${round.gathered}/${round.total} TIME ${seconds}`,
);
}
const width = renderer.cssWidth;
const height = renderer.cssHeight;
// The style is rebuilt only when a resize moves the cell size.
const cell = Math.max(2, Math.round(Math.min(width, height) / 220));
if (cell !== hudStyle.cellSize) hudStyle = { ...hudStyle, cellSize: cell };
renderer.drawText(
hud,
width,
height,
Math.round(width / 2 - renderer.textWidth(hud, cell) / 2),
Math.round(height * 0.08) + cell * 7,
hudStyle,
0,
);
}
// #endregion
// #region loop
newRound();
startLoop({ simulate, render, shouldSimulate: () => !paused });
// #endregionround.drs
examples/first-game/round.drs
// The round: how long it lasts, how close the player has to come to an orb, and when it is won or
// lost. The page holds the player, the world and the orbs, and asks these rules about them.
//
// Under `npm run examples`, change a rule and save: the round plays by it from the next tick, with
// the clock and the score where they were. Try a longer round, a wider reach, or a round that is
// lost the moment the clock runs out even with one orb left to take.
// #region rules
// How long a round lasts, in seconds, and how close the player has to come to an orb, in metres.
let ROUND_SECONDS: f32 = 60
let REACH: f32 = 1
// Where a round is: still being played, or over one way or the other.
enum Phase {
Playing
Won
Lost
}
// One round, which the page owns and hands to every rule below.
data Round {
remaining: f32 = 60
gathered: u32 = 0
total: u32 = 8
phase: Phase = Phase.Playing
}
// #endregion
// #region start
// A new round: a full clock, nothing gathered yet, and the number of orbs the page laid out.
fn start(round: mut Round, total: u32) {
round.remaining = ROUND_SECONDS
round.gathered = 0
round.total = total
round.phase = Phase.Playing
}
// Whether the player, this far from an orb on each axis, is close enough to take it. Pure: it reaches
// nothing outside its arguments and the constants above, which the compiler checks.
@pure
fn reaches(dx: f32, dy: f32, dz: f32) -> bool {
return dx * dx + dy * dy + dz * dz < REACH * REACH
}
fn gather(round: mut Round) {
round.gathered = round.gathered + 1
}
// #endregion
// #region tick
// The clock, once a step. Every orb taken wins the round; the clock reaching zero first loses it.
// Deterministic: the same round and the same step give the same answer on every machine, so a
// recorded round replays exactly. Reading input or the wall clock here would not compile.
@deterministic
fn tick(round: mut Round, dt: f32) {
if round.phase != Phase.Playing {
return
}
round.remaining = round.remaining - dt
if round.gathered == round.total {
round.phase = Phase.Won
} else if round.remaining <= 0 {
round.remaining = 0
round.phase = Phase.Lost
}
}
// #endregionround.test.ts
examples/first-game/round.test.ts
/**
* The first game's rules, tested in Node. A script that holds no engine object is a set of
* functions over a record, so a test can call them without a page to run in.
*/
// #region test
import { describe, expect, it } from 'vitest';
import { loadModule } from 'driftscript';
import * as roundScript from './round.drs';
interface Round {
remaining: number;
gathered: number;
total: number;
phase: { tag: 'Playing' | 'Won' | 'Lost' };
}
const rules = loadModule(roundScript as Record<string, unknown>).exports as unknown as {
createRound(): Round;
start(round: Round, total: number): void;
reaches(dx: number, dy: number, dz: number): boolean;
gather(round: Round): void;
tick(round: Round, dt: number): void;
};
describe('a round', () => {
it('is won when the last orb is gathered before the clock runs out', () => {
const round = rules.createRound();
rules.start(round, 2);
rules.gather(round);
rules.gather(round);
rules.tick(round, 1 / 60);
expect(round.phase.tag).toBe('Won');
});
it('is lost when the clock runs out first, with the clock stopped at zero', () => {
const round = rules.createRound();
rules.start(round, 2);
rules.gather(round);
for (let step = 0; step < 61 * 60; step += 1) rules.tick(round, 1 / 60);
expect(round.phase.tag).toBe('Lost');
expect(round.remaining).toBe(0);
});
it('reaches an orb within a metre and not beyond', () => {
expect(rules.reaches(0.6, 0, 0.6)).toBe(true);
expect(rules.reaches(0.8, 0, 0.8)).toBe(false);
});
});
// #endregion