DriftScript
Patterns
How the examples split work between the page and its scripts, from a record the page owns to a rule that decides and systems over components.
npm install @driftengine/script
Every example in this manual that has behaviour keeps it in DriftScript, and they divide the work the same few ways. This page names each one and shows the example it comes from, so a new game can pick the shape before writing the first rule.
A record the page owns
// The lamp follows the right trigger while a session is running and the controller is tracked. A
// headset taken off ends the session, and the lamp goes out with it.
fn light(lamp: mut Lamp, dt: f32) {
var want: f32 = 0
if xr.presenting() && xr.holding(Hand.Right) {
want = xr.trigger(Hand.Right)
}
lamp.level = lamp.level + (want - lamp.level) * math.min(1, lamp.ease * dt)
}The commonest shape, and the one to start from. The script declares a data record and the
functions that change it; the page creates the record once, keeps it, passes it in, and reads it
back to draw. Here the XR example's lamp eases toward the right trigger, and the page sets the
light's colour from lamp.level every frame.
The record is the line between the two halves. Everything the rule needs to remember goes in it, so a function has nothing hidden to lose when a save replaces it, and everything the page needs to draw comes out of it, so the page never asks the script how it reached a number. When a record gains a field in a save, the live one gains it too, with its declared default.
Deciding, and acting on the decision
// A take-off cuts at once, across the line of travel and low, the shot that sells a gap; a landing
// cuts to an orbit round it. Otherwise a shot holds its time, the tower is framed when the buggy
// passes it, and a long shot gives way to the next in turn. Answers the shot to cut to, or `none`
// to hold the one on screen.
fn direct(director: mut Director, cam: Camera, airborne: bool, nearTower: bool) -> Shot? {
let age = camera.shotAge(cam)
var want = director.shot
if airborne && !director.flying {
want = Shot.LowWide
} else if !airborne && director.flying {
want = Shot.Orbit
} else if age < director.least {
want = director.shot
} else if nearTower && director.shot != Shot.LookAt {
want = Shot.LookAt
} else if age > director.most {
want = next(director.shot)
}
director.flying = airborne
if want == director.shot {
return none
}
director.shot = want
director.cuts += 1
return some(want)
}The cinematic example's director decides which shot the camera cuts to and when. It answers a
Shot?: some of the shot to cut to, a variant of its own Shot enum, or none to stay on the
current one. The page holds a record for each variant and makes the cut with the camera rig it
owns. The script never moves the camera. It reads one
fact from it, camera.shotAge(cam), through the rig the page passed in, and answers a question.
This is the shape for anything with taste in it: which animation to blend to, which line a character says, when an enemy gives up a chase. The decision is the part tuned by watching, so it goes in the script. The doing needs the engine's objects, so it stays in the page:
const script = hostScript(directorScript);
/* A variant reaches the page as `{ tag }`, and an option as `{ tag: 'some', value }` or `none`. */
interface Director {
shot: { tag: ShotName };
cuts: number;
}
const director = exported<() => Director>(script, 'createDirector')();
type Direct = (
director: Director,
camera: CinematicCamera,
airborne: boolean,
nearTower: boolean,
) => { tag: 'none' } | { tag: 'some'; value: { tag: ShotName } };
if (import.meta.hot) {
import.meta.hot.accept('./director.drs', (next) => {
if (next !== undefined) {
patchModule(script, next as Record<string, unknown>, { Director: [director] });
}
});
}Engine objects as arguments
A script cannot create an engine object or look one up. It uses the ones it is handed:
- the puck's rules read the controls through the page's
ActionMap, passed asactions; - the director reads the age of a shot from the page's camera rig, passed as
cam; - the garden's autosave writes through the page's store, passed as
store:
data Autosave {
// Seconds between autosaves, and since the last one.
every: f32 = 5
since: f32 = 0
written: u32 = 0
// How many were held back because the one before had not landed.
held: u32 = 0
}
// The page offers the garden once a second and the script decides whether it is written. An
// autosave waits while the last one is still on its way, so a slow server is never handed a queue.
fn autosave(auto: mut Autosave, store: Store, garden: String, dt: f32) -> bool {
auto.since = auto.since + dt
if auto.since < auto.every {
return false
}
if persistence.pendingSaves(store) > 0 {
auto.held = auto.held + 1
return false
}
persistence.write(store, "driftengine.examples.saves.auto", garden)
auto.since = 0
auto.written = auto.written + 1
return true
}The types (Actions, Camera, Store) are opaque in the script. It can pass one to the engine
functions that take it and do nothing else with it. That is what makes a script safe to replace in a
running game: everything a function can affect arrives through its own arguments, and the page
decides what those are each time it calls.
Services the page gives once
Some engine modules act on something that belongs to the whole game, the entity world, the mixer,
the navigation graph, and passing it to every call would be noise. The page gives those once, at
bind, in services. The XR example hands drift/xr the session it reads; the audio example hands
drift/audio its graph and sound registry; the entities and saves examples hand drift/ecs the
component registry their script declared. Setting up scripts
lists all eight.
Systems over components
// Four times a second, each rested frog fixes on the nearest fly within its reach.
system Hunt {
reads Fly
reads Position
writes Frog
update at 4Hz {
for frog in query<Frog, Position>() {
if frog.Frog.resting > 0 {
continue
}
var best = frog.Frog.reach * frog.Frog.reach
frog.Frog.target = none
for fly in query<Fly, Position>() {
let dx = fly.Position.x - frog.Position.x
let dy = fly.Position.y - frog.Position.y
let dz = fly.Position.z - frog.Position.z
let distance = dx * dx + dy * dy + dz * dz
if distance < best {
best = distance
frog.Frog.target = some(fly)
}
}
}
}
}When a rule applies to many things of one kind, frogs, flies, plants, it is a system over
components, declared in the script. A system says which components it reads and writes, how
often it runs, and loops over a query; the engine's schedule runs the systems each step in an order
their reads and writes allow. The page registers the script's components and systems once and steps
the schedule, and it can read any component back to draw. Entities
covers components, prefabs, systems and rewinding in full, and
Saves and preferences writes a whole world of them out and reads it back.
Many small files
A script is a module, and a game is better as several than as one. A .drs file can import another
by a relative path (import { start } from "./round"), and anything else it imports is an engine or
language module. A data record can extend another and stand in for it wherever the base is
expected. Keep one file per subject: the round, the enemies, the pickups, the camera. A save then
replaces one subject's rules and nothing else's, and each file's imports say exactly which engine
modules that subject touches.
What stays out of a script
- Drawing. A script never calls the renderer. It decides what is drawn and the page draws it.
- Loading. Models, textures, sounds and levels are loaded by the page, which hands the script what it needs once they exist.
- Anything that reads the wall clock or the input inside the fixed step. Rules that run in the simulation see the step's time and the step's input, which the page passes in, so a recorded game replays. Determinism, testing and shipping has the annotation that enforces it.
- The platform. Storage, the network and a desktop shell are the page's; a script reaches them only through what it is handed, as the garden reaches its save store.