Skip to content

PhetioObject

PhetioObject (from scenerystack/tandem) is the base class that gives any object PhET-iO instrumentation: a tandem, an IOType describing its serialization, and a fixed set of metadata flags (phetioState, phetioReadOnly, phetioFeatured, phetioDocumentation, …). You rarely instantiate it directly — Property, Node, Emitter, Screen, and most other instrumentable SceneryStack classes already extend it — but you extend it yourself when authoring a custom stateful class that needs its own PhET-iO presence rather than composing already-instrumented Propertys.

ts
import { PhetioObject, type PhetioObjectOptions, Tandem } from 'scenerystack/tandem';

type SelfOptions = { x?: number; y?: number };
type PositionOptions = SelfOptions & PhetioObjectOptions;

class Position extends PhetioObject {
  public x: number;
  public y: number;

  public constructor( providedOptions: PositionOptions ) {
    super( providedOptions );
    this.x = providedOptions.x ?? 0;
    this.y = providedOptions.y ?? 0;
  }
}

const origin = new Position( {
  x: 0, y: 0,
  tandem: Tandem.REQUIRED,
  phetioType: PositionIO // see IOType for how PositionIO would be declared
} );

PhetioObject extends Disposable, so every PhetioObject (and therefore every Node/Property built on it) already has dispose(), isDisposed, and disposeEmitter for free.

Key behavior: initialization is conditional on being instrumented

The most important thing to understand about PhetioObject is that its PhET-iO machinery only fully activates when PHET_IO_ENABLED && tandem.supplied — that is, when the sim is actually running under the PhET-iO brand and a real (non-sentinel) tandem was passed in. Outside PhET-iO, initializePhetioObject returns early after recording the tandem/phetioID for tree-building purposes, without validating options or registering the object with the data stream. This is why getters like phetioType/phetioState/phetioDocumentation assert if you read them on an uninstrumented object — they're only meaningful once instrumentation actually happened.

Constructor options

PhetioObjectOptions combines a handful of PhET-iO metadata fields with DisposableOptions:

OptionDefaultEffect
tandemTandem.OPTIONALThe instrumentation handle; subclasses often default this to Tandem.REQUIRED
phetioTypeIOType.ObjectIODeclares serialization/methods for this instance — see IOType
phetioStatetrueWhether this object's state is included in PhET-iO save/restore
phetioReadOnlyfalseIf true, external PhET-iO clients can read but not set this object
phetioDocumentation''Client-facing documentation shown in PhET-iO Studio; must not contain literal newlines (<br> instead)
phetioFeaturedfalseMarks this element as one of the "featured" elements surfaced by default in Studio
phetioEventTypeEventType.MODELCategory tag applied to events this object emits via phetioStartEvent/phetioEndEvent
tandemNameSuffixnullIf set, asserts the tandem's name ends with this suffix (or an array of allowed suffixes)

Methods

MethodEffect
isPhetioInstrumented()true only if a real, supplied tandem was provided
addLinkedElement( element, options? )Creates a one-way "symbolic link" to another PhetioObject, shown in Studio as a hyperlink — used by e.g. NumberSpinner/NumberControl to link their display to the Property they control
phetioStartEvent( event, options? ) / phetioEndEvent()Emits a nested event to the PhET-iO data stream, a no-op outside PhET-iO
getMetadata( object? )Returns the plain-object metadata (phetioTypeName, phetioState, etc.) describing this instance or a metadata-shaped input

initializePhetioObject can run after the constructor

Classes like Node need to support mutate()-style deferred configuration, so PhetioObject allows a subclass to call super() with no options and invoke initializePhetioObject later once real options are known. If you're authoring a subclass this way, keep in mind that tandem/phetioID/metadata all read as their un-instrumented defaults until that second call happens — don't read phetioType/phetioDocumentation in between.