Saves
A garden bed that saves to a server that can fail, loads across a change, and keeps preferences.
Starts examples/saves in this page, on WebGPU where your browser has it.
Saves and preferences is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /saves/.
Source
main.ts
examples/saves/main.ts
/**
* A garden bed that saves: seeds sown and growing in DriftScript, the whole bed written out and read
* back as an entity world, an autosave the script holds back while the last one is on its way, and
* the player's pace and season kept as preferences.
*
* The saves go to a server that is this page pretending: it answers after a delay and keeps what it
* is sent in your browser's storage, so a reload finds it. The strip makes it slow or takes it
* offline, and the readout says what the store is doing about it. Save, Load and Clear are on the
* right. Pace and season are preferences: change one and reload, and it stays.
*/
import {
MeshBuilder,
PreferenceStore,
RemoteSaveStore,
computeLightMatrix,
createEnvironment,
defaultSaveTimer,
defaultStore,
} from '@driftengine/core';
import type { PreferenceSchema, SaveBackend } from '@driftengine/core';
import {
World,
buildSchedule,
deserializeWorld,
runSchedule,
serializeWorld,
} from '@driftengine/entities';
import type { ComponentType, Entity, SerializedScene } from '@driftengine/entities';
import { bindModule, registerEntityModule } from '@driftengine/script';
import type { ComponentRegistry } from '@driftengine/script';
import { loadModule, patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as gardenScript from './garden.drs';
const stage = await openStage({
directionalShadows: true,
outputTransform: 'aces',
sceneSamples: 4,
});
const { renderer, camera } = stage;
const panel = document.querySelector<HTMLElement>('#panel');
const note = document.querySelector<HTMLElement>('#note');
const readout = createReadout(renderer, 3, { clearRight: () => (panel?.offsetWidth ?? 0) + 24 });
// #region preferences
/* What the player chose, validated field by field: a pace outside its range is clamped, and a
season that is not one of the two falls back to summer. */
interface GardenPreferences {
pace: number;
season: string;
}
const SCHEMA: PreferenceSchema<GardenPreferences> = {
key: 'driftengine.examples.saves.preferences',
defaults: { pace: 1, season: 'summer' },
ranges: { pace: [0.25, 4] },
options: { season: ['summer', 'autumn'] },
};
const preferences = new PreferenceStore(SCHEMA);
// #endregion
/* A link that names a pace or a season sets it, through the same checks a stored one gets. */
preferences.update({
pace: Number(flag('pace', String(preferences.value.pace))),
season: flag('season', preferences.value.season),
});
// #region server
/* A server that is this page pretending. It answers after a delay, keeps what it is sent in the
browser's own storage so that a reload finds it, and fails when the strip says it is down. */
type Server = 'online' | 'slow' | 'offline';
let server = flag('server', 'online') as Server;
const HELD = 'driftengine.examples.saves.server';
const local = defaultStore();
const answer = (): Promise<void> =>
new Promise((done) => setTimeout(done, server === 'slow' ? 2500 : 300));
const backend: SaveBackend = {
async load() {
await answer();
if (server === 'offline') throw new Error('the server did not answer');
return JSON.parse(local.read(HELD) ?? '[]') as [string, string][];
},
async save(changes) {
await answer();
if (server === 'offline') throw new Error('the server did not answer');
const held = new Map(JSON.parse(local.read(HELD) ?? '[]') as [string, string][]);
for (const [key, value] of changes) {
if (value === null) held.delete(key);
else held.set(key, value);
}
local.write(HELD, JSON.stringify([...held]));
},
};
// #endregion
// #region store
/* The synchronous seam over the server: reads come from a copy loaded once, and writes are batched,
sent after a quiet quarter second, and retried with a growing delay when the server fails. */
const saves = new RemoteSaveStore(backend, defaultSaveTimer(), {
flushDelayMs: 250,
maxAttempts: 3,
retryDelayMs: 1000,
});
await saves.load();
/* A write is durable once the server has it. Leaving the page is the last chance to send one. */
addEventListener('pagehide', () => void saves.flush());
// #endregion
// #region world
/* The script's components become stores and its systems a schedule, as in the entities example. */
const garden = loadModule(gardenScript as Record<string, unknown>);
const registry: ComponentRegistry = new Map();
let registered = registerEntityModule(garden, registry);
const bound = bindModule(garden, {
entities: { components: registry, prefabs: new Map(registered.prefabs.map((p) => [p.name, p])) },
});
if (!bound.bound) throw new Error(bound.reason);
let schedule = buildSchedule(registered.systems);
interface Autosave {
every: number;
since: number;
written: number;
held: number;
}
const autosaves = exported<() => Autosave>(garden, 'createAutosave')();
if (import.meta.hot) {
import.meta.hot.accept('./garden.drs', (next) => {
if (next === undefined) return;
patchModule(garden, next as Record<string, unknown>, { Autosave: [autosaves] });
registered = registerEntityModule(garden, registry);
schedule = buildSchedule(registered.systems);
});
}
/* Looked up each time: a hot edit that adds a field gives the component a new schema. */
const type = (name: string): ComponentType => {
const found = registry.get(name);
if (found === undefined) throw new Error(`garden.drs declares no ${name}`);
return found;
};
let world = new World();
let bed = plantBed();
function plantBed(): Entity {
const entity = world.create();
world.add(entity, type('Bed'), {});
return entity;
}
// #endregion
// #region save
/* The bed and every plant, written out with each component's schema beside it. */
const SLOT = 'driftengine.examples.saves.slot';
const written = (): string => JSON.stringify(serializeWorld(world, [type('Bed'), type('Plant')]));
function save(): string {
saves.write(SLOT, written());
return `saved ${world.count(type('Plant'))} plants; the store sends them`;
}
/* Into a fresh world, which replaces the running one only if every entity loaded. */
function load(key: string): string {
const text = saves.read(key);
if (text === null) return 'nothing is saved there yet';
const fresh = new World();
const result = deserializeWorld(fresh, JSON.parse(text) as SerializedScene, [
type('Bed'),
type('Plant'),
]);
if (!result.loaded) return result.reason;
const found = result.entities.find((entity) => fresh.has(entity, type('Bed')));
if (found === undefined) return 'that save holds no bed';
world = fresh;
bed = found;
return `loaded ${fresh.count(type('Plant'))} plants`;
}
// #endregion
function clear(): string {
world = new World();
bed = plantBed();
return 'cleared; the bed starts again from its first seed';
}
/* The panel's three buttons, each answering in a sentence. */
const buttons: Record<string, () => string> = {
save,
load: () => load(SLOT),
autosave: () => load('driftengine.examples.saves.auto'),
clear,
};
for (const button of document.querySelectorAll<HTMLButtonElement>('#panel button')) {
button.addEventListener('click', () => {
const act = buttons[button.dataset['act'] ?? ''];
if (act !== undefined && note !== null) note.textContent = act();
});
}
controls([
{
key: 'server',
label: 'Server',
value: server,
options: [
{ text: 'online', value: 'online' },
{ text: 'slow', value: 'slow' },
{ text: 'offline', value: 'offline' },
],
change: (value) => {
server = value as Server;
/* Back online, whatever was kept is sent now. */
if (server !== 'offline') void saves.flush();
},
},
{
key: 'pace',
label: 'Pace',
value: String(preferences.value.pace),
options: [
{ text: '½×', value: '0.5' },
{ text: '1×', value: '1' },
{ text: '3×', value: '3' },
],
change: (value) => preferences.update({ pace: Number(value) }),
},
{
key: 'season',
label: 'Season',
value: preferences.value.season,
options: [
{ text: 'summer', value: 'summer' },
{ text: 'autumn', value: 'autumn' },
],
change: (value) => preferences.update({ season: value }),
},
]);
/* The bed, its border, and a plant: a stem and a crown, one mesh per season, scaled as it grows. */
const soil = renderer.createMesh(
new MeshBuilder()
.addBox([0, -0.1, 0], [8, 0.1, 8], [0.36, 0.42, 0.3])
.addBox([0, 0.02, 0], [3, 0.06, 3], [0.3, 0.2, 0.13])
.addBox([0, 0.06, -3.05], [3.1, 0.1, 0.05], [0.55, 0.5, 0.42])
.addBox([0, 0.06, 3.05], [3.1, 0.1, 0.05], [0.55, 0.5, 0.42])
.addBox([-3.05, 0.06, 0], [0.05, 0.1, 3], [0.55, 0.5, 0.42])
.addBox([3.05, 0.06, 0], [0.05, 0.1, 3], [0.55, 0.5, 0.42])
.build(),
);
const plantOf = (crown: [number, number, number]): ReturnType<typeof renderer.createMesh> =>
renderer.createMesh(
new MeshBuilder()
.addCylinder([0, 0.4, 0], 0.03, 0.4, 'y', [0.35, 0.5, 0.2])
.addSphere([0, 0.85, 0], 0.26, crown, 0, 12, 6, 0.2)
.build(),
);
const SEASONS: Record<string, ReturnType<typeof renderer.createMesh>> = {
summer: plantOf([0.3, 0.62, 0.22]),
autumn: plantOf([0.86, 0.45, 0.16]),
};
const env = createEnvironment({
directionalDir: [0.45, 0.8, 0.35],
directionalColor: [1.6, 1.5, 1.35],
ambient: [0.3, 0.34, 0.4],
ambientGround: [0.12, 0.11, 0.09],
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.6;
env.shadowDepthSpan = computeLightMatrix(
env.directionalDir,
0,
0.5,
0,
6,
renderer.shadowMapSize,
lightMatrix,
);
const at = new Float32Array(16);
const identity = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
/* A plant's matrix: where it stands, scaled to how tall it is and how far it has grown. */
function placePlant(x: number, z: number, scale: number): Float32Array {
at.fill(0);
at[0] = at[5] = at[10] = scale;
at[15] = 1;
at[12] = x;
at[14] = z;
return at;
}
let time = 0;
let offered = 0;
let tick = 0;
const GROWN = 20;
stage.run({
simulate(dt) {
time += dt;
/* The preference wins over whatever the bed held, saved or not. */
world.write(bed, type('Bed'), 'pace', preferences.value.pace);
runSchedule(world, schedule, tick);
tick += 1;
/* Once a second the script is offered the garden, and decides whether to write it. */
offered += dt;
if (offered >= 1) {
offered -= 1;
exported<(a: Autosave, s: RemoteSaveStore, g: string, dt: number) => boolean>(
garden,
'autosave',
)(autosaves, saves, written(), 1);
}
},
render() {
const orbit = time * 0.05;
camera.position[0] = Math.sin(orbit) * 5;
camera.position[1] = 3.4;
camera.position[2] = Math.cos(orbit) * 5;
camera.lookAt(0, 0.2, 0);
const Plant = type('Plant');
const plant = SEASONS[preferences.value.season] ?? SEASONS['summer'];
const drawPlants = (draw: (matrix: Float32Array) => void): void => {
for (const entity of world.query(Plant)) {
const age = world.read(entity, Plant, 'age') as number;
const tall = world.read(entity, Plant, 'tall') as number;
const x = world.read(entity, Plant, 'x') as number;
const z = world.read(entity, Plant, 'z') as number;
draw(placePlant(x, z, tall * (0.15 + (0.85 * Math.min(age, GROWN)) / GROWN)));
}
};
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters((sink) => {
sink.mesh(soil, identity);
if (plant !== undefined) drawPlants((matrix) => sink.mesh(plant, matrix));
});
renderer.endShadowPass();
renderer.beginFrame([0.55, 0.68, 0.82]);
renderer.bindMeshPass(camera, env);
renderer.drawMesh(soil, identity);
if (plant !== undefined) drawPlants((matrix) => renderer.drawMesh(plant, matrix));
const status = saves.status;
readout.set(
0,
`BED ${world.count(Plant)} OF 40, PACE ${preferences.value.pace}X, ${preferences.value.season.toUpperCase()}`,
);
readout.set(
1,
status === 'failed'
? `SAVES FAILING, ${saves.pending} KEPT TO RETRY`
: `SAVES ${status.toUpperCase()}${saves.pending > 0 ? `, ${saves.pending} WAITING` : ''}`,
);
readout.set(2, `AUTOSAVES ${autosaves.written}, HELD BACK ${autosaves.held}`);
readout.draw(time);
renderer.endFrame();
},
});garden.drs
examples/saves/garden.drs
// The garden: a seed sown every second until the bed is full, every plant growing at the pace the
// player chose, and an autosave the script holds back while the last one is still on its way.
//
// Under `npm run examples`, change a rule and save: the bed grows by it from the next tick and
// keeps every plant. Try sowing a fuller bed, taller plants, or a new field on Plant: Load still
// reads a garden saved before the field existed, and gives it the field's default.
import { count, instantiate } from "drift/ecs"
import { pendingSaves, write } from "drift/persistence"
import { range } from "drift/random"
import { min } from "std/math"
// How many plants the bed holds, and how many seconds a plant takes to grow at the normal pace.
let FULL: u32 = 40
let GROWN: f32 = 20
// #region components
component Plant {
x: f32 = 0
z: f32 = 0
// Seconds grown, and how tall the plant stands once it is grown, in metres.
age: f32 = 0
tall: f32 = 1
}
// The bed: its own seed, how many seeds have gone in, and how fast it grows, which the page writes
// from the player's preference.
component Bed {
seed: u32 = 7
sown: u32 = 0
pace: f32 = 1
}
// #endregion
prefab Seedling {
Plant {}
}
// #region sow
// Once a second, while the bed has room, a seed goes in where the bed's own sequence puts it, so a
// bed loaded from a save sows the same plants it would have sown.
system Sow {
writes Bed
writes Plant
update at 1Hz {
for bed in query<Bed>() {
if ecs.count(world, "Plant") < FULL {
bed.Bed.sown = bed.Bed.sown +% 1
let seed = (bed.Bed.seed +% bed.Bed.sown) *% 2246822519
let plant = ecs.instantiate(world, "Seedling")
plant.Plant.x = random.range(seed, -2.6, 2.6)
plant.Plant.z = random.range(seed +% 1, -2.6, 2.6)
plant.Plant.tall = random.range(seed +% 2, 0.4, 1.3)
}
}
}
}
// #endregion
// #region grow
// Every plant grows a sixtieth of a second each fixed step, times the bed's pace, until it is grown.
system Grow {
reads Bed
writes Plant
update {
var pace: f32 = 1
for bed in query<Bed>() {
pace = bed.Bed.pace
}
for e in query<Plant>() {
e.Plant.age = math.min(e.Plant.age + pace / 60, GROWN)
}
}
}
// #endregion
// #region autosave
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
}
// #endregion