UGTK — Server setup
Some UGTK systems talk to a server you own. This folder contains everything needed on that side: the database schema, the PHP endpoints reference, and the WebSocket server.
Nothing here is imported by Unity. The folder name ends with ~, which Unity ignores.
Last verified against the shipped PHP endpoints: 4 September 2026.
What needs a server, and what does not
| Needs a server | Works without one |
|---|---|
| Leaderboard, Tickets, Leads Generation, User Credentials, App Info Stats, Table Management, Image upload, Email sending | everything else, which is most of the toolkit |
| WebSockets System, QR Multiplayer, Tournament (online mode) | the same systems in local mode |
If you never touch these systems, you can ignore this folder entirely.
1. Requirements
- PHP 7.4 or newer, with MySQLi
- MySQL 5.7+ or MariaDB 10.3+
- HTTPS (the browser blocks mixed content, and WebGL builds will fail without it)
- For the WebSocket server only: Node.js 18+ or Bun 1.0+, and a port you can open
Any shared hosting with PHP and MySQL is enough for everything except the WebSocket server, which needs a process that stays running.
2. Install in five steps
- Create a database and a user with read and write on it.
- Run
database/ugtk-schema.sqlin phpMyAdmin or from the command line. It creates every table the shipped endpoints expect. - Copy the PHP files onto your server. They live inside the package under each system's folder:
Code/Api/<System>/Php/...andCode/DataSystem/TableManagement/Php/. Keep the folder structure. - Configure the connection. Copy
db_connection.example.phptodb_connection.phpnext to it and fill in host, user, password and database name. Never commit the filled-in file. - Point the game at your server. In Unity, set the URL field on
Mn_PhpApiManager(or on the individual component) to your base address. Every default in the package ishttps://your-server.com/, which is a placeholder and will not work until you change it.
3. Table names are your choice
The endpoints do not hardcode table names: the client sends the table name with each request, and the server sanitises it. The schema file uses these names, and the Unity components default to them:
| System | Table in the schema |
|---|---|
| Leaderboard System | Leaderboard |
| Tickets Api | Tickets |
| Leads Generation | Leads |
| User Credential System | Users |
| App Info Stats | AppStats and Client_App_Config |
If you rename a table, change it in the Unity component too. Running two games on the same database
is the normal case: they share the tables and separate their rows through client_project or
app_identifier.
4. Security, said plainly
These endpoints are the ones used in production, and there are three things you must know before you put them online.
Scores arrive from the client. leaderboard_upload_user.php writes whatever score it receives.
Anyone who can read your build can send a fake one. If the leaderboard has a prize attached, add a
shared secret: sign the payload in Unity, verify it in PHP, and reject anything that does not match.
There is a worked example in database/README-security.md.
Anything serialised into the build is readable. API keys, passwords and endpoints inside a Unity player can be extracted. Credentials belong on the server, never in a component field. The email system in particular must send through your PHP endpoint, not by putting SMTP credentials in the game.
Rate limiting is not included. Add it at the web-server level, or these endpoints can be called in a loop by anyone.
5. The WebSocket server
See websocket/README.md. It is a single file, it has no database, and it runs with one command.
It is what the WebSockets System, the QR Multiplayer System and the online Tournament mode connect to.
6. What to do when something does not work
| Symptom | Cause, almost always |
|---|---|
| The call returns nothing and Unity reports no error | the URL still points at your-server.com |
| Works in the editor, fails in the browser build | the endpoint is on HTTP, or CORS headers are missing |
| "Table doesn't exist" | the table name in the component does not match the one you created |
| Everything returns "connection failed" | wrong credentials in db_connection.php, or the database user has no rights |
| WebSocket connects then drops after a few minutes | a reverse proxy is closing idle connections: raise its timeout above 600 seconds |
The database schema
6 tables, one file. Run it once in phpMyAdmin or from the command line and every shipped endpoint has the table it expects.
-- =====================================================================
-- UGTK — database schema
--
-- Derived from the PHP endpoints shipped with the package, verified on
-- 4 September 2026. Every column here is one the endpoints actually read
-- or write; nothing is speculative.
--
-- Run this once on an empty database. MySQL 5.7+ / MariaDB 10.3+.
--
-- Table names are NOT hardcoded in the endpoints: the client sends the
-- name with each request. These are the names the Unity components use
-- by default. If you rename one here, rename it in Unity too.
-- =====================================================================
SET NAMES utf8mb4;
-- ---------------------------------------------------------------------
-- Leaderboard System
-- leaderboard_upload_user.php -> INSERT / UPDATE by (client_project, nickname)
-- leaderboard_get_user.php -> SELECT nickname, score WHERE client_project
--
-- One row per player per project. Uploading an existing nickname updates
-- the score instead of adding a row, so the table stays the size of your
-- player base and not of your sessions.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Leaderboard` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`client_project` VARCHAR(190) NOT NULL,
`nickname` VARCHAR(190) NOT NULL,
`score` BIGINT NOT NULL DEFAULT 0,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uq_project_nickname` (`client_project`, `nickname`),
KEY `idx_project_score` (`client_project`, `score` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------
-- Tickets Api
-- ticket_create.php / ticket_redeem.php / ticket_list.php / ticket_delete.php
--
-- A ticket is unique per app: the same code may exist for two different
-- apps without colliding. redeemed_at NULL means "not used yet".
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Tickets` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`app_identifier` VARCHAR(190) NOT NULL,
`code` VARCHAR(64) NOT NULL,
`reward_key` VARCHAR(190) NULL,
`generated_at` DATETIME NULL,
`redeemed_at` DATETIME NULL,
`redeemed_by_nickname` VARCHAR(190) NULL,
UNIQUE KEY `uq_app_code` (`app_identifier`, `code`),
KEY `idx_app_redeemed` (`app_identifier`, `redeemed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------
-- Leads Generation
-- add_lead.php -> INSERT (first_name, last_name, email, phone_number,
-- app_identifier, created_at)
-- get_leads.php -> SELECT the same columns
--
-- THIS TABLE HOLDS PERSONAL DATA. Before you put it online, decide who
-- may read it, for how long you keep it, and how you delete a row when
-- someone asks. See README-security.md.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Leads` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`first_name` VARCHAR(190) NULL,
`last_name` VARCHAR(190) NULL,
`email` VARCHAR(190) NULL,
`phone_number` VARCHAR(60) NULL,
`app_identifier` VARCHAR(190) NULL,
`created_at` DATETIME NULL,
KEY `idx_app_created` (`app_identifier`, `created_at`),
KEY `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------
-- User Credential System
-- register_user.php / login_user.php / get_user_data.php /
-- change_email.php / change_password.php / reset_password.php /
-- check_user_exists.php / delete_user_account.php
--
-- password_hash holds the output of PHP's password_hash(). Never store
-- a plain password, and never send one back to the client.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`email` VARCHAR(190) NOT NULL,
`nickname` VARCHAR(190) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uq_email` (`email`),
UNIQUE KEY `uq_nickname` (`nickname`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------
-- App Info Stats
-- app_stats_common.php -> one row per client_project, created on demand
-- add_time.php -> appends to play_times_json
-- add_custom_action.php-> appends to custom_actions_json
-- inc_counter.php -> increments a counter and appends a timestamped
-- entry to its matching *_events_json column
--
-- The three counters the endpoint accepts are exactly these; asking for
-- any other name is rejected. To add a fourth, add both columns here and
-- the pair to the map inside inc_counter.php.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `AppStats` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`client_project` VARCHAR(190) NOT NULL,
`play_times_json` LONGTEXT NULL,
`custom_actions_json` LONGTEXT NULL,
`players_total` BIGINT NOT NULL DEFAULT 0,
`players_total_events_json` LONGTEXT NULL,
`total_games` BIGINT NOT NULL DEFAULT 0,
`total_games_events_json` LONGTEXT NULL,
`render_clicks` BIGINT NOT NULL DEFAULT 0,
`render_clicks_events_json` LONGTEXT NULL,
UNIQUE KEY `uq_client_project` (`client_project`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ---------------------------------------------------------------------
-- Client build expiry
-- get_app_expiration.php -> SELECT config_json FROM Client_App_Config
--
-- This is the ONLY table whose name is hardcoded in the PHP: do not
-- rename it. config_json holds the expiry date and whatever else you
-- want the build to read at start-up.
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `Client_App_Config` (
`client_id` VARCHAR(190) NOT NULL PRIMARY KEY,
`config_json` LONGTEXT NOT NULL,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Example row. The build reads this and stops working after the date.
-- INSERT INTO `Client_App_Config` (`client_id`, `config_json`) VALUES
-- ('my-client-project', '{"expiration":"2027-12-31","message":"This demo has ended."}');
-- ---------------------------------------------------------------------
-- Table Management System
-- export_table.php reads any table you point it at and exports it.
-- It creates nothing: it works on the tables above, or on your own.
-- ---------------------------------------------------------------------
-- =====================================================================
-- Optional: a second project on the same database
--
-- You do not need new tables. Use a different `client_project` (or
-- `app_identifier`) value and the rows stay separated. Only create a
-- second set of tables if you want the data physically apart, for
-- example because two different clients must never see each other.
-- =====================================================================
The WebSocket relay
UGTK — WebSocket relay
The server that the WebSockets System, the QR Multiplayer System and the online Tournament mode talk
to. This is the real, running server, not a sample: the source under src/ is the same code that
serves the production deployment.
What it is, in one paragraph
A relay. One host (the screen: a totem, a laptop, a projector) opens a match. Many players (phones) join it. Players send messages to the host, the host answers one player or broadcasts to all. Matches live in memory and are forwarded, not stored: restart the process and every match is gone, which is correct, because a match lasts minutes.
It is not a game-state authority. The host holds the game; the relay only carries messages.
Install
The server is written in TypeScript and runs on Bun. Everything is under src/.
1. Install the runtime
Ready-made scripts are in src/initializers/:
| Machine | Script |
|---|---|
| Linux, Bun (recommended) | bash initializers/bun.bash |
| Linux, Node | bash initializers/node.bash |
| Windows, Bun | initializers/window.bun.ps1 |
| Windows, Node | initializers/window.node.ps1 |
Or install Bun yourself: curl -fsSL https://bun.sh/install | bash
2. Configure
Create .env.local next to index.ts:
PORT=8888
MODE="prod"
TO_STORE_COORDINATES=0
MODE switches between development and production. TO_STORE_COORDINATES decides whether positional
data is written to the local database; leave it at 0 unless you need the history.
3. Install and run
bun install
bun index.ts
index.ts scans app/, generates $app.ts with one case per handler, and starts the server. Adding
an event is adding a file: app/host/my_event.ts becomes the event host.my_event on the next start.
That is the part worth understanding, because it is why the server has no route table to maintain.
4. Build for production
bun run build
It produces a single bundled index.js you can copy to the server and run with bun index.js.
5. Keep it running
npm install -g pm2
pm2 start "bun index.js" --name ugtk-ws
pm2 save && pm2 startup
6. Put it behind HTTPS
WebGL builds refuse an insecure socket, so the relay must sit behind a domain with a certificate:
location /ws/ {
proxy_pass http://127.0.0.1:8888/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 700s; # must exceed the 600s idle timeout
}
Unity then connects to wss://your-domain.com/ws/. Without the two proxy_set_header lines the
upgrade fails and nothing happens; without the raised timeout the connection drops silently after a
few minutes, which is the single most common problem people hit.
The protocol
Every message is JSON with an event field plus whatever that event needs:
{ "event": "player.join_match", "match_id": "abc", "player_id": "p1" }
Every reply has the same shape:
{ "success": 1, "event": "player.join_match", "data": { "match_id": "abc", "player_id": "p1" } }
{ "success": 0, "event": "player.join_match", "message": "Match not found; Props => {...}" }
The ten events
| Event | Sent by | Fields | What happens |
|---|---|---|---|
host.init_match |
host | match_id optional, max_players optional |
Creates a match. An empty match_id gets a generated one, returned in the reply. Not an integer max_players means no limit (-1) |
host.list_matches |
anyone | match_id optional |
Lists open matches with their player ids and counts. match_id acts as a substring filter |
host.to_player |
host | match_id, player_id, plus anything |
Delivers to that one player |
host.close_match |
host | match_id |
Notifies every player, closes them and the host, deletes the match |
player.init |
player | player_id optional |
Registers a player. An empty id gets a generated one. Fails if the id already exists |
player.join_match |
player | match_id optional, player_id optional |
Adds the player to the match. The reply goes to the host, not to the player |
player.generic_event |
player | match_id plus anything |
Forwards to the host. This is the one you use for input, answers and scores |
player.leave_match |
either | match_id, player_id |
Removes the player from that match and notifies the host |
player.leave |
either | player_id |
Removes the player from every match, notifies each host, closes the socket |
broadcast.to_player |
host | match_id plus anything |
Delivers to every player in the match |
Three behaviours worth knowing before you build on it
Omitting match_id picks the first open match. Convenient when one machine hosts one match,
dangerous when it hosts several. Always send the id in production.
A player who joins is announced to the host, not to himself. The host decides what the player sees
next and tells him with host.to_player. That is why joining looks like nothing happened until the
host answers.
max_players is recorded but not enforced by the relay. The value travels with the match and is
returned in host.list_matches, and the host is expected to act on it. If you need a hard limit,
enforce it in the host before answering the join.
A whole match, in order
HOST -> { "event": "host.init_match", "max_players": 8 }
HOST <- { "success":1, "event":"host.init_match", "data": { "match_id":"01J...", "max_players":8 } }
(the host turns match_id into a QR code)
PLAYER -> { "event": "player.init" }
PLAYER <- { "success":1, "event":"player.init", "data": { "player_id":"01J..." } }
PLAYER -> { "event": "player.join_match", "match_id":"01J...", "player_id":"01J..." }
HOST <- { "success":1, "event":"player.join_match", "data": { "match_id":"...", "player_id":"..." } }
HOST -> { "event": "broadcast.to_player", "match_id":"01J...", "state":"question", "text":"..." }
PLAYER <- the same payload
PLAYER -> { "event": "player.generic_event", "match_id":"01J...", "answer": 2 }
HOST <- the same payload
HOST -> { "event": "host.close_match", "match_id":"01J..." }
What it does not do, and what to add if you need it
- No authentication. Anyone who knows a
match_idcan join. At an event that is usually fine, because the id is on a screen in the room. If it matters, have the host issue a short code and reject players who do not present it insideplayer.generic_event. - No persistence of matches. A restart drops them. If a match must survive one, the host keeps its own state and re-creates the match.
- No authority over the game. The relay believes what it is told. The host must validate: a player can claim any score, exactly as with an HTTP leaderboard.
- No rate limiting. Add it at the proxy if the server is public.
- One process, one machine. State lives in memory, so it does not scale across processes as it
is. For hundreds of simultaneous matches,
roomsandplayerswould move to Redis.
Sizing, from real use
The relay carries small JSON messages, so the limit is connections and not bandwidth. A small virtual server handles a few hundred simultaneous phones without effort. The thing that actually breaks at an event is never the server: it is the venue's wifi.
Where this source comes from
src/ is the published source of the relay, taken from the author's repository. The
app/<group>/<event>.ts layout is the whole design: the file name is the event name, and index.ts
wires them up at start-up. To add an event you add a file, and nothing else changes.
UGTKengine within an engine