Building A New Game
Use this page as the starting recipe for a new Hs2d game. The vertical shooter is the reference implementation; this page turns that pattern into a checklist.
File Shape
Create these pieces first:
| File or folder | Purpose |
|---|---|
src/hosanna-game-examples/<game>/MyGame.ts | Hs2dGame subclass that installs the first scene. |
src/hosanna-game-examples/<game>/MyMenuScene.ts | Menu/start scene. |
src/hosanna-game-examples/<game>/MyLevelScene.ts | Hs2dLevelScene gameplay scene. |
src/hosanna-game-examples/<game>/MyPauseScene.ts | Hs2dOverlayScene pause menu. |
asset-bundles/<game>/asset-bundle.json | Bundle manifest for levels, sprites, audio, fonts, and images. |
asset-bundles/<game>/levels/*.json | Prepared Tiled levels. |
Register the game in the example registry only after the core scene flow works.
Minimal Game Class
import { Hs2dGame } from '@hs-src/hosanna-game/hosanna2d/game';
import type { GameExampleContext } from '../types';
import { MyMenuScene } from './MyMenuScene';
export class MyGame extends Hs2dGame {
constructor(context: GameExampleContext) {
super();
this.replaceScene(new MyMenuScene(context));
}
}
Default to Hs2dGame: it gives you the scene stack, overlay semantics, and debug delegation that Hs2d games expect. Implement GameController directly only for a deliberate headless-model integration, legacy controller, or focused diagnostic.
Minimal Gameplay Scene
import { GameBitmapCache } from '@hs-src/hosanna-game/assets/GameBitmapCache';
import { createGameFont } from '@hs-src/hosanna-game/core/GameFont';
import type { GameInput } from '@hs-src/hosanna-game/core/GameInput';
import type { GameScreen } from '@hs-src/hosanna-game/core/types';
import {
Hs2dAssetGate,
Hs2dLevelManager,
Hs2dLevelScene,
Hs2dWorld,
Hs2dWorldBuilder,
type Hs2dLevel,
} from '@hs-src/hosanna-game/hosanna2d';
import { resolveGameAssetBundleUri } from '../assetBundles';
import type { GameExampleContext } from '../types';
import { MyPauseScene } from './MyPauseScene';
const MY_GAME_ASSETS = {
bundleId: 'my-game',
level1BundleKey: 'my-game.levels.level-1',
level1: 'pkg:/asset-bundles/my-game/levels/level-1.json',
tileAtlasBundleKey: 'my-game.images.tile-atlas',
tileAtlas: 'pkg:/asset-bundles/my-game/images/tile-atlas.png',
playerBundleKey: 'my-game.images.player',
player: 'pkg:/asset-bundles/my-game/images/player.png',
enemyAtlasBundleKey: 'my-game.images.enemy-atlas',
enemyAtlas: 'pkg:/asset-bundles/my-game/images/enemy-atlas.png',
skyBundleKey: 'my-game.images.sky',
sky: 'pkg:/asset-bundles/my-game/images/sky.png',
} as const;
export class MyLevelScene extends Hs2dLevelScene {
private readonly bitmapCache = new GameBitmapCache();
private readonly levelManager: Hs2dLevelManager;
private readonly hudFont = createGameFont(18);
private loadedLevel?: Hs2dLevel;
private assetGate?: Hs2dAssetGate;
private score = 0;
constructor(private readonly context: GameExampleContext) {
super({
viewportWidth: context.screenWidth,
viewportHeight: context.screenHeight,
audioManager: context.audioManager,
bundleId: MY_GAME_ASSETS.bundleId,
resolveAssetUri: (bundleId, key, fallback) =>
resolveGameAssetBundleUri(context, bundleId, key, fallback),
});
this.levelManager = new Hs2dLevelManager({
bundleId: MY_GAME_ASSETS.bundleId,
levels: {
1: {
assetKey: MY_GAME_ASSETS.level1BundleKey,
fallbackUri: MY_GAME_ASSETS.level1,
},
},
resolveAssetUri: (bundleId, key, fallback) =>
resolveGameAssetBundleUri(context, bundleId, key, fallback),
});
}
private getBundleState(): string {
for (let i = 0; i < this.context.assetBundles.length; i++) {
const bundle = this.context.assetBundles[i];
if (bundle.bundleId === MY_GAME_ASSETS.bundleId) return bundle.state;
}
return 'loading';
}
private tryInitializeAssets(): boolean {
if (this.loadedLevel && this.assetGate) return true;
const bundleState = this.getBundleState();
if (bundleState !== 'ready') {
this.setLoadingStatus(
'waiting for asset bundle ' + MY_GAME_ASSETS.bundleId +
' (' + bundleState + ')...'
);
return false;
}
this.loadedLevel = this.levelManager.loadLevel(1);
this.assetGate = new Hs2dAssetGate({ bitmapCache: this.bitmapCache })
.add('tile-atlas', this.resolveBundleUri(
MY_GAME_ASSETS.tileAtlasBundleKey,
MY_GAME_ASSETS.tileAtlas,
))
.add('player', this.resolveBundleUri(
MY_GAME_ASSETS.playerBundleKey,
MY_GAME_ASSETS.player,
))
.add('enemy-atlas', this.resolveBundleUri(
MY_GAME_ASSETS.enemyAtlasBundleKey,
MY_GAME_ASSETS.enemyAtlas,
))
.add('sky', this.resolveBundleUri(
MY_GAME_ASSETS.skyBundleKey,
MY_GAME_ASSETS.sky,
));
return true;
}
protected override buildWorld(): Hs2dWorld | undefined {
if (!this.tryInitializeAssets()) return undefined;
const assets = this.assetGate as Hs2dAssetGate;
if (!assets.poll()) {
this.setLoadingStatus(assets.getStatusText());
return undefined;
}
const built = Hs2dWorldBuilder.fromLevel({
level: this.loadedLevel as Hs2dLevel,
assets,
viewportWidth: this.screenWidth,
viewportHeight: this.screenHeight,
tilesetAsset: 'tile-atlas',
entities: {
enemy: {
capacity: 16,
asset: 'enemy-atlas',
frames: [{ id: 'enemy', x: 0, y: 0, width: 64, height: 64 }],
initialFrameId: 'enemy',
},
},
hud: this,
});
return built.world;
}
protected override handleInputs(inputs: GameInput[]): boolean {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i];
if (input.release) continue;
if (input.isButton('play')) {
this.game?.pushScene(new MyPauseScene(this.context));
return true;
}
}
return false;
}
protected override onUpdate(deltaMs: number, inputs: GameInput[]): void {
this.updateGameRules(deltaMs, inputs);
this.tickSceneCamera(deltaMs);
}
protected override updateSceneSprites(): void {
this.syncSpritesFromState();
}
override drawHud(screen: GameScreen): void {
this.drawCachedText(
screen,
'score',
'Score ' + String(this.score),
32,
24,
0xffffffff,
this.hudFont,
);
}
override dispose(): void {
this.world?.dispose();
this.world = undefined;
this.bitmapCache.dispose();
super.dispose();
}
}
The game loop is not hidden: handleInputs handles controls, onUpdate mutates game state, tickSceneCamera advances the one camera, and updateSceneSprites syncs logical state into renderer-owned sprites.
The naming rule is strict. my-game.images.enemy-atlas is the manifest key. pkg:/asset-bundles/my-game/images/enemy-atlas.png is the fallback URI. enemy-atlas is the asset-gate name used by Hs2dWorldBuilder, Tiled hs2d:asset, and entity binding asset.
Build Order
- Create bundle folders and a minimal
asset-bundle.json. - Prepare one Tiled level with
hst game:prep-level. - Create
MyGame, menu, level, and pause scenes. - Load the level and assets behind an
Hs2dAssetGate. - Build an
Hs2dWorldfrom the prepared level. - Add player/enemy/projectile pools.
- Add HUD text through
drawHud. - Add audio and pause backgrounding.
- Add controller pairing from the pause menu.
- Register the game in the examples registry.
Do And Do Not
| Do | Do not |
|---|---|
Extend Hs2dGame and Hs2dLevelScene for the normal world-based path. | Use a raw GameController without a deliberate integration reason. |
| Use asset bundles, asset gates, and bitmap cache. | Generate placeholder rectangles/text for missing art. |
Build worlds from Tiled with Hs2dWorldBuilder.fromLevel. | Read Tiled JSON every frame. |
| Use sprite, tile, text, particle, and parallax layers. | Draw gameplay sprites or tiles directly to the screen. |
| Keep arrays and pools fixed-capacity in gameplay. | Allocate entities, regions, bitmaps, or closures per frame. |
| Tick one camera once per frame. | Give every layer its own gameplay camera. |
Validation
Run the repository checks:
npm run lint
npm run build
npm test
npm run roku:game:build
Then deploy and follow device logs:
npm run roku:game:run