PWE Language Guide
A complete, self-contained guide to writing PWE models and simulations. It assumes no prior experience with PWE (or with simulation code). Read it front to back once; afterwards use it as a reference.
How to read it
- Part 0–1 (Tutorial) — learn by building, one runnable program at a time. If you are new, start here.
- Part 2 (Reference) — every keyword, field, system, builtin, and rule.
- Part 3 (Semantics & pitfalls), Part 4 (Cookbook), Part 5 (Tools), Part 6 (Diagnostics), Part 7 (std), Part 8 (Examples).
- Appendices: a copy-paste skeleton and a stability checklist.
Every code block here compiles and runs as written.
Part 0 — Getting started
0.1 What is a simulation, in one paragraph
A simulation is some numbers that change over time according to rules. The
numbers are the state (a ball's position and velocity; a temperature field;
…). You advance time in fixed steps: each step you read the current state,
compute how it changes, and write the new state. The size of a step is dt
(seconds). A system is one such rule; the world is the collection of
everything that has state. That is the whole idea — PWE just makes it precise,
fast, and reproducible.
0.2 Install and the three commands
cargo build --release -p pwe-cli # build the `pwe` tool (once)
pwe compile scene.pwe -o scene.pweb # source → checked artifact
pwe run scene.pweb --steps 600 # run N steps, print final state
pwe present scene.pweb --port 8000 # live 3D viewer at localhost:8000
compile catches mistakes with the offending line and a caret. --param K=V
(at run/present) overrides a model parameter — handy for experiments.
0.3 Your first program: a falling ball
world {
gravity = (0, -9.81, 0)
entity ball { position = (0, 5, 0) velocity = (3, 0, 0) sphere = 0.3; color = 0xFF6B4A }
entity ground { position = (0, -0.5, 0) dynamic = false; box = (20, 1, 20); color = 0x557755 }
}
systems {
gravity { gravity_y = -9.81; dt = 0.016 }
integrate { dt = 0.016 }
ground_contact { restitution = 0.6 }
}
Run it: pwe run ball.pweb --steps 300. Observed tail:
ran 300 steps (interpreter == JIT, every step)
sim time: 5.000000 s
#1 ball pos = ( 14.4000, 0.0000, 0.0000)
#2 ground pos = ( 0.0000, -0.5000, 0.0000)
Line by line
world { … }declares what exists.gravity = (0, -9.81, 0)sets world gravity (downward, y).entity ball { … }declares a body.position/velocityare its initial transform/velocity;sphere = 0.3is a collider (radius 0.3);coloris presentation.entity ground { … dynamic = false … }is a static body (never moves);box = (20,1,20)is a box collider 20×1×20.systems { … }declares behaviour, applied in order each step:gravityaddsgravity_y·dtto every dynamic body's y-velocity.integrateaddsvelocity·dtto position.ground_contactstops/reflects bodies that would pass throughy = 0(withrestitution = 0.6, so it bounces and loses energy).
What you saw: the ball accelerated downward, bounced (each bounce lower),
settled on the ground, and drifted in x because of its initial velocity.x = 3.
0.4 Make it yours (change one thing at a time)
- Set
restitution = 1.0— it bounces forever (elastic). - Set
restitution = 0.0— it lands and stops. - Set
velocity = (6, 2, 0)— faster and upward. - Change
dtin both systems to0.008— same physics, smaller steps.
Then re-run (pwe compile … && pwe run …). This edit → run → observe loop is
how you build every model.
0.5 The five ideas you now know
- world = the state (entities, fields, parameters).
- entity = a body with state; ids are 1-based in declaration order.
- systems = rules that run once per step, in order.
- dt = the step size; every system uses it.
- run = repeat the systems over and over, committing new state each step.
0.6 The one idea that trips everyone up: two ways to store a body
PWE has two independent stores for a body's dynamics, and a body uses one of them. If you put numbers in the wrong one, "nothing happens".
| Component body | State-slot body | |
|---|---|---|
| Written as | position/velocity/mass/colliders |
state = (…) |
| Driven by | gravity, integrate, damping, force, wall, ground_contact |
update, rk4, linear, nbody |
| Position is | Transform.position |
state[0..2] (render fallback) |
| Read from rules as | @name.position.x, @name.velocity.x |
x, @name.x, s0, … |
The ball above is a component body — that's why gravity/integrate move it.
The next lesson uses a state-slot body so you write the physics. A body can
have both, but keep it simple: pick one.
Part 1 — Tutorial: build simulations step by step
Each lesson is a complete, runnable program with what to expect.
L1 — Write your own physics: a damped oscillator
Component systems give you Newtonian motion for free. For anything else, put the
numbers in state and write the rules yourself.
world {
gravity = (0, 0, 0)
entity m { state = (x = 1.0, vx = 0.0) }
}
systems {
update { on = m; dt = 0.01
let k = 12.0 # spring constant
let c = 0.4 # damping
vx = vx + inte(0.0 - k*x - c*vx) # vx' = -k·x - c·vx
x = x + inte(vx) # x' = vx
}
}
Run: pwe run osc.pweb --steps 200 → state = [0.6302, -1.5751]. The mass swings
and slowly loses energy (damped), converging to 0.
The crucial rule. slot = expr is a plain assignment: each step the
slot takes that value. To integrate a derivative, make the step explicit with
dt:
vx = vx + inte(0.0 - k*x - c*vx)⇒vx += dt·(−k·x − c·vx)(acceleration).x = x + inte(vx)⇒x += dt·vx(velocity).
The inte(E) operator is the increment dt·E, so x = x + inte(vx) equals
x = x + dt*(vx); the inte slot = rate statement (also integrate and
+=) integrates without spelling out dt. The deriv(E) operator is the
backward difference (E − E_prev)/dt — it differentiates (0 on the first
step). In rk4, inte slot = rate integrates that rate with the classic
4th-order Runge–Kutta method (much more accurate than Euler at the same dt).
Recursion. A function body may use the control-flow form
if cond { return a } else { return b } (lazy): each call pushes a frame on the
call stack, so functions may recurse — e.g.
fact(n) { if n < 1.0 { return 1.0 } else { return n * fact(n - 1.0) } }. The
expression if(c, a, b) evaluates both branches, so it cannot be used for
recursion.
Assign vs integrate. Since = assigns, a constant write is just
slot = target — no idiom needed.
One gotcha, right away: let names may not be t, pi, e, or sN
(detail 67). (A rule x = 1.0 is fine — see below.)
Parameters vs rules. A numeric parameter is recognised by name for the
system kind: update { dt = 0.01 } sets dt, while update { x = 1.0 } is a
rule for the slot x (no need for the old 0.0 + 1.0 trick).
Exercises: raise k (faster); raise c (dies out sooner); add a driving term
+ 3.0*sin(2.0*t) to vx for a driven oscillator.
L2 — Parameters: run experiments without editing code
world {
gravity = (0, 0, 0)
params { k = 12.0; c = 0.4 }
entity m { state = (x = 1.0, vx = 0.0) }
}
systems {
update { on = m; dt = 0.01
vx = vx + dt*(0.0 - k*x - c*vx)
x = x + dt*(vx)
}
}
Now the constants live in params, readable by name in rules. Sweep them without
recompiling:
pwe run osc.pweb --steps 200 --param k=40
pwe run osc.pweb --steps 200 --param k=40 --param c=0.0 # undamped
Parameters are part of world state and recorded in snapshots, so a run is fully reproducible from its parameters.
L3 — Many bodies and interactions
Read another body with @name.<slot>:
world { gravity = (0, 0, 0)
entity a { state = (x = 1.0, vx = 0.0) }
entity b { state = (x = -1.0, vx = 0.0) }
}
systems {
update { on = a; dt = 0.01 vx = vx + dt*(0.0 - 8.0*(x - @b.x)) x = x + dt*(vx) }
update { on = b; dt = 0.01 vx = vx + dt*(0.0 - 8.0*(x - @a.x)) x = x + dt*(vx) }
}
Two masses on a spring between them: they oscillate in anti-phase (equal and
opposite). @b.x is b's slot x; reads are sampled at the start of the step, so
both rules see each other's previous value.
Gravity between bodies is built in — nbody expects
state = (px, py, pz, vx, vy, vz, m) (mass in slot 6, not the mass field):
world { gravity = (0, 0, 0)
entity sun { state = (0, 0, 0, 0, 0, 0, 1000) sphere = 1.2 }
entity earth { state = (4, 0, 0, 0, 15.8, 0, 0.05) sphere = 0.3 }
}
systems { nbody { G = 1.0; dt = 0.001 } }
earth orbits sun: radius stays ≈ 4 (at 500 steps it is near (-1.60, 3.65),
a quarter-turn; a full orbit is ≈ 1571 steps). This is solar.pwe in miniature.
L4 — Fields and PDEs (heat, waves)
A field is a grid of numbers (the continuum substrate). Systems diffuse,
poisson, wave solve PDEs on it; rules read/write cells with fget/fset.
Heat: seed the centre once and let it spread (total is conserved).
world { gravity = (0, 0, 0)
field heat { width = 16; height = 16; dx = 1.0 }
entity probe { state = (x = 0.0) }
}
systems {
diffuse { field = heat; rate = 0.2 } # T += 0.2·∇²T
update { on = probe; dt = 1.0
# seed the centre at t=0; every later step writes the same value back
let _ = fset(heat, 8, 8, if(at(0.0), 100.0, fget(heat, 8, 8)))
x = fget(heat, 8, 8) - x # sample the centre
}
}
Observed: field heat: total=100.000000 at every step (conserved), while the
centre cools 100 → 2.05 (20 steps) → 0.40 (200 steps) as heat spreads out.
A wave is a second-order PDE solved by a leapfrog over two fields:
world { gravity = (0, 0, 0)
field u { width = 41; height = 1; dx = 1.0 } # height=1 ⇒ a 1-D line
field um { width = 41; height = 1; dx = 1.0 } # u(t-dt)
entity probe { state = (x = 0.0) }
}
systems { wave { field = u; prev = um; velocity = 1.0; dt = 0.5 } }
Stability limits matter: rate ≤ 1/4 (2D) for diffuse; Courant
c·dt/dx ≤ 1/√2 (2D) for wave. Exceed them and the field blows up.
L5 — Dynamic entities: a particle system (pools)
Entities are fixed at compile time. To have entities appear and disappear, declare
a pool of initially-inactive slots and spawn/despawn them.
world { gravity = (0, 0, 0)
entity emitter { state = (x = 0.0, y = 0.0, vx = 0.0, vy = 0.0) }
pool p[64] { state = (x = 0.0, y = 0.0, vx = 0.0, vy = 0.0); shape = sphere; size = 0.1 }
}
systems {
spawn { on = emitter; pool = p; count = 2; every = 1 } # 2 slots/step
update { on = p; dt = 0.1
vx = vx + dt*(2.0*(neighbor_mean(0, 0.5) - x)) # simple cohesion
vy = vy + dt*(0.0 - 9.81*0.1) # gravity
x = x + dt*(vx)
y = y + dt*(vy)
}
despawn { on = p; when = y < -10.0 } # recycle
}
pool p[64]makes 64 inactive slots namedp#0…p#63.spawncopies the caller's (emitter) state into the lowest free slots.update { on = p; … }runs only on active slots (on = <pool>expands to all slots).despawnturns matching slots off so they can be reused.neighbor_count(r)/neighbor_mean(slot, r)/nearest_dist()work here — they are deterministic, id-sorted scans.
Observed: 64 particles active after 40 steps, falling and cohering.
L6 — Constraints: a pendulum chain (joints)
Joints are pairwise, position-based constraints between component bodies.
world { gravity = (0, -9.81, 0)
entity anchor { position = (0, 6, 0) mass = 1.0 dynamic = false }
entity b1 { position = (0, 5, 0) mass = 1.0 dynamic = true }
}
systems {
gravity { gravity_y = -9.81; dt = 0.016 }
integrate { dt = 0.016 }
joint { on = anchor; other = b1; type = distance; length = 1.0; iterations = 8 }
}
type = distance keeps |anchor − b1| = 1.0. dynamic = false on anchor
makes it an infinite-mass pivot. Add a b2 and a second joint for a longer chain
(see chain.pwe). Other kinds: spring (adds damping), hinge/ball/weld
(pin an anchor point), prismatic (slide along axis, optional limit).
L7 — Soft bodies (a sheet or a gel)
world { gravity = (0, -9.81, 0)
soft cloth { nx = 6; ny = 6; spacing = 0.4; origin = (-1, 5, 0); mass = 0.1 }
}
systems {
gravity { gravity_y = -9.81; dt = 0.016 }
integrate { dt = 0.016 }
soft { body = cloth; stiffness = 1.0; iterations = 6 }
}
soft creates an nx × ny (× nz) grid of particles joined by structural,
shear, and bend springs; the soft system relaxes them so the sheet keeps its
spacing while sagging. Mesh edges render as bonds. nz > 1 gives a 3-D gel
(jelly.pwe).
L8 — Events and scheduling
world { gravity = (0, 0, 0) entity e { state = (x = 0.0) } }
systems {
update { on = e; dt = 0.1
let fired = at(0.5) # 1.0 in the one step whose [t, t+dt) contains 0.5
x = x + dt*(fired) # integrate the trigger into x
emit(1.0, x) # append an event (kind=1, payload=x)
}
}
emit(kind, payload)records an ordered event; the host reads them via the API, and rules can read the latest withlast_event(kind).at(T)fires once when the clock reachesT;periodic(P[,phase])fires once per period;schedule(gate, delay, kind, payload)enqueues a future event.- Events and their queue are part of the deterministic cross-backend contract.
Event calendar (RFC-0048). The pending schedule(...) queue is readable as a
first-class calendar, ordered by (time, seq) (a tie in time breaks by
insertion order): event_count() pending entries; next_event_time() /
next_event_kind() / next_event_payload() read the earliest entry, and
pop_event() removes it (returning its payload) so a process-flow rule can
consume events instead of only probing last_event. events_seen(kind) counts
the events already delivered this step with that kind. Reads are pure; only
pop_event mutates the calendar, and the ordering is deterministic, so both
backends agree step for step.
world { gravity = (0, 0, 0)
entity q { state = (served = 0.0, last_kind = 0.0, last_payload = 0.0) } }
systems {
update { on = q; dt = 1.0
# every step, if a job is pending, consume the earliest one
schedule(at(0.0), 0.0, 1.0, 10.0) # a job arriving now
let due = event_count() > 0.0
last_kind = next_event_kind()
last_payload = pop_event()
served = served + due
}
}
Resources (seize/release, RFC-0048 C2). A capacity-gated server is a
world declaration plus four builtins:
world { gravity = (0, 0, 0)
resource server { capacity = 2 } # 2 concurrent holders
entity job { state = (got = 0.0, busy = 0.0, cap = 0.0, done = 0.0) } }
systems { update { on = job; dt = 1.0
got = seize(server, 2.0) # 1.0 on success, 0.0 when full
busy = resource_busy(server) # current holders
cap = resource_capacity(server) # the declared capacity
done = release(server) # free one; returns busy after
} }
seize is non-blocking: a full resource returns 0.0 and the model decides
what to do (e.g. reschedule the job on the event calendar and retry later — the
DES "wait" pattern). The capacity is fixed by the first seize (its declared
value), so the order in which rules run cannot widen a resource. Busy/release
counts live in the runtime's execution context (like the event calendar), are
cross-backend checked, and saturate at 0.
Queue discipline (RFC-0048 C1). schedule_at(gate, delay, kind, payload,
priority) is schedule(...) plus a priority: the calendar is ordered by
(time, priority, seq), so at the same time a lower priority pops first and
equal priorities keep insertion order. next_event_priority() reads the head's
priority. Plain schedule(...) uses priority 0 (FIFO). This is the queueing
primitive DES models need: priority 0 is FIFO, priority = -t is LIFO/EDF,
priority = job_length is shortest-job-first.
DES statistics (std/des). import "std/des" gives the deterministic
process-flow statistics — des.utilization(busy_time, t),
des.availability, des.throughput(served, t), des.wait_time(total, served),
des.queue_length(area, t), des.mean(sum, n), des.variance(sum, sum_sq, n),
des.ewma(prev, x, alpha), des.littles_law(rate, time) — all pure and
trap-free (a zero denominator yields 0, not a division trap).
Zero-crossing detection (RFC-0048). cross(e), rise(e), fall(e) fire
1.0 on the (sub)step where e makes a strict sign change; last_cross(e)
returns the time of the most recent one (0.0 before any). The previous value
and the timestamp live in the runtime's per-call-site history — no state slot is
needed, and each system × entity is its own site — so this is the hybrid-control
primitive watch cannot provide: it sees every rk4 substage and reports when
a switch happened. cross is a strict change, so a signal that lands exactly
on zero does not chatter; the first step never fires.
world { gravity = (0, 0, 0) entity ball { state = (y = 1.0, v = 0.0, hit = 0.0, t_hit = 0.0) } }
systems {
update { on = ball; dt = 0.01
v = v - 9.81 * dt # free fall
y = y + v * dt
hit = fall(y) # 1.0 on the step y crosses zero downward
t_hit = last_cross(y) # when it crossed (0.0 until it does)
v = v + hit * 2.0 * abs(v) # reinit: flip velocity on impact
}
}
L9 — Make it look right (presentation)
Presentation never affects the simulation. Attributes:
entity ball { position = (0, 5, 0) sphere = 0.3; color = 0xFF6B4A; opacity = 1.0; glow = 0.3; label = true }
color = 0xRRGGBB(exactly 6 hex digits),opacity[0,1],glow(emissive),label = falsehides the name,size = v | (dx,dy,dz)sets marker/box size. These are checked: anopacityoutside[0,1]is detail 105, a malformed colour detail 64, and every field of oneentity/poolbody may appear at most once (a repeat is detail 107 —tagis the exception, it accumulates — and an entity has at most one collider,box/sphere/hull).shape = point | sphere | box | capsule | <custom>overrides the collider shape.Custom shapes compose primitives,
hull/polypolyhedra, SVG, and each other:world { shape head { part sphere = 0.115 at (0, 0, 0); } shape person { part box = (0.34, 0.46, 0.20) at (0, 0.57, 0); part head at (0, 0.92, 0); # include another shape } entity e { state = (x=0,y=0,z=0) shape = person; color = 0x4AC3FF } }rotation = (rx,ry,rz)tilts a body statically (e.g. a ramp).orient = truelets the simulation steer a state body's facing/lean: state slots 7/8/9 are euler(pitch, yaw, roll). Example (yaw from heading + lean):entity walker { state = (x=0,y=0,z=0, vx=1.0, vz=0.0, dx=1.0, dz=0.0, rx=0.0, ry=0.0, rz=0.0) orient = true; shape = person } systems { update { on = walker; dt = 1.0 ry = (atan2(vx, vz) - ry) # face the heading rx = (0.12 - rx) # lean forward x = 0.05*vx z = 0.05*vz } }vector = falsehides the viewer's velocity arrow / orbit ring.- Triangles, no seams: build one
polymesh from a sampled heightfield for a smooth ground (courtyard.pwe).
L10 — Organise: functions, modules, units
Pure helper functions live in funcs:
funcs { accel(k, x) { 0.0 - k * x } }
systems { update { on = m; dt = 0.01 vx = vx + dt*(accel(k, x)) x = x + dt*(vx) } }
Functions take arguments and return a scalar; they may call other functions and
builtins, but have no access to the world (no entities, no @refs, no
spatial queries).
Modules split a model across files. A module is a .pwe file (with an empty
world { } if it only defines funcs); import paths are relative to the
importing file:
# pkg/util.pwe
world { }
funcs { twice(v) { 2.0 * v } }
# main.pwe
import "pkg/util" # from cli/examples/ you'd write "../../std/…"
world { gravity = (0,0,0) entity e { state = (x=0.0) } }
systems { update { on = e; dt = 0.1 x = util.twice(1.0) } }
Units are optional but catch mistakes at compile time:
params { k = 12.0 [1/s^2] }
entity m { state = (x = 1.0 [m], vx = 0.0 [m/s]) }
update { on = m; dt = 0.01 [s] vx = vx + dt*(accel(k, x)) }
L11 — Exact stochastic kinetics (gillespie)
Some systems are not smooth at all: a few molecules reacting one at a time. An
ODE there describes the mean, not the thing itself — the discreteness is the
whole point. gillespie runs the exact stochastic simulation algorithm
(Gillespie SSA):
world {
gravity = (0, 0, 0)
params { k = 0.15 [1/s] }
entity e { state = (A = 40.0, C = 0.0) tag = rx }
}
systems {
gillespie {
tag = rx
dt = 0.1
events = 8
channel decay = k * A => (A = A - 1, C = C + 1)
}
}
- Each tagged entity carries integer species counts in its state slots;
tagselects the reacting entities,dtthe step,events(1..=512, default 64) the per-step reaction budget. - Every
channel <name> = <propensity> => (<slot> = <expr>, …)is one reaction. The propensity is its current rate —k * Ahere, so this is first-order decay with ratek·A. - Each step draws the exact waiting time
τ ~ Exp(a), fires the reactions that land insidedt, and stops at the step boundary. Nothing is Euler-ised: a step is a truncation of a continuous-time Markov chain, not an approximation of one. - Effects of one event are evaluated together — they all read the state
before the event, so
(A = A - 1, C = C + 1)is one simultaneous move, and the last writer wins if two effects name the same slot. - If a step would need more than
eventsreactions, the run fails with detail 106 instead of silently dropping reactions: raiseeventsor lowerdt. A typo in a propensity or effect is a compile error (detail 48/52), never a silent0.0read.
Averaged over many independent vessels the survivor count follows the
deterministic law A(t) = A0·e^{-kt}: the stochastic and the ODE view agree in
bulk and disagree one molecule at a time. See cli/examples/kinetics.pwe — 24
atoms draining the reactant tray one hop at a time.
Common mistakes (symptom → cause → fix)
| Symptom | Cause | Fix | |
|---|---|---|---|
| My body never moves | it's a state body but you used gravity/integrate (or vice-versa) |
pick one model (§0.6) | |
| A rule "does nothing" | on = missing (runs on every body) or the LHS names no slot |
add `on = <entity\ | pool>`; check the slot/field name |
| Value stays constant unexpectedly | slot = expr assigns |
to integrate, write slot = slot + dt*(rate) (or inte slot = rate) |
|
update runs on the wrong bodies |
no on = → it runs on every dynamic body |
add `on = <entity\ | pool>` |
| Objects move oddly across a step | system order / read timing | order: forces → integrate → constraints; reads are start-of-step | |
nbody does nothing |
mass not in state[6] (or body is component-only) |
state = (px,py,pz,vx,vy,vz,m) |
|
| Field solver explodes | dt/stability exceeded |
rate ≤ 1/4 (diffuse 2D); c·dt/dx ≤ 1/√2 (wave 2D) |
|
| Pool bodies ignored | inactive slots are skipped | they must be spawned first; on = <pool> |
|
let fails to compile |
name shadows t/pi/e/sN |
rename it | |
| Import not found | path is relative to the file | import "../../std/forces" etc. |
|
| Angle/spin looks wrong | state[7] is a Z-spin by default |
set orient = true to use slots 7/8/9 as euler |
A practice progression
- Bounce a ball; tune restitution.
- Oscillator; tune
k,c; add forcing. - Two coupled oscillators; watch energy exchange.
- A small orbit; vary
G/velocity. - Heat diffusion; vary
rate; plot at the probe. - A standing wave on a string.
- A particle fountain with a pool.
- A 3-link pendulum with joints.
- A cloth sheet.
- Rebuild
courtyard.pwe: mesh ground + walking, turning figures. - Stochastic kinetics: tune
k, watchkinetics.pwedrain one hop at a time.
Part 2 — Language reference
2.1 Lexical rules
| Token | Form | Notes | |
|---|---|---|---|
| comment | # … or // … |
to end of line; skipped anywhere | |
ident |
[A-Za-z_][A-Za-z0-9_]* |
names of entities, slots, params, functions, shapes | |
number |
`-? digits ("." digits)? (("e"\ | "E") "-"? digits)?` | f64 |
value |
number (/ number)? |
literal or ratio (1/60) |
|
boolean |
true \ |
false |
|
string |
"…" (no escapes) |
titles, SVG data | |
color |
0x + 6 hex digits |
0xRRGGBB |
|
unit |
[ m/s^2 ] |
base m kg s A K mol cd; * / ^; compile-time only |
|
slot |
s digits |
own state slot by position | |
| constants | t, pi, e |
clock (s), π, Euler's number |
Whitespace is insignificant; ; is optional. Units are bracketed so s[0] is
unambiguous.
2.2 Keywords (reserved)
- sections
worldfuncssystems - world
gravitytitleparamschanresourcecapacityvalueentityfieldpoolsoftstructarraywidthheightdepthdxnxnynzspacingoriginshapepart - entity
positionvelocitystatevecmassdynamicnbodyparentrestitutionfrictionboxspherehullrotationcameracolorsizeopacityglowlabelorientvector - shapes
pointsphereboxcapsulesvghullpolyringcylinderconeplaneatdepthscalefaces - control
returnletrepeatuntilwhileforinbreakcontinueifelse - rules
inteintegrate+=(and the operatorsinte(…),deriv(…)) - systems
sendrecvspawndespawnjointinvariantwatch - world
lang_version - logic
andornot&&||!truefalse - atoms
piet
System kinds and params are ordinary identifiers matched at build time (an unknown kind is detail 49). Builtins are ordinary calls special-cased in lowering.
Casts: i64(x)/i32(x)/u64(x)/u32(x) truncate toward zero,
f64(x) widens an exact integer (identity on a float), bool(x) is
x != 0. They make value-kind boundaries explicit.
Integer values (RFC-0043). A number literal with no ./exponent (2, -7)
is an exact integer; a literal with a fraction (2.0, 1e-3) is an f64.
Integer arithmetic is exact and truncating: 7 / 2 == 3, 7 % 3 == 1, and
1 / 2 == 0. An expression is integer only when both operands are integers —
a single fractional operand widens the whole expression: 2.5 + 1 == 3.5,
1.0 / 2.0 == 0.5. let n: i64 = expr binds an exact integer (truncating a
float expr toward zero and trapping on a non-finite one, detail 18); n / 2
then stays integer, while writing n to a state slot (slot = n) widens it to
f64. This is intentional and documented — old programs that relied on
1 / 2 == 0.5 must write 1.0 / 2.0.
print(x) is a debugging builtin: it records x in the run log (shown by
pwe run / the live viewer) and returns x unchanged — write let _ = print(x).
Precedence (high → low): unary -, not/! → * / % → + - → comparisons →
and → or.
2.3 world items
| Item | Meaning |
|---|---|
gravity = (x,y,z) |
global gravity (component bodies). |
title = "…" |
viewer title. |
params { K = v } |
model parameters (overridable with --param). |
chan <name> { value = v } |
a channel entity (state[0]). |
resource <name> { capacity = n } |
a capacity-gated resource (seize/release, §L8). |
entity <name> { … } |
a body. |
shape <name> { part … } |
a custom render shape. |
struct <name> { field = <default> … } |
a named record type (§2.12). |
field <name> { width; height; dx; depth? } |
a scalar grid. |
pool <name>[N] { … } |
N inactive slots for dynamic entities. |
soft <name> { nx; ny; nz?; spacing; origin; mass } |
a mass-spring grid. |
bond <a> <b> [order=n] [polarity=p] [cloud=true] [min=d] [max=d] |
a render bond between two named entities (ball-and-stick). |
bonds { tag = <t>; other = <u>?; within = <d>; min = <d>?; axis = (x,y,z)?; angle = <rad>? } |
a neighbourhood bond net: auto-bond every pair of entities tagged <t> (with other, the cross t–u pairs) within [min, within], recomputed each frame (bonds form/break by proximity — e.g. a reaction). axis+angle add a bond-angle gate. |
import "…" |
module import (path relative to the file). |
bond is presentation only (not a physics constraint): it draws a stick
between the centres of entities <a> and <b>. Options: order (1/2/3 =
single/double/triple, drawn as parallel sticks), polarity in [0,1] (tints the
bond from A's toward B's colour), cloud=true (a translucent shared-electron
region), min/max distances (the bond is drawn only while the two entities are
within that range — proximity-gated bonds that form/break, e.g. a chemical
reaction). It is the basis of std/molecules/, where each atom is an entity and
each chemical bond a bond — see L9 and cli/examples/molecule.pwe.
Both statements validate their options: each option may appear at most once,
a bond range must satisfy 0 ≤ min ≤ max, and a bonds net requires tag
and within with 0 < min ≤ within. An unknown or non-numeric key is an error
(details 107/108), so a typo like withn fails loudly instead of silently
leaving within unbounded and bonding every tagged pair regardless of distance.
Entity id order: declared entities 1..E, then channels, then pool slots
(<pool>#k), then soft particles (<soft>#k).
2.4 Entity fields
| Field | Meaning |
|---|---|
position / velocity = (x,y,z) |
initial transform / velocity. |
state = (…) |
state slots: positional (v0,…), named (x=0,…), vec3 pos (slots pos.0…), a struct type, or a nested record (p = (x=0,…)). Max 16. |
mass |
mass (nbody, viewer size; required by joint/soft). |
dynamic = false |
static. |
nbody = false |
exclude from nbody. |
parent = <name> |
satellite display. |
restitution / friction |
contact response. |
box / sphere / hull |
collider. |
rotation = (rx,ry,rz) |
static euler rotation (radians). |
camera = true |
viewer camera. |
A neighbourhood bond net needs entities grouped by tag = <name> (an entity
field): entity H { state=(…) tag = atom } + bonds { tag = atom; within = 1.0 }.
Presentation-only: color, shape = point\|sphere\|box\|capsule\|<custom>,
size = v|(dx,dy,dz), opacity, glow, label = false,
orient = true (state 7/8/9 = euler pitch/yaw/roll),
vector = false (hide arrows/rings).
2.5 Custom shapes
part <kind> = <params> [at (x,y,z)] [scale s]:
sphere = r,box = (dx,dy,dz),capsule = (r0, len, r1);ring = (radius, tube)(a torus — e.g. an electron shell);cylinder = (radius, height),cone = (radius, height),plane = (width, height)(3D primitives; aplanerenders double-sided);hull = [(x,y,z), …](convex);poly = [(x,y,z), …] faces = [[i,j,k,…], …](arbitrary mesh);svg = "<d>" depth <d>;part <other-shape> [at …] [scale …](composition, recursive).
Presentation-only part options (all optional; they never affect semantics):
color = 0xRRGGBB— overrides the entity colour for this part (multi-colour shapes: protons vs neutrons vs electrons);opacity = o— overrides the entity opacity for this part (0..1); makes a part faint/transparent (e.g. a pale electron orbit);spin s— self-rotation speed in rad/s (the value follows the keyword directly;spin = sis accepted too);orbit (radius, speed, phase)— animates the part around the entity's local origin (electrons orbiting a nucleus),speedin rad/s;axis (x,y,z)sets the orbit-plane normal (default local+Z).
Each option may appear at most once on a part (detail 107); an opacity
outside 0..1 (detail 105) or a malformed colour literal (detail 64) is an
error rather than a silently dropped value.
std/atoms/ uses these to draw atomic structure; std/molecules/ for bonds.
2.6 Grid fields
field f { width=w; height=h; dx=d; depth? } (2D, or 3D with depth). Rules:
fget(f,i,j[,k]), fset(f,i,j,[k,]v), flap(f,i,j[,k]). First arg is a literal
field name; cell writes are visible to later reads in the same step.
2.7 All system kinds
? = optional; missing required ⇒ detail 48; unknown kind ⇒ detail 49.
| Kind | Parameters | Model | Meaning |
|---|---|---|---|
gravity |
gravity_y, dt |
component | velocity.y += gravity_y·dt |
integrate |
dt |
component | position += velocity·dt |
damping |
factor |
component | velocity *= factor |
force |
ax,ay,az,dt |
component | velocity += (ax,ay,az)·dt |
wall |
x,z,y_min?,restitution? |
component | reflect at ±x,±z |
ground_contact |
restitution |
component | resolve the y=0 plane |
linear |
slots,dt,row0=(…),… |
state | ds/dt = A·s + c (Euler) |
nbody (velocity-Verlet) |
G,dt |
state | inverse-square; state=(px,py,pz,vx,vy,vz,m) |
pair |
tag,other?,dt,law,coord?,cohort? |
state | general pairwise force: for each body in tag, sum law(r) (a force magnitude in the pair distance r, positive = repulsive) over the other group (default itself); kicks the velocity v += a·dt; state=(px,py,pz,vx,vy,vz,m). With coord = rc, the law may also use n (the body's coordination number: how many bodies are within rc), rmin (its nearest-neighbour distance) and rminj (the partner's nearest-neighbour distance) — so a symmetric bond order is expressible. cohort = <tag> sets the group the coordination is measured over (default: tag ∪ other), so it can span all elements — pair an element-specific force with a global environment (tag = H, atom + cohort = atom) |
drift |
tag,dt,damp? |
state | position drift p += v·dt for tagged bodies (optionally damped v *= 1-damp·dt); pairs with pair |
send/recv |
on(req),chan,value / on(req),chan,slot |
channel | channel send/receive (scoped to on) |
update |
dt,on?,when?,every?,substeps?,rules |
state | explicit Euler (rules read the original slot values — no intra-step chaining; give each slot one rule) |
rk4 |
dt,on?,when?,every?,substeps?,rules |
state | Runge–Kutta 4 |
gillespie |
tag(req),dt,events?,channels |
state | exact stochastic kinetics (Gillespie SSA): each channel <name> = <propensity> => (<slot> = …, …) is one reaction; every step draws the exact waiting time from the propensity and fires at most events reactions (1..=512, default 64) — needing more fails with detail 106 |
invariant |
expr,on? |
— | per-step assertion |
watch |
expr,mem,into,on? |
state | zero-crossing flag |
diffuse |
field,rate |
field | T += rate·∇²T |
poisson |
field,iters,source?,scale? |
field | ∇²φ = ρ·scale |
wave |
field,prev,velocity,dt,damping?,absorb?,absorb_width? |
field | u_tt = c²∇²u |
spawn |
on,pool,count?,every?,phase? |
pool | activate slots |
despawn |
on,when |
pool | deactivate slots |
joint |
on,other,type,… |
transform | pairwise constraint |
soft |
body,stiffness?,damping?,iterations? |
transform | mass-spring grid |
Channels are a single-cell mailbox, not a Go-style queue. A
chanholds one value instate[0];sendoverwrites it (last writer wins) andrecvreads it into a slot. There is no queueing or blocking. Because systems run in declaration order, arecvplaced before itssendsees the previous step's value; place it after for same-step delivery.
2.8 Expressions
| Form | Meaning | ||
|---|---|---|---|
s0, s1, … |
own slots | ||
x, @self.x |
own named slot | ||
@name.sN, @name.x, @name.state.x |
another entity's slot | ||
@name.mass, @name.is_dynamic |
another entity's properties | ||
@name.position.x, @name.velocity.x |
another entity's transform/velocity | ||
+ - * / %, -x |
arithmetic | ||
< <= > >= == != |
comparisons → 1.0/0.0 | ||
and/&&, or/`\ |
\ | ,not/!` |
logic |
pi, e, t |
constants / clock |
An unresolved bare name reads 0.0. System params are not in expression scope
(bind let dt = 0.02 to use dt).
2.9 Builtins (arity checked; detail 59)
1-arg math: sin cos exp ln sqrt abs floor ceil round sign log10 log2 sinh cosh
tanh asin acos atan; 2-arg: pow atan2 hypot min max; if(c,a,b); random(),
noise(); at(T),
periodic(P[,phase]), schedule(gate,delay,kind,payload); active();
vlen vdot vdist; spatial neighbor_count(r), nearest_dist(),
neighbor_mean(slot,r), nearest_dx/dy/dz(); field fget fset flap. No tan
(use sin/cos). Any other name is a funcs function.
2.10 funcs, units, loops
Sections must appear in order:
world(required) →funcs(optional) →systems(optional).funcs { f(a,b) { expr } }— pure scalar functions; no world access.funcsparameters and the return value may be unit-annotated (f(x: [m]) : [m/s] { … }); call sites unify argument/result dimensions (detail 77).let name: <type> = expr— optional type annotation (f64/i64/i32/u64/u32/bool); comparisons andand/or/notarebool, other expressions are numeric. A mismatch is detail 89.- Units: annotate
state/params with[m],[m/s],[1/s^2]; mismatches are detail 77; unannotated values are wildcards. Base units arem kg s A K mol cd; named coherent-SI derived units are also accepted (N J W Pa Hz C V F Oh S Wb T H lm lx Bq Gy Sv kat), expanded to base dimensions — e.g.a = F / masswithFin[N],massin[kg]checks asm/s^2. Addunits = "strict"to theworldblock to require an annotation on every state slot and parameter (missing ones are detail 90). s[i]read /s[i] = exprwrite (runtime index;updateonly).repeat n {…}(≤1000),for i in lo..hi {…}(ascending),break/continue(break if (…)), unrolled (≤10000 statements). Loop bodies:let, nested loops,break/continue, andname[j] = expr/name[j] += exprarray writes (updateonly).
2.11 Arrays (fixed-length, named)
array N name [{ v0, … }] declares N consecutive state slots named
name.0 … name.{N-1} and records the length, so name[j] is checked rather
than a raw slot index. It composes with state = (…) in either order.
world {
gravity = (0, 0, 0)
entity swarm { state = (x = 0.0) array 4 samples { 1.0, 2.0, 3.0, 4.0 } }
}
systems {
update { on = swarm; dt = 0.5
samples[0] = samples[0] + dt*(1.0) # constant index → static slot
samples[1] += 2.0 # `+=` integrates with dt
inte samples[2] = 4.0 # `inte name[j] = rate`
x = samples[x] # runtime index (x holds 0..3)
}
}
name[j]read: a constantjlowers to a static slot; a runtimejis bound-checked (BoundsCheck) then read at the absolute slotbase + j.name[j] = expr,name[j] += expr, andinte name[j] = rateare the write forms (the read set is sampled at system start, like every rule).- The same write forms work inside a loop body, binding the loop index:
for j in 0..len(v) { w[j] = v[j] * 2.0 }. The index may be any expression (bound-checked like a read), the write is suppressed when the iteration is skipped bybreak/continueor the systemwhengate is false, and+=accumulates per unrolled write. Array-element writes in loop bodies areupdate-only (detail 73 elsewhere), like thes[i] =form. - A constant
joutside[0, N)is detail 52; a runtimejthat is out of range, fractional, or non-finite is a detail 18 load-class trap (never a silent read/write of an arbitrary slot); an unknown array name is detail 109; an initializer with more thanNvalues is also detail 52. None of these silently read 0.0. vecN posremains the anonymous, length-less vector form;arrayadds the length and the bounds check. Both flatten to flat state slots (zero-cost).A
forbound may belen(name)— the declared length of a named array, resolved at compile time (for j in 0..len(v) { … }). An unknown array name is detail 109. A raws[i]index has no declared length and so stays unchecked.- Reductions fold a whole named array into a scalar:
sum(v),mean(v),norm(v)(Euclidean),asum(v)(Σ|vᵢ|),prod(v),min_of(v),max_of(v), and the two-arraydot(a, b). They unroll to plain arithmetic over the compile-time length (no new opcode), so they run on every backend. An unknown array name is detail 109; adotlength mismatch is detail 52.
2.12 Struct types (records)
A struct names a group of fields; using it in state = … lays the fields out
as dotted scalar slots:
world {
struct Vec3 { x = 0.0; y = 0.0; z = 0.0 }
struct Body { pos = Vec3; vel = Vec3; mass = 1.0 }
entity a { state = Body } # slots pos.x…mass
entity b { state = (p = Vec3, hp = 10.0) } # embed + a scalar
entity c { state = (pos = (x = 7.0, y = 8.0, z = 9.0)) }# inline record
}
systems {
update { on = a; dt = 0.1
pos.x = pos.x + dt*(vel.x) # dotted LHS: pos.x += dt·vel.x
vel.y = vel.y + dt*(0.0 - 9.81)
}
}
- Fields may be scalars (with a default) or other structs (nested).
- Access own fields as
pos.x; another entity's as@a.pos.x; both in rules andfuncs. - A
structis compile-time sugar over flat slots — zero-cost, deterministic, and part of world state. Unknown types and cycles are errors (detail 80). vecN nameis the built-in shorthand:vec3 pos→pos.0,pos.1,pos.2(withposan alias forpos.0).
Part 3 — Semantics and pitfalls
- Time is explicit: every rule's expression is multiplied by
dt;tadvances bydteach step. - Divide-by-zero traps (detail 18) for integers and floats
(RFC-0021) — never silent
inf/NaN. Detail 18 is the load-class trap: divide/remainder by zero, integer overflow, NaN comparison, and a runtime array index outside[0, len)(RFC-0044). slot = exprassigns. Integrate withslot = slot + inte(rate)(or theinte slot = rate/+=statement); inrk4,inte slot = rateintegrates with 4th-order Runge–Kutta.inte(E) = dt·E,deriv(E) = (E−E_prev)/dt.- Language version:
world { lang_version = "0.3" }pins the semantics (v0.3 = the rules above). Absent means "current"; an unsupported value is rejected at compile time (detail 83). Legacy pre-v0.3 sources can be upgraded withpwe migrate <src.pwe> [-o out.pwe](old implicit=integration and the oldderivform are rewritten tointe). - Conserved quantities:
conserved { on = <entity>; expr = <expr>; tolerance = 1e-4 }recordsexpreach step; the run fails (detail 87) if its relative drift exceedstolerance.pwe runprints the drift. - System parameters are recognised by name per kind; any other
name = <expr>(includingname = 1.0) is a rule. - Reads: within one system's function, all reads are sampled once at the start of the (sub)step (rules are simultaneous). Across systems, a later system sees an earlier system's writes — so order matters. Field reads see same-step writes.
- Determinism: seeded
random()/noise(), sorted-id spatial scans, interpreter ≡ JIT byte-for-byte (step_cross). - State ≤ 16 slots.
invariantfails the step before any write. - Two body models (§0.6).
nbodymass isstate[6].state[7]is a Z-spin unlessorient = true. - Pools: inactive slots are skipped and hidden;
on = <pool>expands.
Part 4 — Cookbook (compact)
- Damped/driven oscillator: §L1/L2.
- N-body:
nbodywithstate=(px,py,pz,vx,vy,vz,m). - Exotic pairwise force: an explicit
updatereading@other. - Diffusion / Poisson / wave: §L4; respect stability limits.
- Particle system: pool +
spawn/despawn+neighbor_*(§L5). - Linkage / pendulum: one
distancejoint per link, topdynamic=false(§L6). - Cloth / gel:
softwithnz(§L7). - Events / scheduling:
emit/last_event/at/periodic/schedule; the event calendarevent_count/next_event_time/next_event_kind/next_event_payload/pop_event/events_seen(§L8). - State machine: gate writes with
when = expr; flip modes withwatch. - Units & dimensional checks: §L10.
- Mesh ground (no seams): one
polyshape from a heightfield (§L9).
Part 5 — Tools
pwe compile <src.pwe> -o <out.pweb> [--param K=V]
pwe run <out.pweb> [--steps N] [--param K=V]…
pwe present <out.pweb> [--port P] [--param K=V]…
The .pweb artifact is a self-describing container (magic + version) holding the
verified EIR plus the model source; run/present execute the compiled EIR.
Viewer controls: ⟳ Restart (reload initial scene, paused at t=0), ⏸ Pause /
▶ Resume, 🏷 Labels. Build release for smooth playback.
Embedding API:
let mut rt = pwe_reference::lang::LangRuntime::compile(src)?; // or ::compile_file
rt.step_cross_n(600)?; // interpreter == JIT asserted
let frame = rt.present_frame(None); // render snapshot
Part 6 — Diagnostics
match pwe_reference::lang::LangRuntime::compile(src) {
Ok(_) => {}
Err(e) => eprintln!("{}", pwe_reference::lang::diagnose(src, &e)),
}
error 48: system 'update' is missing required parameter 'dt'
--> line 13, column 9
|
13 | update { on = reactor; dt = 0.0005
| ^
| Code | Meaning |
|---|---|
| 48 | Missing required system parameter. |
| 49 | Unknown system kind. |
| 50 | Cross-backend mismatch (runtime; a bug — please report). |
| 51 | Convex hull needs ≥ 4 points. |
| 52 | State slot index out of range (0..=15). |
| 53 / 54 | linear row missing / wrong length. |
| 55 | Invalid funcs body / empty update rule / bad slot LHS. |
| 56 / 57 / 58 | Expression / number / slot-reference parse failure. |
| 59 | Call arity mismatch. |
| 60 | Program parse failure. |
| 62 | Unknown entity or channel name. |
| 63 | nbody with no dynamic bodies. |
| 64 | Invalid color literal. |
| 65 | Invalid loop count (integer 1..=1000). |
| 66 | Loop unrolls beyond the statement limit. |
| 67 | let shadows a reserved token (t/pi/e/sN). |
| 68 | for range must be ascending integers. |
| 69 | Invariant violated. |
| 70 | Spatial query outside a system rule. |
| 71 | every must be an integer ≥ 1. |
| 72 | substeps must be an integer 1..=1000. |
| 73 | Dynamic slot LHS is update-only (rejected in rk4). |
| 75 | field needs width and height ≥ 1. |
| 76 | Import error (missing file, duplicate entity/field/channel/pool). |
| 77 | Dimensional mismatch. |
| 78 | Unknown pool name (spawn/despawn). |
| 79 | Unknown shape / shape-reference cycle. |
| 80 | Unknown struct type / struct cycle / state too large. |
| 83 | Unsupported lang_version. |
| 84 | Malformed unit annotation. |
| 85 | Unknown identifier (reads 0.0) — a warning. |
| 86 | Unstable solver setting (CFL / diffusion limit). |
| 87 | Conserved quantity drifted beyond tolerance. |
| 88 | Non-finite state (simulation diverged) — with pwe run --check. |
| 89 | let type annotation mismatch. |
| 90 | Missing unit annotation (units = "strict"). |
| 91 | nbody body has fewer than 7 state slots. |
| 92 | nbody body also has a mass field (ignored; mass is state[6]) — a warning. |
| 93 | Plain assignment in rk4 (integrate with inte slot = rate). |
| 94 | State slot name collides with a system parameter name — a warning. |
| 95 | nbody body exposes slots 7/8/9 without orient = true — a warning. |
| 96 | A rule writes slots 7/8/9 without orient = true — a warning. |
| 100 | Assignment to an unknown state slot (ignored) — a warning. |
| 101 | Duplicate module name (two modules declare the same module). |
| 102 | Reference to a non-exported module item. |
| 103 | A field name is not a lowercase identifier. |
| 105 | An opacity outside 0..1 (per-part or entity-level). |
| 106 | A gillespie step exceeded its reaction budget (events) — raise events or lower dt. |
| 107 | A duplicate option or field: each option of a part, bond or bonds, and each non-tag field of an entity / pool body, may appear at most once. |
| 108 | An unknown or invalid bond / bonds option (bad key, non-numeric value, missing tag/within, inverted range). |
| 109 | An unknown array name in name[j] (RFC-0044): the entity declares no name.0 … name.k state run. |
| 110 | A resource capacity that is not a non-negative integer. |
Workflow: reduce to one entity + one system; check the model (§0.6); check
the integrate/assign trap; add an invariant; run with --steps N and read the
printed state.
Part 7 — Standard library (std/)
Pure-function modules; constants are overridable params. Full signatures in
std/README.md.
| Module | Constants | Representative functions |
|---|---|---|
math |
— | clamp clamp01 lerp mix remap step smoothstep wrap sqr deg rad hypot2 hypot3 min3 max3 sgn deadzone ease_in/out |
des |
— | utilization availability throughput wait_time queue_length mean variance ewma littles_law |
md |
kB |
wrap wrap_signed min_image min_image_dist number_density lattice_sc/bcc/fcc nn_dist_sc/bcc/fcc degrees_of_freedom temperature kinetic_energy berendsen_lambda berendsen_baro_lambda half_kick drift msd diffusion |
signal |
— | db from_db power_db alpha_from_tau one_pole alpha_from_fc lowpass_rc highpass_rc dc_block integrate_trap derivative lowpass_b0..a2 biquad biquad_dc_gain envelope rms crest_factor zcr midi_to_hz hz_to_midi osc_sin soft_clip quantize |
forces |
— | hooke spring_accel damping_accel drag_linear/quadratic_accel coulomb_force gravity_force inverse_square_accel buoyancy_force thrust_accel damper_force |
particles |
— | terminal_velocity drag_step ballistic_x/y/vy bounce_vy reflect radius_from_mass stopping_distance freefall_time speed |
mechanics |
— | momentum kinetic_energy reduced_mass elastic_1d_v1/v2 impulse friction_force normal_impulse inertia_rod/disk/sphere torque angular_accel angular_kinetic |
thermal |
sigma_sb |
celsius kelvin newton_cooling heat_capacity sensible_heat conduction_flux stefan_boltzmann radiative_cooling thermal_diffusivity thermostat_hysteresis mixing_temp |
acoustics |
rho_air,c_air,p_ref |
speed_of_sound_air wavelength spl pressure_from_spl acoustic_impedance doppler inverse_square sound_intensity beat_frequency |
optics |
h_planck,c_light |
inverse_square_intensity beer_lambert snell_angle critical_angle fresnel_reflectance reflect_axis wien_peak photon_energy focal_length |
em |
c_light,k_coulomb,mu0 |
coulomb_force electric_field potential lorentz_force cyclotron_radius biot_savart_wire poynting plane_wave_b/e impedance_free_space |
chemistry |
R_gas,avogadro |
atomic_mass element_period/group shell_capacity valence_electrons neutrons mol_from_mass mass_from_mol molarity dilute ideal_pressure/volume arrhenius ph h_from_ph neutralization_volume half_life_decay radioactive_amount |
robotics |
— | planar2_x/y planar2_ik_q1/q2 pid joint_accel diff_drive_v_left/right trapezoid_peak reach rotate_x/y |
control |
— | first_order second_order low_pass complementary integrate derivative pid pid_clamped feedforward state_feedback bang_bang hysteresis rate_limit_delta lead within slew |
units |
— | kmh_to_ms ev_to_j atm_to_pa deg_to_rad g_to_ms2 … |
# from cli/examples/ the paths would be "../../std/…"
import "std/forces"
import "std/thermal"
update { on = body; dt = 0.1
vx = vx + dt*(forces.spring_accel(2.0, x, 1.0) + forces.damping_accel(0.3, vx, 1.0))
temp = temp + dt*(thermal.newton_cooling(temp, 293.15, 0.05))
}
Part 8 — Examples (cli/examples/)
Compile then run/present the .pweb.
| Example | Teaches |
|---|---|
bounce.pwe |
component body: gravity + ground_contact. |
heat.pwe |
2-D field + flap sweep. |
solar.pwe |
nbody (state px,py,pz,vx,vy,vz,m). |
flock.pwe |
boids via neighbor_count/neighbor_mean. |
spring/spring.pwe |
modules + params + units + at/periodic. |
domains.pwe |
composing std/*. |
wave.pwe, wave3d.pwe |
1-D / 3-D wave solvers. |
acoustics.pwe |
2-D sound + dB. |
robot.pwe, humanoid.pwe |
linkages / articulated figures. |
shapes.pwe |
composite shapes, polyhedra, SVG. |
chain.pwe |
distance joints (pendulum). |
cloth.pwe, jelly.pwe |
soft bodies (sheet, 3-D gel). |
particles.pwe |
pool + spawn/despawn. |
courtyard.pwe |
mesh ground + turning/leaning walkers (orient). |
structs.pwe |
struct record types (pos.x, @a.pos.y). |
kinetics.pwe |
exact stochastic kinetics: gillespie channels + SSA. |
Appendix A — Canonical skeleton
# imports (paths relative to this file)
import "std/forces"
world {
title = "…"
gravity = (0, -9.81, 0)
params { k = 12.0 }
entity a { position = (0, 5, 0) velocity = (1, 0, 0) mass = 1.0 sphere = 0.3; color = 0xFF6B4A }
entity ground { position = (0, -0.5, 0) dynamic = false; box = (40,1,40); color = 0x557755 }
}
funcs { accel(k, x) { 0.0 - k * x } }
systems {
gravity { gravity_y = -9.81; dt = 0.016 }
integrate { dt = 0.016 }
ground_contact { restitution = 0.5 }
invariant { on = a; expr = abs(x) < 1e6 }
}
Appendix B — Stability checklist
- [ ] Each body uses one model (§0.6).
- [ ] Rules are
slot = <expression>(an assignment); integrate withslot = slot + inte(rate)orinte slot = rate. - [ ]
on = <entity|pool>where intended. - [ ] System order: forces/gravity → integrate → constraints/boundaries.
- [ ] Solver stability respected (
diffuserate,waveCourant). - [ ]
invariants guard against NaN/blow-ups. - [ ] Spatial queries in rules only; field ops take literal field names.
- [ ] ≤ 16 state slots; loops within limits; dynamic LHS only in
update. - [ ] Slot 7 reserved unless
orient = true. - [ ]
nbodybodies declarestate=(px,py,pz,vx,vy,vz,m)(detail 91). - [ ]
random()/noise()acceptable (seeded, deterministic).