Manage game states/screens with transitions and overlay support:
import type { ScreenDefinition } from 'ecspresso';
type Screens = {
menu: ScreenDefinition<
Record<string, never>, // Config (passed when entering)
{ selectedOption: number } // State (mutable during screen)
>;
gameplay: ScreenDefinition<
{ difficulty: string; level: number },
{ score: number; isPaused: boolean }
>;
pause: ScreenDefinition<Record<string, never>, Record<string, never>>;
};
const game = ECSpresso.create()
.withComponentTypes<Components>()
.withEventTypes<Events>()
.withResourceTypes<Resources>()
.withScreens(screens => screens
.add('menu', {
initialState: () => ({ selectedOption: 0 }),
onEnter: () => console.log('Entered menu'),
onExit: () => console.log('Left menu'),
})
.add('gameplay', {
initialState: () => ({ score: 0, isPaused: false }),
onEnter: (config) => console.log(`Starting level ${config.level}`),
onResume: ({ state }) => console.log(`Resuming with ${state.score} points`),
onExit: () => console.log('Gameplay ended'),
requiredAssetGroups: ['level1'],
})
.add('pause', {
initialState: () => ({}),
})
)
.build();
await game.initialize();
await game.setScreen('menu', {}); // Set initial screen
await game.setScreen('gameplay', { difficulty: 'hard', level: 1 }); // Transition
await game.pushScreen('pause', {}); // Push overlay
await game.popScreen(); // Pop overlay
const current = game.getCurrentScreen(); // 'gameplay'
const config = game.getScreenConfig(); // { difficulty: 'hard', level: 1 }
const state = game.getScreenState(); // { score: 0, isPaused: false }
game.updateScreenState({ score: 100 });
Use the ECSpresso screen manager as the source of truth for navigation whenever a view affects system activity, input routing, lifecycle, or back behavior. This includes menu steps and settings pages, not only gameplay scenes.
A DOM, canvas, or renderer integration may cache view elements, but it should show them from screen lifecycle hooks rather than track an independent current screen:
game.onScreenEnter('menu', () => showView('menu'));
game.onScreenEnter('modeSelect', () => showView('modeSelect'));
game.onScreenEnter('settings', ({ config }) => showSettings(config.returnTo));
game.onScreenResume('gameplay', () => showView('gameplay'));
// Settings preserves the previous screen and returns through the same stack.
const returnTo = game.getCurrentScreen();
if (returnTo !== null) {
await game.pushScreen('settings', { returnTo });
await game.popScreen();
}
Avoid a second UI-only router whose state can disagree with
getCurrentScreen(). That split commonly leaves gameplay systems or menu input
active behind the wrong view.
Use ScreenConfiguratorFn when a .withScreens(...) callback is extracted into a named helper:
import type { ScreenConfiguratorFn, ScreenDefinition } from 'ecspresso';
type AppScreens = {
playing: ScreenDefinition<{ level: number }>;
pause: ScreenDefinition;
};
export const configureScreens: ScreenConfiguratorFn<AppScreens> = function configureScreens(screens) {
return screens
.add('playing', { initialState: config => config })
.add('pause', { initialState: () => ({}) });
};
const game = ECSpresso.create()
.withScreens(configureScreens)
.build();
Subscribe to a specific screen entering, resuming, or exiting without writing inline screenEnter / screenResume / screenExit event guards. Multiple handlers can be registered for the same screen and fire in registration order. Each returns a disposer.
const offEnter = game.onScreenEnter('gameplay', ({ config, ecs }) => {
// Fires on setScreen('gameplay', ...) and pushScreen('gameplay', ...)
console.log(`Starting level ${config.level}`);
ecs.spawn({ player: true });
});
const offResume = game.onScreenResume('gameplay', ({ config, state, ecs }) => {
// Fires after popScreen() restores gameplay as the current screen
console.log(`Resuming level ${config.level} with score ${state.score}`);
ecs.updateScreenState('gameplay', { isPaused: false });
});
const offExit = game.onScreenExit('gameplay', ({ ecs }) => {
// Fires when leaving 'gameplay' via setScreen away or popScreen
console.log('Gameplay ended');
});
// Later, if needed:
offEnter();
offResume();
offExit();
Pass { scope: screenName } to spawn or spawnChild to have the entity automatically removed when that screen exits. This replaces hand-maintained per-component teardown lists.
await game.setScreen('gameplay', { level: 1 });
// Removed automatically when 'gameplay' exits
game.spawn({ enemy: { hp: 10 } }, { scope: 'gameplay' });
game.spawnChild(parentId, { projectile: { speed: 5 } }, { scope: 'gameplay' });
The screen name is type-checked against your declared screens.
game.addSystem('menuUI')
.inScreens(['menu']) // Only runs in 'menu'
.setProcess(({ ecs }) => {
renderMenu(ecs.getScreenState().selectedOption);
});
game.addSystem('animations')
.excludeScreens(['pause']) // Runs in all screens except 'pause'
.setProcess(() => { /* ... */ });
pushScreen('pause', ...) makes pause current while preserving the previous
screen on the stack. Systems gated with .inScreens(['gameplay']) stop because
gameplay is no longer current. ECSpresso does not automatically stop every
other system in the world.
In particular, independently installed timer, tween, coroutine, physics, and animation plugins keep running unless their systems have compatible screen gates or their system groups are disabled. A complete pause implementation must account for both application systems and shared simulation plugins:
const CLOCK_GROUPS = ['timers', 'tweens', 'coroutines'] as const;
function pauseSimulation(ecs: typeof game): void {
CLOCK_GROUPS.forEach(group => ecs.disableSystemGroup(group));
}
function resumeSimulation(ecs: typeof game): void {
CLOCK_GROUPS.forEach(group => ecs.enableSystemGroup(group));
}
game.onScreenEnter('pause', ({ ecs }) => pauseSimulation(ecs));
game.onScreenEnter('gameplay', ({ ecs }) => resumeSimulation(ecs));
game.onScreenResume('gameplay', ({ ecs }) => resumeSimulation(ecs));
Keep input and pause-menu navigation enabled. Disable only groups whose clocks or simulation state should freeze. Also handle non-gameplay destinations such as menus or game-over screens when long-lived entities carry timers or animations that must not continue there.
Access screen state through the $screen resource:
game.addSystem('ui')
.setProcess(({ ecs }) => {
const screen = ecs.getResource('$screen');
screen.current; // Current screen name
screen.config; // Current screen config
screen.state; // Current screen state (mutable)
screen.isOverlay; // true if screen was pushed
screen.stackDepth; // Number of screens in stack
screen.isCurrent('gameplay'); // Check current screen
screen.isActive('menu'); // true if in current or stack
});