This template ships as a single-screen prototype (src/sim-screen/). New sims
should call npm run scaffold-screens (or Baton/scripts/create-sim.sh) so screen
folders use fleet naming (src/intro/, not intro-screen/). This guide covers the
architecture and how to extend an existing sim by hand.
After npm run rename (or via create-sim.sh):
# One screen named after the sim (default when --screens is omitted)
npm run scaffold-screens
# N screens — titles, or kebab:Title pairs
npm run scaffold-screens -- --screens Intro,Lab
npm run scaffold-screens -- --screens intro:Intro,"series-rlc:Series RLC"
# Shared helpers under common/model/ (fleet style)
npm run scaffold-screens -- --screens Intro,Lab --shared-modelThe scaffolder:
- Copies the
sim-screen/prototype intosrc/<kebab>/per screen - Writes
src/common/{Prefix}ScreenIcons.tsstubs and wires icons on each Screen - Updates
main.ts, locale JSON (screens+ nesteda11y), andStringManager - Removes the prototype
sim-screen/folder
Then always:
npm run fix # the emitted/renamed imports need Biome's organizeImports pass
npm run checkIndependent models by default. Pass --shared-model to emit
src/common/model/SharedModel.ts and compose it into each screen model
(see Shared model below).
main.ts
└─ SimScreen (Screen<SimModel, SimScreenView>)
├─ SimModel owns all state
└─ SimScreenView owns all visuals
Each screen is completely self-contained. Use this when screens have no shared physical state — for instance an "Intro" that is purely explanatory and a "Lab" with interactive controls.
main.ts
├─ IntroScreen (Screen<IntroModel, IntroScreenView>)
│ ├─ IntroModel
│ └─ IntroScreenView
└─ LabScreen (Screen<LabModel, LabScreenView>)
├─ LabModel
└─ LabScreenView
Put reusable physics in src/common/model/ under a domain name. Each screen
model composes its own instance — code is shared, live state usually is not:
common/model/RlcCircuitModel.ts (or SkyModel, TimeMaster, …)
main.ts
├─ IntroScreen → IntroModel { circuit = new RlcCircuitModel() }
└─ LabScreen → LabModel { circuit = new RlcCircuitModel() }
When screens must mutate the same Properties, construct once in main.ts
and pass the instance into each Screen/Model. Prefer domain names in
common/model/ — there is no *RootModel and no top-level src/model/
(see Baton CONVENTIONS).
Prefer npm run scaffold-screens when creating the sim. Use this section when
growing an already-scaffolded sim.
src/i18n/strings_en.json (and every other locale file):
{
"title": "Friction",
"screens": {
"intro": "Intro",
"lab": "Lab"
}
}Important: All locale files must define identical keys. TypeScript will error
at compile time if any key is missing (see the satisfies checks in
StringManager.ts).
// src/i18n/StringManager.ts
public getScreenNames(): {
readonly introStringProperty: ReadOnlyProperty<string>;
readonly labStringProperty: ReadOnlyProperty<string>;
} {
return {
introStringProperty: stringProperties.screens.introStringProperty,
labStringProperty: stringProperties.screens.labStringProperty,
};
}Mirror the structure of an existing screen package. Fleet convention: kebab
folder names without a -screen suffix:
src/
├─ common/
│ └─ FrictionScreenIcons.ts # createIntroIcon(), createLabIcon(), …
├─ intro/
│ ├─ IntroScreen.ts
│ ├─ model/
│ │ └─ IntroModel.ts
│ └─ view/
│ ├─ IntroScreenView.ts
│ ├─ IntroScreenSummaryContent.ts
│ └─ IntroKeyboardHelpContent.ts
└─ lab/
├─ LabScreen.ts
├─ model/
│ └─ LabModel.ts
└─ view/
├─ LabScreenView.ts
├─ LabScreenSummaryContent.ts
└─ LabKeyboardHelpContent.ts
Each screen file follows the same Screen<Model, View> pattern as the
template's SimScreen.ts. Screen icons live in one shared module under
src/common/ (see Home screen icons) — do not put
a *ScreenIcon.ts next to each screen.
Automated: npm run scaffold-screens -- --screens Intro,Lab --shared-model
writes src/common/model/SharedModel.ts and has each screen model compose
public readonly shared = new SharedModel() (same pattern as ACPhasor's
RlcCircuitModel / RotatingSky's SkyModel). Rename SharedModel to a domain
noun when you know it.
Manual: add a domain model under common/model/ and compose it:
// src/common/model/FrictionSurface.ts
import { NumberProperty, StringProperty } from "scenerystack/axon";
export class FrictionSurface {
public readonly surfaceTypeProperty = new StringProperty("wood");
public readonly normalForceProperty = new NumberProperty(10, { units: "N" });
public reset(): void {
this.surfaceTypeProperty.reset();
this.normalForceProperty.reset();
}
}Per-screen models compose it (fleet default):
// src/intro/model/IntroModel.ts
import { FrictionSurface } from "../../common/model/FrictionSurface.js";
export class IntroModel implements TModel {
public readonly surface = new FrictionSurface();
public step(_dt: number): void { /* … */ }
public reset(): void { this.surface.reset(); }
}// src/main.ts (inside onReadyToLaunch)
const screens = [
new IntroScreen({
name: stringManager.getScreenNames().introStringProperty,
tandem: Tandem.ROOT.createTandem("introScreen"),
backgroundColorProperty: SimColors.backgroundColorProperty,
}),
new LabScreen({
name: stringManager.getScreenNames().labStringProperty,
tandem: Tandem.ROOT.createTandem("labScreen"),
backgroundColorProperty: SimColors.backgroundColorProperty,
}),
];
const sim = new Sim(stringManager.getTitleStringProperty(), screens, { … });Screen models own their composed common/model/ helpers; main.ts only builds the
screens array (unless you deliberately share one live instance — see above).
| Option | Type | Purpose |
|---|---|---|
name |
ReadOnlyProperty<string> |
Localizable tab label |
tandem |
Tandem |
PhET-iO registration root |
backgroundColorProperty |
TReadOnlyProperty<Color> |
Screen background |
createKeyboardHelpNode |
() => Node |
Per-screen keyboard help |
homeScreenIcon |
ScreenIcon |
Icon on the home screen |
navigationBarIcon |
ScreenIcon |
Smaller icon in the nav bar |
maxDT |
number |
Maximum allowed dt in seconds |
targetFrameRate |
number |
Target FPS for step() |
Multi-screen sims show a home screen by default. Each screen needs a
homeScreenIcon and usually a navigationBarIcon, or SceneryStack falls
back to a generic placeholder.
Put all screen icons in one module:
src/common/{SimName}ScreenIcons.ts
Export one factory per screen named create{Screen}Icon():
| Screen | Factory |
|---|---|
| Intro | createIntroIcon() |
| Lab | createLabIcon() |
| … | create…Icon() |
Wire both icons in each *Screen.ts constructor via optionize defaults
(same pattern as MotionsOfTheSun / TheRamp):
import { createIntroIcon } from "../common/FrictionScreenIcons.js";
optionize<IntroScreenOptions, EmptySelfOptions, ScreenOptions>()(
{
backgroundColorProperty: FrictionColors.backgroundColorProperty,
createKeyboardHelpNode: () => new IntroKeyboardHelpContent(),
homeScreenIcon: createIntroIcon(),
navigationBarIcon: createIntroIcon(),
},
options,
);Do not use per-screen classes like intro-screen/IntroScreenIcon.ts.
Draw on the standard PhET 548 × 373 canvas with scenery primitives and
*Colors ProfileColorPropertys so icons follow default / projector mode:
/**
* FrictionScreenIcons.ts
*
* Programmatic home-screen / navigation-bar icons for each Friction screen.
* Drawn on the standard PhET 548 × 373 canvas using FrictionColors.
*/
import { Node, Rectangle } from "scenerystack/scenery";
import { ScreenIcon } from "scenerystack/sim";
import FrictionColors from "../FrictionColors.js";
const W = 548;
const H = 373;
function background(): Rectangle {
return new Rectangle(0, 0, W, H, { fill: FrictionColors.backgroundColorProperty });
}
function iconFrom(content: Node): ScreenIcon {
return new ScreenIcon(content, {
maxIconWidthProportion: 1,
maxIconHeightProportion: 1,
fill: FrictionColors.backgroundColorProperty,
});
}
export function createIntroIcon(): ScreenIcon {
// Distinctive motif for the Intro screen (keep it readable at navbar size too).
return iconFrom(
new Node({
children: [
background(),
new Rectangle(180, 120, 188, 133, {
fill: FrictionColors.accentColorProperty,
cornerRadius: 12,
}),
],
}),
);
}
export function createLabIcon(): ScreenIcon {
return iconFrom(
new Node({
children: [
background(),
// … Lab-specific motif …
],
}),
);
}Each icon should be a miniature of what that screen is about so learners can tell the screens apart on the home screen.
Each screen must have its own ScreenSummaryContent and KeyboardHelpContent.
The strings live under per-screen keys in the a11y block:
"a11y": {
"intro": {
"screenSummary": { … },
"currentDetails": "…"
},
"lab": {
"screenSummary": { … },
"currentDetails": "…"
}
}Expose them via separate methods in StringManager:
public getIntroA11yStrings() { return stringProperties.a11y.intro; }
public getLabA11yStrings() { return stringProperties.a11y.lab; }The repository is a GitHub template. Use the "Use this template" button on GitHub to create a new repository, then:
npm install
npm run rename -- --id my-sim --name "My Simulation"
npm run scaffold-screens -- --screens Intro,Lab # or omit --screens for one screen
npm run fix
npm run checkrename picks up repository.url from your origin remote; if you cloned the template
directly rather than using Use this template, set it in package.json by hand.
From the OpenPhysics workspace:
Baton/scripts/create-sim.sh \
--repo MySim \
--name "My Simulation" \
--screens Intro,Lab \
--shared-model \
--onboardCreates the GitHub repo from this template, clones it beside Baton, runs
rename + scaffold-screens + check. --onboard finishes catalog, screenshot,
WebP, Pages index, and the OpenPhysics README Layout row; add --pr to open
follow-up PRs. See Baton/doc/add-simulation.md.
For organisations building a suite of simulations, a pnpm/npm workspace lets you share tooling while keeping each sim independent:
physics-sims/
├─ package.json # workspace root (workspaces: ["sims/*"])
├─ sims/
│ ├─ friction/ # forked from this template
│ ├─ waves/
│ └─ optics/
└─ shared/ # optional: shared assets, design tokens
Each sim is still independently deployable; the workspace just gives you a
single npm run build --workspaces command to build all of them.
To pull template improvements back into an existing fork:
# One-time: add the template as a remote
git remote add template https://github.com/OpenPhysics/SceneryStackTemplate.git
# Pull template changes into a branch for review
git fetch template
git merge template/main --allow-unrelated-histories --squashReview the diff carefully — class-name changes in the template may conflict with your sim-specific renames.