A Game-Style Sim with Vegas Scoring
vegas (scenerystack/vegas) supplies the recurring pieces of a PhET-style game: a level-selection screen, a score display, standard game-feedback sounds, and an end-of-game dialog. Each is documented individually in api/vegas/; this page combines four of them — LevelSelectionButton, ScoreDisplayNumberAndStar, GameAudioPlayer, and AllLevelsCompletedNode — into one minimal two-level game: a level-selection grid, a trivial "answer correctly" challenge per level, and a completion dialog once every level's been played.
The model: per-level scores plus overall progress
import { NumberProperty, BooleanProperty, DerivedProperty, type TReadOnlyProperty } from 'scenerystack/axon';
import { Tandem } from 'scenerystack/tandem';
class GameModel {
public static readonly NUMBER_OF_LEVELS = 2;
public static readonly PERFECT_SCORE = 3;
public readonly levelScoreProperties: NumberProperty[];
public readonly allLevelsCompletedProperty: TReadOnlyProperty<boolean>;
private readonly levelCompletedProperties: BooleanProperty[];
public constructor( tandem: Tandem ) {
this.levelScoreProperties = [];
this.levelCompletedProperties = [];
for ( let level = 1; level <= GameModel.NUMBER_OF_LEVELS; level++ ) {
const levelTandem = tandem.createTandem( `level${level}` );
this.levelScoreProperties.push( new NumberProperty( 0, {
tandem: levelTandem.createTandem( 'scoreProperty' )
} ) );
this.levelCompletedProperties.push( new BooleanProperty( false, {
tandem: levelTandem.createTandem( 'completedProperty' )
} ) );
}
this.allLevelsCompletedProperty = DerivedProperty.and( this.levelCompletedProperties );
}
public answerCorrectly( levelIndex: number ): void {
const scoreProperty = this.levelScoreProperties[ levelIndex ];
scoreProperty.value = Math.min( scoreProperty.value + 1, GameModel.PERFECT_SCORE );
if ( scoreProperty.value === GameModel.PERFECT_SCORE ) {
this.levelCompletedProperties[ levelIndex ].value = true;
}
}
public reset(): void {
this.levelScoreProperties.forEach( property => property.reset() );
this.levelCompletedProperties.forEach( property => property.reset() );
}
}The level-selection screen
Rather than one bespoke button per level, build a row of LevelSelectionButtons from the model's arrays — each wired to its own scoreProperty and displaying it with the compact ScoreDisplayNumberAndStar:
import { ScreenView, type ScreenViewOptions } from 'scenerystack/sim';
import { HBox, Text, Node } from 'scenerystack/scenery';
import { LevelSelectionButton, ScoreDisplayNumberAndStar, AllLevelsCompletedNode } from 'scenerystack/vegas';
class GameScreenView extends ScreenView {
private readonly levelSelectionNode: Node;
private readonly challengeNode: Node;
private readonly allLevelsCompletedNode: AllLevelsCompletedNode;
public constructor( model: GameModel, providedOptions: ScreenViewOptions ) {
super( providedOptions );
const levelButtons = model.levelScoreProperties.map( ( scoreProperty, levelIndex ) =>
new LevelSelectionButton( new Text( `${levelIndex + 1}`, { font: '32px sans-serif' } ), scoreProperty, {
buttonWidth: 120,
buttonHeight: 120,
createScoreDisplay: score => new ScoreDisplayNumberAndStar( score ),
listener: () => this.showChallenge( levelIndex ),
tandem: this.tandem.createTandem( `level${levelIndex + 1}Button` )
} )
);
this.levelSelectionNode = new HBox( {
spacing: 20,
children: levelButtons,
center: this.layoutBounds.center
} );
// A stand-in "challenge" view - in a real game this would be the actual question UI.
this.challengeNode = new Node( { visible: false } );
// Shown once model.allLevelsCompletedProperty flips true - see the .link() call below.
this.allLevelsCompletedNode = new AllLevelsCompletedNode( () => {
this.allLevelsCompletedNode.visible = false;
this.levelSelectionNode.visible = true;
}, {
center: this.layoutBounds.center,
visible: false
} );
this.children = [ this.levelSelectionNode, this.challengeNode, this.allLevelsCompletedNode ];
model.allLevelsCompletedProperty.link( allCompleted => {
if ( allCompleted ) {
this.levelSelectionNode.visible = false;
this.allLevelsCompletedNode.visible = true;
}
} );
}
private showChallenge( levelIndex: number ): void {
this.levelSelectionNode.visible = false;
this.challengeNode.visible = true;
// ...build/show the actual per-level challenge here.
}
}Wiring GameAudioPlayer to the standard game moments
GameAudioPlayer supplies the shared sound vocabulary for a game's feedback moments — correct/incorrect answers and the three game-over shapes — so the model's challenge-resolution code plays the standard sound instead of the view constructing its own SoundClips:
import { GameAudioPlayer } from 'scenerystack/vegas';
class ChallengeController {
private readonly gameAudioPlayer = new GameAudioPlayer();
public constructor( private readonly model: GameModel, private readonly levelIndex: number ) {}
public submitAnswer( isCorrect: boolean ): void {
if ( isCorrect ) {
this.gameAudioPlayer.correctAnswer();
this.model.answerCorrectly( this.levelIndex );
const scoreProperty = this.model.levelScoreProperties[ this.levelIndex ];
if ( scoreProperty.value === GameModel.PERFECT_SCORE ) {
this.gameAudioPlayer.gameOverPerfectScore();
}
}
else {
this.gameAudioPlayer.wrongAnswer();
}
}
}A single GameAudioPlayer instance is enough for the whole sim — its five underlying sound clips are module-level singletons (see GameAudioPlayer), so there's no benefit to constructing more than one.
Wiring the Screen and Sim
import { Sim, Screen, onReadyToLaunch } from 'scenerystack/sim';
import { Property } from 'scenerystack/axon';
import { Tandem } from 'scenerystack/tandem';
const screenTandem = Tandem.ROOT.createTandem( 'gameScreen' );
const gameScreen = new Screen(
() => new GameModel( screenTandem.createTandem( 'model' ) ),
model => new GameScreenView( model, { tandem: screenTandem.createTandem( 'view' ) } ),
{
name: new Property( 'Game' ),
backgroundColorProperty: new Property( 'white' ),
tandem: screenTandem
}
);
onReadyToLaunch( () => {
const sim = new Sim( new Property( 'Vegas Game Demo' ), [ gameScreen ] );
sim.start();
} );| Piece | Role in the composition |
|---|---|
One LevelSelectionButton per level, built from model.levelScoreProperties | The entry point into each level, showing its live score without the view hand-tracking button state |
ScoreDisplayNumberAndStar as createScoreDisplay | The compact score readout fits LevelSelectionButton's small footprint better than the default ScoreDisplayStars once more than a couple of points are possible |
GameAudioPlayer, one instance, called from wherever a challenge resolves | Standard sounds for standard moments — no bespoke SoundClip wiring needed for the common correct/incorrect/game-over cases (see A Sound-Rich Simulation with Tambo for building custom sounds when the standard set isn't enough) |
AllLevelsCompletedNode, shown/hidden manually based on model.allLevelsCompletedProperty | Not a Dialog — this example owns showing/hiding it itself, exactly as documented on the AllLevelsCompletedNode page |
Model owns "which levels are complete," not the buttons
model.allLevelsCompletedProperty is a DerivedProperty over the model's own per-level completion state — the view only reacts to it (showing AllLevelsCompletedNode) rather than deciding for itself when the game is finished. This keeps model-view separation intact even though the "game is over" condition spans every level's state at once.
Where to go next
- LevelSelectionButton — the button's full option list, including
soundPlayerIndexfor per-level pitch variation - GameAudioPlayer — the complete set of standard game sounds used above
- AllLevelsCompletedNode — why it's a plain
Node, not aDialog, and what that means for ownership - A Sound-Rich Simulation with Tambo — building custom sounds for interactions vegas doesn't already cover