Skip to content

Dedicated Server

Plume3D can run a game’s authoritative logic headless as a dedicated server — no window, no Vulkan, no render loop — in a persistent fixed-tick loop. The same plume3d binary is both the client (plume3d <app>) and the server (plume3d --server <app>); your main.wren chooses server-vs-client behavior.

Terminal window
plume3d --server <appdir> # also --server=<appdir>

The server:

  1. Loads <appdir>/main.wren and constructs your Game class.
  2. Brings up the networking transport and listens on [Network].port.
  3. Calls Game.init() once (networking is up, so Net works in init()).
  4. Calls Game.update(dt) every tick at [Network].tick_rate Hz (default 60), servicing networking each tick.
  5. Exits on Engine.exit(code) or SIGINT / SIGTERM.

Keep the server code path renderer-free — the server has no window/renderer, so don’t call rendering, GUI, or audio APIs in code that runs server-side. Branch on server vs. client.

Because the engine listens automatically in --server mode, a dedicated server does not call Net.startServer — it just drains Net.poll() each tick and responds with Net.send. See the worked example apps/net_echo_server.

Add a [Network] section to game.toml:

[Network]
port = 7777 # UDP port the dedicated server listens on
tick_rate = 60 # server Game.update(dt) rate, in Hz
max_clients = 32 # advisory cap on simultaneous connections

port is what --server binds; tick_rate drives the loop. All keys are optional (defaults 7777 / 60 / 32).

  • Engine.exit(code) from Wren ends the loop with that code (clamped 0–255).
  • SIGINT / SIGTERM stops the loop cleanly — how a host/orchestrator (including PlayFlow) stops a server.
CodeMeaning
value of Engine.exit(code)Requested by the game.
1Game.init() / Game.update() raised a Wren error, or main.wren failed to compile.
2Setup error: app dir missing or no main.wren.
70Filesystem initialization failed.

Use the seeded Random API so a fixed seed reproduces a server run — important for authoritative simulation and for reproducing bugs.

The transport is live: an authoritative dedicated-server model over GameNetworkingSockets (reliable-ordered and unreliable UDP messages, encrypted), exposed to Wren as Net. A dedicated server drains events each tick and responds:

import "engine" for Engine, Logger, Net
class Game {
construct new() {}
init() {}
update(dt) {
for (ev in Net.poll()) {
if (ev["type"] == "connect") Logger.info("peer %(ev["conn"]) joined")
if (ev["type"] == "data") Net.send(ev["conn"], ev["payload"], true) // echo
if (ev["type"] == "disconnect") Logger.info("peer %(ev["conn"]) left")
}
}
draw() {}
}

A client connects with Net.connect(host, port) and sends with Net.send(conn, bytes, reliable). The apps/net_echo_server + apps/net_echo_client pair is a complete client/server example (run the server with --server, the client windowed). The apps/net_loopback_demo is self-contained — it connects to itself, so run it via --server, not as a client. See the full Net API.

The engine can sync entity transforms server-authoritatively. The server calls Net.setReplicatedEntity(id, x,y,z, qx,qy,qz,qw) each tick (the engine broadcasts a snapshot automatically); the client reads Net.replicatedEntities() (or ...Lerp(alpha) for smooth motion). See the replication section of the Net API and apps/net_replication_demo.

The PlayFlow API matchmakes a client to a PlayFlow-hosted dedicated server: PlayFlow.requestServer(region, customData) → poll PlayFlow.serverInfo(id) until status == "running"Net.connect(host, port). Package the Linux server with docker/server.Dockerfile; PlayFlow injects the port via PLUME_SERVER_PORT and stops the server with SIGTERM. The client key comes from PLAYFLOW_CLIENT_KEY (never committed). The client + packaging are built and mock-tested; a live PlayFlow account is needed to run it end-to-end.


See the Net and PlayFlow API references, the Testing (Headless) guide for --test, and Configuration for the full game.toml reference.