Moat Part 2 — Making a Multiplayer Dungeon Crawler

Revillo
6 min readMar 3, 2019

This post covers creating a basic multiplayer adventure game using the Moat framework for Castle. The source code is available here and if you’ve downloaded the castle app, you can play it here. I’ll discuss code from a high level so you should read the source for the specifics.

Hopefully you’ve read the last post and know how to get set up moving players around in a shared world. Now, let’s add some enemies and objects so that we can give players some good old PVE dungeon action.

First, in serverInitWorld, I create a maze out of a grid of rooms and each room has one or more “doors” into adjacent rooms. Check out lib/maze_gen.lua for the algorithm to make a maze if you’re interested.

function DGame:serverInitWorld()
serverCreateMaze(10, 10);
end

The serverCreateMaze function calls the mazegen algorithm and adds walls and enemies to the scene. Here’s an example function to add a wall:

function addWall(x, y, w, h, isDoor)
if (isDoor) then return end;

DGame:spawn(GameEntities.Wall,
x, y, w, h
);
end

And here’s some code that adds a monster to a room.

--Crop the bounds by 1 unit to ignore walls
local roomArea = {
x = x + 1.0,
y = y + 1.0,
w = roomSize - 2.0,
h = roomSize - 2.0
}

-- Spawn a monster enemy and provide room area info
DGame:spawn(GameEntities.Monster, centerX, centerY, 1.0, 1.0, {
health = 5,
searchArea = roomArea
});

In the last parameter to spawn, we can add any extra metadata like health and a searchArea — this corresponds with the visibility region of the monster entity where it will search for nearby players to attack. In the addHazards function, you can see more examples of adding different enemy types to the room.

The worldUpdate function is called on both the client and the server to update all the non-player entities in the scene once per clock tick. A typical implementation calls the update function for each type:

--Update non-player entitiess
function DGame:worldUpdate(dt)
-- Get the tick (time index) for the current frame
local tick = DGame:getTick();
DGame:eachEntityOfType(GameEntities.Spinner, updateSpinner, tick);
DGame:eachEntityOfType(GameEntities.Monster, updateMonster, tick);
DGame:eachEntityOfType(GameEntities.Eye, updateEye, tick);
DGame:eachEntityOfType(GameEntities.EyeBullet, updateEyeBullet);
DGame:eachEntityOfType(GameEntities.Chest, updateChest, tick);
DGame:eachEntityOfType(GameEntities.IceBullet, updateIceBullet, tick);

end

Here’s the updateMonster implementation

function updateMonster(monster, tick)

if (monster.freezeCounter and monster.freezeCounter > 0) then
monster.freezeCounter = monster.freezeCounter - 1;
return;
end

local closestPlayer = findNearestPlayer(monster, monster.searchArea);

local oldX, oldY = monster.x, monster.y;

if (closestPlayer) then
local dx = closestPlayer.x - monster.x;
local dy = closestPlayer.y - monster.y;
dx, dy = DGame.Utils.normalize(dx, dy);
local x = monster.x + dx * GameConstants.MonsterSpeed;
local y = monster.y + dy * GameConstants.MonsterSpeed;
DGame:moveEntity(monster, x, y);
end

DGame:eachOverlapping(monster, function(entity)

if (entity.type == GameEntities.Wall) then
monster.x, monster.y = oldX, oldY;
DGame:moveEntity(monter);
end

end);

end

I’ll get into the freezeCounter later, this is to allow the player to freeze monsters with a ice spell. The rest of the function calls findNearestPlayer, determines the direction the monster should move in to move towards the player. If the monster hits a wall, it moves back to its previous position. The findNearestPlayer function calls eachOverlapping again but this time with a search area, any table with x, y, w, and h defined, keeping of the player with the smallest distance.

You can’t tell but he’s chasing me.
function findNearestPlayer(entity, searchArea)
local closestPlayer = nil;
local closestPlayerDistance = 100;

DGame:eachOverlapping(searchArea, function(foundEntity)

if (foundEntity.type == GameEntities.Player) then
local distance = DGame.Utils.distance(entity, foundEntity);
if (distance < closestPlayerDistance) then
closestPlayer = foundEntity;
closestPlayerDistance = distance;
end
end

end);

return closestPlayer;
end

One of the nice things about using moat is that we can write the code just like we’d write for a single player game — however behind the scenes there is a fair amount of magic going on, including update calls being called multiple times on a frame during a “rewind.” A rewind occurs when the client syncs all its entities with their counterparts on the server and reapplies frames to catch up to the local tick. (This can make playing sound files problematic because they can get called for previous frames and play too many at once. A playSound function is provided to help throttle sound effects called in update functions.) Importantly, this also means that the client can make predictions about an entity state that diverge from the server. So, the monster on the server may chase player A while on the client, it will decide to chase playerB given that the client is always a little bit ahead of the server and doesn’t know other player’s states perfectly.

One easy solution to this is to have an entity whose state is purely a function of time, making it easy for the client to perfectly predict its state. Here I have a spinning wheel type enemy (think the ghost wheels from Mario), that moves in a circle over time.

function updateSpinner(spinner, tick)

local t = (tick * GameConstants.TickInterval) * spinner.spinDir;

local x, y = math.sin(t + spinner.angle) * spinner.radius + spinner.centerX, math.cos(t + spinner.angle) * spinner.radius + spinner.centerY;

DGame:moveEntity(spinner, x, y);

end
The circling crows of death!

We can trust that the client always sees the correct state of such an entity. Regardless of the state of the world or time differences between client and server.

Another common solution is to have monsters make a decision ahead of time about what course of action it will take in the near future. We could change our monster’s update function to look something more like this:

-- Decide every 2 seconds (120 ticks) 
if (tick % 120 == 0) then

monster.nextMove = {
start = tick + 30,
end = tick + 90,
startX = monster.x, startY = monster.y,
endX = monster.x + dx, startY = monster.y + dy
}
end-- Do the interpolation
local nextMove = monster.nextMove;
if (nextMove and tick > nextMove.start and tick < nextMove.end) then
local t = (tick - nextMove.start) / (nextMove.end - nextMove.start)
local nx = Moat.Utils.lerp(nextMove.startX, nextMove.endX, t);
local ny = Moat.Utils.lerp(nextMove.startY, nextMove.endY, t);
DGame:moveEntity(monster, nx, ny);
end

Most PVE games use something like the above so that server entities can broadcast their intentions before committing to them, allowing all clients to get on the same page.

There are other NPC types, including a eye that shoots orbs and a chest that spawns treasure for the player to collect. For the treasure chest, since it spawns gold randomly, it can’t be predicted at all by the client, and the client should simply wait for the server to send the gold entities over. So, we’ll put a guard against running this code on the client:

function updateChest(chest, tick)
--Client shouldn't update the chest
if (DGame.isClient) then
return
end

if (tick % 1000 == 0) then

local goldCount = 0;
DGame:eachOverlapping(chest.searchArea, function (entity)
if (entity.type == GameEntities.Gold) then
goldCount = goldCount + 1;
end
end);

if (goldCount < 4) then

local x = chest.searchArea.x + (chest.searchArea.w-1) * math.random();
local y = chest.searchArea.y + (chest.searchArea.h-1) * math.random();

DGame:spawn(GameEntities.Gold, x, y, 1, 1);
end

end
end
Ah, the riddling hippogriff

Next let’s let our player fire back at enemies. Back in our playerUpdate function’s input handler. We’ll add the following lines

--Handle shooting ice
if (input.mx) then
local dx, dy = DGame.Utils.normalize(input.mx, input.my);
DGame:spawn(GameEntities.IceBullet, player.x, player.y, 1, 1, {
dx = dx,
dy = dy
});
end
My aim is way off here

When spawn is called on the client, behind the scenes a temporary entity is spawned that will last until the server catches up to the client in time. At this point, the temporary entity will be removed, and either replaced by a server counterpart or dropped altogether depending on whether the server also decided the spawn was valid.

In updateIceBullet, the bullet will despawn an afflict an entity with a freezeCounter that lasts for 120 ticks.

if (hitEntity.type == GameEntities.Monster or hitEntity.type == GameEntities.Eye) then      hitEntity.freezeCounter = 120;
hitOnce = true;
DGame:despawn(bullet);
end
Ice to see you!

The last thing I’d like to point out is the rendering library used here called Sprite in lib/sprite.lua. You can use this library for pixel-art style games. Sprite makes it easy to set a camera, clip the world to the client visibility range, convert between pixels and world coordinates, and draw tiled rectangles.

-- Draws an entity with a given image
Sprite.drawEntity(iceBulletEntity, Sprite.images.ice);

--

--

Revillo
0 Followers

Programmer and Game Developer