tutorial

Getting started

A floor you can walk on, a character you move with the arrow keys, a camera that follows, and a status line: everything below is real, working mwg code. By the end it opens as a plain file, no server.

01 What you need

Node.js and npm, to install packages and run the build (a dev tool only, same as a compiler). Nothing from them reaches the player; the finished page in step 10 is plain HTML, JS and assets.

Two ways to get it, pick whichever matches what you already have:

With npm

npm install @datamoc/mw_games pixi.js vite

Fetches from the registry, what this tutorial uses from here on. pixi.js is the 2D renderer and an optional peer dependency, so name it yourself, exactly as this line does. No registry access? Download datamoc-mw_games-<version>.tgz from a release and run npm install ./datamoc-mw_games-<version>.tgz pixi.js vite instead, same result, same node_modules/@datamoc/mw_games/, every later step identical.

Without npm

<script src="mw_games.global.js"></script>

Download and unzip mw_games-<version>.zip from a release; it holds one file, already file://-ready. No install step, no node_modules/.

This tutorial follows the npm column, since that's what the code samples below assume (import statements, TypeScript). Without npm is the odd one out: it skips step 10's build entirely, and every class below is written as mw_games.Game, mw_games.Scene and so on off the window.mw_games global instead of imported; everything else in steps 02-09 is identical.

Your own art. mwg ships no sprites or sounds of its own: it is a renderer and a set of game systems, not an asset pack. This tutorial's one tileset, tiles.png, is a 16px-grid image you draw or source yourself (frame 0 floor, 1 wall, 2 a character, anything in that layout works for a first try).

A dev server while writing code, Vite works well and is what the framework's own examples use. A page opened straight from file:// cannot load an ES module, and your source is one until it's built. This is temporary: step 10 below turns the finished page into one that opens with no server at all, which is the whole point of mwg.

Lay the project out like this before writing any code:

my-game/
├─ index.html
├─ main.ts
├─ vite.config.ts
└─ assets/
   └─ tiles.png

Assets live in their own folder, separate from source: assets/ is what step 10's build points at to compile every file it holds into the finished page. Nothing under my-game/ needs to be downloaded beyond the framework itself, either way above; everything else here you write or draw.

02 A skeleton page

The only requirement is a canvas, or nothing: Game creates and appends one if you don't give it one.

<!doctype html>
<html>
<body>
	<canvas id="game"></canvas>
	<script type="module" src="./main.ts"></script>
</body>
</html>

03 The game and a scene

Every mwg game is a Game running a Scene. A scene's create() builds its contents; assets are already loaded by the time it runs, which the next step arranges.

import { Game, Scene2D } from '@datamoc/mw_games/two-d';

class TownScene extends Scene2D {
	override create(): void {
		// contents go here
	}
}

async function main(): Promise<void> {
	const game = new Game({ canvas: document.getElementById('game') as HTMLCanvasElement });
	await game.start(TownScene);
}

main();

04 Loading assets

Assets load by path, before the scene that needs them starts. After load resolves, every lookup is synchronous: scene code never awaits an asset mid-build. This example needs the one tileset from step 01: assets/tiles.png, a 16px grid. setBase points dev-mode lookups (a plain path, served by Vite) at that folder; the compiled build in step 10 ignores it; it looks assets up in the script compile-resources generated instead.

import * as Resources from '@datamoc/mw_games/assets';

const TILES = 'tiles.png';

async function main(): Promise<void> {
	const game = new Game({ canvas: document.getElementById('game') as HTMLCanvasElement });
	Resources.setBase('./assets/');
	await Resources.load([TILES]);
	await game.start(TownScene);
}

05 A floor to stand on

A Camera holds the world; a TileMap holds the floor. Frame 0 of the sheet is floor, 1 is wall, whatever the tileset's own layout is. TileMap draws each tile as a TintedSprite (the same class step 06 uses for the hero), so this is also the point where a Pixi render extension needs registering, before the renderer is built, which means before game.start(), not inside create():

import { Camera, TileMap, SpriteSheet, registerColorTransform } from '@datamoc/mw_games/two-d/render';
import { Random } from '@datamoc/mw_games/core';

const FLOOR_TILE = 0;
const WALL_TILE = 1;

class TownScene extends Scene2D {
	private camera!: Camera;
	private map!: TileMap;

	override create(): void {
		const sheet = SpriteSheet.grid(TILES, 16);

		this.camera = new Camera({ zoom: 3, deadzone: 0.2 });
		this.stage.addChild(this.camera.world);

		this.map = new TileMap({ width: 20, height: 15, sheet });

		const cells: number[] = [];
		for (let i = 0; i < 20 * 15; i++) {
			cells.push(Random.chance(0.12) ? WALL_TILE : FLOOR_TILE);
		}
		this.map.addLayer('terrain', cells);

		this.camera.world.addChild(this.map);
		this.camera.setBounds({ minX: 0, minY: 0, maxX: this.map.worldWidth, maxY: this.map.worldHeight });
	}

	override resize(width: number, height: number): void {
		this.camera.setViewport(width, height);
	}
}

And in main(), add the registration to Game's options:

const game = new Game({
	canvas: document.getElementById('game') as HTMLCanvasElement,
	extensions: [registerColorTransform],
});

06 Someone to move

A character is a TintedSprite at a tile position, moved on Input's named actions rather than raw keys: up, down, left, right are bound by default.

import { TintedSprite } from '@datamoc/mw_games/two-d/render';
import { Input } from '@datamoc/mw_games/core';

const HERO_TILE = 2;
const TILE_SIZE = 16;

const MOVES: Record<string, { x: number; y: number }> = {
	up: { x: 0, y: -1 }, down: { x: 0, y: 1 },
	left: { x: -1, y: 0 }, right: { x: 1, y: 0 },
};

// inside TownScene:
private hero!: TintedSprite;
private heroX = 5;
private heroY = 5;

override create(): void {
	// ...as above, then:
	this.hero = new TintedSprite(sheet.get(HERO_TILE));
	this.hero.x = this.heroX * TILE_SIZE;
	this.hero.y = this.heroY * TILE_SIZE;
	this.camera.world.addChild(this.hero);

	Input.onAction.add((action) => this.onAction(action));
}

private onAction(action: string): boolean {
	const move = MOVES[action];
	if (!move) return false;

	const targetX = this.heroX + move.x;
	const targetY = this.heroY + move.y;

	if (this.map.getTile('terrain', targetX, targetY) === WALL_TILE) {
		this.hero.lerpTint(0xff4040, 0.6); // bumped a wall, see step 9
		return true;
	}

	this.heroX = targetX;
	this.heroY = targetY;
	this.hero.x = targetX * TILE_SIZE;
	this.hero.y = targetY * TILE_SIZE;
	return true;
}

07 Following with the camera

A camera that doesn't move is just a canvas. follow eases towards a moving point every frame:

// end of create():
this.camera.follow(this.hero);

override update(dt: number): void {
	this.camera.update(dt);
	this.map.cull(this.camera); // only draws chunks the camera can see
}

08 A flash that isn't just "darker"

Step 7 already flashed the hero red on a bump, using lerpTint, the additive colour term from the landing page demo. It needs to fade, so the flash reads as an instant rather than a stuck colour:

override update(dt: number): void {
	this.camera.update(dt);
	this.map.cull(this.camera);

	if (this.hero.colorAdd !== 0) this.hero.resetColor();
}

09 A status line

Any UI text is a Label, styled from the current theme rather than carrying its own font and colour:

import { Label, theme } from '@datamoc/mw_games/two-d/ui';

// inside TownScene:
private status!: Label;

override create(): void {
	// ...
	this.status = new Label({ text: 'Arrow keys to move.', color: theme().color.textHighlight });
	this.status.x = 8;
	this.status.y = 8;
	this.stage.addChild(this.status); // outside camera.world: stays put on screen
}

10 Building it for file://

The dev server is gone from here on: this step is what removes the need for it. First, tell Vite to bundle as a classic script rather than an ES module, with every path relative rather than site-absolute:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
	base: './',
	build: {
		rollupOptions: {
			output: { format: 'iife', entryFileNames: 'game.js' },
		},
	},
});

Then run the build, and compile assets/ into a script the finished page can load with no server:

npx vite build
node node_modules/@datamoc/mw_games/tools/compile-resources.mjs ./assets ./dist/assets

That leaves dist/index.html, dist/game.js and dist/assets/assets.js, but the page still points at game.js as an ES module, which file:// refuses to load, and has no <script> for the compiled assets yet. Open dist/index.html and change this one line:

<script type="module" crossorigin src="./game.js"></script>

to these two, in this order: the asset script has to run first, so game.js finds window.__MWG_ASSETS__ already populated:

<script defer src="./assets/assets.js"></script>
<script defer src="./game.js"></script>

dist/ is now a finished game: double-click dist/index.html and it runs, no server, nothing installed on the machine that opens it. Ship that folder. tools/emit-page.mjs in the framework's own repository scripts exactly this last edit; every one of the examples is built this same way, for anyone who wants it automated rather than by hand.

11 Finished code

The whole scene, about 60 lines:

import { Game, Scene2D } from '@datamoc/mw_games/two-d';
import { Input, Random } from '@datamoc/mw_games/core';
import { Camera, TileMap, TintedSprite, SpriteSheet, registerColorTransform } from '@datamoc/mw_games/two-d/render';
import { Label, theme } from '@datamoc/mw_games/two-d/ui';
import * as Resources from '@datamoc/mw_games/assets';

const TILES = 'tiles.png';
const TILE_SIZE = 16;
const FLOOR_TILE = 0;
const WALL_TILE = 1;
const HERO_TILE = 2;

const MOVES: Record<string, { x: number; y: number }> = {
	up: { x: 0, y: -1 }, down: { x: 0, y: 1 },
	left: { x: -1, y: 0 }, right: { x: 1, y: 0 },
};

class TownScene extends Scene2D {
	private camera!: Camera;
	private map!: TileMap;
	private hero!: TintedSprite;
	private status!: Label;
	private heroX = 5;
	private heroY = 5;

	override create(): void {
		const sheet = SpriteSheet.grid(TILES, TILE_SIZE);

		this.camera = new Camera({ zoom: 3, deadzone: 0.2 });
		this.stage.addChild(this.camera.world);

		this.map = new TileMap({ width: 20, height: 15, sheet });
		const cells: number[] = [];
		for (let i = 0; i < 20 * 15; i++) cells.push(Random.chance(0.12) ? WALL_TILE : FLOOR_TILE);
		this.map.addLayer('terrain', cells);
		this.camera.world.addChild(this.map);
		this.camera.setBounds({ minX: 0, minY: 0, maxX: this.map.worldWidth, maxY: this.map.worldHeight });

		this.hero = new TintedSprite(sheet.get(HERO_TILE));
		this.hero.x = this.heroX * TILE_SIZE;
		this.hero.y = this.heroY * TILE_SIZE;
		this.camera.world.addChild(this.hero);
		this.camera.follow(this.hero);

		this.status = new Label({ text: 'Arrow keys to move.', color: theme().color.textHighlight });
		this.status.x = 8;
		this.status.y = 8;
		this.stage.addChild(this.status);

		Input.onAction.add((action) => this.onAction(action));
	}

	private onAction(action: string): boolean {
		const move = MOVES[action];
		if (!move) return false;

		const targetX = this.heroX + move.x;
		const targetY = this.heroY + move.y;

		if (this.map.getTile('terrain', targetX, targetY) === WALL_TILE) {
			this.hero.lerpTint(0xff4040, 0.6);
			return true;
		}

		this.heroX = targetX;
		this.heroY = targetY;
		this.hero.x = targetX * TILE_SIZE;
		this.hero.y = targetY * TILE_SIZE;
		return true;
	}

	override update(dt: number): void {
		this.camera.update(dt);
		this.map.cull(this.camera);
		if (this.hero.colorAdd !== 0) this.hero.resetColor();
	}

	override resize(width: number, height: number): void {
		this.camera.setViewport(width, height);
	}
}

async function main(): Promise<void> {
	const game = new Game({
		canvas: document.getElementById('game') as HTMLCanvasElement,
		extensions: [registerColorTransform],
	});
	Resources.setBase('./assets/');
	await Resources.load([TILES]);
	await game.start(TownScene);
}

main();

From here, the examples show where each piece grows: colour-transform pushes the tint further, interface adds windows and menus over this kind of scene, dialogue replaces it with a conversation, and dungeon adds field of view, monsters and a turn scheduler on top of exactly this map-and-camera setup.