- Rust 99.9%
- Dockerfile 0.1%
A room ticket whose account ends in "d" (signed with it) is a developer's: prism-tickets marks it, and the master and game servers let it past the "room full" check. Matchmaking issues these to developers when room tickets are on. The loadtest example takes --ports to spread clients over several game ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| core | ||
| examples | ||
| mods/prism | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| compose.yml | ||
| Dockerfile | ||
| README.md | ||
Realtime
A game server that speaks Photon Realtime's wire protocol, so unmodified Photon clients (PUN 2) can play on it instead of Photon Cloud: rooms, events and the event cache, properties, groups, encryption and Photon Voice. Game logic stays on the clients, as with Photon. The server does what Photon does and no more; everything else is a mod.
| Crate | What |
|---|---|
core/ (realtime) |
the server: transport, protocols, LoadBalancing, and the mod API |
mods/prism/ (realtime-prism) |
Prism's mods for Rec Room 2022 |
. (realtime-server, binary realtime) |
the program: the core plus the mods it's built with (cargo features) |
Core modules: enet.rs (Photon's UDP transport: packets, acks, ordering, fragments, ping, timeouts),
framing.rs (message header, init handshake), crypto.rs (Diffie-Hellman and AES for encrypted operations),
protocol18.rs / protocol16.rs (values and operation/event bodies), lb.rs (name, master and game server
operations), mods.rs (the mod API).
Running
cargo run --release # plain Photon
PHOTON_MODS=prism-tickets,prism-outfit cargo run --release
cargo test --workspace
docker build . builds the same program. Open UDP 5058, 27000, 5055 and 5056.
On its own machine (away from Prism): copy .env.example to .env, fill it in (the public host, Prism's
PRISM_JWT_SECRET, and Prism's public https:// address as PRISM_API_URL), then docker compose up -d --build. Point the Photon host name's DNS at the machine, and set PRISM_PHOTON_REPLICAS=0 in Prism's .env so
it stops running its own copy. Clients don't need patching again if the host name stays the same.
| Variable | Default | |
|---|---|---|
PHOTON_PUBLIC_HOST |
127.0.0.1 | the host or IP clients reach the server at |
PHOTON_NAME_PORTS |
5058,27000 | name server ports |
PHOTON_MASTER_PORT / PHOTON_GAME_PORT |
5055 / 5056 | |
PHOTON_MODS |
none | mods to run, in order, comma-separated. An unknown name stops the server, listing the ones it has |
PHOTON_FLUSH_MS |
5 | how long sends are packed together |
PHOTON_STATS_SECS |
off | log transport totals this often |
RUST_LOG |
info |
Mods read their own settings from the environment.
Mods
A mod implements realtime::mods::Mod. Every hook is optional; mods run in the order PHOTON_MODS lists them.
The hooks follow Photon Server's plugin API:
| Hook | When | Can |
|---|---|---|
authenticate |
name server Authenticate with custom auth values | accept (user id, nickname), refuse, or pass. If no mod decides, the player is anonymous, as on Photon Cloud without custom auth |
resolve_room |
a room name on the master and game server | send the player to another room, with their own UserId and attributes (per-player room tickets) |
before_join / on_join |
a player joining on the game server | change their properties and attributes, or refuse |
on_leave, on_room_created, on_room_closed |
||
on_raise_event |
RaiseEvent, cache operations included | change or drop it |
before_set_properties / on_set_properties |
SetProperties | change values and expected values, or refuse |
on_message |
the mod's background work finished | anything |
Hooks get a Ctx that reads rooms and actors, changes actor properties (telling the others), sends events and
drops players. Hooks run on the event loop and must not block: slow work (HTTP, files) runs in a task that
posts its result to ctx.mailbox(), and it comes back through on_message. Mods share facts about a player
through actor attributes (named strings kept while the actor is in the room).
To add a mod crate: depend on realtime, export a constructor, and add it to build_mod in src/main.rs behind
a cargo feature, like prism.
Prism's mods (--features prism, on by default)
| Mod | What |
|---|---|
prism-tickets |
Rec Room 2022 never authenticates to Photon, yet reads each player's RecNet account id from their Photon UserId. Prism's matchmaking gives every player their own room name, a ticket <room>~<account>.<sig> (HMAC with the shared secret; token.PhotonTicket in Go). This mod files every ticket for <room> into one room (the voice client's <ticket>_voice_ into <room>_voice_), publishes the account as the player's UserId and sets the prism.account attribute. A bad signature joins as an unknown player. |
prism-auth |
checks RecNet tokens ({"accountId","accessToken"}) sent as custom auth values. The 2022 client sends none, so it's only for other clients. |
prism-outfit |
avatar anti-cheat. On every join it asks Prism what the player's account owns (GET /internal/photon/avatar/{id} with a service token) and drops unowned items from the PlayerOutfitDataPropertyKey property (protobuf rec_room.OutfitData, see outfit.rs) before anyone else sees them. Players already in rooms are checked again when the answer arrives. Developers, unlock-all servers and an unreachable Prism mean no checks. Needs prism-tickets. |
Settings: PRISM_JWT_SECRET (Prism's; without it nothing is checked, so anyone can claim any account) and
PRISM_API_URL for prism-outfit: http://api:8080 next to Prism in compose, or Prism's public
https:// address when Realtime runs on another machine.
Point clients at the server with tools/patch_client.py <game> --base <prism url> --photon-host <host> in
RecNet: that replaces the client's Photon name server (ns.exitgames.com).
Load test
cargo build --release && taskset -c 4-31 cargo run --release --example loadtest -- --bots 10,20,40,60 --server-cpus 0-3
examples/loadtest.rs starts the server (pinned to 4 cores, like the VM) and runs bots that do what the 2022
clients do: name server → master → game server with a room ticket, GpBinaryV16 for the room and Protocol18 for
the voice room. Each bot sends 20/s unreliable PUN serialization events (100–300 bytes, receivers Others), about
1/s a reliable RPC, a cached event every 10 s, and the first 10 bots talk (60-byte frames, 50/s) in the paired
_voice_ room. Every payload carries its send time, so receivers measure relay latency and loss. Options:
--secs, --talkers, --rate, --env KEY=VALUE (server), --lossy K (K bots lose 30% of what they're sent),
--external HOST (a server that's already running). The server's PHOTON_STATS_SECS=1 log lines give the
transport totals.
Results on an i9-14900KF over loopback (no ZeroTier or nginx), 20 s measured per row. CPU is % of one core (it's all one event loop); latency is from a sender's RaiseEvent to another bot receiving it.
Before (the original loop):
| bots | server CPU | datagrams out/s | MB/s out | state p50 / p99 ms | voice p50 / p99 ms | resends | loss |
|---|---|---|---|---|---|---|---|
| 10 | 5% | 6,364 | 0.97 | 1.2 / 4.4 | 1.2 / 5.2 | 0 | 0% |
| 20 | 8% | 14,113 | 3.01 | 1.4 / 5.3 | 1.3 / 3.4 | 0 | 0% |
| 40 | 19% | 40,043 | 10.26 | 1.4 / 4.4 | 1.3 / 3.7 | 0 | 0% |
| 60 | 34% | 74,749 | 21.63 | 1.4 / 3.3 | 1.4 / 3.9 | 0 | 0% |
After (default PHOTON_FLUSH_MS=5):
| bots | server CPU | datagrams out/s | MB/s out | state p50 / p99 ms | voice p50 / p99 ms | resends | loss |
|---|---|---|---|---|---|---|---|
| 10 | 4% | 3,608 | 0.93 | 4.2 / 5.5 | 4.2 / 5.5 | 0 | 0% |
| 20 | 7% | 8,380 | 2.93 | 3.4 / 5.4 | 3.4 / 5.4 | 0 | 0% |
| 40 | 12% | 18,835 | 9.98 | 3.7 / 4.9 | 4.4 / 5.0 | 0 | 0.02% |
| 60 | 20% | 34,966 | 21.11 | 3.7 / 5.8 | 4.1 / 5.9 | 0 | 0% |
What changed:
- The loop now flushes after handling a batch of datagrams. Before, it flushed first, so whatever a batch produced waited for the next datagram or the 5 ms tick.
- Sends are packed for up to
PHOTON_FLUSH_MS, i.e. several events per datagram. Server CPU follows the number of datagrams (the sendto calls), not the encoding. At 40 bots:0gives 0.2 / 0.4 ms latency with 49k datagrams/s and 21% CPU,2gives 1.3 / 3.6 ms with 28k/s and 15%, and5gives ~4 / 5 ms with 18k/s and 12%. A few ms doesn't matter next to a 20 Hz game and internet round trips, while the datagram rate is what ZeroTier and the nginx UDP proxy pay for. - A relayed event is built once and encoded once per protocol for all recipients (
Out::Eventcarries anArc<EventData>), and cached events are stored ready to send. try_send_tofailures (full socket buffer) are counted (send_errorsin the stats line); there were none.
Slow clients: with 4 of 40 bots losing 30% of what they're sent, the other 36 lost nothing, the largest per-peer backlog stayed at 1–3 commands, and the 2,171 resends in 30 s all went to the lossy peers. The transport never queues: unreliable data goes out on each flush, and an unacked reliable command is resent with backoff until the peer is dropped (10 sends or 10 s). So a send window or throttling wasn't needed.
What this means for 40 players: the server itself has lots of headroom, about 12% of one fast core with the main
loop 11% busy, so it isn't worth sharding rooms across threads yet. A VM core is slower, but that's still far
from saturating. Upstream bandwidth is the limit: about 10 MB/s (80 Mbit/s) out of the server at 40 players
and 21 MB/s at 60, at these assumed rates (every event fans out to 39 others). Real Rec Room payloads and send
rates may differ, so that's the number to check on the VM's uplink and through ZeroTier, ideally with
--external from another machine.
Not done yet
Datagram encryption (Photon's AES-GCM mode; the client doesn't appear to use it), lobbies with room lists, inactive players / rejoin (PlayerTTL), webhooks.