Making a 3D Game in Castle
Hello Castle community! This tutorial was accompanied by a live stream. Watch the video here.
We’re doing a whole set of tutorial streams as part of Castle Halloween Party. See the full stream schedule here.
This tutorial will cover the basics of making a 3D game in Castle. When we’re done we’ll have a maze game that looks like this:
You can try the game out here.
Castle games use a library called LÖVE 2D. As the name implies, LÖVE is focused on 2D games so there aren’t many built in tools for 3D games. We can still build 3D games but we have to do a little more work to get started! This tutorial will give you the tools to get started on your own 3D game in Castle.
The source code has branches corresponding to the end of each step in the tutorial. This project is too large to cover every line of code in the tutorial so I recommend looking at the diffs between branches on Github. I’ll add a link to a relevant code at the end of each section.
Rendering in 3D
Making a 3D game requires much more boilerplate code than a 2D game. In addition to everything needed for 2D games you also need to store meshes, set up shaders, and do matrix math. Castle and LÖVE don’t provide any of this out of the box so I recommend using an additional library and modifying it as necessary. For this tutorial I started with this library and tweaked it a bit to fit my use case.
You can see the initial commit where I imported this library here. This step is mainly importing the library and making sure we can draw something simple to the screen. I added a helper function to draw a rectangle given 4 vertices:
function rectColor(coords, color, scale)
local model = Engine.newModel({ coords[1], coords[2], coords[4], coords[2], coords[3], coords[4] }, nil, nil, color, {
{"VertexPosition", "float", 3},
}, scale)
table.insert(Scene.modelList, model)
return model
end
and then call that in love.load()
:
rectColor({
{-1, -1, 1},
{-1, 1, 1},
{1, 1, 1},
{1, -1, 1}
}, {1,0,0}, 1.0)
which gives us this:
Adding Lighting
One of the big differences between 2D and 3D rendering is lighting. A lot of 2D games don’t need to consider lighting at all but most 3D games would feel flat and lifeless without lighting. For this tutorial we’re going to implement the Phong lighting model. You can take a look at this guide if you want more details about the math.
The first thing we need to do is add normal vectors to our rectangles. If a rectangle is facing towards our light source it will be more lit up than if it is facing away from our light source, and we need normals to figure that out.
For this tutorial I manually added normal vector for each vertex. Here’s how I changed my call to rectColor
.
-- the 4th and 5th number in each row represent the texture UV coordinates, which are unused for nowrectColor({
{-1, -1, 1, 0,0, 0,0,1},
{-1, 1, 1, 0,0, 0,0,1},
{1, 1, 1, 0,0, 0,0,1},
{1, -1, 1, 0,0, 0,0,1}
}, {1,0,0}, 1.0)
In engine.newModel
you can see that the last 3 numbers represent the normal:
format = {
{"VertexPosition", "float", 3},
{"VertexTexCoord", "float", 2},
{"VertexNormal", "float", 3},
}
Now that we have normals, we just need to implement the Phong lighting model in our shaders. Take a look at the diff for this step to see how the shaders changed.
Adding Textures
Now we actually get to start designing the game! I decided to make a spooky maze game so I found a wall texture, a ground texture, and a skybox texture. The engine I imported when I started the project already did a good job supporting textures, so I just had to modify my rectColor
function to take a texture instead of a color and then pass UV coordinates along with my vertex positions and normal.
wallImage = love.graphics.newImage("assets/wall.png")-- 1-3 are position, 4-5 are UV coordinates, 6-8 are the normal
rect({
{-1, -1, 1, 0,1, 0,0,1},
{-1, 1, 1, 0,0, 0,0,1},
{1, 1, 1, 1,0, 0,0,1},
{1, -1, 1, 1,1, 0,0,1}
}, wallImage, 1.0)
After I added the skybox I decided that the skybox looked wrong with Phong lighting so I modified my fragment shader so that the skybox would only be lit with ambient light. I did this by setting the skybox normals to {0, 0, 0}
and then only using Phong lighting in the fragment shader if the length of the normal is greater than 0
.
You can see all the changes for this step here.
Creating a Map
Now that we can render blocks and make them look good, it’s time to actually make this a game! There are many different ways you could handle map data. For this example I decided to use a string where each character represents a tile. Here’s an example of that:
-- x is a wall, s is start, e is endMap = [[
e
xxxxxxxx xxx
xx x x
xx xx xxx x
xxxxxx xxx
x xxx x
x xxxx x x
x x x
xx xxxxxxxxx
s
]]
Once I decided on that format I added this code to parse the string:
local z = 0for line in Map:gmatch("[^\r\n]+") do
for x = 0, string.len(line) do
local char = string.sub(line, x, x) if char == 'x' then
box(x, z)
elseif char == 's' then
Engine.camera.pos.x = x + 0.5
Engine.camera.pos.z = z + 0.5
end -- no end yet
end
z = z + 1
end
I also changed my box
function to take x and z coordinates. I used a setTransform
function provided by the 3D engine to move the boxes around.
local m1 = rect({
{0, 0, 1, 0,1, 0,0,1},
{0, 1, 1, 0,0, 0,0,1},
{1, 1, 1, 1,0, 0,0,1},
{1, 0, 1, 1,1, 0,0,1}
}, wallImage, 1.0)... similar code for the other sideslocal models = {m1, m2, m3, m4}for k,v in pairs(models) do
v:setTransform({x, 0, z})
end
Now we have a full map! See the code for this step.
Making it a Real Game
At this point you can move around the map and explore the maze, but you can also go straight through walls and there’s no way to actually win. We’ll start off by preventing you from walking through walls.
Since everything is laid out in a grid we don’t have to do any complicated collision detection. Here’s the code for checking if pos
is in the (x, z)
cell:
function isInSquare(pos, x, z)
-- this gives us some buffer so that the camera doesn't go partially into a wall
local d = 0.1 local camX = pos.x
local camZ = pos.z return camX >= x - d and camX <= x + 1 + d and camZ >= z - d and camZ <= z + 1 + d
end
Now that we have that function, we just need to keep track of which cells have walls and then check all of those cells before we move. I made a new table and added table.insert(Blocks, {x, z})
to the box
function, then added some an extra check before moving the camera:
local canMove = truefor k,v in pairs(Blocks) do
if isInSquare(newPos, v[1], v[2]) then
canMove = false
end
end
Now that you can’t move through walls, all that’s left is to make the game winnable. I added another isInSquare
call at the end of love.update()
which tests if you are in the ending square:
if isInSquare(Engine.camera.pos, EndPosition[1], EndPosition[2]) then
WonGame = true
end
If WonGame
is true then we show a text overlay and let you restart the game by pressing space.
Here is the diff for this step.
Future Work
You could extend this game by adding visual effects (fog? ghosts? rain?) or by adding multiple levels. You could even add a level editor using the Post API!
I hope you enjoyed this tutorial and are able to make something fun. Reach out to me on Castle chat or Discord if you have any questions.