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.