Model-View Separation
Every SceneryStack application is built around a strict one-way dependency: the model is a plain TypeScript object graph whose state lives in axon Property instances, and the view is a scenery Node tree that observes the model. The model never imports from the view, never mentions pixels, and can run headless in a unit test.
The model: axon Properties
ts
import { NumberProperty, Property, DerivedProperty, type TReadOnlyProperty } from 'scenerystack/axon';
import { Vector2, Range } from 'scenerystack/dot';
export class ProjectileModel {
// Mutable state is a Property. Physical units only — meters, seconds.
public readonly positionProperty = new Property( new Vector2( 0, 0 ) );
public readonly massProperty = new NumberProperty( 5, { range: new Range( 1, 20 ) } ); // kg
// Derived state is a DerivedProperty: never stored redundantly, never stale.
// Typed as TReadOnlyProperty, not DerivedProperty<...>, because DerivedProperty's own
// generic parameters are positional per-dependency (T, T1, T2, ... T15), not a single
// tuple - TReadOnlyProperty<number> is what you actually want to expose here.
public readonly weightProperty: TReadOnlyProperty<number>;
public constructor() {
this.weightProperty = new DerivedProperty( [ this.massProperty ], mass => mass * 9.8 );
}
public step( dt: number ): void {
// Advance physics in model units; observers update automatically.
}
public reset(): void {
this.positionProperty.reset();
this.massProperty.reset();
}
}Guidelines:
- Expose Properties as
public readonly; mutate their.value, not the field. - Anything computable from other state should be a
DerivedProperty, not a second source of truth. - Discrete events that aren't state (a collision, a reset) use
Emitter. - Provide a
reset()that resets every Property — the standard Reset All button calls it.
The view: observing, never owning state
ts
import { Circle } from 'scenerystack/scenery';
import { ModelViewTransform2 } from 'scenerystack/phetcommon';
export class ProjectileNode extends Circle {
public constructor( model: ProjectileModel, modelViewTransform: ModelViewTransform2 ) {
super( 15, { fill: 'orange' } );
// View subscribes to the model. Conversion to pixels happens here, at the
// boundary, via the transform — see /api/phetcommon/model-view-transform.
model.positionProperty.link( position => {
this.translation = modelViewTransform.modelToViewPosition( position );
} );
}
}When the view needs to change the model (a slider, a drag listener), it writes to the model's Properties — it does not keep its own copy:
ts
massSlider = new HSlider( model.massProperty, model.massProperty.range );The Property is the single source of truth; the slider both writes it and reacts to it, so external changes (reset, PhET-iO, another control) stay consistent for free.
Why this matters at scale
- Testability — the model runs and is assertable without a DOM.
- Multiple views — a chart, a readout, and the scene graph can all observe the same Property without coordination.
- Reset, undo, PhET-iO serialization — all become mechanical when state is enumerable Properties instead of scattered fields.