Skip to content

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

  1. Small and focused, one concern per component
  2. No methods, components hold data only
  3. Composable, build complex objects by combining simple components
  4. Tag components, zero-data components that classify entities
typescript
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:

typescript
// 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

typescript
// 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

typescript
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

typescript
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

typescript
// 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)

typescript
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():

typescript
// 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:

typescript
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 (Dead flag vs Dead tag) for state that changes frequently.
  • Use tag components wisely, Player, Enemy, Dead are ZST (zero-size) and don't waste memory.
  • Bundle similar entities, entities that share the same component set iterate faster.
typescript
// 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

Proprietary software. All rights reserved.