Entities
Frogs catching fireflies over a pond, every rule a DriftScript system, with time that rewinds.
Starts examples/entities in this page, on WebGPU where your browser has it.
Entities is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /entities/.
Source
main.ts
examples/entities/main.ts
/**
* A pond at dusk: fireflies wander over the water, four frogs on lily pads catch the ones that come
* near, and new ones hatch to keep the number the pond is set to.
*
* Every rule is a DriftScript `system` over components the script declares, run by the engine's
* schedule against one world. The page builds the world, puts the frogs on their pads from the
* script's own prefab, and draws what the stores hold. It also keeps the last five seconds as world
* snapshots, so time can run backwards and pick up again from wherever it stops.
*/
import {
World,
buildSchedule,
createWorldSnapshot,
instantiate,
runSchedule,
} from '@driftengine/entities';
import type { ComponentType, Entity, Prefab, WorldSnapshot } from '@driftengine/entities';
import {
MeshBuilder,
computeLightMatrix,
createEnvironment,
createLineSegments,
} from '@driftengine/core';
import { bindModule, registerEntityModule } from '@driftengine/script';
import type { ComponentRegistry } from '@driftengine/script';
import { loadModule, patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { controls, flag, openStage } from '../common/stage';
import * as pondScript from './pond.drs';
const stage = await openStage({
directionalShadows: true,
outputTransform: 'aces',
sceneSamples: 4,
});
const { renderer, camera } = stage;
// #region host
/**
* The script's components become stores, its prefabs become prefabs, and its systems become a
* schedule. The registry and the world outlive every reload; the schedule is rebuilt on one.
*/
const pond = loadModule(pondScript as Record<string, unknown>);
const registry: ComponentRegistry = new Map();
const prefabs = new Map<string, Prefab>();
let registered = registerEntityModule(pond, registry);
for (const prefab of registered.prefabs) prefabs.set(prefab.name, prefab);
const bound = bindModule(pond, { entities: { components: registry, prefabs } });
if (!bound.bound) throw new Error(bound.reason);
const world = new World();
let schedule = buildSchedule(registered.systems);
if (import.meta.hot) {
import.meta.hot.accept('./pond.drs', (next) => {
if (next === undefined) return;
patchModule(pond, next as Record<string, unknown>);
/* The stores are kept, so every fly and frog keeps its components; the systems are new. */
registered = registerEntityModule(pond, registry);
for (const prefab of registered.prefabs) prefabs.set(prefab.name, prefab);
schedule = buildSchedule(registered.systems);
});
}
// #endregion
const component = (name: string): ComponentType => {
const type = registry.get(name);
if (type === undefined) throw new Error(`pond.drs declares no ${name}`);
return type;
};
const Position = component('Position');
const Frog = component('Frog');
const Fly = component('Fly');
const Pond = component('Pond');
// #region frogs
/** A frog on each lily pad, from the script's own prefab, with the page saying where. */
const PADS: [number, number][] = [
[4, 0],
[0, 4],
[-4, 0],
[0, -4],
];
const sitter = prefabs.get('Sitter');
if (sitter === undefined) throw new Error('pond.drs declares no Sitter');
const frogs: Entity[] = PADS.map(([x, z]) =>
instantiate(world, sitter, { Position: { x, y: 0.15, z } }),
);
const pondEntity = world.create();
world.add(pondEntity, Pond, { flies: 12 });
// #endregion
let flies = Number(flag('flies', '12'));
let running = flag('time', 'forward') === 'forward';
// #region rewind
/**
* Five seconds of the whole world, a snapshot a tick. A slot is reused once it is written, so after
* the first lap the ring allocates nothing.
*/
const RING = 300;
const ring: WorldSnapshot[] = Array.from({ length: RING }, createWorldSnapshot);
const ringTicks = new Float64Array(RING);
let head = 0;
let held = 0;
let tick = 0;
function step(): void {
/* The switch's number wins over whatever the world held, rewound or not. */
world.write(pondEntity, Pond, 'flies', flies);
runSchedule(world, schedule, tick);
tick += 1;
world.saveInto(ring[head % RING] as WorldSnapshot);
ringTicks[head % RING] = tick;
head += 1;
held = Math.min(held + 1, RING);
}
function stepBack(): void {
if (held <= 1) return;
head -= 1;
held -= 1;
const at = (head - 1 + RING) % RING;
world.loadFrom(ring[at] as WorldSnapshot);
tick = ringTicks[at] ?? tick;
}
// #endregion
controls([
{
key: 'flies',
label: 'flies',
value: String(flies),
options: ['12', '40'].map((n) => ({ text: n, value: n })),
change: (value) => {
flies = Number(value);
},
},
{
key: 'time',
label: 'time',
value: running ? 'forward' : 'back',
options: ['forward', 'back'].map((t) => ({ text: t, value: t })),
change: (value) => {
running = value === 'forward';
},
},
]);
/* Drawing: the pond, its pads and reeds, the frogs, the flies, and a frog's tongue. */
const scenery = new MeshBuilder()
.addBox([0, -0.2, 0], [40, 0.2, 40], [0.2, 0.28, 0.16])
.addCylinder([0, 0.01, 0], 7.2, 0.02, 'y', [0.16, 0.14, 0.1], 0, 48)
.addCylinder([0, 0.03, 0], 7, 0.02, 'y', [0.08, 0.16, 0.22], 0, 48);
for (const [x, z] of PADS)
scenery.addCylinder([x, 0.06, z], 0.75, 0.02, 'y', [0.2, 0.42, 0.18], 0, 20);
/* Reeds round the edge, drawn and not casting: a low sun would lay their shadows across the water. */
const reeds = new MeshBuilder();
for (let k = 0; k < 60; k += 1) {
const angle = k * 2.399;
const radius = 7.3 + (k % 5) * 0.2;
const height = 0.35 + (k % 3) * 0.15;
reeds.addCylinder(
[Math.cos(angle) * radius, height, Math.sin(angle) * radius],
0.025,
height,
'y',
[0.36, 0.42, 0.2],
);
}
const reedMesh = renderer.createMesh(reeds.build());
const sceneryMesh = renderer.createMesh(scenery.build());
const frogMesh = renderer.createMesh(
new MeshBuilder()
.addSphere([0, 0.22, 0], 0.28, [0.32, 0.55, 0.22], 0, 16, 10)
.addSphere([-0.12, 0.42, 0.12], 0.08, [0.85, 0.85, 0.6])
.addSphere([0.12, 0.42, 0.12], 0.08, [0.85, 0.85, 0.6])
.addSphere([-0.12, 0.44, 0.18], 0.035, [0.05, 0.05, 0.05])
.addSphere([0.12, 0.44, 0.18], 0.035, [0.05, 0.05, 0.05])
.build(),
);
const flyMesh = renderer.createMesh(
new MeshBuilder().addSphere([0, 0, 0], 0.07, [1, 0.62, 0.18], 1.4, 8, 5).build(),
);
const tongues = createLineSegments(PADS.length);
const tongueBatch = renderer.createLines(PADS.length, 'tongues');
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const frogModels = frogs.map(() => new Float32Array(16));
const flyModel = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const env = createEnvironment({
directionalDir: [-0.5, 0.45, 0.6],
directionalColor: [1.1, 0.75, 0.55],
ambient: [0.16, 0.18, 0.28],
ambientGround: [0.06, 0.06, 0.05],
nightFactor: 0.8,
emissiveGain: 1.6,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.7;
env.shadowDepthSpan = computeLightMatrix(
env.directionalDir,
0,
0.5,
0,
10,
renderer.shadowMapSize,
lightMatrix,
);
const readout = createReadout(renderer, 2);
let time = 0;
stage.run({
simulate() {
if (running) step();
else stepBack();
},
render() {
time += 1 / 60;
camera.fovYDeg = 45;
camera.position[0] = Math.sin(time * 0.04) * 3;
camera.position[1] = 7.5;
camera.position[2] = 11;
camera.lookAt(0, 0.4, 0.5);
// #region views
/* A host reads the stores' columns directly, the way any renderer would. */
const positions = world.view(Position);
const frogColumns = world.view(Frog);
const spot = (entity: Entity): number => positions.sparse[entity % 2 ** 26] ?? 0;
// #endregion
frogs.forEach((frog, at) => {
const i = spot(frog);
const x = (positions.x as Float32Array)[i] ?? 0;
const z = (positions.z as Float32Array)[i] ?? 0;
/* Facing the middle of the pond. */
const heading = Math.atan2(-x, -z);
const c = Math.cos(heading);
const s = Math.sin(heading);
frogModels[at]?.set([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, x, 0.15, z, 1]);
});
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters((sink) => {
sink.mesh(sceneryMesh, IDENTITY);
for (const model of frogModels) sink.mesh(frogMesh, model);
});
renderer.endShadowPass();
renderer.beginFrame([0.12, 0.1, 0.18]);
renderer.bindMeshPass(camera, env);
renderer.drawMesh(sceneryMesh, IDENTITY);
renderer.drawMesh(reedMesh, IDENTITY);
for (const model of frogModels) renderer.drawMesh(frogMesh, model);
// #region flies
/* Every entity with a Fly and a Position, drawn where its Position column says. */
let count = 0;
for (const fly of world.query(Fly, Position)) {
const i = spot(fly);
flyModel[12] = (positions.x as Float32Array)[i] ?? 0;
flyModel[13] = (positions.y as Float32Array)[i] ?? 0;
flyModel[14] = (positions.z as Float32Array)[i] ?? 0;
renderer.drawMesh(flyMesh, flyModel);
count += 1;
}
// #endregion
/* A tongue for each frog that has just caught something, from its mouth to where the fly was. */
let out = 0;
let caught = 0;
for (const frog of frogs) {
const f = frogColumns.sparse[frog % 2 ** 26] ?? 0;
caught += (frogColumns.caught as Uint32Array)[f] ?? 0;
if (((frogColumns.tongue as Float32Array)[f] ?? 0) <= 0) continue;
const i = spot(frog);
tongues.from.set(
[(positions.x as Float32Array)[i] ?? 0, 0.45, (positions.z as Float32Array)[i] ?? 0],
out * 3,
);
tongues.to.set(
[
(frogColumns.tongueX as Float32Array)[f] ?? 0,
(frogColumns.tongueY as Float32Array)[f] ?? 0,
(frogColumns.tongueZ as Float32Array)[f] ?? 0,
],
out * 3,
);
out += 1;
}
tongues.count = out;
renderer.drawLines(tongueBatch, tongues, IDENTITY, camera, env, [1.4, 0.3, 0.35], 0.05, 1, 0.3);
readout.set(0, `${count} FLIES ${caught} CAUGHT ${world.liveCount} ENTITIES`);
readout.set(1, running ? `TICK ${tick}` : `REWINDING ${((held - 1) / 60).toFixed(1)} S LEFT`);
readout.draw(time);
renderer.endFrame();
},
});pond.drs
examples/entities/pond.drs
// The pond: flies wander over the water, frogs on the lily pads catch the ones that come near, and
// new flies hatch to keep the number the pond is set to.
//
// Under `npm run examples`, change a rule and save: the pond runs by it from the next tick and
// keeps every fly and frog it has. Try a longer reach, frogs that rest longer, or flies that keep low.
import { alive, count, destroy, instantiate } from "drift/ecs"
import { range } from "drift/random"
import { atan2, clamp, cos, max, sin } from "std/math"
// #region components
component Position {
x: f32 = 0
y: f32 = 0
z: f32 = 0
}
component Velocity {
x: f32 = 0
y: f32 = 0
z: f32 = 0
}
// A fly, and its own seed and count of turns, which together pick its next heading.
component Fly {
seed: u32 = 0
turns: u32 = 0
}
// A frog: how far its tongue reaches, the fly it has its eye on if any, how many it has caught,
// how long before it can catch again, and how long its tongue stays out and where it went.
component Frog {
reach: f32 = 2.5
target: Entity?
caught: u32 = 0
resting: f32 = 0
tongue: f32 = 0
tongueX: f32 = 0
tongueY: f32 = 0
tongueZ: f32 = 0
}
// The pond itself: how many flies it keeps, and how many have hatched.
component Pond {
flies: u32 = 12
hatched: u32 = 0
}
// #endregion
// #region prefabs
// What a hatchling and a frog start as. Every value is a constant: where each goes is set after.
prefab Hatchling {
Position { y: 1 }
Velocity {}
Fly {}
}
prefab Sitter {
Position {}
Frog { reach: 2.5 }
}
// #endregion
// #region wander
// A new heading for every fly twice a second, turning back toward the middle past six metres out.
system Wander {
reads Position
writes Fly
writes Velocity
update at 2Hz {
for e in query<Fly, Velocity, Position>() {
e.Fly.turns = e.Fly.turns +% 1
let seed = e.Fly.seed +% e.Fly.turns *% 2654435761
var angle = random.range(seed, 0, 6.2831853)
if e.Position.x * e.Position.x + e.Position.z * e.Position.z > 36 {
angle = math.atan2(0 - e.Position.z, 0 - e.Position.x)
}
e.Velocity.x = math.cos(angle) * 1.2
e.Velocity.z = math.sin(angle) * 1.2
e.Velocity.y = random.range(seed +% 1, -0.4, 0.4)
}
}
}
// #endregion
// #region move
// Everything that has a velocity moves by it, a sixtieth of a second each fixed step.
system Move {
reads Velocity
writes Position
update {
for e in query<Position, Velocity>() {
e.Position.x = e.Position.x + e.Velocity.x / 60
e.Position.y = math.clamp(e.Position.y + e.Velocity.y / 60, 0.4, 2)
e.Position.z = e.Position.z + e.Velocity.z / 60
}
}
}
// #endregion
// #region hunt
// 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)
}
}
}
}
}
// #endregion
// #region catch
// A frog with a fly in its eye catches it: out goes the tongue, and the fly is gone.
system Catch {
reads Position
writes Frog
update {
for frog in query<Frog, Position>() {
frog.Frog.tongue = math.max(frog.Frog.tongue - 1 / 60, 0)
frog.Frog.resting = math.max(frog.Frog.resting - 1 / 60, 0)
if let fly = frog.Frog.target {
if ecs.alive(world, fly) {
frog.Frog.tongueX = fly.Position.x
frog.Frog.tongueY = fly.Position.y
frog.Frog.tongueZ = fly.Position.z
frog.Frog.tongue = 0.2
frog.Frog.resting = 4
frog.Frog.caught = frog.Frog.caught + 1
ecs.destroy(world, fly)
}
frog.Frog.target = none
}
}
}
}
// #endregion
// #region hatch
// Four times a second, while the pond has fewer flies than it keeps, one hatches over the water.
// A hatchling is made with every component its prefab names, so the system declares all three: the
// compiler sees the fields this body writes, and the schedule checks the components it adds.
system Hatch {
writes Pond
writes Position
writes Velocity
writes Fly
update at 4Hz {
for pond in query<Pond>() {
if ecs.count(world, "Fly") < pond.Pond.flies {
pond.Pond.hatched = pond.Pond.hatched +% 1
let seed = pond.Pond.hatched *% 2246822519
let fly = ecs.instantiate(world, "Hatchling")
fly.Position.x = random.range(seed, -5, 5)
fly.Position.z = random.range(seed +% 1, -5, 5)
// Hatching from the water, it rises before it wanders.
fly.Velocity.y = 0.6
fly.Fly.seed = seed
}
}
}
}
// #endregion