npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bloody-engine

v1.1.17

Published

A WebGL-based 2.5D graphics engine for isometric rendering

Downloads

2,945

Readme

Bloody Engine

A WebGL-based 2.5D graphics engine for isometric rendering on Node.js, written in TypeScript. Designed for server-side rendering, headless graphics processing, and networked multiplayer games.

Features

  • Structure of Arrays (SoA) - Entity storage with typed arrays for zero-copy GPU transfers and cache-friendly access
  • 2.5D Rendering - Optimized for isometric and dimetric projections with depth sorting
  • Instanced Rendering - WebGL2 GPU instancing for rendering thousands of identical meshes in a single draw call
  • Ring Buffer Streaming - Triple-buffered GPU streaming for zero-copy dynamic updates
  • Server-Side Rendering - Headless WebGL rendering on Node.js using gl and @kmamal/sdl
  • Batch Rendering - Efficient sprite batching with GPU-accelerated transformations
  • Persistent Buffer Mapping - WebGL2 zero-copy GPU transfers for maximum performance
  • Resource Management - Unified asset loading pipeline for textures and shaders
  • Input System - Command queue pattern supporting SDL and network input sources
  • Collision Detection - Spatial hashing with O(N) collision detection and configurable responses
  • Networking - Client-side prediction, server reconciliation, and state synchronization
  • Simulation - Pure game logic simulation system with entity management
  • Game Loop - Fixed timestep ticker for deterministic game logic
  • TypeScript - Fully typed for excellent developer experience
  • Object Pooling - Memory-efficient object reuse patterns
  • Window Management - SDL-based window creation for interactive applications
  • Custom Properties - Opt-in extensible system for game-specific entity properties

Installation

npm install bloody-engine

Understanding Coordinate Systems

⚠️ IMPORTANT: Before building your game, understand the coordinate systems to avoid inverted controls!

Bloody Engine uses different coordinate systems for different purposes. Mixing these up is the #1 cause of inverted controls.

Quick Summary

| System | Used For | Y-Axis | Example | |--------|----------|--------|---------| | Grid Space | Game logic, entity positions | Y-UP (↓ Y = North/Up) | entity.move(0, -1, 0) moves up on screen | | Screen Space | Rendering, camera, mouse | Y-DOWN (↓ Y = Down) | camera.y += 10 moves camera down |

Golden Rule: Use grid space for game logic, transform to screen space only for rendering.

Common Mistake

Wrong: camera.y += 1 for "up" movement (moves down on screen!) ✅ Right: Use direction deltas: entity.move(0, -1, 0) for North

WASD Controls

| Key | Direction | Delta | Screen Effect | |-----|-----------|-------|---------------| | W / ↑ | North | {dx: 0, dy: -1} | ✅ Up | | S / ↓ | South | {dx: 0, dy: 1} | ✅ Down | | A / ← | West | {dx: -1, dy: 0} | ✅ Left | | D / → | East | {dx: 1, dy: 0} | ✅ Right |

📖 Full Guide: docs/COORDINATE_SYSTEMS.md 🚀 Interactive Demo: Run npm run demo:coordinates after building

API Overview

Core Graphics

| Class | Description | |-------|-------------| | GraphicsDevice | Main graphics device with WebGL context management | | Shader | Shader program compilation and uniform/attribute management | | Texture | Texture creation, binding, and management | | VertexBuffer / IndexBuffer | GPU buffer management for geometry | | Camera | 2D camera with position, zoom, and view matrix |

Rendering

| Class | Description | |-------|-------------| | BatchRenderer | Generic quad batch rendering | | SpriteBatchRenderer | Sprite-specific batch renderer with depth sorting | | InstancedRenderer | WebGL2 GPU instancing for thousands of instances in single draw call | | HybridRenderer | Automatic detection between instanced and batch rendering | | RingBuffer | Triple-buffered GPU ring buffer for zero-copy streaming | | ProjectionConfig | Isometric/dimetric projection utilities | | SpatialHash | Spatial partitioning for efficient queries |

Resource Loading

| Class | Description | |-------|-------------| | NodeResourceLoader | File system resource loader for Node.js | | NodeTextureLoader | PNG texture loading for Node.js | | ResourcePipeline | Batch resource loading with caching | | TextureAtlas | Sprite atlas packing and UV coordinate management |

Input System

| Class | Description | |-------|-------------| | CommandQueue | Thread-safe command queue for input | | SDLInputSource | SDL keyboard/mouse input | | NetworkInputSource | Network-based input for multiplayer |

Simulation & Networking

| Class | Description | |-------|-------------| | EntityStorage | SoA storage with typed arrays for high-performance entity data | | EntityHandle | Opaque handles for safe entity references | | EntityTypeRegistry | Type string to ID mapping for storage efficiency | | Entity / EntityManager | Entity component system (now uses SoA storage internally) | | SimulationLoop | Deterministic game logic simulation | | SoaWebGLRenderer | WebGL2 renderer with persistent buffer mapping | | ClientPredictor | Client-side prediction for lag compensation | | ServerReconciler | Server-side reconciliation | | StateSnapshot | World state serialization | | BinarySerializer | Efficient binary serialization |

Utilities

| Class | Description | |-------|-------------| | ObjectPool | Generic object pooling for GC optimization | | Matrix4Pool | Matrix4 specific pooling | | lerp, lerpVec2, lerpVec3 | Interpolation utilities |

Quick Start

Basic Rendering Setup

import { GraphicsDevice, Shader, Texture, VertexBuffer } from 'bloody-engine';

// Create graphics device (800x600)
const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();

// Create a shader
const shader = device.createShader(`
  attribute vec3 aPosition;
  uniform mat4 uMatrix;

  void main() {
    gl_Position = uMatrix * vec4(aPosition, 1.0);
  }
`, `
  precision mediump float;
  uniform vec3 uColor;

  void main() {
    gl_FragColor = vec4(uColor, 1.0);
  }
`);

// Create a gradient texture
const texture = Texture.createGradient(gl, 256, 256);

// Create geometry
const vertices = new Float32Array([
  // x, y, z, u, v
  -0.5, -0.5, 0, 0, 1,
   0.5, -0.5, 0, 1, 1,
   0.5,  0.5, 0, 1, 0,
  -0.5, -0.5, 0, 0, 1,
   0.5,  0.5, 0, 1, 0,
  -0.5,  0.5, 0, 0, 0
]);
const buffer = new VertexBuffer(gl, vertices, 20); // 5 floats * 4 bytes

// Setup and render
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
shader.use();
buffer.bind();
// ... configure attributes ...
gl.drawArrays(gl.TRIANGLES, 0, buffer.getVertexCount());
device.present();

Sprite Batch Rendering with Camera

import { SpriteBatchRenderer, Camera, Texture, GraphicsDevice } from 'bloody-engine';

const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();

// Create shader (use built-in V2 shader for sprites)
const shader = device.createShader(vertexSource, fragmentSource);

// Create sprite batch renderer (capacity: 1000 sprites)
const batchRenderer = new SpriteBatchRenderer(gl, shader, 1000);
batchRenderer.setTexture(Texture.createGradient(gl, 256, 256));

// Create camera
const camera = new Camera(0, 0, 1.0); // x=0, y=0, zoom=1x

// Add sprites to batch
batchRenderer.addQuad({
  x: 100, y: 100, z: 0,
  width: 64, height: 64,
  rotation: 0,
  color: { r: 1, g: 1, b: 1, a: 1 },
  texIndex: 0
});

// Render with camera
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
batchRenderer.render(camera);
device.present();

Instanced Rendering (WebGL2)

For rendering thousands of identical meshes (floor tiles, sprites, particles), use the instanced renderer for massive performance gains:

import {
  HybridRenderer,
  InstancedRenderer,
  GraphicsDevice,
  Camera
} from 'bloody-engine';
import { SHADERS_V4 } from 'bloody-engine/scene';

// Create graphics device
const device = new GraphicsDevice(800, 600);

// Check WebGL2 support
if (!device.isWebGL2()) {
  throw new Error('Instanced rendering requires WebGL2');
}

if (!device.supportsInstancing()) {
  throw new Error('GPU instancing not supported');
}

// Get WebGL2 context
const gl = device.getWebGL2Context();

// Create shaders
const instancedShader = device.createShader(
  SHADERS_V4.vertex,
  SHADERS_V4.fragment
);

// Use existing V3 shader for batch rendering
const batchShader = device.createShader(
  SHADERS_V3.vertex,
  SHADERS_V3.fragment
);

// Create hybrid renderer (auto-detects when to use instancing)
const renderer = new HybridRenderer(gl, instancedShader, batchShader, {
  instancingThreshold: 100,  // Use instancing for 100+ instances
  maxInstances: 10000,
  tileSize: { width: 64, height: 32 },
  zScale: 1.0
});

// Create camera
const camera = new Camera(0, 0, 1.0);

// Set texture
renderer.setTexture(texture);

// Add thousands of floor tiles
for (let x = 0; x < 100; x++) {
  for (let y = 0; y < 100; y++) {
    renderer.addSprite({
      gridX: x,
      gridY: y,
      z: 0,
      width: 32,
      height: 32,
      texIndex: 0,
      color: { r: 1, g: 1, b: 1, a: 1 },
      rotation: 0
    });
  }
}

// Render (automatically uses instancing for large batches)
device.clear({ r: 0.1, g: 0.1, b: 0.1, a: 1.0 });
const metrics = renderer.render(camera);
device.present();

// Check performance metrics
console.log(`Instanced: ${metrics.instancedInstances} instances in ${metrics.instancedDrawCalls} draw calls`);
console.log(`Batched: ${metrics.batchedInstances} instances in ${metrics.batchedDrawCalls} draw calls`);

// Output with 10,000 tiles:
// Instanced: 10000 instances in 1 draw calls
// Batched: 0 instances in 0 draw calls
// (100x performance improvement!)

Performance Comparison:

| Instance Count | Batch Renderer | Instanced Renderer | Speedup | |----------------|----------------|-------------------|---------| | 100 | ~1ms | ~0.5ms | 2x | | 1,000 | ~10ms | ~1ms | 10x | | 10,000 | ~100ms | ~5ms | 20x |

When to Use Instanced Rendering:

  • ✅ Floor tiles, walls, terrain (many identical meshes)
  • ✅ Particles, projectiles (same geometry, different positions)
  • ✅ Sprites with same texture and size
  • ❌ Unique sprites with different textures
  • ❌ Small batches (< 100 instances)

The HybridRenderer automatically detects when to use instancing, so you get the best of both worlds!


Shader System Guide

Bloody Engine provides 6 built-in shader versions optimized for different rendering scenarios. Choosing the right shader is critical for performance and correct rendering.

Quick Reference Table

| Shader | Projection | Features | Best For | Coordinate System | |--------|------------|----------|----------|-------------------| | V1 | Flexible (any) | Basic texturing | Simple 2D quads | Screen/world (you control via matrix) | | V2 | Flexible (any) | Color tint, texture atlas | 2D sprites with colors | Screen/world (you control via matrix) | | V3 | Isometric | GPU-based transform | Isometric batch rendering | Grid coordinates | | V4 | Isometric | Instanced rendering | Isometric tiles (1000+) | Grid coordinates | | V5 | Top-Down | Instanced rendering | Top-down tiles (1000+) | World/pixel coordinates | | V6 | Top-Down | GPU-based transform | Top-down batch rendering | World/pixel coordinates |


Projection Types Explained

Isometric Projection (V3, V4)

    Y
    ↑
    |  Screen coordinates are rotated 45°
    |  Creates a "fake 3D" look
    └──────→ X

Grid (5,3) → Screen (64, 256)
  • X axis: Diagonal (↗) on screen
  • Y axis: Diagonal (↘) on screen
  • Use for: Isometric games, city builders, RPGs

Top-Down Projection (V5, V6)

    Y
    ↑
    |  Standard 2D coordinates
    |  Y-UP: lower Y = higher on screen
    └──────→ X

World (320, 240) → Screen (320, 240)
  • X axis: Horizontal (→) on screen
  • Y axis: Vertical (↑) on screen (before camera transform)
  • Use for: Top-down shooters, strategy games, platformers

V1 & V2 - Flexible 2D Shaders

Use these when you need full control over projection.

import { SHADERS_V1, SHADERS_V2 } from 'bloody-engine';

// V1: Basic textured quads
const shaderV1 = device.createShader(
  SHADERS_V1.vertex,
  SHADERS_V1.fragment
);

// V2: Adds color tint and texture atlas support
const shaderV2 = device.createShader(
  SHADERS_V2.vertex,
  SHADERS_V2.fragment
);

Shader V1 attributes:

  • aPosition (vec3): x, y, z position
  • aTexCoord (vec2): u, v texture coordinates

Shader V2 adds:

  • aColor (vec4): r, g, b, a color tint
  • aTexIndex (float): texture atlas index

Both use:

  • uMatrix (mat4): You control projection (orthographic, perspective, etc.)

When to use:

  • ✅ Standard 2D games with custom projections
  • ✅ UI rendering
  • ✅ Non-isometric views
  • ✅ When you need camera matrix flexibility

V3 & V4 - Isometric Shaders

Use these for isometric games (city builders, isometric RPGs).

import { SHADERS_V3, SHADERS_V4 } from 'bloody-engine';

// V3: Batch rendering (CPU-side batching)
const shaderV3 = device.createShader(
  SHADERS_V3.vertex,
  SHADERS_V3.fragment
);

// V4: Instanced rendering (GPU-side batching)
const shaderV4 = device.createShader(
  SHADERS_V4.vertex,
  SHADERS_V4.fragment
);

Isometric projection in shader:

// Both V3 and V4 use this transform:
vec2 isoScreen = vec2(
  (aGridPosition.x - aGridPosition.y) * uTileSize.x * 0.5,
  (aGridPosition.x + aGridPosition.y) * uTileSize.y * 0.5
);

Coordinate system:

  • Input: Grid coordinates (tile indices, not pixels)
  • Example: gridX: 5, gridY: 3 → 5th tile right, 3rd tile down

V3 (Batch) uses:

  • uCamera (vec3): x, y position and zoom
  • uResolution (vec2): screen width/height for NDC conversion
  • uTileSize (vec2): isometric tile dimensions
  • uZScale (float): height exaggeration

V4 (Instanced) uses:

  • uMatrix (mat4): camera transform (more flexible)
  • uTileSize (vec2): isometric tile dimensions
  • uZScale (float): height exaggeration

When to use:

  • ✅ Isometric city builders
  • ✅ Isometric RPGs
  • ✅ Games with diagonal movement
  • ❌ Not suitable for standard 2D views

Example usage (V3):

const batchRenderer = new GPUBasedSpriteBatchRenderer(
  gl, shaderV3, 10000,
  { width: 64, height: 32 },  // Isometric tile size
  1.0                         // Z scale
);

// Add sprites in GRID coordinates (tile indices)
batchRenderer.addQuad({
  x: 320, y: 240,  // World pixel position (optional)
  gridX: 5,         // Grid X tile index ← Actual rendering position
  gridY: 3,         // Grid Y tile index ← Actual rendering position
  z: 0,
  width: 64,
  height: 32,
  color: { r: 1, g: 1, b: 1, a: 1 }
});

V5 & V6 - Top-Down Shaders (NEW!)

Use these for standard 2D top-down games.

import { SHADERS_V5, SHADERS_V6 } from 'bloody-engine';

// V5: Top-down instanced rendering
const shaderV5 = device.createShader(
  SHADERS_V5.vertex,
  SHADERS_V5.fragment
);

// V6: Top-down batch rendering
const shaderV6 = device.createShader(
  SHADERS_V6.vertex,
  SHADERS_V6.fragment
);

Direct coordinates (no isometric transform):

// Both V5 and V6 use direct coordinates:
vec2 worldPos = aGridPosition + localPos;  // No transform!

Coordinate system:

  • Input: World/pixel coordinates (direct screen positions)
  • Example: gridX: 320, gridY: 240 → position at (320, 240) pixels

V6 (Batch) uses:

  • uCamera (vec3): x, y position and zoom
  • uResolution (vec2): screen width/height for NDC conversion
  • uZScale (float): depth scale (for sorting, not visual height)

V5 (Instanced) uses:

  • uMatrix (mat4): camera transform
  • uZScale (float): depth scale

When to use:

  • ✅ Top-down shooters
  • ✅ Strategy games
  • ✅ Platformers
  • ✅ Any standard 2D game
  • ❌ Not suitable for isometric views

Example usage (V6):

const batchRenderer = new GPUBasedSpriteBatchRenderer(
  gl, shaderV6, 10000,
  { width: 64, height: 64 },  // Regular tile size (square)
  1.0                         // Z scale for depth sorting
);

// Add sprites in WORLD/PIXEL coordinates (direct positions)
batchRenderer.addQuad({
  x: 320,           // Direct pixel X position
  y: 240,           // Direct pixel Y position
  gridX: 320,       // Same as x (used for rendering)
  gridY: 240,       // Same as y (used for rendering)
  z: 0,             // Depth for sorting (0 = background)
  width: 64,
  height: 64,
  color: { r: 1, g: 0.5, b: 0.2, a: 1 }
});

Migration Guide: Isometric → Top-Down

If you're using V3/V4 (isometric) and want to switch to V5/V6 (top-down):

// BEFORE (Isometric V3/V4):
renderer.addQuad({
  x: 320, y: 240,
  gridX: Math.floor(x / 64),  // Converting to grid indices
  gridY: Math.floor(y / 64),
  z: y / 64,
  width: 64,
  height: 32,  // Non-square for isometric
  ...
});

// AFTER (Top-Down V5/V6):
renderer.addQuad({
  x: 320, y: 240,
  gridX: x,     // Direct pixel coordinates
  gridY: y,     // Direct pixel coordinates
  z: 0,         // Depth for sorting only
  width: 64,
  height: 64,   // Square for top-down
  ...
});

Key changes:

  1. Remove Math.floor() - use coordinates as-is
  2. Use square tiles (width === height) instead of isometric (height = width/2)
  3. z is now only for depth sorting, not visual height

Choosing the Right Shader

Decision tree:

Need isometric view?
├─ Yes → Use V3 (batch) or V4 (instanced)
│   └─ Coordinate system: Grid indices (0, 1, 2, ...)
│
└─ No (standard 2D) → Need custom projection?
    ├─ Yes → Use V1 or V2 (flexible)
    │   └─ You control uMatrix for any projection
    │
    └─ No (standard top-down) → Use V5 (instanced) or V6 (batch)
        └─ Coordinate system: Pixel/world coordinates (320, 240, ...)

Performance recommendations:

| Entity Count | Recommended Shader | Rationale | |--------------|-------------------|-----------| | < 100 | Any | Overhead is negligible | | 100-1000 | V3, V6 (batch) | Good balance | | 1000+ | V4, V5 (instanced) | Best GPU utilization | | Mixed | HybridRenderer | Auto-detects optimal method |


Complete Example: Top-Down Game with V6

import {
  GraphicsDevice,
  Camera,
  GPUBasedSpriteBatchRenderer,
  SHADERS_V6
} from 'bloody-engine';

// Setup
const device = new GraphicsDevice(800, 600);
const gl = device.getGLContext();

// Use top-down batch shader (V6)
const shader = device.createShader(
  SHADERS_V6.vertex,
  SHADERS_V6.fragment
);

// Create batch renderer with top-down settings
const renderer = new GPUBasedSpriteBatchRenderer(
  gl,
  shader,
  10000,                    // Max sprites
  { width: 64, height: 64 }, // Square tiles
  1.0                        // Z scale (depth sorting)
);

// Create camera
const camera = new Camera(400, 300, 1.0); // Center of screen, 1x zoom

// Game loop
function render() {
  // Clear screen
  device.clear({ r: 0.1, g: 0.1, b: 0.15, a: 1.0 });

  // Add player at pixel position (300, 200)
  renderer.addQuad({
    x: 300,
    y: 200,
    gridX: 300,  // Direct pixel position
    gridY: 200,
    z: 1,        // Player in front of background
    width: 64,
    height: 64,
    color: { r: 0.2, g: 0.6, b: 1.0, a: 1 },
    rotation: 0,
    texIndex: 0
  });

  // Add enemy at pixel position (500, 350)
  renderer.addQuad({
    x: 500,
    y: 350,
    gridX: 500,  // Direct pixel position
    gridY: 350,
    z: 1,        // Same layer as player
    width: 48,
    height: 48,
    color: { r: 1.0, g: 0.2, b: 0.2, a: 1 },
    rotation: 0,
    texIndex: 0
  });

  // Render with camera
  renderer.render(camera);
  device.present();
}

Notice: No coordinate conversion needed! Just pass pixel positions directly.


Resource Loading

import {
  ResourceLoaderFactory,
  createResourcePipeline,
  NodeTextureLoader
} from 'bloody-engine';

// Create resource pipeline
const pipeline = await createResourcePipeline({
  concurrency: 5,
  cache: true,
  baseDir: process.cwd()
});

// Load shaders
const shaders = await pipeline.loadShaders([
  { name: 'basic', vertex: 'shaders/basic.vert', fragment: 'shaders/basic.frag' }
]);

// Batch load resources
const { succeeded, failed } = await pipeline.loadMultiple([
  'textures/sprite1.png',
  'textures/sprite2.png'
]);

// Load texture from PNG
const textureLoader = new NodeTextureLoader();
const texture = await textureLoader.loadTexture(gl, 'textures/sprite.png');

Game Loop with Fixed Timestep

import { Ticker, type TickerConfig } from 'bloody-engine';

const config: TickerConfig = {
  targetFPS: 60,
  fixedDeltaTime: 1 / 60, // 60 physics updates per second
  maxFrameTime: 0.25 // Prevent spiral of death
};

const ticker = new Ticker(config);

ticker.start({
  update: (deltaTime) => {
    // Game logic update (fixed timestep)
    console.log(`Update: ${deltaTime.toFixed(3)}s`);
  },
  render: (interpolation) => {
    // Render with interpolation factor
    console.log(`Render: interpolation=${interpolation.toFixed(3)}`);
  }
});

// Get performance metrics
const metrics = ticker.getMetrics();
console.log(`FPS: ${metrics.fps}, Delta Time: ${metrics.deltaTime}s`);

Entity System (SoA Architecture)

The engine now uses Structure of Arrays (SoA) for entity storage, providing:

  • Zero-copy GPU transfers - Direct typed array uploads to WebGL buffers
  • Better cache locality - Sequential memory access patterns
  • SIMD-ready - Data layout enables future vectorization
  • Extensible properties - Add custom typed arrays for game-specific data
import { EntityManager, type EntityState } from 'bloody-engine';

// Create entity manager (uses SoA storage internally)
const manager = new EntityManager();

// Create entity with initial state
const player = manager.createEntity("player", {
  gridPos: { xgrid: 10, ygrid: 20, zheight: 5 },
  velocity: { x: 1, y: 0, z: 0 },
  speed: 2.5
});

// All existing methods work unchanged (full backward compatibility)
player.setGridPos(50, 60, 10);
player.move(5, 5, 0);
player.setVelocity(2, 1, 0);

// Query entities
const players = manager.getEntitiesByType("player");
const nearby = manager.getEntitiesInRange(50, 60, 100);

// Register custom properties (opt-in extension)
manager.registerCustomProperty("health", Float32Array);
manager.registerCustomProperty("stamina", Uint32Array);

// Access SoA storage directly for advanced use
const storage = manager.getStorage();
const handle = (player as any).getHandle();

// Set custom property
storage.setCustomProperty(handle.index, "health", 100);

Input System with Command Queue

import {
  CommandQueue,
  SDLInputSource,
  createSDLInputSource,
  CommandType
} from 'bloody-engine';

// Create command queue
const queue = new CommandQueue();

// Create SDL input source (requires SDL window)
const sdlWindow = new SDLWindow(800, 600, 'Game');
const inputSource = createSDLInputSource(sdlWindow, {
  keyMapping: {
    moveUp: ['w', 'arrowup'],
    moveDown: ['s', 'arrowdown'],
    moveLeft: ['a', 'arrowleft'],
    moveRight: ['d', 'arrowright']
  }
});

// Process input in game loop
while (running) {
  // Collect input commands
  inputSource.update(queue);

  // Process commands
  while (queue.hasCommands()) {
    const command = queue.dequeue();
    switch (command.type) {
      case CommandType.Move:
        handleMove(command);
        break;
      case CommandType.Attack:
        handleAttack(command);
        break;
    }
  }
}

Networking - Client-Side Prediction

import {
  createClientPredictor,
  ClientPredictor,
  type ClientInputMessage
} from 'bloody-engine';

// Create predictor with config
const predictor = createClientPredictor({
  maxPredictedTicks: 100,
  reconciliationDelay: 100 // ms
});

// Client loop: send input
const onInput = (input: MoveCommand) => {
  const tick = currentTick;
  predictor.addLocalInput(tick, input);

  // Send to server
  socket.send(JSON.stringify({
    type: 'client_input',
    tick,
    input
  } as ClientInputMessage));
};

// Receive server update
const onServerUpdate = (message: ServerStateUpdateMessage) => {
  const result = predictor.reconcile(message);

  if (result.corrected) {
    console.log(`Reconciled: corrected=${result.corrected}, error=${result.error}`);
  }
};

Object Pooling for Performance

import { ObjectPool, type ObjectPoolConfig } from 'bloody-engine';

// Create pool for Vector3 objects
const pool = new ObjectPool<Vector3>({
  initialSize: 100,
  growthFactor: 2,
  factory: () => ({ x: 0, y: 0, z: 0 }),
  reset: (obj) => { obj.x = 0; obj.y = 0; obj.z = 0; }
});

// Acquire from pool
const vec = pool.acquire();
vec.x = 10; vec.y = 20; vec.z = 30;

// Return to pool when done
pool.release(vec);

// Get pool statistics
const stats = pool.getStats();
console.log(`Size: ${stats.size}, Active: ${stats.active}, Hits: ${stats.hits}`);

Isometric Projection

import { ProjectionConfig, gridToScreen, screenToGrid } from 'bloody-engine';

// Configure isometric projection
const config = new ProjectionConfig({
  tileWidth: 64,
  tileHeight: 32,
  angle: Math.PI / 6, // 30 degrees
  screenWidth: 800,
  screenHeight: 600
});

// Convert grid to screen coordinates
const gridPos = { xgrid: 5, ygrid: 3, zheight: 0 };
const screenPos = gridToScreen(gridPos, config);
console.log(`Screen: x=${screenPos.xscreen}, y=${screenPos.yscreen}`);

// Convert screen to grid coordinates
const gridPos2 = screenToGrid(screenPos, config);

Texture Atlas for Sprite Sheets

import { TextureAtlas, AtlasLoader } from 'bloody-engine';

// Load sprite atlas
const atlas = await AtlasLoader.loadFromJSON(gl, 'atlas.json');

// Get sprite info
const sprite = atlas.getSprite('player_idle_01');

// Use UV rect for rendering
batchRenderer.addQuad({
  x: 100, y: 100, z: 0,
  width: sprite.pixelRect.width,
  height: sprite.pixelRect.height,
  uvRect: sprite.uvRect
});

SoA Deep Dive: Zero-Copy GPU Rendering

The Structure of Arrays (SoA) architecture enables direct GPU transfers without intermediate copying:

import { EntityStorage, SoaWebGLRenderer, Shader } from 'bloody-engine';

// Create SoA storage and populate with entities
const storage = new EntityStorage(10000);
// ... add entities ...

// Create WebGL2 renderer with persistent buffer mapping
const gl = device.getGLContext() as WebGL2RenderingContext;
const shader = device.createShader(vertexSource, fragmentSource);
const renderer = new SoaWebGLRenderer(gl, shader, 10000);

// Initialize persistent buffers (maps GPU memory to CPU arrays)
renderer.initialize(storage);

// In your render loop:
function render() {
  // Zero-copy: Update GPU memory directly
  renderer.render(storage);

  // GPU sees changes immediately (coherent mapping)
  device.present();
}

Performance Benefits:

  • No bufferSubData overhead: Direct CPU→GPU memory writes
  • Better cache locality: Sequential access to entity data
  • SIMD-ready: Data layout enables future vectorization
  • Memory efficient: Typed arrays use 2-4x less memory than objects

Custom Properties Extension

Add game-specific properties without modifying core classes:

import { EntityManager, Float32Array, Uint32Array } from 'bloody-engine';

const manager = new EntityManager();

// Register custom properties (opt-in)
manager.registerCustomProperty('health', Float32Array);    // Float values
manager.registerCustomProperty('mana', Uint32Array);       // Integer values
manager.registerCustomProperty('xp', Float32Array);        // Experience points

// Create entity
const player = manager.createEntity('player');

// Access storage to set custom properties
const storage = manager.getStorage();
const handle = (player as any).getHandle();

storage.setCustomProperty(handle.index, 'health', 100.0);
storage.setCustomProperty(handle.index, 'mana', 50);
storage.setCustomProperty(handle.index, 'xp', 0);

// Bulk update all entities (cache-efficient)
const allHealth = storage.getCustomPropertyArray('health');
for (let i = 0; i < storage.getCount(); i++) {
  allHealth[i] += 10; // Regenerate health for all entities
}

SoA Memory Layout

Understanding the SoA memory layout helps with performance optimization:

// Entity 0 data at indices 0-2
positions[0] = entity0.x
positions[1] = entity0.y
positions[2] = entity0.z

// Entity 1 data at indices 3-5
positions[3] = entity1.x
positions[4] = entity1.y
positions[5] = entity1.z

// Same pattern for all properties:
// - velocities: [vx0, vy0, vz0, vx1, vy1, vz1, ...]
// - colors: [r0, g0, b0, a0, r1, g1, b1, a1, ...]
// - rotations: [rot0, rot1, rot2, ...]
// - textureIds: [id0, id1, id2, ...]

This layout enables:

  • Zero-copy views: positions.subarray(0, entityCount * 3) → GPU
  • Bulk updates: Loop through contiguous memory
  • Cache efficiency: Predictable access patterns

Migration from AoS to SoA

If you have existing code using the old Array-of-Structures pattern:

Before (AoS - deprecated):

// This no longer works
const entity = new Entity("player1", "player", {
  gridPos: { xgrid: 10, ygrid: 20, zheight: 0 }
});

After (SoA - current):

// Use EntityManager factory
const manager = new EntityManager();
const entity = manager.createEntity("player", {
  gridPos: { xgrid: 10, ygrid: 20, zheight: 0 }
});

// Everything else works the same!
entity.setGridPos(50, 60, 10);
entity.move(5, 5, 0);
entity.setVelocity(1, 0, 0);

Breaking Changes:

  • Direct new Entity() construction is no longer supported
  • Use EntityManager.createEntity() for all entity creation
  • Deserialization: Use EntityManager.deserializeAll() instead of Entity.deserialize()

Advanced Examples

Networked Game Architecture

import {
  SimulationLoop,
  Entity,
  ClientPredictor,
  ServerReconciler,
  StateSnapshot,
  Ticker
} from 'bloody-engine';

// Server-side simulation
const serverSim = new SimulationLoop({
  fixedDeltaTime: 1 / 60
});

// Client-side prediction
const clientPredictor = createClientPredictor({
  maxPredictedTicks: 100
});

// Server reconciliation
const serverReconciler = createServerReconciler({
  maxRewindTicks: 50
});

// Game loop on client
const ticker = new Ticker({ targetFPS: 60 });
ticker.start({
  update: (deltaTime) => {
    // 1. Collect input and send to server
    const input = collectInput();
    socket.send({ type: 'input', input, tick: currentTick });

    // 2. Predict locally
    clientPredictor.addLocalInput(currentTick, input);
    const predictedState = predictState();

    // 3. Handle server updates
    onServerUpdate = (update) => {
      clientPredictor.reconcile(update);
    };
  },
  render: (interpolation) => {
    renderGame(clientPredictor.getLatestState(), interpolation);
  }
});

Deterministic Simulation Testing

import { SimulationLoop, Entity } from 'bloody-engine';

// Create two simulations for testing determinism
const sim1 = new SimulationLoop({ fixedDeltaTime: 1 / 60, seed: 12345 });
const sim2 = new SimulationLoop({ fixedDeltaTime: 1 / 60, seed: 12345 });

// Add identical entities
sim1.addEntity(new Entity({ id: '1', x: 0, y: 0 }));
sim2.addEntity(new Entity({ id: '1', x: 0, y: 0 }));

// Run simulations
for (let i = 0; i < 1000; i++) {
  sim1.update(1 / 60);
  sim2.update(1 / 60);
}

// Verify determinism
const state1 = sim1.getStateSnapshot();
const state2 = sim2.getStateSnapshot();
console.log('Deterministic:', JSON.stringify(state1) === JSON.stringify(state2));

Testing

The engine includes comprehensive tests for determinism, visual regression, and SoA functionality:

# Run all tests
npm test

# Run specific test suites
npm run test:determinism    # Test simulation determinism
npm run test:visual         # Visual regression tests
npm run test:state-sync     # State synchronization tests
npm run test:coverage       # Generate coverage report

Dependencies

  • gl - Headless WebGL for Node.js
  • @kmamal/sdl - SDL2 bindings for window and input management
  • pngjs - PNG image decoding

Platform Support

| Platform | Status | Notes | |----------|--------|-------| | Node.js (Linux) | ✅ Full | Headless rendering + SDL window | | Node.js (macOS) | ✅ Full | Headless rendering + SDL window | | Node.js (Windows) | ✅ Full | Headless rendering + SDL window | | Browser | ⚠️ Planned | WebGL rendering planned |

Documentation

Source Code Organization

src/
├── core/              # Core graphics and utilities
│   ├── grahpic-device.ts
│   ├── shader.ts
│   ├── texture.ts
│   ├── buffer.ts
│   ├── object-pool.ts
│   └── ticker.ts
├── rendering/         # Rendering systems
│   ├── batch-renderer.ts
│   ├── camera.ts
│   ├── projection.ts
│   ├── instanced-renderer.ts      # WebGL2 GPU instancing
│   ├── hybrid-renderer.ts          # Auto-detection (instanced vs batch)
│   ├── ring-buffer.ts              # Triple-buffered GPU streaming
│   ├── soa-webgl-renderer.ts      # WebGL2 zero-copy renderer
│   └── spatial-hash.ts
├── input/             # Input system (command queue)
│   ├── command-queue.ts
│   ├── sdl-input-source.ts
│   └── network-input-source.ts
├── simulation/        # Game logic simulation
│   ├── entity.ts
│   ├── entity-manager.ts
│   ├── entity-storage.ts        # SoA storage with typed arrays
│   ├── entity-handle.ts          # Handle-based entity references
│   ├── entity-type-registry.ts   # Type string to ID mapping
│   └── simulation-loop.ts
├── networking/        # Networking for multiplayer
│   ├── client-predictor.ts
│   ├── server-reconciler.ts
│   ├── state-snapshot.ts
│   └── binary-serializer.ts
└── platforms/
    └── node/          # Node.js-specific implementations
        ├── node-context.ts
        ├── node-resource-loader.ts
        └── sdl-window.ts

Key Concepts

  • Separation of Concerns: Rendering, input, simulation, and networking are completely separate systems
  • Structure of Arrays (SoA): Entity storage uses typed arrays for zero-copy GPU transfers and cache efficiency
  • Deterministic Simulation: Game logic runs in fixed timestep for consistency across clients
  • Command Pattern: All input goes through a command queue for easy recording/replay
  • Client-Side Prediction: Reduces perceived lag in networked games
  • Object Pooling: Minimizes garbage collection for smooth performance

For detailed documentation and architecture, see docs/README.MD.

Building

npm run build

This will generate the distribution files in dist/node/.

License

MIT License - see LICENSE for details.

Repository

https://github.com/BLooDek/bloody-engine

Issues

Report bugs and request features at: https://github.com/BLooDek/bloody-engine/issues