npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

driftscript

v1.13.0

Published

A strict, deterministic-aware, hot-reloadable scripting language. Host-neutral: it knows types, effects and capabilities, and never a scene, a mesh or a mix bus

Readme

DriftScript

A strict, deterministic-aware, hot-reloadable scripting language for consumer-authored behaviour.

.drs source is compiled by your toolchain to JavaScript ES modules with source maps, and loaded through a small runtime. It has explicit host bindings, compiler-checked effects, structured cancellable tasks, and state that survives a reload.

npm i driftscript

Zero runtime dependencies. npm ls driftscript shows one package, and that is a claim you can check in ten seconds, not one you have to take on trust.

The language outlives its first host

DriftScript is a reusable language, and DriftEngine is its first host.

That is a structural claim, not a slogan. This package depends on nothing: no engine, and not even for a type — and three separate mechanisms in its repository fail if that stops being true:

| Mechanism | What it catches | |---|---| | scripts/boundaries.test.mjs | an import or a declared dependency reaching a foreign package, type-only ones included | | scripts/version.test.mjs | the language drifting onto somebody else's version line | | scripts/size-gate.test.mjs | the runtime fixture failing to bundle with no host present |

What that buys you: everything under std/ works in any host, and everything under a host's own prefix is that host's. The prefix in an import tells you which you are looking at.

Hello world

fn hello() -> String {
    return "hello, world"
}

Nothing in that file needs a host. It compiles, and it runs, against a target that provides no capabilities at all.

The two prefixes

import { clamp } from "std/math"      // the language's own. Pure. Every host.
import { play } from "drift/audio"    // a host's. Has an effect. That host.

A target declares which non-std modules it provides. A module it does not provide is refused at link time, in words — naming the module, the target, and whether the capability exists in that host at all. A .drs file using an unprovided surface still parses and still type-checks; only linking declines it.

An imported name is reached through its module: import { play } from "drift/audio" is called as audio.play(…), and import { clamp } from "std/math" as math.clamp(…). The import is what makes the name available and what the linker checks; the prefix is what you write.

A file may name that prefix itself, which matters when the path's own last segment is not one a call could be written with:

import { sprite } from "drift/2d" as sprites    // `2d.sprite(…)` does not lex; this does

fn hud(batch: SpriteBatch) {
    sprites.sprite(batch, 0, 10, 10, 32, 32)
}

The alias is a name in your file and nothing else: the module string is still what the linker checks and still what reaches the host. Two modules whose paths end in the same segment are the other reason to reach for one. A module whose segment is not an identifier and which names no alias is refused at the import, in words, with the line to write — DS0139.

Entities are language forms, not library calls

component Health {
    current: f64 = 100
    maximum: f64 = 100
}

entity Animal {
    require Health
    var target: Entity?
}

system Hunger {
    writes Health

    update at 1Hz {
        for a in query<Animal>().without<Resting>() {
            a.Health.current = a.Health.current - 1
        }
    }
}

prefab Guard {
    Health { current: 60 }
}

reads and writes are checked, not documentation. The compiler infers what a system touches — through the functions it calls, not only its own body — and refuses a declaration that omits a write, naming the system and the component. A declaration wider than the body is a warning instead, because widening deliberately is sometimes what an author means. A system that declares neither is fully described by the inference and says nothing about itself.

A query needs a world in scope. A system has one, bound as world; a fn or task has one when it declares a World parameter. There is no implicit argument anywhere, and a query loop with no world is refused saying so.

uses is how a system is handed anything else. A system takes no arguments, so a handle a host owns — a route, a behaviour tree, an input map — reaches one through a clause in its head:

system Walk {
    uses graph: NavGraph
    writes Placement

    update {
        for e in query<Placement>() {
            e.Placement.speed = navigation.remaining(navigation.pathOf(graph, e))
        }
    }
}

The type is one the host registered, and it is what the host is asked for: a resource is one per type, so the name is only a binding and two systems naming it differently are handed one object. Without this, anything per-entity had to be driven from the host — the walk over entities leaves the schedule and the declared-access checks, and only the rule stays in the script.

A query loop may not await. Its cursor comes from a pool and is given back when the loop ends, so a suspension would hold one across a frame where the result is already invalid.

The forms need a host that provides drift/ecs, and a .drs file using them requires that module whether or not it imports anything from it — the form is a use of the capability.

Adding it to a project

// vite.config.ts
import { driftScript } from 'driftscript/vite';
export default { plugins: [driftScript()] };

That configuration checks nothing, and it is the right one for a first look and the wrong one for a build that ships. With no capabilities and no manifest the plugin infers no effects and refuses no unprovided module, so @deterministic is a claim nothing verified and a script may call a surface the target does not have. It will not resolve std/math either, because with no registry there is nothing to resolve it against.

A mode: 'production' build is refused unless it has both. That is new in 1.10.0, and it exists because the shortest working config was also the one with both guarantees off — a shipping bundle in which @deterministic had been checked by nothing looked exactly like one in which it had. A build that genuinely wants no verification says so:

driftScript({ mode: 'production', verification: 'none' })

Development is unaffected, because an editor open on a file with no project configured has to report the errors it can see.

Turn both on by describing your host:

// vite.config.ts
import { createRegistry, defineTarget } from 'driftscript';
import { registerStd } from 'driftscript/std';
import { driftScript } from 'driftscript/vite';

const registry = createRegistry();
registerStd(registry);
// …and your own `defineCapability` calls, or `registryFromJson` on a file your host generates.

export default {
  plugins: [
    driftScript({
      registry,
      manifest: defineTarget('my-game', ['drift/ecs', 'drift/audio', 'drift/input']),
    }),
  ],
};

A bundler config is loaded by Node before any bundler exists, so whatever it imports has to be loadable by Node. This package is, deliberately: it ships compiled JavaScript with declarations beside it. A host whose own packages are not — extensionless relative imports are the usual reason — cannot be imported here at all, and for that case the plugin takes its registry as data:

driftScript({
  capabilities: fileURLToPath(import.meta.resolve('my-host/capabilities.json')),
  manifest: { name: 'my-game', provides: ['drift/ecs'] },
})

A registry describes and never invokes, so nothing in a definition is a function and all of it survives a process boundary. serializeRegistry writes that file and registryFromJson reads it back. The language server reads the same file, for the same reason.

Passing both registry and capabilities is refused. Neither one silently wins.

If your simulation does not run at sixty steps a second

update at 1Hz compiles to a stride — how many fixed steps to skip — so the number of fixed steps in a second is what turns a rate into one. It defaults to 60, which is what this language's first host runs. A host whose loop differs says so:

driftScript({ fixedStepsPerSecond: 30 })

Getting this wrong is silent: at 30 a second, a module built for 60 runs every system half as often as it asked for, and nothing in the output says why. The value a module was built with is recorded in its metadata as fixedStepsPerSecond, so a host can compare. DS0133 also lists the rates that divide your step when one does not — at 30, 4Hz is refused and 1, 2, 3, 5, 6, 10, 15, 30Hz are offered.

A capability that works at either float width

A parameter or return written float means f32 or f64, the same one throughout the call:

defineCapability({
  module: 'drift/ecs',
  name: 'lengthOf',
  signature: 'fn(x: float, y: float) -> float',
  params: [{ name: 'x', type: 'float' }, { name: 'y', type: 'float' }],
  returns: 'float',
  effects: ['pure'],
  deterministic: true,
  doc: 'The length of a vector.',
  implementation: 'drift.ecs.lengthOf',
});

The width is fixed by the first argument that has one; bare literals take whatever that turns out to be; a call where nothing fixes it is f32. So this widens what a signature accepts without adding a coercion anywhere — an f32 still does not become an f64 by itself, and a call mixing both widths is refused with f32.nearest named as the fix.

Implement it once, in double, and do not round. The compiler wraps an f32-resolved call in Math.fround and leaves an f64 one alone, because only it knows which width the call resolved to. std/math and std/time are written this way and are the worked example.

A parameter or return may also be written List<T> — a navigation capability answering a path is what that is for. Result<T, E> is deliberately not accepted across the boundary: a capability that can fail has an option and an effect to say so with.

A float return needs at least one float parameter, since otherwise nothing could fix the width; defineCapability refuses that at registration rather than letting every call quietly resolve to f32.

Telling TypeScript what a .drs import is

/// <reference types="driftscript/drs" />

One line, in any file your tsc project includes. It declares the module shape for *.drs, and what it deliberately does not declare is the generated exports: a .drs file's exports depend on what it declares, and discovering that means compiling it. So a generated function is reached through Record<string, unknown>, which is uncomfortable on purpose — it is exactly as much type safety as exists today.

Driving the runtime

import { loadModule, setClockSource, tickTasks } from 'driftscript';
import * as door from './door.drs';

setClockSource({ fixedSteps: () => steps, fixedStep: () => 1 / 60, frame: () => t, wall: () => t });
const module = loadModule(door as unknown as Record<string, unknown>);
// in simulate(): tickTasks();

setClockSource comes before loadModule if the file declares a task: a spawn runs its task up to the first await, and an await asks the clock what step it is on.

Reloading a module without restarting the scene

import { patchModule } from 'driftscript';

const result = patchModule(module, await import(`./door.drs?t=${Date.now()}`), { Door: doors });
if (!result.patched) console.warn(result.reason);

A patch either preserves the running state or is refused whole, and the second argument is why: records are plain objects your code holds, so a shape change is migrated across the instances you pass and refused in words if you pass none. Nothing is written until every part of the patch is known to be safe.

A suspended task is state too, and as of 1.10.0 it is checked like the rest. Each task's frame layout and the shape of its resume points ride in the module's metadata, so a patch across a live task either carries it or says why not:

| Edit, while a task is suspended | What happens | |---|---| | change what runs after the await | patched, the task keeps the time it has already waited | | change a wait's duration | patched; the current wait finishes on the old one | | add or remove a local | patched, the frame is migrated | | change a live local's type | refused, naming the field | | add, remove or move an await | refused, naming the task | | rename or remove the task | left running on its old code |

Before 1.10.0 a running task was rebound on its exported name alone, so the last two rows ran anyway — an old frame's resume point selected a continuation belonging to different source, and a newly added local was read from a frame that never had it. Neither failed anywhere visible.

Entry points

driftscript            the runtime      ships to a browser
driftscript/std        the standard library, for a host to register
driftscript/compiler   the compiler     build side only
driftscript/vite       the transform    build side only
driftscript/drs        the *.drs ambient declaration
driftscript/grammar.json   the TextMate grammar, generated from the compiler's token table

The compiler never reaches a production browser bundle. The exports map is what lets a bundler drop it, and a size gate in the repository is what proves it did.

driftscript/std is what a host calls to register the standard library into its own registry — registerStd(registry) and stdImplementations(). It belongs to the host, not to a script: a script reaches std/math through an import, and this is how the functions behind that import get there. A target may not decline any of it, which is what "standard" means.

driftscript/grammar.json is there so an editor that is not VSCode does not have to re-derive the language's keywords by reading the lexer. The VSCode client is in the same repository.

What it costs

497.8 kB packed, 2.2 MB unpacked, because the tarball carries compiled JavaScript, declarations, source maps and the source those maps point at. The runtime a browser actually receives is a few kilobytes gzipped — the compiler is behind its own entry point and a production bundle drops it.

Version

driftscript and driftscript-language move together, in that order, and nothing else moves them. An engine release does not, because an engine version must never quietly redefine what a language means. See the changelog.

Licence

MIT. See LICENSE.