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/mwg 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-mwg-<version>.tgz from a release and run npm install ./datamoc-mwg-<version>.tgz pixi.js vite instead, same result, same node_modules/@datamoc/mwg/, every later step identical.

Without npm

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

Download and unzip mwg-<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 mwg.Game, mwg.Scene and so on off the window.mwg 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/mwg/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/mwg/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/mwg/two-d/render';
import { Random } from '@datamoc/mwg/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/mwg/two-d/render';
import { Input } from '@datamoc/mwg/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/mwg/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. The framework ships a Vite plugin that does the whole job, so the configuration is one line:

// vite.config.ts
import { defineConfig } from 'vite';
import { mwgPage } from '@datamoc/mwg/tools/vite';

export default defineConfig({ plugins: [mwgPage()] });

Then build:

npx vite build

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. Every one of the examples is finished by the same code. mwgPage({ singleFile: true }) also writes dist/standalone.html, one file with every script inlined. The page carries a Content-Security-Policy that blocks every network request by default; a game that talks to a server names it, mwgPage({ csp: { connect: ['https://news.example'] } }).

What the plugin does, in case you want to do it by hand or with another bundler. It bundles as a classic script rather than an ES module (format: 'iife'), with every path relative rather than site-absolute (base: './'). It compiles assets/ into dist/assets/assets.js, since a file:// page cannot fetch files (node node_modules/@datamoc/mwg/tools/compile-resources.mjs ./assets ./dist/assets). Last, in dist/index.html, it replaces <script type="module" crossorigin src="./game.js"></script>, which file:// refuses to run, with these two lines, in this order, so game.js finds window.__MWG_ASSETS__ already populated:

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

The same finishing step is also a command, npx mwg-emit ., run after vite build (it finds dist/ and assets/, both overridable with --dist= and --assets=).

11 A real app window (optional)

dist/ opens by double-click, in whatever browser the machine has. To ship it as its own window instead, wrap it in a native host: this repository carries a minimal WebView2 one at desktop/MwgDesktopHost, and it is not part of the npm package, so its two files are copied into your project rather than installed:

cp -r <a clone of mwg>/desktop/MwgDesktopHost ./desktop/MwgDesktopHost
dotnet run --project desktop/MwgDesktopHost -- dist/index.html

Building it needs the .NET 8 SDK on your machine; the machine that runs the finished app needs only the WebView2 Runtime, which current Windows already has. The argument is the page step 10 produced, resolved against the folder you run from: dotnet publish -c Release -r win-x64 --self-contained true turns the host into a distributable build.

That host serves the page from a virtual https://mwg.local/ origin rather than file://, which is what lets real network calls (assets.fetchWithByteProgress, a WebSocket multiplayer connection) work inside the shipped app. tools.md carries this recipe in full, and says where the mobile (Capacitor) target still stands.

12 Finished code

The whole scene, about 60 lines:

import { Game, Scene2D } from '@datamoc/mwg/two-d';
import { Input, Random } from '@datamoc/mwg/core';
import { Camera, TileMap, TintedSprite, SpriteSheet, registerColorTransform } from '@datamoc/mwg/two-d/render';
import { Label, theme } from '@datamoc/mwg/two-d/ui';
import * as Resources from '@datamoc/mwg/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.