Skip to content

Build Knights vs Monsters

This tutorial builds a very small online game called Knights vs Monsters. Every player controls a knight. Everybody can see the same monster. Knights can walk near it and attack it. The server decides whether an attack counts and how much HP the monster has left.

It is deliberately tiny. Tiny is good: you can understand it, change it, break it, and fix it without needing a committee meeting with a dragon.

By the end, you will have:

  • a Citadel server running a Lua game script;
  • a monster definition owned by the game script, not by a browser tab;
  • a small three-message protocol: move, attack, and state update;
  • a client path for Unreal C++, Unreal Blueprint, Unity C#, Godot GDScript, and Web JavaScript with Three.js;
  • a clear next step for saving player-owned data safely.

There are two jobs in an online game:

your game client Citadel + Lua game script
--------------- --------------------------
draw knight --> remember the real positions
ask to move --> check the request is sensible
ask to attack --> check range, reduce monster HP
draw received state <-- announce the approved result

Your client is allowed to ask. The server is the referee that says yes, no, or occasionally nice try. This is called server authority. It is the first habit that keeps an online game fair and debuggable.

Download and extract a Citadel server release as described in Getting started. In the extracted folder, Citadel already gives you this layout:

your-game/
citadel.toml
scripts/
main.lua

Citadel loads scripts/main.lua as the Lua game layer. The standalone release configuration already points there and enables hot reload. Its relevant runtime section looks like this:

[runtime]
enabled = true
language = "lua"
tier = "trusted" # your own server-side game code
scripts_dir = "./scripts"
tick_hz = 10
hot_reload = true # save main.lua; Citadel reloads a valid edit
hot_reload_poll_ms = 500
[transport.websocket]
enabled = true
bind = "127.0.0.1:7352"

Why WebSocket? It is the easiest first transport to inspect in a browser. You can move to QUIC later when your first knight has stopped walking into walls.

Start the server from the extracted release folder:

Terminal window
# Windows PowerShell
.\citadel.exe
Terminal window
# Linux
./citadel

You should see a line that says Citadel loaded an embedded Lua runtime. Keep this terminal open. It is now your game server, not a decorative terminal plant.

Games are easier when you write down what crosses the network. Citadel reserves kinds 1 through 25 for its built-in protocol, so our game uses values above 100.

Kind Name Direction Body Why it exists
100 MOVE client → server "x,z" A knight asks to move.
101 ATTACK client → server monster id, e.g. "moss-ogre" A knight asks to swing.
102 STATE server → clients a small text record Everyone redraws the approved world.

For a learning project, human-readable text records are wonderfully boring in a good way: you can log them and understand them. A production game will normally move to a compact binary schema after the rules are stable.

The STATE records below have these shapes:

knight,<player-id>,<x>,<z>
monster,<monster-id>,<x>,<z>,<hp>,<max-hp>,<alive>

Put this complete first version in scripts/main.lua.

-- Knights vs Monsters: a tiny authoritative server.
-- Kinds 1..25 are Citadel-reserved. Game kinds begin at 100.
local KIND_MOVE = 100
local KIND_ATTACK = 101
local KIND_STATE = 102
local ARENA_LIMIT = 20.0
local ATTACK_RANGE = 3.0
local RESPAWN_SECONDS = 5.0
-- A Lua "class" is a table plus a metatable. We keep behaviour in Lua and
-- store/transmit plain data, never the metatable itself.
local Character = {}
Character.__index = Character
function Character:new(fields)
return setmetatable(fields or {}, self)
end
local Monster = setmetatable({}, { __index = Character })
Monster.__index = Monster
function Monster:new(fields)
return Character.new(self, fields)
end
-- This is global game content. It belongs in Lua/Git so a code review can see
-- it. It is not a player-owned storage object.
local moss_ogre = Monster:new({
id = "moss-ogre", name = "Moss Ogre", x = 0.0, z = 0.0,
hp = 80, max_hp = 80, alive = true, respawn_in = 0.0,
})
local knights = {} -- participant id -> { x, z }
local function clamp(value, low, high)
return math.max(low, math.min(high, value))
end
local function distance(ax, az, bx, bz)
local dx, dz = bx - ax, bz - az
return math.sqrt(dx * dx + dz * dz)
end
local function knight_state(id, knight)
return string.format("knight,%d,%.2f,%.2f", id, knight.x, knight.z)
end
local function monster_state
return string.format("monster,%s,%.2f,%.2f,%d,%d,%d",
moss_ogre.id, moss_ogre.x, moss_ogre.z, moss_ogre.hp,
moss_ogre.max_hp, moss_ogre.alive and 1 or 0)
end
local function tell_everyone_the_monster_state
citadel.broadcast(KIND_STATE, monster_state, true)
end
citadel.on_join(function(ctx)
knights[ctx.sender] = { x = -8.0, z = 0.0 }
citadel.broadcast(KIND_STATE, knight_state(ctx.sender, knights[ctx.sender]), true)
citadel.send(ctx.sender, KIND_STATE, monster_state, true)
citadel.log("A knight joined. Helmets are optional; server authority is not.", "info")
end)
citadel.on_leave(function(ctx)
knights[ctx.sender] = nil
end)
citadel.on_message(KIND_MOVE, function(ctx, body)
local knight = knights[ctx.sender]
if not knight then return end
local x_text, z_text = string.match(body, "^([^,]+),([^,]+)$")
local x, z = tonumber(x_text), tonumber(z_text)
if not x or not z then return end -- malformed request: ignore it safely
knight.x = clamp(x, -ARENA_LIMIT, ARENA_LIMIT)
knight.z = clamp(z, -ARENA_LIMIT, ARENA_LIMIT)
citadel.broadcast(KIND_STATE, knight_state(ctx.sender, knight), false)
end)
citadel.on_message(KIND_ATTACK, function(ctx, body)
local knight = knights[ctx.sender]
if not knight or body ~= moss_ogre.id or not moss_ogre.alive then return end
if distance(knight.x, knight.z, moss_ogre.x, moss_ogre.z) > ATTACK_RANGE then
citadel.send(ctx.sender, KIND_STATE, "notice,Too far away. Your sword is not Wi-Fi.", true)
return
end
moss_ogre.hp = math.max(0, moss_ogre.hp - 10)
if moss_ogre.hp == 0 then
moss_ogre.alive = false
moss_ogre.respawn_in = RESPAWN_SECONDS
end
tell_everyone_the_monster_state
end)
citadel.on_tick(function(dt)
if moss_ogre.alive then return end
moss_ogre.respawn_in = moss_ogre.respawn_in - dt
if moss_ogre.respawn_in <= 0 then
moss_ogre.hp = moss_ogre.max_hp
moss_ogre.alive = true
tell_everyone_the_monster_state
end
end)
  1. knights and moss_ogre live on the server. A browser refresh cannot award itself a legendary sword by changing one JavaScript variable.
  2. A move is clamped to the arena. The client can ask for (999999, 0); the server calmly says “nope” and uses (20, 0).
  3. The server checks attack range before it subtracts HP.
  4. Every accepted change is broadcast as STATE. Clients draw what the server said happened.

Save the file. With hot reload enabled, Citadel accepts a valid edit without a restart. If your edit has a syntax error, it keeps the previous working script. That is less dramatic than a server crash, which is exactly the point.

Choose one client path below. All paths share the exact three game kinds and the same text bodies from Step 2.

  1. Follow the Unreal plugin guide to bundle and copy the Citadel plugin into your project.
  2. Create a C++ UActorComponent named UKnightsClientComponent and add it to your player pawn.
  3. In BeginPlay, get UCitadelClientSubsystem, connect, then send the empty guest handshake (KIND_AUTH = 5) before game messages.
// KnightsClientComponent.cpp (important parts)
#include "CitadelClientSubsystem.h"
#include "CitadelWire.h"
static constexpr uint16 KIND_MOVE = 100;
static constexpr uint16 KIND_ATTACK = 101;
static constexpr uint16 KIND_STATE = 102;
void UKnightsClientComponent::BeginPlay
{
Super::BeginPlay;
Client = GetWorld->GetGameInstance->GetSubsystem<UCitadelClientSubsystem>;
if (Client->ConnectWebSocket(TEXT("ws://127.0.0.1:7352/")) != ECitadelStatus::Ok)
{
UE_LOG(LogTemp, Error, TEXT("Citadel: %s"), *Client->GetLastError);
return;
}
Client->Send(CitadelWire::KIND_AUTH, {}, true); // explicit guest handshake
}
void UKnightsClientComponent::SendMove(FVector2D Position)
{
const FString Text = FString::Printf(TEXT("%.2f,%.2f"), Position.X, Position.Y);
FTCHARToUTF8 Utf8(*Text);
TArray<uint8> Bytes;
Bytes.Append(reinterpret_cast<const uint8*>(Utf8.Get), Utf8.Length);
Client->Send(KIND_MOVE, Bytes, false);
}
void UKnightsClientComponent::AttackMossOgre
{
const char* Target = "moss-ogre";
TArray<uint8> Bytes;
Bytes.Append(reinterpret_cast<const uint8*>(Target), FCStringAnsi::Strlen(Target));
Client->Send(KIND_ATTACK, Bytes, true);
}
void UKnightsClientComponent::TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction)
{
uint16 Kind = 0;
TArray<uint8> Payload;
while (Client && Client->Poll(Kind, Payload) == ECitadelStatus::Ok)
{
if (Kind == KIND_STATE)
{
Payload.Add(0); // make a temporary NUL-terminated UTF-8 string
const FString State = UTF8_TO_TCHAR(reinterpret_cast<const char*>(Payload.GetData));
ApplyStateLine(State); // split `knight,...`, `monster,...`, or `notice,...`
}
}
}

ApplyStateLine is your presentation code: move a knight mesh, update an HP bar, or show a notice. It must not decide damage. The Lua script already did that work.

Step 5: test it like a player, not like a hopeful poet

Section titled “Step 5: test it like a player, not like a hopeful poet”

Use two clients of the same engine or mix engines that have a live transport. Check these four things in order:

  1. Both clients receive a monster STATE after joining.
  2. A movement request produces a knight STATE for both clients.
  3. Attacking from far away produces notice,Too far away... and does not reduce HP.
  4. Attacking near the ogre reduces HP by 10 for every connected client; at zero it sleeps for five seconds, then respawns with full HP.

If step 3 fails, celebrate briefly: you found a server-authority bug before a player did. Then fix the Lua rule, not the client animation.

Step 6: make the game feel better (without changing who is in charge)

Section titled “Step 6: make the game feel better (without changing who is in charge)”

Your next upgrades can stay small and safe:

  • Add a second monster by creating another Monster:new({...}) definition.
  • Broadcast a notice when a monster dies and display it in the HUD.
  • Add a cooldown per knight in Lua so holding the attack key does not become an industrial-grade sword factory.
  • Replace the CSV-like text records with a typed binary or JSON protocol once you are tired of counting commas.
  • Draw interpolation on clients so remote movement looks smooth; do not invent damage client-side to make an animation feel faster.

Optional: save a player-owned monster unlock

Section titled “Optional: save a player-owned monster unlock”

The moss ogre definition above is global game content, so it stays in Lua and Git. A player’s unlock, pet, or quest progress is different: it belongs to that player and should survive a restart.

For an authenticated player (not a guest), Lua can write one JSON object with a version. "" means create this only if it does not exist.

-- Call this only when ctx.user_id exists (the player authenticated).
local saved = citadel.storage_write(
ctx.user_id,
"knights_progress",
"moss-ogre-unlock",
'{"schema_version":1,"monster":"moss-ogre","unlocked":true}',
"", -- create-only; prevents an accidental overwrite
1, -- owner may read
0 -- only trusted game logic may write
)
-- For a later edit: read first, then pass current.version to make it a CAS.
local current = citadel.storage_read(ctx.user_id, "knights_progress", "moss-ogre-unlock")
local updated = citadel.storage_write(
ctx.user_id,
"knights_progress",
"moss-ogre-unlock",
'{"schema_version":1,"monster":"moss-ogre","unlocked":true,"victories":2}',
current.version,
1,
0
)

Do not invent an account called "system" for global monster definitions. The storage core has a system owner, but the shipped script API is deliberately user-owned today. Keep static game content in your game files until an explicit system-storage host API exists.

You now have the important shape of an online action game:

input → request → server validates → server changes state → everyone renders it

That shape scales much better than “every client edits its own truth and hopes for the best.” Next, read the Lua runtime reference for every host function, or add rooms when your knights need separate arenas.