Examples

Hello world

A lit cube turning at a rate a DriftScript rule sets. The one to copy out to start a game.

Starts examples/starter in this page, on WebGPU where your browser has it.

Open on its own · Source

Hello world is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /starter/.

Source

main.ts

examples/starter/main.ts
/**
 * The smallest thing that is still a game: a lit cube on a ground plane, turning on a fixed
 * timestep, drawn through whichever backend the browser actually handed over, at a rate a
 * DriftScript rule decides.
 *
 * Everything here is reached through the public barrel, because that barrel is the whole of
 * what a consumer receives. A starter that reached past it would be demonstrating privileged
 * access, not the engine.
 */
// #region imports
import {
  Camera,
  MeshBuilder,
  SceneNode,
  createEnvironment,
  createRenderer,
  startLoop,
} from '@driftengine/core';
import { bindModule } from '@driftengine/script';
import { loadModule, patchModule } from 'driftscript';
import * as spinScript from './spin.drs';
// #endregion

// #region renderer
const canvas = document.querySelector<HTMLCanvasElement>('#stage');
if (canvas === null) throw new Error('the page must carry <canvas id="stage">');

/*
 * Asynchronous because it has to be: asking for a WebGPU adapter returns a promise, and there
 * is no synchronous way to learn whether a usable device exists. The fall back to WebGL2 is
 * automatic and silent, so `reason` is the only thing that says why. Print it: the first
 * question asked about any picture is what drew it.
 */
const { renderer, backend, reason } = await createRenderer(canvas, {
  maxDevicePixelRatio: 1.75,
});

const readout = document.querySelector('#backend');
if (readout !== null) readout.textContent = `${backend}: ${reason}`;

renderer.resize();
addEventListener('resize', () => renderer.resize());
// #endregion

// #region environment
/** Light, air and ground bounce. Chosen once, because these are allocation-sized decisions. */
const ENV = createEnvironment({
  directionalDir: [0.4, 0.7, 0.35],
  directionalColor: [1, 0.96, 0.88],
  ambient: [0.2, 0.22, 0.28],
  ambientGround: [0.08, 0.08, 0.1],
  emissiveGain: 0,
  nightFactor: 0,
  fogColor: [0.16, 0.18, 0.24],
  fogDensity: 0.004,
  fogHeightFalloff: 0.03,
  fogBaseY: 0,
});
// #endregion

// #region meshes
/*
 * Colour is vertex data here, which is what lets a whole world be flat-shaded draw calls.
 * `addBox` takes a centre and *half* extents, so this ground is 24 units across and the cube
 * is two units on a side.
 */
const shapes = new MeshBuilder();
shapes.addBox([0, 1, 0], [1, 1, 1], [0.85, 0.45, 0.25]);
const cube = renderer.createMesh(shapes.build());

const slab = new MeshBuilder();
slab.addBox([0, -0.25, 0], [12, 0.25, 12], [0.3, 0.32, 0.36]);
const ground = renderer.createMesh(slab.build());
// #endregion

// #region scene
const spinner = new SceneNode();
spinner.setPosition(0, 1, 0);
const stillness = new SceneNode();
stillness.updateWorld();

const camera = new Camera();
camera.position[0] = 6;
camera.position[1] = 4.5;
camera.position[2] = 8;
camera.lookAt(0, 1, 0);
// #endregion

// #region script
/*
 * How fast the cube turns is a rule, and rules live in DriftScript: `spin.drs`. The bundler compiles
 * the file, `loadModule` makes a module of it, and `bindModule` gives it the engine capabilities it
 * imports. Saving the file while the page is open replaces its function in place.
 */
const spinModule = loadModule(spinScript as Record<string, unknown>);
const bound = bindModule(spinModule, {});
if (!bound.bound) throw new Error(bound.reason);
const rules = spinModule.exports as unknown as { spinRate(time: number): number };

if (import.meta.hot) {
  import.meta.hot.accept('./spin.drs', (next) => {
    if (next !== undefined) patchModule(spinModule, next as Record<string, unknown>);
  });
}
// #endregion

// #region loop
/*
 * Two angles, because that is what `alpha` is for. The simulation advances in fixed steps and
 * the display does not, so a frame almost never lands on a step boundary: drawing `spin`
 * directly judders at any refresh rate that is not a multiple of the step. Interpolating
 * between the last two states is the whole reason the loop hands `alpha` over.
 */
let spin = 0;
let previousSpin = 0;
let time = 0;

startLoop({
  simulate(dt) {
    previousSpin = spin;
    time += dt;
    spin += dt * rules.spinRate(time);
  },
  render(alpha) {
    spinner.setRotationAxisAngle(0, 1, 0, previousSpin + (spin - previousSpin) * alpha);
    spinner.updateWorld();

    camera.updateMatrices(canvas.height > 0 ? canvas.width / canvas.height : 1);

    renderer.beginFrame([0.05, 0.06, 0.09]);
    renderer.bindMeshPass(camera, ENV);
    renderer.drawMesh(ground, stillness.worldMatrix);
    renderer.drawMesh(cube, spinner.worldMatrix);
    renderer.endFrame();
  },
});
// #endregion

spin.drs

examples/starter/spin.drs
// How the cube turns. The page owns the cube; this file only says how fast it goes.
//
// Save it while the page is open and the cube takes the new rate without the page reloading. Try a
// steady 2, a cube that stops every few seconds, or one that turns the other way.

import { sin } from "std/math"

// #region rate
// Radians a second: a steady turn that quickens and eases every few seconds.
fn spinRate(time: f32) -> f32 {
    return 0.8 + 0.4 * math.sin(time * 0.5)
}
// #endregion

vite.config.ts

examples/starter/vite.config.ts
/**
 * The build a copied starter needs: Vite, and DriftScript's plugin, which compiles each `.drs`
 * import against what the engine provides.
 *
 * Inside this repository the examples' own config does the same job, so this file only matters once
 * the folder is copied into a project of its own.
 */
// #region plugin
import { fileURLToPath } from 'node:url';
import { driftScript } from 'driftscript/vite';
import { defineConfig } from 'vite';

export default defineConfig(({ command }) => ({
  plugins: [
    driftScript({
      /* The engine's capabilities, as data: every `drift/*` function, its types and its effects. */
      capabilities: fileURLToPath(import.meta.resolve('@driftengine/script/capabilities.json')),
      /* The engine modules a script in this game may import. Add one here when a script needs it;
         an import of a module not named is refused when the file compiles, naming the module. */
      manifest: { name: 'my-game', provides: ['drift/random', 'drift/scene', 'drift/input'] },
      /* `vite build` compiles for shipping: the editor's metadata stays out of the bundle. */
      mode: command === 'build' ? 'production' : 'development',
    }),
  ],
  build: {
    target: 'es2022',
    /* Just above the engine's WebGPU renderer, one module of about 2,100 kB minified that loads only
       where WebGPU does. Vite warns past 500 kB by default; anything larger than the renderer still
       warns. */
    chunkSizeWarningLimit: 2200,
  },
}));
// #endregion