No description
  • Rust 99.9%
  • Dockerfile 0.1%
Find a file
zigzag f0cf2dbbf8 Let developer tickets into full rooms; loadtest --ports
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>
2026-11-06 23:35:31 -05:00
core Let developer tickets into full rooms; loadtest --ports 2026-11-06 23:35:31 -05:00
examples Let developer tickets into full rooms; loadtest --ports 2026-11-06 23:35:31 -05:00
mods/prism Let developer tickets into full rooms; loadtest --ports 2026-11-06 23:35:31 -05:00
src Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
tests Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
.dockerignore Prism Realtime: Photon-compatible game server 2026-11-05 21:06:03 -05:00
.env.example Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
.gitignore Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
Cargo.lock Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
Cargo.toml Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
compose.yml Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
Dockerfile Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00
README.md Realtime: plain Photon core with mods 2026-11-06 13:17:25 -05:00

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: 0 gives 0.2 / 0.4 ms latency with 49k datagrams/s and 21% CPU, 2 gives 1.3 / 3.6 ms with 28k/s and 15%, and 5 gives ~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::Event carries an Arc<EventData>), and cached events are stored ready to send.
  • try_send_to failures (full socket buffer) are counted (send_errors in 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.