Repository navigation
Releases: drftrun/driftscript
Release list
1.16.0
An optional component field reads and writes as an option, in a query loop and through a handle.
A host keeps an optional field as two columns, its value and whether it is present, and the compiler read only the first. In a loop e.Follow.target answered whatever number the slot last held, so if let on a cleared field matched and on a set one never produced its value, and assigning some(x) or none put the option object in a typed column, which stored NaN. Through a handle, who.Follow.target answered the bare number. A consumer kept every optional handle as a plain entity and a flag beside it to get round this.
A loop now reads <field>$present beside the value and writes both, and none clears the presence. That column name is part of the entity ABI from this version, beside the view's sparse and dense. A read through a handle or a row is wrapped in $opt, which turns the host's undefined for an absent field into none, and a write in $unopt, which hands it undefined for none. A host's ecs.read has to answer undefined for an absent optional field for this to work; one that answers zero reads every absent field as some(0).
A system's component access follows its calls into other files. Access was inferred from the module's own functions, so a call into another file touched nothing as far as the system was concerned. A system that declared what an imported helper wrote got DS0291 calling the declaration unused, and one that left it undeclared got no DS0288 and __drift metadata that never mentioned the write, so a host built a schedule that refused it when the system ran. Each module now publishes what its functions read and write with their signatures, and the importing module's inference starts from that.
1.15.0
== compares what two values hold, so a variant still equals itself after a hot reload.
== and != were JavaScript's own, and every value the language makes that is not a number, a string or a bool is an object in the emitted code, which JavaScript compares by identity. Two Shape.Circle(2) were unequal. A hot reload makes every variant a new object, so a record kept its old Phase.Playing, the new module compared it with its own, and if round.phase != Phase.Playing { return } returned on every call after the first save. A variant read back from a save never equalled anything. match reads the tag, so it was right all along.
A variant now compares by its tag and its payload, an option or a result by its case and value, a record field by field and a list element by element. A host's opaque value has nothing a script can see inside it, so it still compares by identity. Numbers, strings and bools emit as before.
A host can declare an enum. OpaqueType gains variants, and a type registered with them is an enum: a script writes Contact.Began, compares it and matches on it, and a match has to cover every variant. The capability returns { tag: 'Began' }. Until now a capability whose answer was one of a few things could only return a number, with a sentence saying what each number meant, and every script restated that sentence as constants nothing checked. A variant has to start with a capital and appear once, and an enum needs at least one, all refused at registration. A script's own enum of the same name wins inside that script. The capability file carries the variants, so a language server reads them from the file the build does.
1.14.0
A component's declared values reach the host, so an entity made without a field gets the value the script gave it.
component Bed { seed: u32 = 7 } parsed the 7, lowered it, and dropped it: the metadata a host builds a component from carried each field's id, name and type and nothing else. So every place a host fills a field it was not given filled zero. An entity added with some of its fields, a prefab that names a few, a saved scene from before a field existed: each got 0, false or nothing, and nothing said so.
DriftComponentInfo gains defaults, the constant each field was declared with, by name. A field declared without one is left out, and so is the whole record when no field has one. An entity's own var fields are its implicit component and carry theirs the same way. A host reads it where it reads schema; one built against an earlier version reads nothing and fills zero, as before.
A component's defaults are checked now, and have to be constants. They were never type-checked, so pace: f32 = "fast" compiled; it is DS0202 like a record's. And a default that is computed is DS0275, on checkPrefab's reasoning for DS0297: a host fills the columns from data, so a value computed when an entity is made has nowhere to be computed. Every component in every script in the corpus and the engine's examples already used constants.
1.13.1
The Vite plugin names a module by its path from the project root, so a bundle no longer carries the directory it was built in.
The compiler writes a module's name into the code it emits. It is __drift.module, and it begins every field id a migration matches on. The plugin passed the bundler's id, which is an absolute path, so every .drs file in a production bundle carried the build machine's directory: the author's home directory when built on a laptop, the runner's workspace when built in CI. The same source also compiled to different output in two checkouts.
The name is now the file's path from the bundler's root, with forward slashes and without a query, and the plugin reads the root in a new configResolved hook. A bundler that has no such hook gets the working directory, which is what its root means anyway. Both versions of a module in a hot patch are named the same way, so field ids still match across an edit, and an edit that adds a timestamp query to the request is named like the first load. Imports between .drs files resolve as before and are watched by their absolute paths. The source map names its source by the file's own name, which is how a bundler reads a map a transform returns.
The runtime compares no module names, so a host sees the change only where it prints one in a message, which now reads audio/courtyard.drs where it read a full path.
The size the driftscript README quotes had fallen 3.5% behind the tarball before this release, and is the measured figure again.
1.13.0
DS-8 is answered, which was the last refused phase and the only one that waited on a track.
DriftEngine's Track J built a networking host — a transport seam, a rewind loop, lockstep and an authoritative model — and DS-8 waited on exactly that. Three questions, and two of them turned out to have been answered years earlier by decisions made for other reasons.
The fixed clock already survived a rewind
runtime/clocks.ts carried the open question in its own header: "a deadline cannot express 'resume at step N' across a rewind. That is a host's to decide when it has a rollback loop to decide it against."
It can, and it always could. The fixed clock is a step count rather than seconds, so a deadline is an absolute step number and an absolute number means the same thing after the clock moves in either direction. A task waiting for step 500 is still waiting for step 500 after a rewind to 483. What the deferral was protecting against is a deadline held as a remaining duration, which would slip by the whole rewind every time; nothing here holds one.
The decision that made this work was made in DS-3, when the fixed clock was given steps as its unit for a reason about replay, and it paid for itself five phases later. clocks.test.ts drives a host backwards by hand and asserts both halves. The header is corrected rather than left hedging.
@replicated means something
It has been a lexer token since the design specified it and read by nothing. It now marks a component field a host publishes, and the compiler asserts two things: that it is a component's field rather than a record's, because a record passed between functions has no identity to replicate to; and that it holds a number, because a replication path carries scalars and anything richer needs a schema on the wire. An optional is refused too, since it needs a presence bit and a packet has no column for one. Entity is permitted with the caveat that whether a handle means the same thing at the other end depends on the host's model.
It registers nothing, so a file carrying it links against a target providing no networking at all and behaves exactly as it did.
network.write stays outside the deterministic effects, and now for a reason
The exclusion was a deferral on the condition that the track that builds one is the one that can say whether its writes are the simulation or a consequence of it. Track J's answer: it stays outside, and the reason is the rewind loop rather than the send. The tempting argument for admitting it is that publishing changes no simulation state, so a replay would publish the same thing. That is backwards once rollback exists — a @deterministic function is precisely the kind that gets re-run, and a send is not idempotent, so a correction replaying twelve ticks would put twelve duplicate messages on the wire.
network.read stays inside, narrowed to the parts of a session that cannot vary with packet timing: which participant this is, and whether it is the authority.
The corpus file describes a real surface
NetworkReplicatedActor.drs was written before any host existed and its shape was bent around that: it called network.replicate(x, y) with no session, because an unwired file is compiled with no registry and an opaque type it named would read as an undeclared type. It moves into the wired half, takes its session, publishes into numbered slots, and carries @replicated on the fields that cross.
What is refused, in writing
Five of the six annotations the design sketched are refused rather than half-built, each with the condition that reverses it: @ownerOnly and @interpolated describe a host's model and a rendering policy; @server and @client partition a program by where it runs, which is a compilation-target question; and @rollback would let a script claim membership of a set the host owns.
And one row is left: a rewind does not restore the scheduler. The layout it would need already exists, because frameLayout enumerates every slot a task's frame carries for the sake of hot patching and every task local is rewritten into a frame slot rather than a closure. docs/DS-8.md carries all of it.
1.12.0
Two rows an engine consumer filed, and only one of them was a gap.
navigation.write is inside the simulation boundary
DETERMINISTIC_EFFECTS held eleven effects and this was not one, so a @deterministic system could steer along a route and could not compute one. An agent that had to re-path — because a bridge dropped, because a door shut — left the fixed step to do it, computed the route in an ordinary system, and handed the result back in.
It came in on the condition the deferral itself set. That paragraph said physics.write, navigation.write, behavior.write and network.write stay outside because each belongs to a track that has not shipped, and the track that builds one is the one that can say whether its writes are the simulation or a consequence of it. Navigation shipped: a graph, an A* over it, and a follower.
Three things had to hold and all three do. A route is a function of the graph and its two endpoints, so it reads no clock and no entropy. The search breaks ties on the node index rather than on heap order — its own source calls that arbitrary but stable, and it is there precisely so two runs and two machines agree. And the path written into is simulation state in the sense ecs.write was admitted on, not a view of it.
The other three stay outside, unchanged, for the reason they always did.
What this changes for a host: nothing it must do. A capability declaring navigation.write with deterministic: true was refused at defineCapability — before this, that call threw — and is now accepted. Nothing that compiled before compiles differently.
An undefined name says where it is defined
A consumer concluded this language had no maths. They reached for floor, sin and cos, got three bare DS0205 is not defined, read the drift/* module list, found no arithmetic in it, and wrote their behaviour around the gap — raising a flag their host answered, one call per decision. The absence was filed as a language row on two of their documents.
It was never absent. std/math has abs, min, max, clamp, lerp, floor, ceil, round, sqrt, sin, cos, atan2 and exp, and % is an operator. Every host has it unconditionally: STD_MODULES is provided rather than claimed, and a manifest may not even list one. The compiler knew where floor lived at the moment it said the name was not defined, and said nothing about it.
So DS0205 now names the module:
`floor` is not defined. `floor` is defined in `std/math`; import it to use it here.
Exact matches only, and only modules the target actually has. A near-miss belongs to the import path, where a suggestion already runs against one named module; here the question is which module, and a confident wrong guess sends somebody to the wrong import. A name nothing defines keeps the message it had.
Bare DS0205 was not wrong, it was incomplete, and the cost of the missing half was a consumer routing every arithmetic decision back through their host, and a language row on two documents for a module that already existed.
1.11.0
A module this language specifies could not be called, and the reason was a derivation nobody had looked at. A capability is reached through a namespace, and the namespace was the last segment of the import's path — module.split('/').pop(), in the checker, in the lowering and in the pass that builds the hot-path capability table. For drift/2d, a surface in SPECIFIED_MODULES since the language shipped, that segment is 2d: a number followed by an identifier, which the lexer refuses before the checker sees the call.
It was found by building a provider for it. An engine wired the module, ran a script at it, and got `d` is not defined from inside its own call — a message naming nothing an author could act on. Five spellings were tried against a real registry and every one failed:
| written | answer |
|---|---|
sprite(batch, …) after import { sprite } |
DS0205 — sprite is not defined |
2d.sprite(batch, …) |
DS0205 — d is not defined, then DS0260 |
import { sprite as quad } |
DS0102 — expected } but found as |
import * as g2d from "drift/2d" |
DS0101 — expected { but found * |
`2d`.sprite(batch, …) |
DS0003 — unexpected character |
ui.layout(…) and terrain.heightAt(…) compiled in the same harness, so the failure was the name and nothing else. The provider was withdrawn rather than shipped, because a capability nobody can spell is worse than a module the linker refuses by name: the refusal at least says so.
An import may name its own namespace
import { sprite } from "drift/2d" as sprites
fn hud(batch: SpriteBatch) {
sprites.sprite(batch, 0, 10, 10, 32, 32)
}
The fix is at the import rather than at the derivation, and that is the choice worth recording. A rule that turned a bad segment into a good identifier would be the language picking a name on the author's behalf, and every candidate rule is a guess somebody has to memorise. A namespace the author writes is one they can read back.
It answers two things that had nothing to do with drift/2d: two modules whose paths end in the same segment, which previously merged into one namespace, and a name that reads better in one file than the path does. as was already a keyword, so this costs no new token and no grammar change.
The alias is a name in your file and nothing else. The module string is still what the linker checks, still what metadata.requires reports, and still what the emitted __bind looks up on the host — so nothing a host implements changes, and a file that names no alias means exactly what it meant before.
DS0139, so the next one fails at the import
A non-relative import whose namespace is not an identifier and which names no alias is refused where it is written, with the line to write:
`drift/2d`cannot be named directly, because2dis not an identifier. Import it with a
name:from "drift/2d" as d2d
Relative specifiers are exempt: they name a file and bring their names in directly, so ./2d is still a legal import of a legal file name. DS0138 is the smaller sibling — as with nothing after it.
And the list is guarded. SPECIFIED_MODULES may hold a segment that is not an identifier, and every one of them is now named in link.test.ts, so adding a second by accident fails there rather than waiting years for somebody to build a provider for it. Today the list is one entry long.
One thing was quietly wrong and is fixed with it
The hot-path pass looks a capability up by the callee written in the file, and the table it looks in was keyed by the module's own last segment. With aliases that misses every renamed call — and misses it by finding nothing, which reads as a call the pass has no opinion about rather than as a lookup that went wrong. It is keyed by the file's own namespaces now. Without the fix, a @hot function calling audio.play through an alias would have compiled clean.
namespace.ts holds the derivation and the identifier rule, in one place, because there were three copies of the first and none of the second.
Twelve tests, over the parser, the end-to-end compile, the emitted __bind, the formatter's round-trip, the hot-path table and the specified list.
1.10.0
Nine semantic passes each carried their own recursive walk over the compiler's tree, and every one of them ended in a permissive default — so a node kind a pass did not name was skipped in silence. A source review asked whether @hot was complete. It was not, and neither was anything else built the same way: five programs compiled clean and failed at run time, and two inference passes let @deterministic and a system's declared access pass for code that violated both.
The five, all of which shipped:
- a task local read inside
[a, b]orxs[i]emitted a bare identifier — aReferenceErroron the first resume that reached the line; - a
letinside aforloop in a task was written to the frame and read from a scope; - an
emitinside aforloop compiled to$rt.emit(…)in a module that declared no$rtand exported no__runtime; - a
?inside aforloop or ascopeleft its function unwrapped, so the internal carrier object escaped instead of becoming anErr; @hotaccepted a list literal, an allocation anywhere in a loop body, and anything reached through a condition, anifLetsubject, an index subscript, a spawn argument or an event payload.
ir/walk.ts and astWalk.ts are the one description of what a node contains now, both exhaustive on never. A pass that only recurses asks them and names no kind, so a node added later is walked correctly without it changing; a pass making a claim about a kind — what allocates, how to rebuild a node — keeps its own switch and ends it on never, because a claim has no safe default. walkers.test.ts asserts the matrix by nested position rather than by feature.
await inside for … in used to throw a bare internal error from the emitter, and now it works. The cutter splits a list loop the way it already split a while, with the list and the index on the frame. A continue lands on the increment rather than the loop head, because one that jumped to the head would re-bind the same element for ever — a task alive, resuming, and making no progress. A query loop still refuses to suspend, and now refuses however deeply the await is nested.
i64 and u64 claimed a range the backend cannot represent. A JavaScript number is a double: exact to 2^53 - 1 and no further. The bounds were computed as 2 ** 64 - 1, which is 2^64 as a double, so nothing was ever outside a u64 and sums went on running with bits already lost. Their domain is now -(2^53 - 1) … 2^53 - 1 and 0 … 2^53 - 1; the nominal width still means what it says wherever a value is stored, which is why the types stay. Wrapping is refused on both by name, since two values near the top add to almost 2^54 where the sum is rounded before anything can reduce it; checked and saturating arithmetic are untouched. A literal is now checked against its type at every width — let n: u8 = 300 compiled before this — and a negative literal is checked after its sign is applied, which was wrong in both directions.
A live task is rebound only when its frame and its resume points agree. A suspended task was being handed to new code on its exported name alone. Each task now emits its frame layout and the shape of its suspensions, and patchModule plans every live task before the first write and refuses atomically. Body edits, duration changes and added or removed locals are carried; a type change or a moved await is refused, naming what it is. The package README has the table.
typeKey returned type.kind for anything it did not special-case, so every integer width keyed as int, every enum as enum and every list as list — and callers compare it for equality. A migration would carry a u8 into an i64, an interface change from u8 to i64 hashed identically, and, because a component field's type is this string while a host's column table is keyed by the width, every integer component field threw at bind. Nothing in the corpus declares one, which is why it stood.
A module specifier is serialised rather than quoted. The parser keeps whatever sits between the quotes and a filesystem host resolves it as a path, so a directory holding an apostrophe produced generated JavaScript that would not parse. AGENTS.md carries the rule this generalises to.
A mode: 'production' build is refused without a manifest and a registry, unless verification: 'none' says it is deliberately unverified. Without them nothing links and @deterministic is a claim nothing checked, and that was the shortest working config. Development is unchanged.
The fixed simulation step is a target's, not the parser's. CompileOptions.fixedStepsPerSecond defaults to 60; DS0133 lists the rates that divide your step, and the value a module was built with rides in its metadata, so a module cached at 30 and loaded by a host running at 60 can be told apart from one built for it.
SECURITY.md now separates capability enforcement from execution isolation: the model controls what a script may name, and gives no CPU budget, no memory quota and no containment. Untrusted content belongs in a Worker with capabilities passed across it.
1.9.0
The compiler and a host's runtime gave opposite instructions about the same line, and this one followed the compiler. A component named in query<…> is a component a host is handed: the engine that reported this refuses a query unless every component in it is declared in reads or writes, because a schedule derived from declarations is wrong the moment a system touches more than it says. The access analysis here walked a loop's body and never its terms, so a component that appears in a query and nowhere else was invisible to it — and DS0291 called the declaration the host demanded unused.
Following that advice produced a module that compiled clean and threw once a tick. It was found from play rather than from a check: the throw lands inside the host's schedule, so every system after it stops, and what the reporter saw was a different system stuttering — a vehicle moving in jerks — with the only evidence in a browser console no headless gate reads.
Two diagnostics change direction together. DS0291 no longer calls a query's own declaration unused, and DS0288 now refuses the omission, which moves the failure from once-per-tick at runtime to once at compile time. A with term counts, because it reaches the same host call; a without term does not, because an exclusion never looks inside a component and an entity it matched is not in the result at all. An entity term expands to everything it stands for, its own implicit component included, since that is what the host is handed.
A minor rather than a patch, because a build that was green can go red. A module querying a component it never declared is refused now. That module was already broken — it would have thrown in any host that checks — but the day it starts failing is a day a consumer can name.
This repository's own fixture was one of them: entityMeta.test.ts compiled a system that queried an entity and declared only one of its components, which is to say it had been asserting the shape of metadata for a module that could not run.
1.8.1
1.8.0 emitted a field it did not declare, and the first host to read it found out. A system's uses clauses reach the generated metadata — that is the whole point of them, since a host has to know what to supply — but DriftSystemInfo, the type a host reads that metadata back through, never gained the field. The data shipped; TypeScript denied it existed; a consumer needed a cast to reach what the release was for.
The fix is that there is one description now instead of two. EntityMetadata restated DriftModuleInfo's shapes by hand, and the two agreed only because neither had moved. The compiler's half is written in terms of the runtime's types now, so a field added to one is a compile error in the other until it is populated. uses is optional there, because a module compiled before 1.8.0 has none and a runtime that threw on one would refuse to load a module that works.
Nothing a script can write changed, and no emitted byte moved.