Wednesday, April 15, 2026
Your First Spaceship Multiplayer Portal
Posted by
What We're Building
A WebSocket game server that already has the right multiplayer shape:
- built-in handshake, join-game, and leave-game flow
- spawned player entities in a shared world
- explicit spawn and remove packets for entity lifetime
- sync patches for position and facing changes
- session-backed player state through
IGameSessionService
Everything is still copy-pasteable. No database required yet.
1. Create the Project
dotnet new install Altruist.Templates
dotnet new altruist -n SpaceGame
cd SpaceGame
Or with the CLI:
dotnet tool install -g Altruist.CLI
altruist create SpaceGame
cd SpaceGame
2. See What You Get
The template creates a working server out of the box:
SpaceGame/
Program.cs
config.yml
SpaceGame.csproj
Portals/
HelloPortal.cs
Http/
HealthController.cs
Run it immediately:
dotnet run
Then replace the sample portal with our first real multiplayer slice.
3. Update config.yml
Use a game-specific WebSocket path and keep an in-memory cache:
altruist:
server:
http:
host: "0.0.0.0"
port: 8080
path: "/"
transport:
codec:
provider: json
websocket:
enabled: true
path: /game
persistence:
cache:
provider: inmemory
Delete the generated Portals/HelloPortal.cs and keep the HTTP health controller.
4. Know What Is Already Built In
For session-oriented game flow, Altruist already gives you:
HandshakeRequestPacketwithmessageCode: 7JoinGamePacketwithmessageCode: 9LeaveGamePacketwithmessageCode: 10
Those are handled by AltruistGameSessionPortal.
That means we only need gameplay packets for:
- chat
- client input state
- first-time entity spawn
- entity removal
The important split is:
- the client sends what buttons are pressed
- the server simulates movement
- the resulting movement becomes visible through sync patches
5. Define Gameplay Packets
Create Packets/GamePackets.cs:
using System.Text.Json.Serialization;
using Altruist;
namespace SpaceGame.Packets;
public struct ChatPacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("sender")] public string Sender { get; set; }
[JsonPropertyName("text")] public string Text { get; set; }
public ChatPacket()
{
MessageCode = 1000;
Sender = "";
Text = "";
}
}
public struct PlayerInputPacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("left")] public bool Left { get; set; }
[JsonPropertyName("right")] public bool Right { get; set; }
[JsonPropertyName("up")] public bool Up { get; set; }
[JsonPropertyName("down")] public bool Down { get; set; }
[JsonPropertyName("sprint")] public bool Sprint { get; set; }
public PlayerInputPacket()
{
MessageCode = 1001;
Left = false;
Right = false;
Up = false;
Down = false;
Sprint = false;
}
}
public struct PlayerSpawnPacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("entityId")] public uint EntityId { get; set; }
[JsonPropertyName("name")] public string Name { get; set; }
[JsonPropertyName("position")] public float[] Position { get; set; }
[JsonPropertyName("facingYaw")] public float FacingYaw { get; set; }
public PlayerSpawnPacket()
{
MessageCode = 1002;
EntityId = 0;
Name = "";
Position = new float[2];
FacingYaw = 0;
}
}
public struct EntityRemovePacket : IPacketBase
{
[JsonPropertyName("messageCode")] public uint MessageCode { get; set; }
[JsonPropertyName("entityId")] public uint EntityId { get; set; }
public EntityRemovePacket()
{
MessageCode = 1003;
EntityId = 0;
}
}
6. Add a Tiny Entity ID Allocator
The built-in sync packet does not inject your game's stable entity ID for you. If the client needs to map patches to local objects, expose one on the synced surface.
Create World/EntityIdAllocator.cs:
using System.Threading;
using Altruist;
namespace SpaceGame.World;
[Service]
public sealed class EntityIdAllocator
{
private int _next = 1000;
public uint Next() => (uint)Interlocked.Increment(ref _next);
}
7. Define a Synced Player Prefab
Create World/PlayerPrefab.cs:
using System.Numerics;
using Altruist.Gaming;
using Altruist.Gaming.ThreeD;
using Altruist.Networking;
using Altruist.ThreeD.Numerics;
using SpaceGame.Packets;
namespace SpaceGame.World;
[Synchronized(20, SyncUnit.Hz)]
[WorldObject("player")]
public sealed class PlayerPrefab : WorldObjectPrefab3D, ISynchronizedEntity
{
public string Name { get; set; } = "";
public bool InputLeft { get; private set; }
public bool InputRight { get; private set; }
public bool InputUp { get; private set; }
public bool InputDown { get; private set; }
public bool InputSprint { get; private set; }
[Synced(0, SyncAlways: true)]
public uint EntityId => VirtualId;
[Synced(1)]
public float PosX => Transform.Position.X;
[Synced(2)]
public float PosY => Transform.Position.Z;
[Synced(3)]
public float FacingYaw { get; set; }
public PlayerPrefab(uint entityId, string name, Transform3D transform)
{
VirtualId = entityId;
Name = name;
Transform = transform;
}
public void SetInput(PlayerInputPacket packet)
{
InputLeft = packet.Left;
InputRight = packet.Right;
InputUp = packet.Up;
InputDown = packet.Down;
InputSprint = packet.Sprint;
}
public override void Step(float dt, IGameWorldManager3D world)
{
float x = 0f;
float z = 0f;
if (InputLeft) x -= 1f;
if (InputRight) x += 1f;
if (InputUp) z += 1f;
if (InputDown) z -= 1f;
var input = new Vector3(x, 0f, z);
if (input.LengthSquared() < 0.0001f)
return;
input = Vector3.Normalize(input);
float speed = InputSprint ? 220f : 140f;
var current = Transform.Position.ToVector3();
var next = current + (input * speed * dt);
Transform = Transform.WithPosition(
Position3D.From(new Vector3(next.X, current.Y, next.Z)));
FacingYaw = MathF.Atan2(input.X, input.Z);
world.UpdateObjectPosition(this).GetAwaiter().GetResult();
}
}
The important parts:
[Synchronized]enables sync processingISynchronizedEntitygives the runtime aClientIdEntityIdis markedSyncAlwaysso each patch can be mapped on the client- position and facing now flow as sync fields instead of custom movement output packets
- movement lives in the prefab's
Step(...), so the world tick drives simulation directly
8. Create the Portal
Create Portals/GamePortal.cs:
using System.Numerics;
using Altruist;
using Altruist.Gaming;
using Altruist.Gaming.ThreeD;
using Microsoft.Extensions.Logging;
using SpaceGame.Packets;
using SpaceGame.World;
namespace SpaceGame.Portals;
[Portal("/game")]
public sealed class GamePortal : AltruistGameSessionPortal
{
private readonly IGameWorldOrganizer3D _worlds;
private readonly EntityIdAllocator _entityIds;
private readonly ILogger<GamePortal> _logger;
public GamePortal(
IGameSessionService gameSessionService,
IAltruistRouter router,
IGameWorldOrganizer3D worlds,
EntityIdAllocator entityIds,
ILoggerFactory loggerFactory)
: base(gameSessionService, router)
{
_worlds = worlds;
_entityIds = entityIds;
_logger = loggerFactory.CreateLogger<GamePortal>();
}
protected override async Task<IResultPacket> OnJoinGameReceived(
JoinGamePacket message,
string clientId,
IResultPacket result)
{
if (result is FailedPacket)
return result;
var spawn = message.Position ?? new[] { 0f, 0f };
float x = spawn.Length > 0 ? spawn[0] : 0f;
float y = spawn.Length > 1 ? spawn[1] : 0f;
var session = _gameSessionService.CreateSession(
clientId,
DateTime.UtcNow.AddHours(2));
var world = _worlds.GetWorld(0);
var player = new PlayerPrefab(
_entityIds.Next(),
message.Name,
Transform3D.From(new Vector3(x, 0f, y)))
{
ClientId = clientId,
};
var existingPlayers = _gameSessionService
.FindAllContexts<PlayerPrefab>()
.Where(p => p.ClientId != clientId)
.ToList();
world.SpawnLightweight(player);
session.SetContext(clientId, player);
await _router.Client.SendAsync(clientId, ToSpawnPacket(player));
foreach (var existing in existingPlayers)
await _router.Client.SendAsync(clientId, ToSpawnPacket(existing));
await _router.Broadcast.SendAsync(
ToSpawnPacket(player),
excludeClientId: clientId);
_logger.LogInformation("{Name} joined with entity {EntityId}", player.Name, player.EntityId);
return result;
}
[Gate("chat")]
public async Task OnChat(ChatPacket packet, string clientId)
{
var player = GetPlayer(clientId);
if (player == null)
return;
await _router.Broadcast.SendAsync(new ChatPacket
{
Sender = player.Name,
Text = packet.Text
}, excludeClientId: clientId);
}
[Gate("input")]
public Task OnInput(PlayerInputPacket packet, string clientId)
{
var player = GetPlayer(clientId);
if (player == null)
return Task.CompletedTask;
player.SetInput(packet);
return Task.CompletedTask;
}
protected override async Task<RoomBroadcast?> OnExitGameReceived(
LeaveGamePacket message,
string clientId,
RoomBroadcast? result)
{
await RemovePlayerAsync(clientId);
return result;
}
public override async Task OnDisconnectedAsync(string clientId, Exception? exception)
{
await RemovePlayerAsync(clientId);
await base.OnDisconnectedAsync(clientId, exception);
}
private async Task RemovePlayerAsync(string clientId)
{
var player = GetPlayer(clientId);
if (player == null)
return;
await _router.Broadcast.SendAsync(new EntityRemovePacket
{
EntityId = player.EntityId
}, excludeClientId: clientId);
if (_worlds.GetWorld(0) is { } world)
world.DestroyObject(player);
}
private static PlayerSpawnPacket ToSpawnPacket(PlayerPrefab player)
{
return new PlayerSpawnPacket
{
EntityId = player.EntityId,
Name = player.Name,
Position = new[]
{
player.Transform.Position.X,
player.Transform.Position.Z
},
FacingYaw = player.FacingYaw
};
}
private PlayerPrefab? GetPlayer(string clientId)
{
var session = _gameSessionService.GetSession(clientId);
return session?.GetContext<PlayerPrefab>(clientId);
}
}
This is the big architectural win:
- the portal does not treat players as just socket sessions anymore
- players already exist as world objects in the first post
- the client only sends input state, not final coordinates
- the prefab's
Step(...)simulates movement inside the world tick - sync exposes the result to clients without a custom movement broadcast
9. What the Sync Packet Looks Like
When the server movement loop advances a player and calls:
await _router.Synchronize.SendAsync(player);
Altruist emits a built-in SyncPacket with messageCode: 3.
A typical JSON payload looks like this:
{
"messageCode": 3,
"message": {
"entityType": "PlayerPrefab",
"data": {
"EntityId": 1001,
"PosX": 128,
"PosY": 412,
"FacingYaw": 1.57
}
}
}
Two details matter:
- sync sends changed fields plus anything marked
SyncAlways - sync does not create the entity on the client for the first time
That is why PlayerSpawnPacket still exists. Spawn/remove handles lifetime. Sync handles deltas.
10. Test It
Open two browser tabs and paste this into the console:
const ws = new WebSocket("ws://localhost:8080/game");
const players = new Map();
let joined = false;
ws.onopen = () => {
ws.send(JSON.stringify({
messageCode: 7,
token: ""
}));
};
ws.onmessage = (e) => {
const text = typeof e.data === "string" ? e.data : new TextDecoder().decode(e.data);
const envelope = JSON.parse(text);
if (!joined && envelope.messageCode === 8) {
joined = true;
ws.send(JSON.stringify({
messageCode: 9,
name: "Player1",
roomId: null,
world: 0,
position: [0, 0]
}));
ws.send(JSON.stringify({
messageCode: 4,
event: "chat",
text: "Hello everyone!"
}));
ws.send(JSON.stringify({
messageCode: 4,
event: "input",
up: true,
right: true,
sprint: false
}));
setTimeout(() => {
ws.send(JSON.stringify({
messageCode: 4,
event: "input",
up: false,
right: false,
sprint: false
}));
}, 600);
return;
}
if (envelope.messageCode === 1002) {
players.set(envelope.entityId, {
id: envelope.entityId,
name: envelope.name,
x: envelope.position[0],
y: envelope.position[1],
facingYaw: envelope.facingYaw
});
console.log("Spawned:", players.get(envelope.entityId));
return;
}
if (envelope.messageCode === 1003) {
players.delete(envelope.entityId);
console.log("Removed:", envelope.entityId);
return;
}
if (envelope.messageCode === 3) {
const patch = envelope.message.data;
const player = players.get(patch.EntityId);
if (!player) return;
if ("PosX" in patch) player.x = patch.PosX;
if ("PosY" in patch) player.y = patch.PosY;
if ("FacingYaw" in patch) player.facingYaw = patch.FacingYaw;
console.log("Patched:", player);
}
};
What happens:
messageCode: 7sends the built-in handshake request.messageCode: 8comes back as the built-in handshake response.messageCode: 9joins the game and spawns aPlayerPrefab.messageCode: 1002gives the client the initial player object.messageCode: 4withevent: "input"updates the server-side input state.- the prefab
Step(...)moves the player on the server during the world tick. messageCode: 3returns the visible state change through sync.
What You Get for Free
With just these files, Altruist already handles:
- WebSocket connection management
- packet routing for custom
[Gate]events - built-in handshake, join-game, and leave-game flow
- session-backed player state
- spawned world objects
- structured sync packets
- dependency injection
- health endpoints
Project Structure
SpaceGame/
Program.cs
config.yml
SpaceGame.csproj
Http/
HealthController.cs
Packets/
GamePackets.cs
Portals/
GamePortal.cs
World/
EntityIdAllocator.cs
PlayerPrefab.cs
What's Next
From here you can grow without throwing this away:
- Authentication with Your First Authentication
- Persistence with Your First Vault
- Autosave with Your First Auto Save
- AI with Your First AI
- Visibility with Your First Visibility
The important thing is that the first post already starts from real world entities and sync, so the later posts build on the correct shape instead of replacing a throwaway prototype.
Continue the Series