A lightweight 2D game engine with pluggable physics (Rapier by default, Matter.js fallback) and WebGL2 rendering (Canvas 2D fallback). Features a component-based architecture, scene management, and UI system.
For the next development priorities and acceptance criteria, see ROADMAP.md. See CONTRIBUTING.md for setup and contribution guidelines.
Screen.Recording.2024-12-30.at.22.51.33.mp4
- Physics Engine: Fixed-timestep 2D simulation behind a swappable backend contract (Rapier default, Matter.js fallback,
?physics=to switch) - Scene Management: JSON scene definitions, validated on load
- Component System: Flexible entity-component architecture
- Input Handling: Keyboard input with configurable controls
- Camera System: Smooth following with optional world bounds
- Abstracted rendering interface for multiple backends
- Batched WebGL2 renderer with a Canvas 2D fallback, both validated by one conformance suite
- Separate world and UI rendering contexts
- Built-in performance monitoring: FPS, primitives, draw calls and state changes
- Independent view, world-render and UI resolutions — lower the render resolution for performance without changing how much world is visible or blurring the HUD
- Letterbox or expand on a differing display aspect; fullscreen and DPR tracked automatically
nearestorlinearupscaling for pixel art- A ready-made settings overlay (resolution, aspect, fullscreen, debug overlay, renderer backend, pause)
- Simple UI framework with buttons, text, images and rects
- Screen-space rendering, anchored to real screen edges at any aspect ratio
- Event handling and hit testing that account for letterboxing and pixel ratio
- Clipping support for scrollable areas
- World-anchored annotations drawn at display resolution
- Sprite loading and animation support
- Category-based collision filtering
- Sound system with music and effects
- Debug overlay (D): physics bodies, velocities, entity state and performance metrics
- JSON level format with prefabs, layered objects, per-object properties and animation/physics configuration — validated on load
- Install dependencies:
npm install- Generate sprite assets:
npm run generate-sprites- Start development server:
npm run devThe Pages workflow installs dependencies, runs
the Node and Chromium tests, builds the demo, and deploys docs/ on every
push to master. It can also be started from Actions → Deploy demo to GitHub
Pages → Run workflow (select master). A failed test or build prevents deployment.
To enable it, set Settings → Pages → Build and deployment → Source to
GitHub Actions. If the github-pages environment has branch restrictions,
allow master to deploy. No personal access token is needed. The docs branch
is retained as deployment history; this workflow publishes build artifacts
directly and does not update that branch.
A batching stress test that doubles as the renderer comparison harness:
- Thousands of batched sprites, a flowering reed bank, and a text storm
- A lighthouse whose beam sweeps in perspective, built from stacked clips in world space
- Rain as a single packed particle buffer, thousands of drops per draw call
- A harbour chart that nests clips in screen space and plots the same beam
- Additive firefly glow and per-sprite depth tinting on the reed bank
- A harbour at night: horizon, rippling water, reed bank and lantern-lit shore
- Flickering lantern lights over a dark harbour, with bloom and a vignette
- A live overlay reporting FPS, primitives, draw calls and state changes
- The same deterministic scene on either backend, for a like-for-like comparison
On the WebGL2 backend the field collapses into a handful of draw calls; on
Canvas it costs one call per primitive. The overlay prints that ratio, and
lists which optional renderer capabilities the active backend supports — on
Canvas the reed tinting drops out, the rain is capped and the harbour stays
evenly lit, because that backend declines tint, particles, lights and
postEffects.
Controls:
[/]: Decrease / increase load1-7: Toggle layers (drift, reeds, labels, chart, rain, lights, bloom)P: Pause the camera panH: Hide the overlayO: Settings (resolution, fullscreen, aspect, debug, backend swap)
Any example also accepts ?renderer=canvas or ?renderer=webgl in the URL.
A side-scrolling platformer showcasing:
- Physics-based movement
- Moving platforms
- Enemy AI
- Collectibles
- Trigger zones
Controls:
- Arrow keys: Move left/right
- Space: Jump
- X: Attack
- O: Settings
A top-down maze game demonstrating:
- A deterministic, procedurally carved maze (wall cells merged into few static bodies)
- A camera following the player through a world larger than the view
- A sensor goal: touching it shows a banner and resets the run
- Top-down physics with direct-velocity movement
Controls:
- Arrow keys: Move in all directions
- O: Settings
An interactive physics sandbox featuring:
- Dynamic object spawning
- Rotated static geometry: a funnel, a splitter and a peg field
- Different physics materials and object interactions
Controls:
- Space: Spawn random objects
- O: Settings
A top-down game demonstrating:
- 8-directional movement
- Zero-gravity physics
- Enemies, turrets and projectiles
Controls:
- Arrow keys: Move
- X: Attack
- O: Settings
A rhythm-and-order game built mostly in screen space, showing the UI layer driving a whole game rather than an overlay.
Controls:
- A / S / D / F: Rhythm keys, pressed in sequence
- Space: Serve order
- Backspace: Clear sandwich
- O: Settings
m2d-engine/
├── src/
│ ├── core/ # Engine core components
│ │ ├── m2d.js # Main engine class
│ │ ├── scene.js # Scene management
│ │ ├── entity.js # Base entity class
│ │ ├── camera.js # Camera system
│ │ ├── renderers/ # Backend contract and backends
│ │ │ ├── baseRenderer.js # The contract every backend implements
│ │ │ ├── canvasRenderer.js # Canvas 2D backend
│ │ │ ├── webglRenderer.js # WebGL2 backend (default)
│ │ │ ├── geometry/ # API-neutral tessellation and matrices
│ │ │ └── webgl/ # Batching, shaders, shadow and effect passes
│ │ └── ui/ # UI components
│ ├── actors/ # Game-specific actors
│ ├── constraints/ # Entity constraints/components
│ └── examples/ # Example implementations
├── scripts/ # Build and utility scripts
├── test/ # Renderer conformance and engine tests
└── docs/ # Build output (GitHub Pages)
Levels are defined in JSON format with the following structure:
Scene files are validated strictly when they load: a wrong version, an
unknown field, a prefab naming an unregistered actor, a button pointing at a
scene nobody registered — each throws a SceneValidationError naming the
scene and the exact path to the offending field, rather than half-building
the scene and logging as it goes. Anything not in the schema below is an
error, including misspellings like "anchour".
{
"version": 1,
"type": "level",
"name": "level1",
"title": "Level 1",
"gameType": "platformer",
"world": {
"gravity": { "x": 0, "y": 1000 }
},
"assets": {
"spritesheets": {
"player": {
"url": "path/to/sprite.png",
"frameWidth": 32,
"frameHeight": 32,
"animations": {
"idle": { "frames": [[0, 0]] },
"run": { "frames": [[1, 0], [2, 0]] }
}
}
}
},
"prefabs": {
"player": {
"actor": "Player",
"spritesheet": "player",
"physics": {
"bodyType": "dynamic",
"friction": 0.1,
"frictionStatic": 0.5
}
},
"movingPlatform": {
"actor": "MovingPlatform",
"spritesheet": "platform",
"physics": {
"bodyType": "kinematic",
"friction": 1
}
}
},
"layers": [
{
"name": "main",
"objects": [
{
"prefab": "player",
"x": 100,
"y": 100,
"width": 32,
"height": 32,
"properties": {}
},
{
"prefab": "movingPlatform",
"x": 200,
"y": 200,
"width": 96,
"height": 32,
"properties": {
"path": {
"points": [
{"x": 200, "y": 200},
{"x": 400, "y": 200}
],
"speed": 3,
"waitTime": 1000
}
}
}
]
}
]
}- version: Scene format version — currently
1(a number) - type: Scene type ("level" or "ui")
- world: Physics world configuration
- assets: Sprite and audio assets used in the level
- prefabs: Reusable entity definitions with physics and sprite properties
- layers: Groups of game objects with their positions and properties
- background (optional):
{ "spritesheet": "tiles", "tileX": 0, "tileY": 0 }— tiles one frame across the whole world behind everything else
Objects default to 32x32 when they give no width/height. Every
cross-reference is checked at load: object.prefab against prefabs,
prefab.spritesheet and background.spritesheet against
assets.spritesheets, prefab.defaultAnimation against that sheet's
animations, and prefab.actor against the actors registered with
game.registerActor.
A "type": "ui" scene holds elements instead of layers. Each element has a
type (button, text, image, rect), a position, an optional anchor,
and a properties block whose allowed keys depend on the type:
{
"version": 1,
"type": "ui",
"name": "mainMenu",
"elements": [
{ "type": "rect", "id": "background", "fill": true, "color": "#298ccc" },
{
"type": "button", "id": "start", "anchor": "center", "x": 0, "y": -80,
"properties": { "text": "Start Game", "onClick": "level1", "width": 200, "height": 50 }
},
{
"type": "button", "id": "settings", "anchor": "center", "x": 0, "y": -10,
"properties": { "text": "Settings", "action": "settings", "width": 200, "height": 50 }
}
]
}A button must have exactly one of onClick (switch to that scene, which must
be registered) or action (a named behaviour — settings opens the settings
overlay, anything else reaches the scene config's onAction hook). A button
with neither, or with both, is a load error.
- bodyType: Physics body type ("static", "dynamic", or "kinematic")
- friction/frictionStatic: Surface friction properties
- isSensor: Whether the object triggers collisions without physical response
- path: Movement path for platforms (points, speed, waitTime)
- triggers: Actions or events triggered by collision
All scenes in M2D Engine must be registered with a fetch method that returns the scene configuration. The scene manager uses this method to load scene data asynchronously.
Register a scene using addScene:
// Loading a level scene from a JSON file
game.sceneManager.addScene('level1', {
fetch: async () => (await import('./scenes/level1.json')).default,
onEnter() {
game.soundManager.playMusic('game');
}
});
// Loading a UI scene from a JSON file
game.sceneManager.addScene('mainMenu', {
fetch: async () => (await import('./scenes/mainMenu.json')).default,
onEnter() {
game.soundManager.playMusic('menu');
}
});The scene configuration:
- Must include a
fetchmethod that returns the scene data - Can include lifecycle hooks:
onEnter,onExit,onUpdate(deltaTime),onDraw(deltaTime),onResize(width, height, viewport)andonAction(name)for button actions - Supports lazy loading through dynamic imports
- Can load data from any async source (files, API, etc.)
- Is cached after first load for faster scene switching
Fetched data is validated before the scene is built, so a malformed scene fails with a message naming the field rather than loading half-built.
Switch between scenes using:
await game.sceneManager.switchTo('sceneName');This will:
- Unload the current scene if one exists
- Load the new scene using its
fetchmethod - Initialize the scene with assets and entities
- Call the scene's
onEnterhook if defined
- Define your actors by extending
Entity(orProjectile) - Describe levels and menus as scene JSON, using prefabs to place actors
- Register the actors and scenes with the engine
- Mount a settings overlay and start the loop
The name an actor is registered under is the name prefabs reference in
"actor", and the loader checks the two match.
Example:
// Create game instance
const game = new M2D(canvas, {
width: 1280,
height: 720,
initialScene: 'mainMenu'
});
// Register actors — the name here is what scene JSON's "actor" refers to
game.registerActor('Player', Player);
game.registerActor('Enemy', Enemy);
// Register scenes
game.sceneManager.addScene('mainMenu', {
fetch: async () => (await import('./scenes/mainMenu.json')).default
});
game.sceneManager.addScene('level1', {
fetch: async () => (await import('./scenes/level1.json')).default
});
// Resolution, aspect, fullscreen and pause, toggled with O
new SettingsMenu(game);
// Start the game
game.start();width and height are the design size: how much world is visible, in
world units. The engine tracks the canvas's displayed size, the device pixel
ratio and fullscreen transitions on its own, and keeps three resolutions
independent so each can be set for the right reason.
const game = new M2D(canvas, {
width: 1280, height: 720, // design size = the camera's view, in world units
aspectMode: 'letterbox', // or 'expand' to widen the view on wider displays
resolution: 'native', // world render resolution; see setResolution
worldFilter: 'linear' // 'nearest' for pixel art
});
// A settings menu changes only how sharply the world is drawn. The same
// amount of world stays visible, and the UI stays at display resolution.
game.setResolution({ width: 1280, height: 720 });
game.setResolution('native');
game.setWorldFilter('nearest'); // how the world is upscaled below 'native'
game.toggleFullscreen(); // also requestFullscreen() / exitFullscreen()
game.onResize((width, height) => { /* relayout a custom HUD */ });Below 'native' the world is rendered into a surface of its own and scaled up
to the screen, so worldFilter decides what that scaling looks like:
'nearest' keeps pixel art blocky and crisp, 'linear' smooths it. At
'native' there is nothing to upscale and the setting does not apply.
Give the canvas an explicit CSS size (width: 100%; height: 100%) and let the
engine letterbox inside it — object-fit: contain is not needed and sizing the
element from its own width/height attributes fights the engine's resize
tracking.
UI is drawn at the display's full pixel density regardless of the world resolution, and UI space always covers the whole screen. Position UI against an anchor so it tracks the real screen edges at any aspect ratio:
new UIButton(-20, -20, 160, 40, 'Settings', onClick, { anchor: 'bottom-right' });Scene JSON elements take the same "anchor" field. The default is
'top-left', where the offset is just an absolute position. For full-screen
backgrounds give a rect "fill": true instead of a size — UI space widens
with the display, so no fixed design-sized rect can be relied on to cover it:
{ "type": "rect", "id": "background", "fill": true, "color": "#298ccc" }Games that follow a player should also set camera bounds so neither the
follow nor an 'expand' aspect mode reveals the void past the map; when the
view is larger than the bounded area, the camera centres on it:
game.camera.bounds = { left: 0, top: 0, right: worldWidth, bottom: worldHeight };For pointer input use game.getMousePosition(event) (world coordinates) and
game.getUIPosition(event) (UI coordinates); both account for letterboxing and
pixel ratio. The opposite direction is game.worldToUI(x, y): where a world
point lands in UI space.
Content drawn on the world rather than in it — an enemy's attention radius, an attack range, a target dot, a readout over someone's head — should not go through the world renderer, where it blurs with the render resolution. Submit it to the world overlay instead: world coordinates in, rasterized by the engine's overlay pass at full display sharpness. The API mirrors the renderer's, so switching is a one-word change:
// Inside any draw(): per-frame submissions, like ordinary draw calls.
const overlay = game.worldOverlay;
// Geometry keeps its world size, so it stays glued to what it describes.
overlay.drawArc({ x: enemy.x, y: enemy.y, radius: enemy.detectionRange,
startAngle: 0, endAngle: Math.PI * 2, strokeStyle: '#ff03' });
// Text keeps a fixed UI size, so it stays equally legible at any resolution.
overlay.drawText({ x: enemy.x, y: enemy.y, // world anchor
offsetY: -20, // UI units
text: `hp: ${hp}/${max}`, fillStyle: '#0f0', fontSize: '12px' });The engine's own debug visuals — detection ranges, aim lines, facing vectors, bounds boxes and every readout — already go through this, so the debug overlay (toggled from the settings menu) stays readable at any resolution. An actor's actual appearance stays in the world pass, where it is meant to share the world's fidelity.
A ready-made settings overlay ships with the engine and is mounted in every example — resolution presets, fullscreen, aspect mode, debug overlay, renderer backend and pause, toggled with O (Escape closes it too):
import { SettingsMenu } from 'm2d/core/ui/settingsMenu.js';
new SettingsMenu(game); // that's all
new SettingsMenu(game, { hotkey: 'p', resolutions: ['native', 0.5] });
new SettingsMenu(game, { pauseWhenOpen: true }); // pause while the menu is upWhile open it captures mouse and keyboard, so clicks and movement keys never
leak into the game underneath. Opening does not pause unless you pass
pauseWhenOpen: true; either way the menu's Paused row toggles game.paused,
a flag the engine loop honours by freezing the simulation while continuing to
draw.
The mounted menu is reachable as game.settings, and a scene-JSON button can
open it directly — this is how the example main menus wire their Settings
button:
{ "type": "button", "properties": { "text": "Settings", "action": "settings" } }("action" names a behaviour — unknown names are handed to the scene config's
onAction(action) hook — while "onClick" still switches scenes.)
It draws above every scene via the engine's overlay pass (game.overlays,
rendered by game.drawOverlays()); a game that replaces the engine loop calls
drawOverlays() at the end of its own frame.
MIT. See LICENSE. Third-party dependencies retain their own licenses.
Examples use original synthesized sound effects and system fonts. Jazz music by Kevin MacLeod
is licensed under CC BY 4.0; see music credits.
Existing demo artwork is retained;
missing Doner sprites are replaced with original SVG artwork generated by
node scripts/generateDonerSprites.js.