This template ships as a single-screen simulation. Many physics simulations expose multiple conceptual modes — "Intro" + "Lab", "Basics" + "Advanced", etc. This guide shows how to extend the template to two or more screens.
main.ts
└─ SolarSystemModelsScreen (Screen<SolarSystemModelsModel, SolarSystemModelsScreenView>)
├─ SolarSystemModelsModel owns all state
└─ SolarSystemModelsScreenView 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
A top-level "root model" owns shared state (e.g. selected material, common parameters). Each screen model receives a reference to it.
main.ts → creates FrictionModel (shared)
├─ IntroScreen receives FrictionModel → IntroModel(frictionModel)
└─ LabScreen receives FrictionModel → LabModel(frictionModel)
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 src/solar-system-models-screen/:
src/
├─ intro-screen/
│ ├─ IntroScreen.ts
│ ├─ model/
│ │ └─ IntroModel.ts
│ └─ view/
│ ├─ IntroScreenView.ts
│ ├─ IntroScreenSummaryContent.ts
│ └─ IntroKeyboardHelpContent.ts
└─ lab-screen/
├─ LabScreen.ts
├─ model/
│ └─ LabModel.ts
└─ view/
├─ LabScreenView.ts
├─ LabScreenSummaryContent.ts
└─ LabKeyboardHelpContent.ts
Each screen file follows the same Screen<Model, View> pattern as the
existing SolarSystemModelsScreen.ts.
If screens share state, create a top-level model before constructing screens:
// src/model/FrictionModel.ts
import { BooleanProperty, NumberProperty } from "scenerystack/axon";
export class FrictionModel {
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 then take it as a constructor argument:
// src/intro-screen/model/IntroModel.ts
export class IntroModel implements TModel {
public constructor(public readonly shared: FrictionModel) {}
public step(_dt: number): void { /* … */ }
public reset(): void { this.shared.reset(); }
}// src/main.ts (inside onReadyToLaunch)
// Shared model — created once, passed to both screens
const frictionModel = new FrictionModel();
const screens = [
new IntroScreen(frictionModel, {
name: stringManager.getScreenNames().introStringProperty,
tandem: Tandem.ROOT.createTandem("introScreen"),
backgroundColorProperty: SolarSystemModelsColors.backgroundColorProperty,
}),
new LabScreen(frictionModel, {
name: stringManager.getScreenNames().labStringProperty,
tandem: Tandem.ROOT.createTandem("labScreen"),
backgroundColorProperty: SolarSystemModelsColors.backgroundColorProperty,
}),
];
const sim = new Sim(stringManager.getTitleStringProperty(), screens, { … });| 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 548×373 px
ScreenIcon (or the SceneryStack default is used):
import { ScreenIcon } from "scenerystack/sim";
import { Rectangle } from "scenerystack/scenery";
const icon = new ScreenIcon(
new Rectangle(0, 0, 548, 373, { fill: SolarSystemModelsColors.accentColorProperty }),
{ maxIconWidthProportion: 1, maxIconHeightProportion: 1 }
);Pass it as homeScreenIcon and navigationBarIcon on the Screen options.
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 configured as a GitHub template. Use the "Use this template" button on GitHub to create a new repository pre-populated with all template files. Then run:
npm install
npm run rename -- --id my-sim --name "My Simulation"
npm run checkIf your organisation maintains multiple sims, create an npm initializer that wraps the rename step:
npm create openlyceum-sim@latest my-sim
# → clones the template, runs npm run rename automaticallySee scripts/rename-sim.ts for the rename logic you can reuse.
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/OpenLyceum/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.