| .. | ||
| .github/workflows | ||
| public | ||
| src | ||
| .esr.yml | ||
| .gitignore | ||
| .npmignore | ||
| .prettierrc | ||
| bun.lockb | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
ngn
An ECS framework (and robust input system) for the web.
Comprehensive sample
import merge from "lodash/merge";
import { createWorld, type WorldState } from "@prsm/ngn";
import {
inputSystem,
gamepad,
GamepadMapping,
SCUFVantage2,
onGamepadConnected,
} from "@prsm/ngn/input";
// Create a mapping with unique button/key names.
const MyMapping = (): GamepadMapping => {
return merge(SCUFVantage2(), {
axes: {
2: "LookHorizontal",
3: "LookVertical",
},
buttons: {
0: "Sprint", // X
2: "Jump", // ■
3: "Action", // ▲
},
});
};
// Assign this mapping to gamepads when they connect.
onGamepadConnected((e: GamepadEvent) => {
gamepad(e.gamepad.index).useMapping(MyMapping);
});
// Create a world
const {
state,
query,
createEntity,
addSystem,
start,
step,
defineMain,
} = createWorld();
// Create components
const Position = () => ({ x: 0, y: 0 });
const Velocity = () => ({ x: 0, y: 0 });
const Alive = () => ({});
const Dead = () => ({});
// Create entities
const player =
createEntity()
.addComponent(Position)
.addComponent(Velocity)
.addComponent(Alive)
.addTag("player");
// Create a bunch of monsters
Array
.from(Array(50))
.forEach((i) =>
createEntity({ name: `monster ${i}`, hp: 100 })
.addComponent(Position)
.addComponent(Velocity)
.addComponent(Alive)
.addTag("monster");
// Create queries
const movables = query({ and: [Position, Velocity] });
const livingMonsters = query({ tag: ["monster"], and: [Alive] });
const deadOrAliveMonsters = query({ tag: ["monster"], or: [Dead, Alive] });
// Create systems
const moveSystem = (_: WorldState) => {
movables((results) => {
results.forEach(({ entity, Position, Velocity }) => {
Position.x += Velocity.x;
Position.y += Velocity.y;
});
});
};
const monsterDeathSystem = (_: WorldState) => {
livingMonsters((results) => {
results.forEach(({ entity }) => {
if (entity.hp <= 0) {
entity.removeComponent(Alive);
}
})
});
// Just for demonstration of 'or' query results:
deadOrAliveMonsters((results) => {
// Since this query uses 'or', `Dead` OR `Alive` will be
// present on the results. You will need to check for existence:
results.forEach(({ entity, Dead, Alive }) => {
if (Dead) { }
if (Alive) { }
});
});
};
const gravitySystem = (w: WorldState) => {
movables((results) => {
results.
forEach(({ Velocity }) => {
Velocity.y += 4.9 * w.time.delta;
})
});
};
const playerControlSystem = (_: WorldState) => {
if (gamepad(0).getButton("Jump").justPressed) {
player.getComponent(Velocity).y = 1;
}
};
// Add or remove systems at any time
addSystem(inputSystem, moveSystem, monsterDeathSystem);
// Finally, define your main entry point with `defineMain`:
defineMain(() => {
// Once `start` is called, this will be called every frame.
// Call `step` to call each registered system, passing the state of the world to each.
//
// This is intentionally handled by *you*, because there's a good chance
// you'd prefer to dictate the order of execution here.
step();
});
start();
Installation
npm install @prsm/ngn
API overview
createWorld
const { state, createEntity, getEntity, onEntityCreated, query, addSystem, removeSystem, start, stop, step } = createWorld();
-
state- Stores all the entities.
- Tracks relationships between entities and components for fast lookups.
- Tracks query dependencies and caches results.
- Is passed to all systems (if you use ngn's system mechanics, which is optional).
- Contains a useful
timeobject that looks like:
state.time.delta- time since last frame in ms, scaled by time.scale. Use this value for all physics and movement calculations to ensure they respect the time scale.state.time.rawDelta- raw, unscaled time since last frame in ms. This is the actual time between render frames and doesn't change with time scale.state.time.loopDelta- time since last call to main game loop, affected by scale.state.time.scale- time scale. (default:1).- Does not affect framerate at all. The scale affects both how often the main game loop is called and the delta time used for physics/movement calculations. At a scale of 1, the main loop is called every frame and delta equals rawDelta. At a scale of 0.5, the main loop is called approximately every other frame and delta is half of rawDelta.
Important: Time scaling separates rendering framerate from simulation speed. The game will always render at the device's refresh rate (e.g., 60fps), but the simulation speed (how fast objects move, animations play, etc.) is controlled by the time scale. Always use
deltain your movement and physics calculations to ensure they respect the time scale:// This will move at half speed when time.scale is 0.5 position.x += velocity.x * state.time.delta;state.time.elapsed- time sincestartwas called in ms.state.time.fps- frames per second.
Note: The "last frame" and "last call to main game loop" are different concepts. The engine always runs at the device's refresh rate (e.g. 60fps), so
rawDeltaanddeltaupdate every frame. However, the main game loop (where your game logic runs) may be called less frequently based on the time scale. For example, at scale 0.5, the main game loop runs every other frame, resulting in aloopDeltathat's approximately twice thedelta.
This table may help provide clarity to the behavior of time.scale:
| scale | fps | rawDelta | delta | loopDelta | Description |
|---|---|---|---|---|---|
| 1 | 60 | 16.67 | 16.67 | 16.67 | Normal speed - main loop called exactly once per frame |
| 0.5 | 60 | 16.67 | 8.33 | 33.34 | Half speed - main loop called every ~2 frames |
| 2.0 | 60 | 16.67 | 33.34 | 8.33 | Double speed - main loop called ~twice per frame |
The engine always renders at the device's refresh rate (fps), but the frequency of main loop calls and the simulation time (delta) are affected by the time scale.
Entities
-
World > createEntityconst { id, addTag, removeTag, getTag, addComponent, hasComponent, getComponent, removeComponent, destroy } = createEntity({ optional: "default values" });Forcefully setting the entity ID
You can forcefully set the entity ID by providing it as one of the properties of the object passed to
createEntity. This is a feature that's probably not very useful in the context of this library alone, but this is a critical feature that@prsm/ngn-netrelies on. An authoritative game server must be able to assign IDs to entities.// IDs are not numbers, but this example serves to // illustrate a behavior. // This entity will have id 1 (not really, but go with it). const firstEntity = createEntity(); // Now this entity has id 1, and `firstEntity` has id 2. const secondEntity = createEntity({ id: 1 }); // This entity has id 3. const thirdEntity = createEntity();-
Entity > addTagAdds a tag to the entity. Tags are only useful for querying entities. An entity can only have one tag.
entity.addTag("coin"); -
Entity > removeTagRemoves the tag from the entity.
entity.removeTag(); -
Entity > getTagReturns the tag of the entity.
const tag = entity.getTag(); -
Entity > destroyDestroys the entity. Removes it from the world.
entity.destroy();
-
-
World > getEntityReturns the entity with the given ID.
const entity = getEntity("ngnluxhlpj30271be3f727d31");
Components
-
Entity > addComponentAdds a component to the entity. Components are functions that return an object. An entity can only have one of each type of a component. Components are just stored as an array of objects on the entity.
const Position = () => ({ x: 50, y: 50 }); const Velocity = () => ({ x: 0, y: 0 }); entity.addComponent(Position).addComponent(Velocity); // entity: // { // ..., // components: [ // { x: 50, y: 50 }, <-- Position // { x: 0, y: 0 }, <-- Velocity // ], // }If the object returned by the component function includes an
onAttachfunction, it is called at this time.const MeshComponent = () => ({ entityId: null, mesh: null, onAttach(entity: Entity) { this.entityId = entity.id; }, });You can override default values:
entity.addComponent(Position, { y: 10 }); // entity: // { // ..., // components: [ // { x: 50, y: 10 }, <-- Position // ], // } -
Entity > hasComponentReturns
trueif the entity has the component.const hasPosition = entity.hasComponent(Position); -
Entity > getComponentReturns the component of the entity.
const position = entity.getComponent<typeof Position>(Position); -
Entity > removeComponentRemoves the component from the entity. Provide either the component function or the string name of the component (
.nameproperty).entity.removeComponent(Position); // is the same as: entity.removeComponent("Position");If the object returned by the component function includes an
onDetachfunction, it is called at this time.const MeshComponent = () => ({ mesh: null, onDetach(entity: Entity) { if (mesh) { dispose(mesh); } }, });
Extending components
Occasionally you will want to override the component defaults when instantiating a component.
You can do something like addComponent(Position, { y: CURRENT_Y }), but for something more generic you can extend the component:
import { extend } from "@prsm/ngn";
const Health = () => ({ max: 100 });
const WarriorHealth = extend(Health)({ max: 200 });
const MageHealth = extend(Health)({ max: 75 });
// Internally, `WarriorHealth` and `MageHealth` are still
// identified as a `Health` components.
// This means that queries that match against `Health` will be updated
// to include anything that has `WarriorHealth` or `MageHealth`.
warriorEntity.addComponent(WarriorHealth));
const mortals = query({ and: [Health] });
mortals((results) => {
// results includes warriorEntity
});
-
World > queryQueries the world for entities with the given tags and components.
queryreturns a function that accepts a callback. The callback is immediately called with an array of results. Each result is an object that contains anentitykey, and a key for each component that is found on the entity.queryaccepts an object with the following properties:{ and: [], // matched Entities will have all of these components or: [], // matched Entities will have any of these components not: [], // matched Entities will have none of these components tags: [], // matched Entities will have any of these tags }createEntity().addComponent(Position).addComponent(Velocity); createEntity().addComponent(Position).addComponent(Velocity).addComponent(Dead); const movables = query({ and: [Position, Velocity], not: [Dead] }); movables((results) => { results.forEach(({ Position, Velocity }) => { Position.x += Velocity.x; Position.y += Velocity.y; }); });For optimum performance, query results are cached while entity state is clean. When an entity is created, destroyed, or has a component added or removed, the cache is invalidated.
-
World > addSystemAdds a system to the world. Systems are either:
- A function that receives the
WorldStateas its only argument. - An object with an
updatefunction that receives theWorldStateas its only argument. - An instance of a class that has an
updatefunction that receives theWorldStateas its only argument.
None of these need to return anything, and the
WorldStatethey receive is mutable.Systems are called in the order they were added.
const MovementSystem = (state: WorldState) => {}; addSystem(MovementSystem); const MovementSystem = { update: (state: WorldState) => {} }; addSystem(MovementSystem); class MovementSystem { update(state: WorldState) {} } addSystem(new MovementSystem()); - A function that receives the
-
World > removeSystemRemoves a system from the world. Preserves the order of the remaining systems.
removeSystem(movableSystem);World > defineMainDefines the main program loop. The callback will be called every frame once
startis called.defineMain(() => { // .. }); -
World > startStarts the main program loop. Does not do anything other than call the callback provided to
defineMain.You can use your own loop instead of this one if you prefer, but the builtin loop does things like calculate fps and frame delta for you. These values are stored in
state.time. If you create your own loop, it would be a good idea to calculate these values yourself and populatestate.timewith them.start(); -
World > stopStops the main program loop (which was defined by passing it to
defineMain).// if gameover, or something stop();
Scene Management
NGN doesn't enforce any specific scene management pattern, giving you the freedom to implement what works best for your game. The simplest approach is to use separate worlds as scenes:
// Create different worlds for different scenes
const menuScene = createWorld();
const gameScene = createWorld();
const pauseScene = createWorld();
// Configure each scene
menuScene.defineMain(() => { /* menu logic */ });
gameScene.defineMain(() => { /* game logic */ });
// Track current scene
let currentScene = menuScene;
// Switch scenes
function switchToScene(newScene) {
currentScene.stop();
currentScene = newScene;
currentScene.start();
}
// Start with menu
menuScene.start();
// Later, switch to game
switchToScene(gameScene);
-
World > stepCalls all systems once. Passes the
WorldStateto each system. You should do this in your main program loop, e.g.:const main = () => { step(); }; defineMain(main); start(); // later on: stop();
Extras
Some completely optional extras are provided.
Keyboard, mouse and gamepad input
Input system
This input system recognizes keyboard, mouse and gamepad input and has a simple API.
There is a provided input system that is responsible for deriving the state of devices from their inputs. Import it, and make sure it's called before any systems that depend on the latest input state.
import { inputSystem } from "@prsm/ngn/input";
world.addSystem(inputSystem);
ButtonState
For keyboard and mouse devices, the state of a button is represented as a ButtonState object:
export interface ButtonState {
// This is true for one frame only.
justPressed: boolean;
// This is true for as long as the button is being pressed.
pressed: boolean;
// This is true for one frame only.
justReleased: boolean;
}
Gamepad button state is represented as a GamepadButtonState object:
export interface GamepadButtonState extends ButtonState {
// This is true for as long as the button is being touched (e.g. the touchpad on a PS5 controller)
touched: boolean;
// This is the value of the button, between 0 and 1. For triggers, this is the amount the trigger is pressed.
value: number;
}
Mouse
import { mouse } from "@prsm/ngn/input";
-
useMappingmouse.useMapping(m: MouseMapping): voidDefines a human-readable mapping to mouse buttons and axes.
By default, the
StandardMousemapping is used and you probably don't need to call this. -
getButtonmouse.getButton(): ButtonStateReturns the state of a mouse button, e.g.:
const { pressed, justPressed, justReleased } = mouse.getButton("Mouse1"); -
getAxismouse.getAxis(axis: string): numberReturns the value of a mouse axis. With the
StandardMousemapping, the axes are:Horizontal,Vertical, andWheel. -
getPositionmouse.getPosition(): { x: number, y: number }Returns the position of the mouse relative to the window.
-
getAccelerationmouse.getAcceleration(): numberReturns the acceleration of the mouse.
Keyboard
import { keyboard } from "@prsm/ngn/input";
-
useMappingkeyboard.useMapping(m: KeyboardMapping): voidDefines a human-readable mapping to keyboard keys.
By default, the
StandardKeyboardmapping is used and you probably don't need to call this, unless you want to rename some keys:import { StandardKeyboard } from "@prsm/ngn"; const MyKeyboardMapping = (): KeyboardMapping => { return { ...StandardKeyboard(), [KeyboardKey.Space]: "Jump", [KeyboardKey.KeyC]: "FireLazerz", }; }; keyboard.useMapping(MyKeyboardMapping); keyboard.getKey("FireLazerz"); -
getKeykeyboard.getKey(b: string): ButtonStateReturns the state of a keyboard key. The key should be the human readable name value defined in the mapping used.
Gamepad
import { gamepad } from "@prsm/ngn/input";
-
useMappinggamepad(index: number).useMapping(m: GamepadMapping): voidDefines a human-readable mapping to gamepad buttons and axes.
The default mapping is assigned by inspecting the
Gamepad.idproperty.You can see all of the built-in mappings
here, which includes mappings for PlayStation5, Xbox, and SCUF Vantage 2 controllers.PRs that add additional mappings are welcome!
-
getButtongamepad(index: number).getButton(button: string): GamepadButtonStateReturns the state of a gamepad button.
-
getAxisgamepad(index: number).getAxis(axis: string): numberReturns the value of a gamepad axis.
if (gamepad(0).getAxis("Look") < 0) { /* Left */ } -
devicegamepad(index: number).device: GamepadReturns the Gamepad object from the navigator at the provided index.
-
rumblegamepad(index: number).rumble(options: RumbleOptions): voidRumble the device.
gamepad(1).rumble({ startDelay: 0, duration: 500, strongMagnitude: 1.0, weakMagnitude: 1.0, });
Input usage examples
Gamepad
import { keyboard, mouse, gamepad } from "@prsm/ngn/input";
if (gamepad(0).getAxis("Look") < 0) {
/* Left */
}
if (gamepad(0).getAxis("Look") > 0) {
/* Right */
}
gamepad(1).rumble({
startDelay: 0,
duration: 500,
strongMagnitude: 1.0,
weakMagnitude: 1.0,
});
Keyboard
import { keyboard } from "@prsm/ngn/input";
if (keyboard.getKey("Space").justPressed) {
/* Jump! */
}
Mouse
import { mouse } from "@prsm/ngn/input";
if (mouse.getAxis("Wheel")) {
/* Scrolling */
}
if (mouse.getAcceleration() > 5) {
/* Woah, slow down */
}
Expiring log system
-
logSystemThis log system takes advantage of
state.time.deltato expire log entries over time. By default, this is 10 seconds, but this is configurable.The whole point of this system is to draw debug messages to a canvas, but have them disappear after a while.
import { createLogSystem, type WorldState } from "@prsm/ngn"; const logSystem = createLogSystem({ maxLifetime: 5_000 }); const logDrawSystem = (state: WorldState) => { logSystem.expiringLogs.forEach(({ message }, index) => { drawTextToCanvas(message, { x: 0, y: index * 20 }); }); logSystem.update(state); }; addSystem(logDrawSystem); logSystem.log("some useful debug message");