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.