Skip to content

2D Game Development

This guide covers everything specific to building 2D games with Web Engine Dev: tilemaps and tile-based worlds, sprite rendering and batching, 2D lighting, pixel art mode, and common 2D game patterns.

Sprite Rendering

typescript
import { Sprite, SpriteRenderer } from '@web-engine-dev/sprites';

// Basic sprite
world.insert(playerEntity, Sprite, {
  texture: 'sprites/hero.png',
  width: 48,
  height: 48,
  pivot: { x: 0.5, y: 0.5 }, // center pivot
  flipX: false,
  flipY: false,
  color: [1, 1, 1, 1], // tint (white = no tint)
  sortingLayer: 'characters',
  sortingOrder: 0,
});

// Flip sprite based on movement direction
function FlipSpriteSystem(world: World): void {
  const q = world.query().with(Velocity, Sprite, Player).build();
  for (const result of world.run(q)) {
    const [vel] = result.components;
    if (vel.x !== 0) {
      const sprite = world.get(result.entity, Sprite);
      world.insert(result.entity, Sprite, { ...sprite, flipX: vel.x < 0 });
    }
  }
}

Sprite Sheets

typescript
import { SpriteSheet, SpriteFrame } from '@web-engine-dev/sprites';

// Define a sprite sheet with named frames
const heroSheet = new SpriteSheet('sprites/hero-sheet.png', {
  cellWidth: 48,
  cellHeight: 48,
});

world.insert(playerEntity, Sprite, {
  sheet: heroSheet,
  frame: 'idle_0',
  width: 48,
  height: 48,
});

Sprite Sorting

Control draw order with sorting layers:

typescript
// Define sorting layers in your engine config (order = back to front)
const SORTING_LAYERS = ['background', 'terrain', 'pickups', 'characters', 'fx', 'ui-world'];

world.insert(backgroundEntity, Sprite, { sortingLayer: 'background', sortingOrder: 0 });
world.insert(treeEntity, Sprite, { sortingLayer: 'terrain', sortingOrder: 5 });
world.insert(playerEntity, Sprite, { sortingLayer: 'characters', sortingOrder: 0 });
world.insert(effectEntity, Sprite, { sortingLayer: 'fx', sortingOrder: 0 });

Tilemaps

Loading from Tiled

The engine imports .tmj (Tiled JSON) files natively:

typescript
import { TiledImporter } from '@web-engine-dev/tilemap';

// Parse a Tiled map JSON (fetched or bundled asset)
const tiledJson = await fetch('maps/level-01.tmj').then((r) => r.json());
const importer = new TiledImporter();
const result = importer.import(tiledJson, {
  generateColliders: true, // auto-generate physics colliders from collision layer
  collisionLayers: ['Collision'], // layer names in Tiled to use as colliders
});

// result.tilemap, result.layers, result.collisionShapes are ready to use

Creating Tilemaps Procedurally

typescript
import { Tilemap, TileLayer, TileSet } from '@web-engine-dev/tilemap';

const tileset = new TileSet('tilesets/dungeon.tsj', { tileWidth: 16, tileHeight: 16 });

const tilemapEntity = world.spawn();
world.insert(tilemapEntity, Tilemap, {
  tileWidth: 16,
  tileHeight: 16,
  mapWidth: 64,
  mapHeight: 32,
});

// Attach a TileLayer component for each layer
world.insert(tilemapEntity, TileLayer, {
  name: 'Ground',
  tiles: generateGroundTiles(64, 32), // Int32Array of tile IDs
  visible: true,
  opacity: 1.0,
});

Procedural Generation

typescript
import { NoiseGenerator } from '@web-engine-dev/procgen';

function generateLevel(): Int32Array {
  const noise = new NoiseGenerator({ seed: 42, scale: 0.05 });
  const tiles = new Int32Array(MAP_WIDTH * MAP_HEIGHT);

  for (let y = 0; y < MAP_HEIGHT; y++) {
    for (let x = 0; x < MAP_WIDTH; x++) {
      const value = noise.sample2D(x, y);
      tiles[y * MAP_WIDTH + x] =
        value < -0.3
          ? TILE_WATER
          : value < 0.0
            ? TILE_SAND
            : value < 0.4
              ? TILE_GRASS
              : value < 0.6
                ? TILE_FOREST
                : TILE_MOUNTAIN;
    }
  }

  return tiles;
}

Tile Queries

typescript
import { Tilemap, TileLayer } from '@web-engine-dev/tilemap';

// Read the Tilemap and TileLayer components (returns copies - must re-insert to persist changes)
const tilemap = world.get(tilemapEntity, Tilemap);
const layer = world.get(tilemapEntity, TileLayer);

// Convert world position to tile coordinates
const tileX = Math.floor(worldX / tilemap.tileWidth);
const tileY = Math.floor(worldY / tilemap.tileHeight);

// Get tile index at tile coordinate
const tileIndex = layer.tiles[tileY * tilemap.mapWidth + tileX];

// Set a tile (mutate the copy, then re-insert to persist)
const updatedLayer = { ...layer };
updatedLayer.tiles = new Int32Array(layer.tiles);
updatedLayer.tiles[tileY * tilemap.mapWidth + tileX] = TILE_TORCH;
world.insert(tilemapEntity, TileLayer, updatedLayer);

2D Lighting

typescript
import {
  createGlobalLightComponent,
  createPointLightComponent,
  createSpotLightComponent,
  Light2DComponentData,
  GlobalLight2DData,
} from '@web-engine-dev/sprites';

// Global ambient light on a dedicated entity
const ambientEntity = world.spawn();
world.insert(
  ambientEntity,
  Light2DComponentData,
  createGlobalLightComponent({
    colorR: 0.1,
    colorG: 0.1,
    colorB: 0.2,
    intensity: 0.3,
  })
);

// Torch/point light attached to an entity
world.insert(
  torchEntity,
  Light2DComponentData,
  createPointLightComponent({
    colorR: 1.0,
    colorG: 0.7,
    colorB: 0.3,
    intensity: 2.5,
    radius: 200,
  })
);

// Flashlight / spot light
world.insert(
  playerEntity,
  Light2DComponentData,
  createSpotLightComponent({
    colorR: 1,
    colorG: 1,
    colorB: 0.9,
    intensity: 3,
    radius: 300,
    falloff: 60, // inner cone angle (degrees)
    outerAngle: 80, // outer cone angle (degrees)
  })
);

Pixel Art Mode

typescript
import { PixelPerfectSettings } from '@web-engine-dev/renderer';

// Enable pixel-perfect rendering (no sub-pixel filtering)
world.insertResource(PixelPerfectSettings, {
  enabled: true,
  pixelScale: 3, // 3x native pixel size (e.g. 320×180 → 960×540)
  filterMode: 'nearest', // no bilinear filtering
  snapCameraToPixel: true, // prevent sub-pixel camera movement
});

Parallax Backgrounds

typescript
import { Sprite } from '@web-engine-dev/sprites';
import { Position } from '@web-engine-dev/ecs';

// Create parallax layers (further = slower scroll)
// PixelScale/layerDepth convention: lower z = more parallax
const skyEntity = world.spawn();
world.insert(skyEntity, Sprite, { texture: 'bg/sky.png', width: 1280, height: 720 });
world.insert(skyEntity, Position, { x: 0, y: 0, z: -3 }); // static sky

const mountainEntity = world.spawn();
world.insert(mountainEntity, Sprite, { texture: 'bg/mountains.png', width: 1280, height: 360 });
world.insert(mountainEntity, Position, { x: 0, y: 360, z: -2 }); // slow mountains

const forestEntity = world.spawn();
world.insert(forestEntity, Sprite, { texture: 'bg/forest.png', width: 1280, height: 200 });
world.insert(forestEntity, Position, { x: 0, y: 520, z: -1 }); // medium trees

Common 2D Patterns

Platformer Ground Detection

typescript
function GroundDetectionSystem(world: World): void {
  const physics = world.getResource(PhysicsWorld2DResource);

  const groundQ = world.query().with(Position, RigidBody2D, Player).build();
  for (const result of world.run(groundQ)) {
    const [pos] = result.components;
    const entity = result.entity;
    // Cast two rays from the feet for robust detection
    const leftHit = physics.raycast(
      { x: pos.x - 12, y: pos.y + 22 },
      { x: 0, y: 1 },
      { maxDistance: 3 }
    );
    const rightHit = physics.raycast(
      { x: pos.x + 12, y: pos.y + 22 },
      { x: 0, y: 1 },
      { maxDistance: 3 }
    );

    const grounded = !!(leftHit || rightHit);
    if (grounded && !world.has(entity, Grounded)) {
      world.insert(entity, Grounded, {});
    } else if (!grounded && world.has(entity, Grounded)) {
      world.remove(entity, Grounded);
    }
  }
}

Top-Down RPG Movement

typescript
function TopDownMovementSystem(world: World): void {
  const input = world.getResource(InputState);
  const { delta } = world.getResource(Time);

  const moveQ = world.query().with(Position, PlayerStats, Player).build();
  for (const result of world.run(moveQ)) {
    const [pos, stats] = result.components;
    const entity = result.entity;
    const speed = stats.moveSpeed;
    let dx = 0,
      dy = 0;

    if (input.pressedKeys.has('ArrowLeft')) dx -= 1;
    if (input.pressedKeys.has('ArrowRight')) dx += 1;
    if (input.pressedKeys.has('ArrowUp')) dy -= 1;
    if (input.pressedKeys.has('ArrowDown')) dy += 1;

    // Normalize diagonal movement
    if (dx !== 0 && dy !== 0) {
      dx *= 0.707;
      dy *= 0.707;
    }

    world.insert(entity, Position, {
      x: pos.x + dx * speed * delta,
      y: pos.y + dy * speed * delta,
      z: 0,
    });

    // Update facing direction
    if (dx !== 0 || dy !== 0) {
      world.insert(entity, Facing, { x: dx, y: dy });
    }
  }
}

Next Steps

Proprietary software. All rights reserved.