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.