Predator's Lament

Started Sep 1, 2025

Cross-platform session-based ARPG for iOS, Android, and Web. Matches run on dedicated authoritative servers, with a separate backend for persistent player data and a data-driven content system for things like items, enemies, abilities, and game modes.

Live
https://play.predatorslament.com
Technologies
c#postgresqltypescriptpython
On this page
  1. Overview
  2. Requirements
  3. Architecture
  4. Client
  5. Networking
  6. Backend
  7. Chat
  8. Channels
  9. Message path
  10. Social event stream
  11. Trade
  12. Weapon Grips
  13. Combat
  14. One match, end to end
  15. Modeling and Animations
  16. Classes
  17. Administration, monitoring, and ops

Overview#

Predator's Lament is a session-based multiplayer ARPG that I am building for iOS, Android, and Web.

Web builds cannot use normal UDP sockets, while the mobile builds can, so both transports need to connect players to the same match server. Each match also runs in its own dedicated server container, which means the backend has to allocate a server before the match starts, give it the correct session state, route players to it, and clean it up afterward.

Requirements#

  • Server-authoritative combat. Health, damage, death, loot, currency, and item grants are decided by the server, not the client.
  • Mobile and Web players need to be able to join the same match.
  • Game content should mostly be data. Adding an enemy, ability, game mode, or store item should not require hardcoding another branch into the client.
  • Purchase fulfillment needs to safely handle retries, duplicate webhooks, and refunds.
  • Important architectural claims should be testable against the actual project instead of existing only in documentation.

Architecture#

The Unity client and dedicated game server are deployed together. The backend and content catalog can be deployed separately.

flowchart TB
  subgraph client[Clients]
    M[iOS and Android]
    W[Web]
  end
  subgraph match[Per match]
    G[Game server]
  end
  subgraph platform[Platform]
    B[Backend]
    D[(Postgres)]
    F[Fleet provider]
  end
  A[Admin portal] -->|authored catalog| B
  M -->|login, matchmake| B
  W -->|login, matchmake| B
  B -->|allocate| F
  F -->|container| G
  B -->|session payload| G
  M -->|UDP| G
  W -->|WebSocket| G
  G -->|results, grants| B
  B <--> D
  B -.invalidation.-> G

The main authority split is:

  • Game server: simulation, combat, hit resolution, death.
  • Backend: inventory, currency, progression, purchases, and other state that survives after a match ends.

The two systems mainly interact when a match starts and when it ends. The backend creates a signed session payload that the match server starts with, and the server submits the resulting persistent changes back afterward.

Client#

The client is built in Unity 6 using URP.

Runtime UI uses UI Toolkit with an action → reducer → immutable state → presenter → view flow.

Simulation runs at a fixed 30 Hz tick rate. The tick rate comes from one shared constant and is validated at the different simulation entry points so the server and clients do not silently start with different timing settings.

Networking#

Networking uses FishNet with server authority, client-side prediction, and server reconciliation.

The same dedicated server container exposes two transports:

  • UDP for native mobile clients
  • WebSocket for Web clients

Both connect to the same running match instead of separating Web and mobile into different server pools.

Each session advertises its supported transports as capabilities, and the client chooses the correct one for its platform.

Backend#

The backend runs on a Nakama runtime over PostgreSQL

Persistent player mutations are designed so the entire operation resolves inside one database transaction whenever possible.

A mutation:

  1. writes a durable outbox entry,
  2. increments a monotonic version,
  3. broadcasts an invalidation event.

The normal path reaches consumers in roughly 1–2 seconds. A scheduled drainer retries anything that did not deliver, and the game server periodically checks the stored version as a fallback.

Chat#

Text chat runs on the same backend as the rest of the persistent game state, not on the match server. Nothing about chat depends on being inside a match.

Each client holds one WebSocket to the backend for the whole session. Chat messages, channel joins, and the realtime social events all share it. Message history is fetched over plain HTTP when a channel is opened.

flowchart TB
  subgraph sender[Sending client]
    UI[Chat UI and store] --> AD[Chat adapter]
  end
  subgraph backend[Backend]
    HK[Send hook] --> RM["Channel rooms<br/>Global, Session, Party,<br/>Guild, Direct message"]
    RM --> MS[(Message history)]
    PR[Presence] --> EV[Social event stream]
  end
  RC["Receiving clients<br/>adapter, store, chat UI"]
  AD -->|WebSocket| HK
  RM -->|broadcast| RC
  MS -->|HTTP, on open| RC
  EV -->|friends, presence,<br/>DM alerts| RC

Channels#

Every chat channel is a persistent room on the backend. The type of channel is carried entirely by the room's name, so adding a channel kind is a naming convention plus a client thread type, not a new server concept.

Channel Room Scope
Global Global Everyone online
Session Session:<instance> Players in the same match instance
Party Party:<party> Members of one party
Guild <guild id> Members of one guild
Direct message DM:<hash> Two characters

Direct message room names are derived from the two character ids, sorted so both sides compute the same name, and hashed to a fixed length. The client and the backend share that naming rule byte for byte. A separate per-character thread index holds unread state and previews, so the inbox does not have to open every room to draw itself.

Session chat also feeds the speech bubbles above characters in the match. The bubble is a presentation of the same room message, not a second channel.

Rooms belong to the socket, so a reconnect invalidates their ids. The client keeps a record of which rooms it intends to be in and rejoins them when the socket comes back. Global is always part of that set.

Message path#

Every outgoing message passes through a server-side hook before it reaches the room. The hook is the point where the backend, not the client, decides what a message is.

  1. The sender's identity is looked up and stamped onto the message: character id, name, name color, and admin flag.
  2. The text is sanitized and run through the content filter. Profanity is masked, and the filter's rules are data that admins edit live.
  3. The sender's active penalties are checked, so a muted player's message is replaced rather than delivered.
  4. A per-sender rate limit is applied.
  5. The stamped message is broadcast to everyone in the room and stored.

Refused messages are replaced with a short placeholder instead of being dropped, which keeps the socket and the sender's view of the conversation intact. Strikes from the filter accumulate and can apply penalties automatically. Staff review flagged messages and issue or revoke penalties from the admin portal.

Social event stream#

Presence and social changes do not travel through chat rooms. Each character subscribes to a realtime stream after character select, and the backend pushes friend changes, presence, renames, invites, guild events, and a "new direct message" nudge onto it. Presence itself is computed by the backend (offline, in town, or in a match) rather than reported by the client.

A separate per-account stream carries wallet and entitlement invalidations. After a reconnect, the client refreshes rather than replaying missed events.

The client side follows the same shape as the rest of the UI: socket events become actions, reducers update immutable state, and the chat views render from that state.

Trade#

Player-to-player trade runs on the same backend as chat and the rest of the persistent game state, not on the match server. Nothing about it depends on the two players sharing a server: a trade is rows in the database plus a realtime stream, so two players in different town instances can trade.

A trade is a short handshake. One player starts it, the other accepts, both build an offer of items and gold, and both mark it ready. The second ready settles the whole exchange in one database transaction. There is no separate confirm step.

sequenceDiagram
  participant A as Player A
  participant B as Backend
  participant S as Trade stream
  participant P as Player B
  A->>B: start trade
  B->>S: trade_request
  S-->>P: invite
  P->>B: accept
  loop Each offer change
    A->>B: add or remove item or gold
    Note over B: Items move to escrow,<br/>offer revision increases,<br/>both readies clear
    B->>S: trade_updated
    S-->>A: new revision
    S-->>P: new revision
  end
  A->>B: ready, at revision N
  P->>B: ready, at revision N
  Note over B: Settle gold and items<br/>in one transaction
  B->>S: trade_updated, Confirmed
  S-->>A: settled
  S-->>P: settled

Starting a trade#

Starting a trade is gated on both players. Each rule is checked by the backend, so the client's buttons are a convenience and never the authority.

Rule Checked by
Both players are online in a live town session The start RPC, from session membership
Both are in the same league, and that league allows trade One shared interaction gate in SQL
Neither player has blocked the other The shared block gate
Neither player is in a wager The wager lock
Neither player is under an economy hold The hold gate, and again in SQL
The action is not paused by staff A per-action kill switch

Starting is create-or-join. If both players click Trade on each other at nearly the same moment, the database locks the pair in a fixed order first, so they end up in one trade instead of two. The invite reaches the other player as a bell card whose id is the trade id, and it is retired as soon as that player joins, declines, or the trade ends unanswered.

Offer and escrow#

An offered item leaves the player's bag and sits in an escrow container owned by that trade. Only the trade procedures can move an item into or out of escrow, and every other writer that could touch an item asks whether it is in an open trade and refuses if so. That is what makes the offer trustworthy: nothing can change it without the partner seeing.

Items are checked as they enter escrow. Loaned, soulbound, account-bound and definition-untradeable items never get in, and character-bound items cannot cross accounts. Gold is offered the same way, and the amount is validated against the player's wallet.

Every change to the offer increases the trade's offer revision and clears both players' readies. A ready is tied to the revision the player was looking at, so a player cannot agree to an offer that changed a moment ago.

Ready and settlement#

Ready is the whole handshake, and it is the only door to settlement.

  1. A player marks ready, naming the offer revision they saw.
  2. The backend refuses a revision that is no longer current, and the client shows the new offer.
  3. When both players are ready at the current revision, the same transaction settles: gold moves between wallets and each escrow is delivered to the other player.
  4. Received items land in the receiver's character inventory, or in the league stash when the bag is full.
  5. The trade is marked confirmed, recorded for the trade history, and both players get a wallet and inventory invalidation so their screens update.

A settled trade does not close the window. It opens a fresh trade with the same partner so the pair can go again.

Ending a trade#

A trade is open, confirmed, or cancelled, and open is the only live state. Every way out of an open trade goes through the same cancel, which returns each player's escrow to their own bag or stash.

  • Either player can cancel.
  • Leaving a session cancels that account's open trades.
  • A scheduler sweeps trades that sit idle.
  • Staff actions that must reach an offered item, such as a forced equip, a revoke, or a character deletion, interrupt the trade holding it first.

Each cancel records why it ended, and the client uses that to tell the remaining player who left and how: a cancel, an idle timeout, an interruption, or a disconnect.

Trade event stream#

Each trade RPC answers the caller directly, and a second realtime channel tells the partner. Every character has a trade stream on the same WebSocket that chat uses. After any successful change the backend pushes a small trade_updated event to both players, carrying the trade id, status, offer revision and version. An invite is a separate trade_request event. Offline players are skipped, and opening the trade overlay fetches a fresh snapshot to catch up.

The client follows the same shape as the rest of the UI. Stream events and RPC replies become actions, a reducer keeps the trade session as immutable state, and the trade overlay renders from it. Snapshots are ordered by server version, so a late refresh can never replace a newer one, and replies belong to the window that requested them, so a late answer cannot act on a newer trade.

Weapon Grips#

I attempted to make a very basic grip system because the look I was going for was not working well with the unity resolver. Weapon and shield prefabs are saved with rotation, handle, and roll pitch yaw for easy adjustment in data post-build.

Combat#

Targeting#

sticky tap-to-target for auto attacks and directional combat abilities

One match, end to end#

sequenceDiagram
  participant C as Client
  participant B as Backend
  participant F as Fleet
  participant G as Game server
  C->>B: authenticate, request match
  B->>B: freeze content snapshot
  B->>F: allocate container
  F->>G: start with signed session payload
  B-->>C: join token and routing
  C->>G: connect over UDP or WebSocket
  G->>G: authoritative simulation, 30 Hz
  G->>B: results, grants, telemetry
  B->>F: release container

A normal match startup looks roughly like this:

  1. Client authenticates and requests a match.
  2. Backend freezes the content snapshot for that session.
  3. Backend requests a server from the fleet provider.
  4. The container starts with a signed session payload.
  5. Backend returns the join token and routing information.
  6. The client connects through UDP or WebSocket.
  7. The server runs the authoritative 30 Hz simulation.
  8. Match results, grants, and telemetry are submitted.
  9. The container is released.

The content snapshot is important because balance data can change while a match is running.

A match keeps the version of the content it started with instead of suddenly changing rules halfway through because someone edited an ability or enemy value.

Modeling and Animations#

I learned a moderate amount of Blender over the course of the last year, by finishing Humanoid sculpting courses, hard model surfacing, and beginner to intermediate projects. Mainly through Udemy and YouTube. This game uses a combination of:

  • Unity Asset Store packs
  • Models by me
  • Fiverr Professionals
  • Mixamo animations

Classes#

The three playable archetypes use the same skeleton, animation set, and armor geometry.

Skins are material changes on the shared geometry. The modular gear has contracts in game that deal with horns, head shape, open/closed helmets, hiding fur, body meshes in ways not recreated in the 3-d viewers below.

Administration, monitoring, and ops#

The project has a Next.js admin portal using CF Zero trust.