Back to blog
Your FirstPart 1 of 9

Wednesday, April 15, 2026

Your First Spaceship Multiplayer Portal

cover

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:

  • HandshakeRequestPacket with messageCode: 7
  • JoinGamePacket with messageCode: 9
  • LeaveGamePacket with messageCode: 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 processing
  • ISynchronizedEntity gives the runtime a ClientId
  • EntityId is marked SyncAlways so 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:

  1. messageCode: 7 sends the built-in handshake request.
  2. messageCode: 8 comes back as the built-in handshake response.
  3. messageCode: 9 joins the game and spawns a PlayerPrefab.
  4. messageCode: 1002 gives the client the initial player object.
  5. messageCode: 4 with event: "input" updates the server-side input state.
  6. the prefab Step(...) moves the player on the server during the world tick.
  7. messageCode: 3 returns 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:

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

Your First