Skip to main content

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 folderPurpose
src/hosanna-game-examples/<game>/MyGame.tsHs2dGame subclass that installs the first scene.
src/hosanna-game-examples/<game>/MyMenuScene.tsMenu/start scene.
src/hosanna-game-examples/<game>/MyLevelScene.tsHs2dLevelScene gameplay scene.
src/hosanna-game-examples/<game>/MyPauseScene.tsHs2dOverlayScene pause menu.
asset-bundles/<game>/asset-bundle.jsonBundle manifest for levels, sprites, audio, fonts, and images.
asset-bundles/<game>/levels/*.jsonPrepared 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

  1. Create bundle folders and a minimal asset-bundle.json.
  2. Prepare one Tiled level with hst game:prep-level.
  3. Create MyGame, menu, level, and pause scenes.
  4. Load the level and assets behind an Hs2dAssetGate.
  5. Build an Hs2dWorld from the prepared level.
  6. Add player/enemy/projectile pools.
  7. Add HUD text through drawHud.
  8. Add audio and pause backgrounding.
  9. Add controller pairing from the pause menu.
  10. Register the game in the examples registry.

Do And Do Not

DoDo 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
Talk to us