Part 7 of this challenge got a 2D camera working that smoothly tracks the player. Everything looks great until Scarfy walks too far in either direction. At that point it's obvious that the ground was totally faked. Here's the offending code:

auto boundingRect = actor->getBoundingBox();
auto distToGround = groundYPos - (boundingRect.y + boundingRect.height);
bool isOnGround =  distToGround <= 0;

if(actor->isAffectedByGravity()) {
	if(isOnGround) {
		actor->velocity.y = 0;
		actor->position.y += distToGround;
	} else {
		actor->velocity.y += gravity;
	}
}
actor->update(isOnGround);

I strategically made groundYPos match where Scarfy is initially standing. It fooled a few people, but not for long...

Today, we're going to enable Scarfy to jump and run all over the actual terrain.

How to Make a Game Character Walk on Terrain

There are two basic steps:

  1. Check if the character is touching the ground below
  2. If the character is touching the ground, then stop the character from falling through

Step 1 is the easier of the two. The world is made of tiles, so we can put a bounding box around Scarfy, and then:

  • Build a list of which tiles the bounding box touches
  • For each tile with a collision shape, check if the character touches/collides with the collision shape
  • Determine which collision shape the character hit first (if any)

Step 2 is the actual physics simulation,where objects collide and bounce/stop. This is where things get more complicated because simply setting the vertical velocity to zero isn't enough. Game physics is simulated in time steps, so the character may have partially fallen through the ground. Hence, the character's position may need to be moved back so it's on top of the ground.

But, we're not done. Okay, we would be done if there were only two objects involved (Scarfy, and the ground). However, real worlds have more objects, and one object bounce or motion change could cause another collision. That collision must also be processed, which may in turn trigger another... It may take multiple iterations through the physics solver before everything is done.

Using Box2D As Physics Engine

I originally planned to write my own collision and physics code, but then decided that I'd rather use a ready made one: Box2D. I know, I know, some people would puke at that idea. They'd tell you it's overkill and that you don't want realistic physics for a platform game. However, others have already successfully used Box2D for platformer physics, and it works well. Box2D is something I'd like to try, and this is a good opportunity.

Using Box2D is relatively straightforward (although it did take quite some code and refactoring). Physics bodies are added to Box2D's world for each game object. Here's the code for Scarfy's physics object(s):

void CharacterActor::createPhysicsBody(b2World &world, float worldScale, 
		const b2Vec2 &position, const b2Vec2 &velocity) {
	raylib::Rectangle boundingBox = getBoundingBox(worldScale);
	
	b2BodyDef bodyDef;
	bodyDef.type = b2_dynamicBody;
	bodyDef.fixedRotation = true;
	bodyDef.position = position;
	bodyDef.linearVelocity = velocity;
	
	float halfWidth = boundingBox.width * widthScale / 2.0f;
	float halfHeight = boundingBox.height / 2.0f;
	
	// Need to tweak the friction depending on the situation
	frictionAdjuster.setPreSolveFunc([this](PhysicsObject *thisObject, PhysicsObject *otherObject,
	b2Contact *contact, const b2Manifold *oldManifold) {
		// Increase the friction so that the character can stand still on slopes, but still 
		// walk them
		float otherFriction = friction;
		thisObject->getOurFixture(contact)->SetFriction(friction);
		b2Fixture *otherFixture = thisObject->getOtherFixture(contact);
		if(otherFixture) { otherFriction = otherFixture->GetFriction(); }
		float adjustedFriction = sqrtf(friction * otherFriction);
		contact->SetFriction(adjustedFriction);
	});
	
	// Approximating our character with a rectangle and a circle for feet.
	// The circle avoids the character catching at the vertices at tile edges.
	// See the following for a description of the problem: https://www.iforce2d.net/b2dtut/ghost-vertices
	physicsBody = world.CreateBody(&bodyDef);
	
	float torsoHeight = boundingBox.height - halfWidth;
	float torsoHalfHeight = torsoHeight / 2.0f;
	
	b2PolygonShape torsoShape;
	b2Vec2 boxCentre(0.0f, -boundingBox.height + torsoHalfHeight);
	torsoShape.SetAsBox(halfWidth, torsoHalfHeight, boxCentre, 0.0f);
	b2FixtureDef torsoDef;
	torsoDef.shape = &torsoShape;
	torsoDef.density = 1.0f;
	physicsBody->CreateFixture(&torsoDef);
	
	b2CircleShape legsShape;
	legsShape.m_radius = halfWidth;
	legsShape.m_p.y = -halfWidth;
	b2FixtureDef legsDef;
	legsDef.shape = &legsShape;
	legsDef.density = 1.0f;
	frictionAdjuster.attachToFixture(legsDef); 
	physicsBody->CreateFixture(&legsDef);
	
	// Adding a ground sensor, for reliable "am I on the ground" detection
	groundSensor.setContactHandler([this](PhysicsObject *thisObject, PhysicsObject *otherObject,
			b2Contact *contact, bool contactBegin) {
		groundContactCount += contactBegin ? 1 : -1;
		isOnGround = (groundContactCount != 0);
	});
	
	// The foot sensor should stick out by a few pixels so slight floating-point inaccuracies don't trigger
	// repeated on/off the ground events when the character is actually walking
	float footSensorOffset = 3.0f / worldScale; 
	b2CircleShape footSensor;
	footSensor.m_radius = halfWidth;
	footSensor.m_p.y = legsShape.m_p.y + footSensorOffset;
	b2FixtureDef footSensorDef;
	footSensorDef.shape = &footSensor;
	footSensorDef.isSensor = true;
	groundSensor.attachToFixture(footSensorDef);
	physicsBody->CreateFixture(&footSensorDef);
}

For the terrain, I created one static body per tile:

void TileMap2D::generatePhysicsObjects(b2World &physicsWorld, float worldScale) {
	// Create vertices variable here to avoid repeated construction/destruction overhead (for every tile)
	std::vector<b2Vec2> vertices;
	const float defaultFriction = 1.0f;
	
	auto &layers = tileMap->getLayers();
	auto tileSizeTson = tileMap->getTileSize();
	b2Vec2 tileSize_2(tileSizeTson.x / (2.0f * worldScale), tileSizeTson.y / (2.0f * worldScale));
	if(groundLayerIdx < layers.size()) {
		auto &layer = layers[groundLayerIdx];
		
		auto layerOffsetTson = layer.getOffset();
		b2Vec2 layerOffset(layerOffsetTson.x / worldScale, layerOffsetTson.y / worldScale);
		
		
		for (auto& [pos, tileObject] : layer.getTileObjects()) {
			tson::Tile* tile = tileObject.getTile();
			auto tilePos = tileObject.getPosition();
			b2Vec2 tileOffset = b2Vec2(tilePos.x / worldScale, tilePos.y / worldScale) + layerOffset;
			
			auto &objectGroup = tile->getObjectgroup();
			auto collisionShapes = objectGroup.getObjects();
			
			for(auto &shape : collisionShapes) {
				auto &shapePos = shape.getPosition();
				b2Vec2 shapeOffset = b2Vec2((float)shapePos.x / worldScale, 
					(float)shapePos.y / worldScale) + tileOffset;
				
				b2BodyDef bodyDef;
				bodyDef.type = b2_staticBody;
				bodyDef.fixedRotation = true;
				bodyDef.position = shapeOffset + tileSize_2;
				b2Body *tileBody = physicsWorld.CreateBody(&bodyDef);
				b2Fixture *tileFixture = NULL;
				
				float tileFriction = defaultFriction;
				
				switch(shape.getObjectType()) {
				case tson::ObjectType::Polygon:
				case tson::ObjectType::Polyline: // NOTE: Treating a polyline as a polygon for collision
				{
					vertices.clear();
					auto &polygon = shape.getPolygons();
					if(polygon.size() >= 3) {
						for(auto &currPoint : polygon) {
							b2Vec2 currPos = b2Vec2((float)currPoint.x / worldScale, 
								(float)currPoint.y / worldScale) - tileSize_2;
							vertices.push_back(currPos);
						}
						b2ChainShape shape;
						shape.CreateLoop(vertices.data(), (int32)vertices.size());
						tileFixture = tileBody->CreateFixture(&shape, 0.0f);
					}
					break;
				}
				case tson::ObjectType::Rectangle:
				{
					auto &rectSize = shape.getSize();
					raylib::Rectangle rect((float)shapeOffset.x, (float)shapeOffset.y, 
						(float)rectSize.x / worldScale, (float)rectSize.y / worldScale);
					b2PolygonShape shape;
					shape.SetAsBox((float)rectSize.x / (2.0f * worldScale), 
						(float)rectSize.y / (2.0f * worldScale));
					tileFixture = tileBody->CreateFixture(&shape, 0.0f);
					break;
				}
				default:
					TraceLog(LOG_ERROR, "Error: Unrecognized collision shape object type: %u", shape.getObjectType());
				}
				
				if(tileFixture) {
					tileFixture->SetFriction(tileFriction);
				}
			}
		}
	}
}

Performance and Bug Warning!

There are two problems with the tile-map physics code above. First, one static body per tile uses extra memory and processing power. That's fine for a small world like Scarfy's, especially when run on powerful machines. However, at a certain scale your game will slow down.

The other problem is that a rectangular object may get stuck (or catch) at tile boundaries on what should be a smooth surface. That's because floating-point numbers aren't 100% precise, resulting in slight misalignments between objects.

The best solution would be to merge collision shapes that are next to each other into larger objects. I worked around it instead by using a circle as physics object for Scarfy's feet. Circles slide smoothly over the tile boundaries instead of catching.

Hacks for Platformer Physics with a Physics Engines

Remember how some people said "you don't want realistic physics for platform games?" That's true. You want the game to be responsive to user control, like the platformers we're all used to. Here are some tricks I pulled:

  • User controls adjust Scarfy's velocity directly instead of applying forces, resulting in instant response to user control
  • Scarfy's physics body has a ground sensor whose job it is to reliably detect when he's on the ground. It's a circle that sticks out a few pixels below his feet. Sensors don't interact physically with the rest of the world beyond detecting contact with other bodies. Without the sensor, the user's controls would randomly fail due to Scarfy slightly bouncing up and down (e.g., you cannot walk if you're not touching the ground)
  • Scarfy's coefficient of friction is adjusted based on what he's doing. The friction is high when standing so that he can stand still on slopes, and the friction is low when moving for smooth speed

Thanks to Ben Hopkins for the last two items above.

Useful Resources

Here are some useful links for 2D collision detection and platform game physics:

Links for using Box2D in a platform game:

What's Next?

Click here for Part 9.

Download the Code

Click here to download the source code.