bloody-engine
v1.1.17
Published
A WebGL-based 2.5D graphics engine for isometric rendering
Downloads
2,945
Maintainers
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
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
Installation
npm install bloody-engineUnderstanding 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 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
│ ├── 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.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.
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
