In the previous topic we saw some settings that can be used to prevent two shapes from colliding. They just pass through each other as if they couldn't see each other at all, even though we can see on the screen that they are overlapping. While this might be the physical behaviour we want, it comes with a drawback: since the shapes don't collide, they never give us any BeginContact/EndContact information!

A shape can be made into a 'sensor' by setting the isSensor property of the shape definition to true when you create it, or by calling b2Shape_SetSensor() on the shape after it has been created if you need to change it during the simulation. Sensors behave as if their maskBits is set to zero - they never collide with anything. But they do generate sensor events to let us know when they start or stop overlapping another shape.

All other features of sensor shapes remain the same as for a normal shape. They can be added to any type of body. They still have a mass and will affect the overall mass of the body they are attached to. Remember you can have more than one shape on a body so you can have a mix of solid shapes and sensors, allowing for all kinds of neat things.

Common uses for sensors include:

  • Detect entities entering or leaving a certain area
  • Switches to trigger an event
  • Detect ground under player character
  • A field of vision for an entity

Example usage

Let's make a demonstration for the last example above, once again with the top-down battlefield scenario where we use a sensor to represent what an entity can see. Along with sensors, we'll throw in a bit of each of the last few topics - user data, collision filtering and callbacks.

In this demonstration, our scene will have one friendly ship and some enemy aircraft. The ship will be equipped with a circular radar to detect the aircraft when they come within range. We will also have a friendly radar tower in a fixed position, with a similar but rotating radar to detect the enemy aircraft. The collision bits will be set as you might expect: all entities at surface level collide with each other, but the aircraft only collide with the boundary fence.

When one of the friendly entities detects an enemy aircraft, we will change it's color so we can see that the code is working correctly. We will also go one step further and keep track of which enemies each friendly can see, so that we can have efficient access to an up-to-date list of enemies within range for each friendly - this is obviously a useful thing to have for things like AI and other game logic.

Here are the entity category constants for collision filtering:

const BOUNDARY = 0x0001;
const FRIENDLY_SHIP = 0x0002;
const ENEMY_SHIP = 0x0004;
const FRIENDLY_AIRCRAFT = 0x0008;
const ENEMY_AIRCRAFT = 0x0010;
const FRIENDLY_TOWER = 0x0020;
const RADAR_SENSOR = 0x0040;

Setting up the entities:

// Create world without gravity
const worldDef = b2DefaultWorldDef();
worldDef.gravity = new b2Vec2(0, 0);
const world = b2CreateWorld(worldDef);

// Create a friendly ship
const shipDef = b2DefaultBodyDef();
shipDef.type = DYNAMIC;
const ship = b2CreateBody(world, shipDef);

const shipShape = new b2Circle(new b2Vec2(0,0), 3.0);
const shipShapeDef = b2DefaultShapeDef();
shipShapeDef.filter.categoryBits = FRIENDLY_SHIP;
shipShapeDef.filter.maskBits = BOUNDARY | FRIENDLY_TOWER;
b2CreateCircleShape(ship, shipShapeDef, shipShape);

// Create some enemy aircraft
for (let i = 0; i < 3; i++) {
  const aircraftDef = b2DefaultBodyDef();
  aircraftDef.type = DYNAMIC;
  const aircraft = b2CreateBody(world, aircraftDef);

  const aircraftShape = new b2Circle(new b2Vec2(0,0), 1.0);
  const aircraftShapeDef = b2DefaultShapeDef();
  aircraftShapeDef.filter.categoryBits = ENEMY_AIRCRAFT;
  aircraftShapeDef.filter.maskBits = BOUNDARY | RADAR_SENSOR;
  b2CreateCircleShape(aircraft, aircraftShapeDef, aircraftShape);
}

// Create the tower
const towerDef = b2DefaultBodyDef();
towerDef.type = KINEMATIC;
const tower = b2CreateBody(world, towerDef);

const towerShape = new b2Circle(new b2Vec2(0,0), 1.0);
const towerShapeDef = b2DefaultShapeDef();
towerShapeDef.filter.categoryBits = FRIENDLY_TOWER;
towerShapeDef.filter.maskBits = FRIENDLY_SHIP;
b2CreateCircleShape(tower, towerShapeDef, towerShape);

green and red circles overlapping and colliding

Adding a radar sensor to the ship:

const radarShape = new b2Circle(new b2Vec2(0,0), 8.0);
const radarShapeDef = b2DefaultShapeDef();
radarShapeDef.isSensor = true;
radarShapeDef.filter.categoryBits = RADAR_SENSOR;
radarShapeDef.filter.maskBits = ENEMY_AIRCRAFT;
b2CreateCircleShape(ship, radarShapeDef, radarShape);

a larger circle showing the sensor area around the green circle ship

Adding a rotating semicircle radar to the tower:

const radius = 8.0;
const vertices = [new b2Vec2(0, 0)];

for (let i = 0; i < 7; i++) {
  const angle = (i / 6.0) * 90 * Math.PI / 180;
  vertices.push(new b2Vec2(
    radius * Math.cos(angle),
    radius * Math.sin(angle)
  ));
}

const sensorHull = b2ComputeHull(vertices, vertices.length);
const sensorShape = b2MakePolygon(sensorHull, 0);

const sensorShapeDef = b2DefaultShapeDef();
sensorShapeDef.isSensor = true;
sensorShapeDef.filter.categoryBits = RADAR_SENSOR;
sensorShapeDef.filter.maskBits = ENEMY_AIRCRAFT;
b2CreatePolygonShape(tower, sensorShapeDef, sensorShape);

// Make tower rotate at 45 degrees per second
b2Body_SetAngularVelocity(tower, 45 * Math.PI / 180);

a blue arc showing the sensor area from the green circle tower

Now all that's left to do is the collision callback implementation. Remember we want to store a list of the enemy aircraft that each friendly entity can currently see. For that obviously we will need to have a list to store these in, and it would also be good to have functions to make changes to the list when Box2D lets us know something has changed.

class Entity {
  constructor(bodyId) {
    this.bodyId = bodyId;
    this.visibleEnemies = new Set();
  }

  radarAcquiredEnemy(enemy) {
    this.visibleEnemies.add(enemy);
  }

  radarLostEnemy(enemy) {
    this.visibleEnemies.delete(enemy);
  }

  render(ctx) {
    if (this.visibleEnemies.size > 0) {
      ctx.fillStyle = 'yellow';
    } else {
      ctx.fillStyle = this.color;
    }
    // Drawing code...
  }
}

Now set up a collision callback to tell the friendly entities when their radar sensor begins or ends contact with an enemy aircraft.

function handleSensorEvents(world) {
  const sensorEvents = b2World_GetSensorEvents(world);

  for (const event of sensorEvents.beginEvents) {
    if (b2Shape_GetFilter(event.sensorShapeId).categoryBits === RADAR_SENSOR) {
      const sensorEntity = b2Shape_GetUserData(event.sensorShapeId);
      const otherEntity = b2Shape_GetUserData(event.otherShapeId);
      sensorEntity.radarAcquiredEnemy(otherEntity);
    }
  }

  for (const event of sensorEvents.endEvents) {
    if (b2Shape_GetFilter(event.sensorShapeId).categoryBits === RADAR_SENSOR) {
      const sensorEntity = b2Shape_GetUserData(event.sensorShapeId);
      const otherEntity = b2Shape_GetUserData(event.otherShapeId);
      sensorEntity.radarLostEnemy(otherEntity);
    }
  }
}

a yellow ship sees a red enemy, the tower is yellow and sees another red enemy

As one last little exercise, remember how we wanted to have an up-to-date list of all visible enemies for each friendly entity that we could call efficiently? Well now we have one, so let's take it for a spin. We can draw a line between each radar and the entities in view.

function renderEntity(ctx, entity) {
  const pos = b2Body_GetPosition(entity.bodyId);

  ctx.strokeStyle = 'white';
  ctx.setLineDash([5, 5]);
  ctx.beginPath();

  for (const enemy of entity.visibleEnemies) {
    const enemyPos = b2Body_GetPosition(enemy.bodyId);
    ctx.moveTo(pos.x, pos.y);
    ctx.lineTo(enemyPos.x, enemyPos.y);
  }

  ctx.stroke();
  ctx.setLineDash([]);
}

all enemy vehicles are spotted, dashed lines connect each radar with visible targets

Credits

This tutorial is adapted from an original piece of work created by Chris Campbell and is used under license.