Entities & Components
This guide covers practical component design patterns for game objects. Every game object in Web Engine Dev is an entity, a unique ID with a set of components attached to it. Components are pure data; all behavior lives in systems or scripts.
Component Design Principles
- Small and focused, one concern per component
- No methods, components hold data only
- Composable, build complex objects by combining simple components
- Tag components, zero-data components that classify entities
import { defineComponent } from '@web-engine-dev/ecs';
// Transform: position, rotation, scale
// Schema uses typed field descriptors, NOT factory callbacks
export const Position = defineComponent('Position', { x: 'f32', y: 'f32', z: 'f32' });
export const Rotation = defineComponent('Rotation', { x: 'f32', y: 'f32', z: 'f32', w: 'f32' });
export const Scale = defineComponent('Scale', { x: 'f32', y: 'f32', z: 'f32' });
// Motion
export const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32', z: 'f32' });
export const Acceleration = defineComponent('Acceleration', { x: 'f32', y: 'f32', z: 'f32' });
// State
export const Health = defineComponent('Health', { current: 'f32', max: 'f32' });
export const Team = defineComponent('Team', { id: 'u32' });
// Tags (zero-data components that act as boolean flags)
// Use an empty schema object for tag components
export const Player = defineComponent('Player', {});
export const Enemy = defineComponent('Enemy', {});
export const Dead = defineComponent('Dead', {});
export const Invincible = defineComponent('Invincible', {});
export const Grounded = defineComponent('Grounded', {});Component Bundles
Bundles are arrays of component values passed to world.spawn(). Define named bundle factories for reuse:
// Bundle factories (functions that return component arrays)
export function playerBundle(x: number, y: number) {
return [
[Position, { x, y, z: 0 }],
[Velocity, { x: 0, y: 0, z: 0 }],
[Health, { current: 100, max: 100 }],
[Player, {}],
[Sprite, { texture: 'player.png', width: 32, height: 32 }],
[Collider, { shape: 'box', width: 28, height: 30 }],
[Team, { id: 0 }],
] as const;
}
export function enemyBundle(x: number, y: number, type: EnemyType) {
return [
[Position, { x, y, z: 0 }],
[Velocity, { x: 0, y: 0, z: 0 }],
[Health, { current: ENEMY_CONFIGS[type].health, max: ENEMY_CONFIGS[type].health }],
[Enemy, {}],
[EnemyData, { type, damage: ENEMY_CONFIGS[type].damage }],
[Sprite, { texture: ENEMY_CONFIGS[type].texture, width: 32, height: 32 }],
[Collider, { shape: 'box', width: 28, height: 30 }],
[Team, { id: 1 }],
] as const;
}
// Spawn using the bundle
const player = world.spawn(...playerBundle(100, 200));
const grunt = world.spawn(...enemyBundle(400, 300, 'grunt'));Common Game Object Patterns
Player Entity
// Components that make up a player
const player = world.spawn(
[Position, { x: 100, y: 100, z: 0 }],
[Velocity, { x: 0, y: 0, z: 0 }],
[Health, { current: 100, max: 100 }],
[Mana, { current: 50, max: 50, regenRate: 5 }],
[Stamina, { current: 100, max: 100, regenRate: 20 }],
[Player, {}],
[Sprite, { texture: 'hero.png', frameWidth: 48, frameHeight: 48 }],
[Animator, { clip: 'idle', speed: 1 }],
[Collider, { shape: 'capsule', radius: 14, height: 40 }],
[Inventory, { slots: 20, items: [] }],
[PlayerStats, { level: 1, exp: 0, strength: 10, dexterity: 10 }],
[Camera2DTarget, {}] // tag: camera follows this entity
);Projectile Entity
function spawnBullet(world: World, ownerEntity: Entity, direction: Vec2, damage: number) {
const ownerPos = world.get(ownerEntity, Position);
const cmd = world.commands();
cmd.spawnBundle([
[Position, { x: ownerPos.x, y: ownerPos.y, z: 0 }],
[Velocity, { x: direction.x * 600, y: direction.y * 600, z: 0 }],
[Bullet, { damage, owner: ownerEntity, lifetime: 3.0 }],
[Sprite, { texture: 'bullet.png', width: 8, height: 8 }],
[Collider, { shape: 'circle', radius: 4, isTrigger: true }],
]);
}
// System: expire bullets by lifetime
function BulletLifetimeSystem(world: World): void {
const time = world.getResource(CurrentTime); // import CurrentTime from '@web-engine-dev/ecs'
const cmd = world.commands();
const query = world.query().with(Bullet).build();
for (const result of world.run(query)) {
const [bullet] = result.components;
const entity = result.entity;
const next = { ...bullet, lifetime: bullet.lifetime - time.delta };
if (next.lifetime <= 0) {
cmd.despawn(entity);
} else {
world.insert(entity, Bullet, next);
}
}
}Item & Pickup
const healthPickup = world.spawn(
[Position, { x: 250, y: 180, z: 0 }],
[Sprite, { texture: 'health-potion.png', width: 16, height: 16 }],
[Collider, { shape: 'circle', radius: 8, isTrigger: true }],
[Pickup, { type: 'health', value: 50 }],
[Bob, { amplitude: 4, frequency: 2, phase: 0 }] // visual bobbing
);
// System: handle pickup collection
function PickupSystem(world: World): void {
const collisions = world.eventReader(TriggerEnterEvent);
const cmd = world.commands();
for (const { a, b } of collisions.read()) {
const [playerEntity, pickupEntity] = identifyPair(world, a, b, Player, Pickup);
if (!playerEntity) continue;
const pickup = world.get(pickupEntity, Pickup);
const health = world.get(playerEntity, Health);
if (pickup.type === 'health') {
world.insert(playerEntity, Health, {
...health,
current: Math.min(health.current + pickup.value, health.max),
});
}
cmd.despawn(pickupEntity);
world.eventWriter(PickupCollectedEvent).send({ player: playerEntity, pickup });
}
}Area Trigger / Zone
// A zone that fires an event when the player enters
const goalZone = world.spawn(
[Position, { x: 600, y: 100, z: 0 }],
[Zone, { id: 'goal', type: 'level-end' }],
[Collider, { shape: 'box', width: 64, height: 64, isTrigger: true }]
);Platform (Static Collider)
function spawnPlatform(world: World, x: number, y: number, w: number, h: number) {
return world.spawn(
[Position, { x, y, z: 0 }],
[Platform, {}],
[StaticBody, {}],
[Collider, { shape: 'box', width: w, height: h }],
[TiledSprite, { texture: 'platform.png', width: w, height: h }]
);
}Component Query Patterns
Use the fluent query builder to filter entities. Queries must be built first, then iterated with world.run():
// Build the query once (or inline)
const aliveEnemiesQuery = world.query().with(Position, Enemy).without(Dead).build();
// Run the query each frame
for (const result of world.run(aliveEnemiesQuery)) {
const [pos, enemy] = result.components;
const entity = result.entity;
// ...
}
// Convenience: directly iterate entities with specific components
for (const entity of world.entitiesWith(Player)) {
const pos = world.get(entity, Position);
console.log('Player at', pos.x, pos.y);
}Query Result Destructuring
result.components matches the order of .with(A, B). The first element is A, the second is B.
Hierarchies & Parent/Child Relationships
Use the @web-engine-dev/hierarchy package for parent/child transforms:
import { createHierarchy } from '@web-engine-dev/hierarchy';
// Create the hierarchy manager (once, typically stored in a resource)
const hierarchy = createHierarchy();
// Spawn a weapon
const sword = world.spawn(
[Position, { x: 20, y: 5, z: 0 }], // offset from parent
[Sprite, { texture: 'sword.png', width: 16, height: 32 }],
[WeaponData, { damage: 30, range: 40 }]
);
// Parent the sword to the player
hierarchy.setParent(sword, playerEntity);
// The hierarchy system propagates world transforms automatically
// sword's world position = player's world position + (20, 5)Archetype Storage: Performance Notes
The ECS groups entities with the same set of components into archetypes for cache-friendly iteration. Keep these tips in mind:
- Avoid frequent add/remove of components, it triggers archetype migration. Use data (
Deadflag vsDeadtag) for state that changes frequently. - Use tag components wisely,
Player,Enemy,Deadare ZST (zero-size) and don't waste memory. - Bundle similar entities, entities that share the same component set iterate faster.
// BAD: toggling a component every frame triggers archetype migration
function UpdateInvincibility(world: World): void {
for (const entity of world.entitiesWith(Player)) {
if (isInvincible)
world.insert(entity, Invincible, {}); // archetype migration!
else world.remove(entity, Invincible); // archetype migration!
}
}
// GOOD: use a data field instead for frequently-changing state
const Invincibility = defineComponent('Invincibility', { active: 'u8', timer: 'f32' });Next Steps
- Scenes & Prefabs, save entity configurations as reusable templates
- Physics, add Collider components and handle collision events
- Scripting, attach gameplay logic to specific entities