DriftScript
Behaviour you can edit while it runs.
A strict language for the code that decides what a world does. Save a file and the running function is replaced while the state it was working on stays where it is. Absence is a type, overflow says which kind it is, and a capability the host has not provided is refused at link time with the reason written out.
npm i driftscript
Wiring it into a host of your own is four steps and a bundler plugin. Hosting the language
Diagnostics, completion and hover come from this same compiler, in your editor. The VSCode extension
// Overflow says which kind it is. A health bar saturates rather than wrapping to zero.
fn health(current: u8, healed: u8) -> u8 {
return current +| healed
}
import
Two prefixes
Two prefixes, and the difference between them is the difference between the language and the host it happens to be running in. The prefix in an import tells you which one you are looking at.
std/*
Duration arithmetic, the scalar functions, the collections. It observes nothing, and that follows from what it is: a clock, a scene and a world all belong to a host, so a library function that read one would belong under that host prefix instead. A target may not decline any of it, which is what standard means here, so a program built only from these runs anywhere DriftScript runs.
drift/*
Scene, audio, input, camera, physics, animation, entities, persistence and the rest, each carrying the effect it has. A different host would supply its own prefix for its own capabilities and inherit the standard library unchanged.
// The two prefixes, in one file.
//
// `std/math` is the language's own and is pure, so it links in any host. `drift/scene` is this
// engine's, and it carries the effect of reaching a scene. The prefix in the import is what tells
// you which of the two you are looking at.
//
// `Node` is not a language type either. It arrives with the capability, from whatever the host's
// scene is made of, which is why a consumer with no renderer still has a language.
import { clamp } from "std/math"
import { setRotation } from "drift/scene"
data Spin {
speed: f32 = 45deg
}
fn turn(spin: Spin, node: Node, dt: f32) {
let step = math.clamp(spin.speed * dt, 0, 90deg)
scene.setRotation(node, 0, 1, 0, step)
}
DS02xx
What it declines
The most useful thing a strict language has to show you is what it declines, and why. Every one of these compiles in your browser when you press the button, and prints what the compiler actually said.
Absence is a type
There is no null and no undefined, so an option is the only way to say a value might not be there. A bare value does not flow into an option position either: returning 1 where f32? was declared is an error, and some(1) is what was meant. Accepting the first would reinvent implicit null in the other direction, where a reader can no longer tell an absence that was decided from one that was never filled in.
// Deliberately refused.
//
// A bare value does not flow into an option position. `return 1` from a function returning `f32?`
// is an error, and `return some(1)` is what was meant. A language that accepted the first has
// reinvented implicit null in the other direction: a reader can no longer tell an absence that
// was decided from one that was never filled in.
fn find(present: bool) -> f32? {
if present {
return 1
} else {
return none
}
}
A list of wolves is not a list of dogs
A Wolf is a Dog, and a List<Wolf> is still not a List<Dog>. Lists are invariant, and the reason is that they can be written through: a covariant one would let a plain Dog be pushed into a reference whose real list holds wolves, and every reader of that list believes it holds wolves. Record subtyping is sound here partly because that cannot happen: a record is read through its fields, and a list is not.
// Deliberately refused.
//
// A `List<Wolf>` is not a `List<Dog>`, even though a `Wolf` is a `Dog`. With covariance the call
// below would hand `adopt` a reference whose real list holds wolves, and pushing a plain `Dog` into
// it would put one in a list every reader believes is wolves.
//
// Record subtyping is sound in this language partly because that cannot happen: a record is read
// through its fields and a list can be written through.
//
// `:` before the brace is the base clause. It is not a new keyword, and a reader meets `:` here
// meaning "extends" and three lines later meaning "has type"; the position is what keeps the two
// apart, since a type annotation never follows a record's name.
data Dog {
weight: f32
}
data Wolf: Dog {
pack: u32
}
fn adopt(kennel: mut List<Dog>, arrival: Dog) -> u32 {
push(kennel, arrival)
return len(kennel)
}
fn release(pack: mut List<Wolf>, stray: Dog) -> u32 {
return adopt(pack, stray)
}
A condition is a bool
There is no truthiness. A count is not a condition, an empty string is not a condition, and a handle is not a condition. The error says to compare explicitly, because the fix is to write down which comparison you had in mind, and the compiler cannot know that.
// Deliberately refused.
//
// There is no truthiness. A condition must be `bool`, and the fix is to say what you meant:
// `if count != 0`.
fn ready(count: u32) -> bool {
if count {
return true
} else {
return false
}
}
Widening is something you write
Adding a u8 to a u32 names the three conversions available and picks none of them. The guess is where a value quietly changes width, and a language that guessed would be making that decision in a place nobody reads, on a line that looks like arithmetic.
// Deliberately refused.
//
// There is no implicit widening. The compiler names the conversions rather than guessing which
// one was meant, because the guess is where a value quietly changes width.
fn add(a: u8, b: u32) -> u8 {
return a + b
}
A system says what it touches
A system declares the components it reads and the ones it writes, and the compiler works out what it actually touches, following the functions it calls as well as its own body. A write left out of the declaration is an error naming the system and the component. Claiming more than you touch is only a warning, because that is sometimes deliberate and costs a scheduler that keeps two systems apart rather than a wrong answer.
// A system that writes a component it did not say it writes.
//
// `reads` and `writes` are assertions rather than documentation: the compiler works out what a
// system touches, following the functions it calls as well as its own body, and refuses a
// declaration that leaves a write out. The name of the system and the name of the component are
// both in the message, so the fix is where the reader is already looking.
//
// A declaration wider than the body is a warning instead, because claiming more than you touch is
// sometimes deliberate and costs only a scheduler that keeps two systems apart.
component Hunger {
value: f32 = 0
}
system Feeder {
reads Hunger
update {
for who in query<Hunger>() {
who.Hunger.value = who.Hunger.value + 1
}
}
}
A match covers every case
An unhandled variant is an error that names the variant rather than saying the match is not exhaustive, because the name is the answer you were about to go and look up. The alternative is a surprise on the one afternoon the light is amber.
// Deliberately refused.
//
// A `match` must cover every variant, and the error names the ones it missed rather than saying
// "not exhaustive", which is the answer you were about to go and look up. `Amber` is missing here,
// and the alternative to this error is a run-time surprise on the one afternoon the light is amber.
enum Light {
Red
Amber
Green
}
fn go(light: Light) -> bool {
return match light {
Red => false
Green => true
}
}
List<T>
Somewhere to put things
A list, the loops that go with it, and constants a whole file can name.
// A list, the loops that go with it, and a constant the whole file can name.
//
// `[a, b]`, `xs[i]`, `len`, `push` and `for … in` are language forms rather than a module, which is
// why there is no `std/collections`: a capability's parameter types are names in a data format a
// host writes, and a module function over `List<T>` would need a type variable there.
// A `let` at the top of a file is a constant. Its value is arithmetic over literals and other
// constants; a call is not allowed, because a module is evaluated before its host is bound and
// there would be nothing to call. Another file can import it the way it imports a function.
let CALM = 0.15
// Annotated, because `len` answers a `u32` and there is no implicit widening: a bare `3` here is
// an `f32` and comparing the two is refused by name.
let SAMPLES: u32 = 3
// `continue` skips to the next turn, `break` leaves the loop. There are no labels, so a jump
// always means the loop it is written in.
fn strongestGust(gusts: List<f32>) -> f32 {
var best: f32 = 0
for gust in gusts {
if gust < CALM {
continue
}
if gust > best {
best = gust
}
}
return best
}
// `push` needs a mutable binding, because growing a list writes to the container.
fn firstFew(gusts: List<f32>) -> List<f32> {
var picked: List<f32> = []
for gust in gusts {
if len(picked) == SAMPLES {
break
}
push(picked, gust)
}
return picked
}
// An index past the end throws, for the same reason integer overflow does: there is no value that
// would be right, and returning one would be inventing an answer.
fn opening(gusts: List<f32>) -> f32 {
if len(gusts) == 0 {
return 0
}
return gusts[0]
}
These are language forms rather than a module, which is why there is no standard collections library to import. A capability parameter type is a name in a data format a host writes, and a module function over a list would need a type variable there, so the list is in the language and stays out of the boundary. An index past the end throws, for the reason integer overflow does: there is no value that would be right, and returning one would be inventing an answer. Growing a list needs a mutable binding, because a push writes to the container.
| Rule | Why |
|---|---|
| Lists are invariant | A list of wolves is not a list of dogs, even where a wolf is a dog. A list can be written through, so covariance would let a plain dog into it. |
| No labels on a jump | break leaves the innermost loop and continue skips to its next turn, and a jump always means the loop it is written in. A break out of a query loop still finishes walking the cursor, because that is the only route a cursor takes back to the pool. |
| A constant is not a call | A let at the top of a file is arithmetic over literals and other constants. A call is refused, because a module is evaluated before its host is bound and there would be nothing to call. |
@hot
Edit it while it runs
Hot reload is in the runtime architecture rather than beside it.
// A value the page keeps, and a function the page replaces while it keeps it.
//
// `ticks` counts how many times `advance` has run since the page loaded. Edit the body below and
// the count carries on from where it was: the code is replaced, the state it was working on is
// not. That is what hot reload means here, and it is the reason records have no methods.
data Pulse {
ticks: u32 = 0
value: f32 = 0
}
fn advance(pulse: mut Pulse, dt: f32) {
pulse.ticks = pulse.ticks +| 1
pulse.value = pulse.value + dt
}
A caller holds a module and reaches its functions through that module, so replacing one is a single assignment into a record every caller already reads through, and nothing has to be found. That is also why records have no methods: behaviour is a function that takes the record, which is what lets the compiler tell a read from a write, and what lets a function be swapped while live instances carry on working. The box above is running now, and the count does not restart when you edit it.
| Change | For example | What happens |
|---|---|---|
| Patched | a function body, a constant, a private helper | the code is replaced and the state is kept |
| Migrated | a new field with a default, a new optional field | the schema is reconciled and live instances are updated in place |
| Migration declared | a field renamed, a field whose type changed | the author says how, or states a reset policy |
| Handed back | a runtime or compiler ABI change, an incompatible host surface | the consumer is told in words and decides what a restart means |
task
Tasks have an owner
Asynchronous behaviour is a task with an owner rather than a promise nobody holds. Leaving a scope cancels what has not finished, so detached work cannot outlive the scene that started it. A task awaits one of three clocks, and which one is a decision the compiler holds you to.
// A task is asynchronous behaviour with an owner.
//
// `spawn` starts one inside a `scope`, and leaving the scope cancels whatever has not finished.
// That is the rule that stops detached work outliving the thing that created it, and it is
// structural here rather than something every author has to remember.
//
// `begin` is a task rather than a function, and the compiler insists: a scope opened in a function
// would be left on the same tick it was opened, cancelling everything spawned into it.
data Signal {
fired: bool = false
count: u32 = 0
}
task pulse(signal: mut Signal) {
await fixedTime(250ms)
signal.count = signal.count +| 1
signal.fired = true
}
task begin(signal: mut Signal) {
scope effect {
spawn pulse(signal)
await fixedTime(1s)
}
}
| Await | Which delta | The rule |
|---|---|---|
fixedTime(2s) |
the fixed simulation step | the only clock a deterministic task may await, because a resume point counted in whole steps replays identically |
frameTime(2s) |
the frame delta, clamped | the one to animate against, so a stall cannot advance two seconds of animation in a single frame |
wallTime(2s) |
the same gap with nothing done to it | diagnostics, and awaiting it outside a diagnostic context is a warning that quotes this reason |
DS0301
One source, two targets
A capability the target does not provide is refused at link time, by name.
Importing a module declares a requirement and a target declares what it provides. A file using a surface the target lacks still parses and still type-checks; only linking declines it, and the refusal says which module, which target, and whether the capability exists in this host at all. That is what lets one file be written against a capability that has not shipped and link unchanged on the day it does. Press each target below: the source between them does not change.
// The same source against two targets.
//
// One provides `drift/animation` and one does not, and the difference is a refusal with the
// module and the target named in it. Nothing about this file changes between the two.
//
// **A script drives a blend tree; it does not build one.** The tree is built where the skeleton
// and the clips already are, and handed to a system through `uses` — so what a script does with
// it is set its parameters and evaluate it. Both kinds of parameter are set the same way: a blend
// weight, and a clock, which is the time a `clip` node was told to read instead of the frame's.
import { blendSet, blendAt, pose } from "drift/animation"
data GaitState {
// Seconds, for anything that carries on while the character stands still.
phase: f32 = 0
// The distance the character has covered, on the gait clips' own axis.
stride: f32 = 0
}
fn step(state: mut GaitState, dt: f32, speed: f32) {
state.phase += dt
state.stride += dt * speed
}
fn drive(tree: Blend, state: GaitState, joints: u32) -> Pose {
let out = animation.pose(joints)
animation.blendSet(tree, "speed", 1.0)
animation.blendSet(tree, "gait", state.stride)
animation.blendAt(tree, state.phase, out)
return out
}
web-minrefused, and the message says this host has the capability and the manifest does not list itweb-fulllinks, with the same source and no edit
@deterministic
Determinism is checked
Marking a function deterministic is a claim the compiler checks rather than a note to the reader. Inside one, anything that would make a replay diverge is refused at the call.
// `@deterministic` is a claim the compiler checks, not a note to the reader.
//
// A simulation is given its delta as a parameter rather than reaching out for one. That is what
// makes it a function of its inputs, which is the property a replay depends on.
@deterministic
fn step(position: f32, velocity: f32, dt: f32) -> f32 {
return position + velocity * dt
}
@pure
fn squared(x: f32) -> f32 {
return x * x
}
- the wall clock
- unseeded entropy
- model inference
- unrestricted promises
- the network
- renderer state
- pointer and touch sampling
- host services
Awaiting a clock and reading one are different rules
Awaiting the fixed step inside a deterministic function is accepted, because a resume point counted in whole ticks is a function of the tick count and replays identically. Reading a delta is refused, because a simulation is given its delta as a parameter, and one that reached out for it has stopped being a function of its inputs, which is the property replay depends on.
drift/*
Its first host
DriftEngine is its first host, and the language is built so it is not the only possible one.
The language package depends on nothing: not the engine, not even for a type, and three mechanisms in that repository fail if that stops being true. Everything the engine contributes arrives through one package of bindings, where a capability is described as data and the host supplies the functions at link time. That is what lets the language server read what a host provides from a process that cannot import the engine at all, and it is why std/* works in a target that provides nothing.
npm i @driftengine/script
That is the bindings package: the capability table this page is generated from, and the functions a host binds at link time. The engine it belongs to is Apache 2.0 and its source is at github.com/drftrun/driftengine.
// A script driving real geometry.
//
// It reaches the engine through `drift/scene`, and it knows nothing about a canvas, a mesh or a
// renderer. The scene beside it does not animate: everything moving on screen is moving because
// this function ran.
//
// **Edit `advance`** — multiply the step, swap the axis — and the cube responds on the next frame
// while `turned` keeps counting. The state survives; the code does not have to.
//
// **Editing `speed`'s default does not move the running cube**, and that is the same rule rather
// than an exception to it. A default is what a *fresh* instance starts with; a live one keeps every
// value it already has. If a reload reapplied defaults, `turned` would snap back to 0 on every
// keystroke — which is exactly the thing this box exists to show it does not do. Add a field and it
// arrives at its default; change an existing one and the running state is untouched.
import { setRotation } from "drift/scene"
data Spin {
speed: f32 = 45deg
turned: f32 = 0
tilt: f32 = 0
}
fn advance(spin: mut Spin, node: Node, dt: f32) {
spin.turned = spin.turned + spin.speed * dt
scene.setRotation(node, 0, 1, 0, spin.turned)
}
// A record, a mutation, and the two things the compiler will not let you skip.
//
// `door` is `mut`, so its fields may be written. `by` is not, so it may only be read. A record
// literal gives every field a value: there is no partial construction.
import { clamp } from "std/math"
data Door {
open: bool = false
angle: f32 = 0
}
fn swing(door: mut Door, by: f32) {
door.angle = math.clamp(door.angle + by, 0, 180deg)
if door.angle > 45deg {
door.open = true
}
}
fn closed() -> Door {
return Door { open: false, angle: 0 }
}
drift/ai
Written before it exists
A capability can be written against before anything provides it.
A file naming a surface no host has yet still parses and still type-checks, and the refusal names what is missing. Nothing about that file changes on the day a provider ships: it links then, unedited. That is what lets a consumer write against a capability while it is still being built, and it is why the list of what does not link is read from the compiler rather than written down somewhere that can go stale.
// Written before this host provided the capability. It links now, and not a character of it moved.
//
// This is the property the whole language is arranged around, and this file is the receipt. It was
// written when `drift/ai` had no provider here: it parsed, it type-checked, and only linking
// declined it, naming the module and the target and whether the capability existed at all, which is
// a different answer from "no". Then the engine bound the module, and the file linked that day,
// unedited.
//
// So press compile and watch it link. The point is not that it works; it is that nothing about the
// file had to change for it to start working.
//
// What the script does and does not do is worth reading twice. It tells an agent that something
// happened and asks what the agent is doing. It does not receive a plan, and it never runs one:
// a model's output is not an execution path, and a tool resolves by id to something registered
// long before the model was asked.
import { agent, wake, degraded, intentId } from "drift/ai"
data Sentry {
alarmed: bool = false
lastIntent: String = ""
}
fn alarm(sentry: mut Sentry, id: String, priority: i32) {
if let found = ai.agent(id) {
ai.wake(found, "heard something", priority)
sentry.alarmed = true
}
}
// `degraded` is how a script asks whether the agent is over budget and running on its policy floor
// alone. A floor is deterministic and synchronous, so an agent with no provider still behaves, and
// the answer here is a quality signal rather than a failure.
fn observe(sentry: mut Sentry, id: String) -> bool {
if let found = ai.agent(id) {
sentry.lastIntent = ai.intentId(found)
return ai.degraded(found)
} else {
return true
}
}
What the example asks for, and what it never does
The script tells an agent that something happened and asks what the agent is doing. It does not receive a plan and it never runs one: model output is not an execution path, a tool resolves by id to an implementation registered long before the model was asked, and an intent whose world has moved on is discarded rather than executed late. Live inference is measured in whole seconds, so an agent that waited for it would visibly stand still; a deterministic floor runs on the fixed step and supplies behaviour whether or not a model ever answers.
What it costs
Every figure here is measured by the build that publishes this page, by bundling the thing it describes and compressing the result.
| Compiler, gzipped | 42.2 KB |
|---|---|
| Runtime, gzipped | 4.7 KB |
| Capabilities bound | 343 |
| Language version | 1.17.0 |
| Tests in the language | 1002 |
Next
Where to go next.
- The guide Seventeen chapters in the order a reader needs them, each with something to run.
- The reference Every capability this host binds, with its effects and whether it is deterministic, generated from the registry the linker reads.
- The playground A blank file, the real compiler, and a target you can change under it.
- The VSCode extension The compiler in an editor: diagnostics, completion, hover and semantic tokens, with no language logic in the client.
- DriftEngine The engine that hosts it, which is where anything about the engine itself is kept current.