Tutorial 93: Building a First-Person 3D Game
What you’ll learn
- Combining mouse-look and WASD into an FPS controller.
- Raycasting for hit detection against world geometry.
- A simple enemy state machine, and a minimap drawn to a render target.
Before you start — Tutorial 34: 3D Camera Setup and Control (the camera this builds on), Tutorial 44: Bounding Volumes and Spatial Queries (ray/volume intersection) and Tutorial 23: Render Targets for Off-Screen Rendering (the minimap). Requires a 3D-capable renderer such as OPENGLES3 or VULKAN; the 2D-only renderer (SDL_RENDERER) throws on 3D calls by default, and STUB reports no 3D capability at all. The minimap also needs a renderer with render-target support, which every 3D renderer has.
FPS camera (mouse look + WASD)
class FPSCamera {
public:
Vector3 Position;
float Yaw = 0.0f; // horizontal rotation (radians)
float Pitch = 0.0f; // vertical rotation (radians, clamped)
void Update(GameTime& gt) {
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
// Mouse look
auto ms = Mouse::GetState();
int cx = 400, cy = 300; // screen center
float dx = (float)(ms.getXProperty() - cx) * 0.002f;
float dy = (float)(ms.getYProperty() - cy) * 0.002f;
Yaw += dx;
Pitch = std::clamp(Pitch + dy, -MathHelper::PiOver2 + 0.01f,
MathHelper::PiOver2 - 0.01f);
Mouse::SetPosition(cx, cy);
// WASD movement
Vector3 forward = Forward();
Vector3 right = Vector3::Cross(forward, Vector3::Up);
right.Normalize();
auto ks = Keyboard::GetState();
float speed = 5.0f * dt;
if (ks.IsKeyDown(Keys::W)) Position += forward * speed;
if (ks.IsKeyDown(Keys::S)) Position -= forward * speed;
if (ks.IsKeyDown(Keys::A)) Position -= right * speed;
if (ks.IsKeyDown(Keys::D)) Position += right * speed;
}
// Same convention as Tutorial 34: yaw 0 looks down -Z (XNA's "forward"),
// positive yaw turns right, and a positive pitch (mouse moved down) looks down.
Vector3 Forward() const {
return Vector3(
std::cos(Pitch) * std::sin(Yaw),
-std::sin(Pitch),
-std::cos(Pitch) * std::cos(Yaw));
}
Matrix ViewMatrix() const {
return Matrix::CreateLookAt(Position, Position + Forward(), Vector3::Up);
}
};
A purity note: the compound operators Vector3::operator+=, -= and *= used above and below are CNAEXT conveniences, deprecated under CNA_STRICT_XNA_API; the XNA-pure spelling is Position = Position + forward * speed;. The mouse-look here re-centres the cursor every frame, as in Tutorial 34; the CNAEXT Mouse::setIsRelativeMouseModeEXTProperty(true) is the alternative.
Raycasting for hit detection
struct HitResult { bool hit; Vector3 point; float distance; };
HitResult Raycast(const Vector3& origin, const Vector3& dir,
const std::vector<BoundingBox>& boxes) {
HitResult best { false, {}, std::numeric_limits<float>::max() };
const Ray ray(origin, dir); // dir must be normalised
for (const BoundingBox& box : boxes) {
// BoundingBox::Intersects(Ray) returns the distance along the ray,
// or an empty std::optional when the ray misses.
std::optional<float> t = box.Intersects(ray);
if (t && *t < best.distance) {
best = { true, origin + dir * *t, *t };
}
}
return best;
}
Do not name the distances near and far: on Windows those are macros from the platform headers and will break the build.
Enemy AI (simple state machine)
enum class EnemyState { Idle, Chase, Attack, Dead };
class Enemy {
public:
Vector3 Position;
EnemyState State = EnemyState::Idle;
float Health = 100.0f;
float AlertRadius = 10.0f;
void Update(GameTime& gt, const Vector3& playerPos) {
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
float dist = Vector3::Distance(Position, playerPos);
switch (State) {
case EnemyState::Idle:
if (dist < AlertRadius) State = EnemyState::Chase;
break;
case EnemyState::Chase: {
Vector3 dir = playerPos - Position;
dir.Normalize();
Position += dir * 3.0f * dt;
if (dist < 1.5f) State = EnemyState::Attack;
if (dist > AlertRadius * 2.0f) State = EnemyState::Idle;
break;
}
case EnemyState::Attack:
attackTimer_ -= dt;
if (attackTimer_ <= 0.0f) {
// Deal damage to player here
attackTimer_ = 1.0f;
}
if (dist > 2.0f) State = EnemyState::Chase;
break;
case EnemyState::Dead:
break;
}
}
void TakeDamage(float dmg) {
Health -= dmg;
if (Health <= 0.0f) State = EnemyState::Dead;
}
private:
float attackTimer_ = 1.0f;
};
Minimap on RenderTarget2D
// Create minimap render target (128 x 128 is a power of two and well inside the
// default Reach profile's 2048 texture-size limit; Color is a Reach-legal format)
minimap_ = std::make_unique<RenderTarget2D>(
getGraphicsDeviceProperty(), 128, 128,
false, SurfaceFormat::Color, DepthFormat::None);
// Call this at the START of Draw(), before the main scene: switching render targets
// is not guaranteed to preserve the back buffer's contents.
void DrawMinimap(GraphicsDevice& gd, SpriteBatch& sb) {
// Render to minimap RT (SetRenderTarget takes a RenderTarget2D*)
gd.SetRenderTarget(minimap_.get());
gd.Clear(Color(20, 20, 20, 255));
sb.Begin();
// Draw rooms, corridors, enemies as colored dots
for (auto& e : enemies_) {
if (e.State == EnemyState::Dead) continue;
Vector2 mp = WorldToMinimap(e.Position);
sb.Draw(*pixel_, Rectangle((int)mp.X-2, (int)mp.Y-2, 4, 4), Color::Red);
}
Vector2 pp = WorldToMinimap(camera_.Position);
sb.Draw(*pixel_, Rectangle((int)pp.X-3, (int)pp.Y-3, 6, 6), Color::Yellow);
sb.End();
gd.SetRenderTarget(nullptr);
}
// Later in Draw(), after the 3D scene: put the minimap on the HUD (top-right corner)
sb.Begin();
sb.Draw(*minimap_, Vector2(800-138, 10), Color::White); // a RenderTarget2D is a Texture2D
sb.End();
// SpriteBatch does not restore the blend, depth, sampler or rasterizer state it changes,
// so set them back before the next frame's 3D pass (see Tutorial 31).
FPS camera class + raycasting hit test
The complete program below puts the pieces together: mouse-look and WASD, red cube enemies that chase you, a left-click that casts a ray from the eye and removes the nearest enemy it hits, and the minimap drawn to a RenderTarget2D and shown in the corner. It stays inside the default Reach profile: no 32-bit indices, no float render targets, no multiple render targets.
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Graphics/BasicEffect.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexBuffer.hpp"
#include "Microsoft/Xna/Framework/Graphics/VertexPositionColor.hpp"
#include "Microsoft/Xna/Framework/Graphics/RenderTarget2D.hpp"
#include "Microsoft/Xna/Framework/Graphics/BlendState.hpp"
#include "Microsoft/Xna/Framework/Graphics/DepthStencilState.hpp"
#include "Microsoft/Xna/Framework/Graphics/RasterizerState.hpp"
#include "Microsoft/Xna/Framework/Graphics/SamplerState.hpp"
#include "Microsoft/Xna/Framework/Input/ButtonState.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Mouse.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
#include "Microsoft/Xna/Framework/BoundingBox.hpp"
#include "Microsoft/Xna/Framework/Ray.hpp"
#include <vector>
#include <memory>
#include <optional>
#include <cmath>
#include <algorithm>
#include <limits>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
using namespace Microsoft::Xna::Framework::Input;
class FPSGame final : public Game {
public:
FPSGame() : graphics_(this) {
graphics_.setPreferredBackBufferWidthProperty(800);
graphics_.setPreferredBackBufferHeightProperty(600);
}
protected:
void LoadContent() override {
auto& gd = getGraphicsDeviceProperty();
effect_ = std::make_unique<BasicEffect>(gd);
effect_->VertexColorEnabled = true;
sb_ = std::make_unique<SpriteBatch>(gd);
pixel_ = std::make_unique<Texture2D>(gd, 1, 1);
Color w = Color::White;
pixel_->SetData(&w, 1);
minimap_ = std::make_unique<RenderTarget2D>(
gd, 128, 128, false, SurfaceFormat::Color, DepthFormat::None);
// Floor: a strip of two triangles, clockwise as seen from above
VertexPositionColor floor[] = {
{ Vector3(-10,0,-10), Color(80,80,80) },
{ Vector3( 10,0,-10), Color(80,80,80) },
{ Vector3(-10,0, 10), Color(80,80,80) },
{ Vector3( 10,0, 10), Color(80,80,80) },
};
floorVB_ = std::make_unique<VertexBuffer>(
gd, VertexPositionColor::getVertexDeclarationStatic(), 4, BufferUsage::WriteOnly);
floorVB_->SetData(floor, 4);
BuildEnemyCube();
camPos_ = Vector3(0, 1.7f, 0);
enemies_ = { { Vector3(3, 0.5f, -5) }, { Vector3(-4, 0.5f, -7) }, { Vector3(0, 0.5f, -9) } };
setIsMouseVisibleProperty(false);
}
void Update(GameTime& gt) override {
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
auto ms = Mouse::GetState();
float dx = (ms.getXProperty() - 400) * 0.002f;
float dy = (ms.getYProperty() - 300) * 0.002f;
yaw_ += dx;
pitch_ = std::clamp(pitch_ + dy, -1.5f, 1.5f);
Mouse::SetPosition(400, 300);
Vector3 fwd = Forward();
Vector3 right = Vector3::Cross(fwd, Vector3::Up);
right.Normalize();
auto ks = Keyboard::GetState();
float sp = 5.0f * dt;
if (ks.IsKeyDown(Keys::W)) camPos_ += fwd * sp;
if (ks.IsKeyDown(Keys::S)) camPos_ -= fwd * sp;
if (ks.IsKeyDown(Keys::A)) camPos_ -= right * sp;
if (ks.IsKeyDown(Keys::D)) camPos_ += right * sp;
if (ks.IsKeyDown(Keys::Escape)) Exit();
// Fire on the frame the left button goes down: ray from the eye along the view direction
bool down = ms.getLeftButtonProperty() == ButtonState::Pressed;
if (down && !wasDown_) Fire(fwd);
wasDown_ = down;
for (auto& e : enemies_) e.Update(gt, camPos_);
}
void Draw(const GameTime&) override {
auto& gd = getGraphicsDeviceProperty();
// Off-screen pass first: switching render targets is not guaranteed to keep the
// back buffer's contents, so do it before the main scene is drawn.
DrawMinimap(gd);
gd.Clear(Color(30, 30, 50));
// SpriteBatch (used by the minimap and the HUD) leaves its own blend, depth,
// sampler and rasterizer state behind: restore the 3D state every frame.
gd.setBlendStateProperty(BlendState::Opaque);
gd.setDepthStencilStateProperty(DepthStencilState::Default);
gd.setRasterizerStateProperty(RasterizerState::CullCounterClockwise);
gd.getSamplerStatesProperty()[0] = SamplerState::LinearClamp;
Matrix view = Matrix::CreateLookAt(camPos_, camPos_ + Forward(), Vector3::Up);
Matrix proj = Matrix::CreatePerspectiveFieldOfView(
MathHelper::ToRadians(75.0f), 800.0f/600.0f, 0.1f, 100.0f);
effect_->setViewProperty(view);
effect_->setProjectionProperty(proj);
// Floor
effect_->setWorldProperty(Matrix::getIdentityProperty());
gd.SetVertexBuffer(floorVB_.get());
for (auto& p : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
p.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleStrip, 0, 2);
}
// Enemies: one shared red cube, moved with a world matrix
gd.SetVertexBuffer(cubeVB_.get());
for (auto& e : enemies_) {
if (!e.alive) continue;
effect_->setWorldProperty(Matrix::CreateTranslation(e.pos));
for (auto& p : effect_->getCurrentTechniqueProperty()->getPassesProperty()) {
p.Apply();
gd.DrawPrimitives(PrimitiveType::TriangleList, 0, 12);
}
}
// HUD: minimap in the top-right corner and a crosshair
sb_->Begin();
sb_->Draw(*minimap_, Vector2(800 - 138, 10), Color::White);
sb_->Draw(*pixel_, Rectangle(400 - 2, 300 - 2, 4, 4), Color::White);
sb_->End();
// No gd.Present(): Game presents in EndDraw, after Draw() returns.
}
private:
struct SimpleEnemy {
Vector3 pos;
bool alive = true;
BoundingBox Bounds() const {
return BoundingBox(pos - Vector3(0.5f, 0.5f, 0.5f), pos + Vector3(0.5f, 0.5f, 0.5f));
}
void Update(GameTime& gt, const Vector3& pp) {
if (!alive) return;
float dt = static_cast<float>(gt.getElapsedGameTimeProperty().getTotalSecondsProperty());
Vector3 d = pp - pos; d.Y = 0;
float len = std::sqrt(d.X*d.X+d.Z*d.Z);
if (len > 0) { d = d/len; pos += d*1.5f*dt; }
}
};
// Same convention as Tutorial 34: yaw 0 looks down -Z, positive yaw turns right.
Vector3 Forward() const {
return Vector3(std::cos(pitch_)*std::sin(yaw_),
-std::sin(pitch_),
-std::cos(pitch_)*std::cos(yaw_));
}
void Fire(const Vector3& dir) {
// BoundingBox::Intersects(Ray) returns the distance along the ray, or an empty optional.
const Ray ray(camPos_, dir);
SimpleEnemy* nearest = nullptr;
float nearestT = std::numeric_limits<float>::max();
for (auto& e : enemies_) {
if (!e.alive) continue;
std::optional<float> t = e.Bounds().Intersects(ray);
if (t && *t < nearestT) { nearestT = *t; nearest = &e; }
}
if (nearest) nearest->alive = false; // a one-shot kill
}
Vector2 WorldToMinimap(const Vector3& p) const {
// The world spans -10..10 on X and Z; the minimap is 128 x 128 pixels
return Vector2((p.X + 10.0f) / 20.0f * 128.0f, (p.Z + 10.0f) / 20.0f * 128.0f);
}
void DrawMinimap(GraphicsDevice& gd) {
gd.SetRenderTarget(minimap_.get());
gd.Clear(Color(20, 20, 20, 255));
sb_->Begin();
for (auto& e : enemies_) {
if (!e.alive) continue;
Vector2 mp = WorldToMinimap(e.pos);
sb_->Draw(*pixel_, Rectangle((int)mp.X - 2, (int)mp.Y - 2, 4, 4), Color::Red);
}
Vector2 pp = WorldToMinimap(camPos_);
sb_->Draw(*pixel_, Rectangle((int)pp.X - 3, (int)pp.Y - 3, 6, 6), Color::Yellow);
sb_->End();
gd.SetRenderTarget(nullptr);
}
void BuildEnemyCube() {
const float h = 0.5f;
const Vector3 c[8] = {
{-h,-h,-h},{+h,-h,-h},{+h,+h,-h},{-h,+h,-h},
{-h,-h,+h},{+h,-h,+h},{+h,+h,+h},{-h,+h,+h}
};
// 12 triangles, each wound clockwise as seen from outside (front-facing under
// the default CullCounterClockwise state)
const int faces[36] = {
0,1,2, 0,2,3, 4,6,5, 4,7,6, 0,3,7, 0,7,4,
1,5,6, 1,6,2, 3,2,6, 3,6,7, 0,4,5, 0,5,1
};
std::vector<VertexPositionColor> verts;
for (int i : faces) verts.push_back({ c[i], Color::Red });
cubeVB_ = std::make_unique<VertexBuffer>(
getGraphicsDeviceProperty(), VertexPositionColor::getVertexDeclarationStatic(),
static_cast<int>(verts.size()), BufferUsage::WriteOnly);
cubeVB_->SetData(verts.data(), static_cast<int>(verts.size()));
}
GraphicsDeviceManager graphics_;
std::unique_ptr<BasicEffect> effect_;
std::unique_ptr<SpriteBatch> sb_;
std::unique_ptr<Texture2D> pixel_;
std::unique_ptr<RenderTarget2D> minimap_;
std::unique_ptr<VertexBuffer> floorVB_, cubeVB_;
std::vector<SimpleEnemy> enemies_;
Vector3 camPos_;
float yaw_ = 0, pitch_ = 0;
bool wasDown_ = false;
};
int main() { FPSGame game; game.Run(); }