bloody-engine
v1.1.18
Published
A WebGL-based 2.5D graphics engine for isometric rendering
Maintainers
Readme
Bloody Engine
A high-performance WebGL-based 2.5D graphics engine for isometric rendering on Node.js
Perfect for: Isometric games, city builders, multiplayer servers, procedural generation, and headless graphics processing.
Why Bloody Engine?
Traditional game engines require separate client and server codebases, leading to duplicated logic and synchronization bugs. Bloody Engine solves this with an isomorphic architecture:
- Server-Side Rendering: Run the exact same rendering code on Node.js using headless WebGL
- Zero-Copy GPU Transfers: Structure of Arrays (SoA) architecture enables direct memory transfers
- Deterministic Simulation: Fixed timestep game loop ensures consistent state across all clients
- Authoritative Multiplayer: Server can render scenes virtually to validate visibility and prevent cheats
- Performance: Render 10,000+ entities at 60 FPS with instanced rendering
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
gland@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
Prerequisites
- Node.js 18+ or 20+
- Native dependencies: Requires compilation of
gland@kmamal/sdl
Platform-Specific Requirements
Linux (Debian/Ubuntu):
sudo apt-get install build-essential libx11-dev libgl1-mesa-dev libxi-devmacOS:
xcode-select --installWindows:
- Install Visual Studio Build Tools
- Ensure "C++ build tools" workload is selected
Installation
npm install bloody-engineQuick Start (5 Minutes)
Let's create a simple isometric tile renderer from scratch:
Step 1: Create Your First Scene
import { GraphicsDevice, HybridRenderer, Camera, SHADERS_V4 } from 'bloody-engine';
// 1. Create graphics device (800x600 window)
const device = new GraphicsDevice(800, 600);
const gl = device.getWebGL2Context();
// 2. Create isometric shader for instanced rendering
const shader = device.createShader(SHADERS_V4.vertex, SHADERS_V4.fragment);
// 3. Create hybrid renderer (auto-detects optimal rendering method)
const renderer = new HybridRenderer(gl, shader, shader, {
instancingThreshold: 100,
maxInstances: 10000,
tileSize: { width: 64, height: 32 },
zScale: 1.0
});
// 4. Create camera centered on screen
const camera = new Camera(400, 300, 1.0);Step 2: Add Some Tiles
// Set a gradient texture (or load your own with TextureAtlas)
renderer.setTexture(Texture.createGradient(gl, 256, 256));
// Create a 10x10 isometric grid
for (let x = 0; x < 10; x++) {
for (let y = 0; y < 10; y++) {
renderer.addSprite({
gridX: x, // Grid X position (tile index)
gridY: y, // Grid Y position (tile index)
z: 0, // Height/depth layer
width: 64, // Tile width
height: 32, // Tile height (isometric = width/2)
texIndex: 0,
color: { r: 1, g: 1, b: 1, a: 1 },
rotation: 0
});
}
}Step 3: Render Loop
function render() {
// Clear screen with dark background
device.clear({ r: 0.1, g: 0.1, b: 0.15, a: 1.0 });
// Render all tiles with camera
const metrics = renderer.render(camera);
// Display to screen
device.present();
// Log performance
console.log(`Drew ${metrics.instancedInstances} sprites in ${metrics.instancedDrawCalls} draw calls`);
// Request next frame
requestAnimationFrame(render);
}
// Start rendering
render();Step 4: Add Camera Movement
// Add keyboard controls for camera
import { SDLWindow } from 'bloody-engine';
const window = new SDLWindow(800, 600, 'My Isometric Game');
window.onKeyDown = (key) => {
const speed = 10;
switch(key.toLowerCase()) {
case 'w': camera.y -= speed; break; // Up
case 's': camera.y += speed; break; // Down
case 'a': camera.x -= speed; break; // Left
case 'd': camera.x += speed; break; // Right
}
};That's it! You now have a working isometric renderer with:
- ✅ 100 tiles rendered efficiently
- ✅ Camera controls
- ✅ Performance metrics
Next steps:
- Add sprites with
TextureAtlasfor custom graphics - Implement collision detection with
SpatialHash - Add multiplayer with
ClientPredictorandServerReconciler
Table of Contents
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 |
Examples
Runnable Demos
Build the library first, then run any demo:
npm run build # Build the library
# Run interactive demos
npm run demo # Main demo with various features
npm run demo:coordinates # Visualize coordinate systemsCode Examples by Difficulty
Beginner:
- Basic Rendering Setup - Create a device and render a quad
- Sprite Batch Rendering - Render multiple sprites with camera
- Game Loop - Implement a fixed timestep loop
Intermediate:
- Instanced Rendering - Render thousands of tiles efficiently
- Shader System Guide - Choose and use the right shader
- Entity System - Manage game entities with SoA storage
Advanced:
- Input System - Handle keyboard/mouse with command pattern
- Networking - Client-side prediction for multiplayer
- Networked Game Architecture - Full multiplayer setup
Code Examples
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!
Performance Benchmarks
Tested on: AMD Ryzen 9 5900X, NVIDIA RTX 3080, Node.js v20
| Scenario | Entities | FPS | Draw Calls | Frame Time | |----------|----------|-----|------------|------------| | Batch Renderer | 1,000 | 60 | 1,000 | ~10ms | | Instanced Renderer | 10,000 | 60 | 1 | ~5ms | | Hybrid Renderer (mixed) | 5,000 | 60 | 50 | ~8ms | | Collision Detection (Spatial Hash) | 10,000 | 60 | N/A | ~2ms | | Full Game Loop (render + physics) | 5,000 | 60 | 50 | ~12ms |
Key Performance Insights:
- Instanced rendering provides 10-20x speedup for 1000+ identical entities
- SoA entity storage reduces memory usage by 60-70% vs traditional object-based storage
- Spatial hash collision detection maintains O(N) performance even at high entity counts
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 positionaTexCoord(vec2): u, v texture coordinates
Shader V2 adds:
aColor(vec4): r, g, b, a color tintaTexIndex(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 zoomuResolution(vec2): screen width/height for NDC conversionuTileSize(vec2): isometric tile dimensionsuZScale(float): height exaggeration
V4 (Instanced) uses:
uMatrix(mat4): camera transform (more flexible)uTileSize(vec2): isometric tile dimensionsuZScale(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 zoomuResolution(vec2): screen width/height for NDC conversionuZScale(float): depth scale (for sorting, not visual height)
V5 (Instanced) uses:
uMatrix(mat4): camera transformuZScale(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:
- Remove
Math.floor()- use coordinates as-is - Use square tiles (width === height) instead of isometric (height = width/2)
zis 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 ofEntity.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 reportDependencies
- 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
│ ├── graphics-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.tsKey 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.
Troubleshooting
Native Module Compilation Fails
Error: gyp ERR! stack Error: not found: make
Solution: Install build tools for your platform:
- Linux:
sudo apt-get install build-essential - macOS:
xcode-select --install - Windows: Install Visual Studio Build Tools
SDL/OpenGL Driver Issues
Error: Error: SDL could not create window
Solution:
- On Linux headless servers, use a virtual display:
Xvfb :99 -screen 0 1024x768x24 & export DISPLAY=:99 - On Windows, ensure GPU drivers are up to date
- On macOS, ensure you're not in a restricted sandbox environment
Texture Loading Fails
Error: Error: Failed to load texture
Solution:
- Ensure PNG files are in the correct directory relative to
process.cwd() - Verify
@kmamal/sdlis installed correctly - Check file permissions
Performance Issues
Symptoms: Low FPS, frame drops
Solutions:
- Use
HybridRendererfor automatic optimization - Reduce entity count or use spatial partitioning
- Enable instanced rendering for 1000+ identical sprites
- Check for memory leaks in the entity system
- Profile with Chrome DevTools (Node.js inspector)
For more help, please open an issue.
Contributing
Contributions are welcome! Please follow these guidelines:
Development Setup
# Clone the repository
git clone https://github.com/BLooDek/bloody-engine.git
cd bloody-engine
# Install dependencies
npm install
# Build the library
npm run build
# Run tests in watch mode
npm run test:watch
# Run linting
npm run lintMaking Changes
- Create a feature branch:
git checkout -b feature/my-feature - Make your changes and add tests
- Ensure tests pass:
npm test - Ensure code is linted:
npm run lint - Commit with clear messages
- Push and create a pull request
Coding Standards
- TypeScript: Use strict mode, provide types for all exports
- Tests: Add unit tests for new features
- Documentation: Update README and code comments as needed
- Formatting: Follow existing code style (enforced by ESLint)
Reporting Issues
When reporting bugs, please include:
- Node.js version
- Platform (Linux/macOS/Windows)
- Minimal reproducible example
- Expected vs actual behavior
Building
npm run buildThis 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
