A self-hosted matchmaking and turn sync server for turn-based multiplayer games. One small PHP app, one SQLite file, no websockets, no game engine lock-in. The server holds zero game rules, your clients define what the moves mean, and the server handles finding opponents, private lobbies, and delivering every action to every player in order. Any device that can make an HTTPS call can use it, so it works for web games, Unity, mobile, or anything else that talks to the web.
Project page: AbraTabia Game Server on abratabia.com
- Public matchmaking: players join a queue with one or more game modes and a party size, and the server groups compatible players into a match. Modes are just strings your game invents, like "1V1", "RANKED", or "TICTACTOE".
- Private matches: a player opens a lobby and gets a short join code to share. Friends join with the code and the match starts when the lobby is full.
- Matches of 2 to 10 players: party size is part of matchmaking, so 4 player free-for-alls queue separately from duels.
- Ordered action log: each move is an action with a type and three opaque data fields. Clients poll for actions newer than the last one they saw and always receive them in order, so every client replays the same game.
- Player state recovery: a client that reconnects can ask where it is (matched, waiting, or nothing) and re-pull the full action log, so a dropped phone connection does not kill the game.
- Self-cleaning storage: queue entries and abandoned matches expire on their own, no cron needed.
- Admin area: watch the live queue and active matches, inspect any match's players and full action log, and end stuck matches.
- Demo game: a playable two-player tic tac toe page. Open it in two browser tabs and the tabs match against each other through your server.
- Legacy split-string mode: every endpoint can answer in a plain text comma-and-newline protocol instead of JSON, made for clients that would rather
.Split(',')than parse JSON (this is how the original Unity integration works).
Requires PHP 8.1+ with the pdo_sqlite extension (standard almost everywhere).
cp config.sample.php config.php
# edit config.php: set apiKey and adminPassword
php -S localhost:8080 -t public
Then open http://localhost:8080/demo.php in two browser tabs, enter your API key in both, and click Find Public Match in each.
cp config.sample.php config.php
# edit config.php as above
docker compose up --build
The app is at http://localhost:8080/ with the same admin and demo pages.
Upload the project and point your docroot (or a subdomain) at the public/ folder. If you can only upload into an existing docroot, upload everything into a subfolder and the API lives at yoursite.com/subfolder/public/api.php. The SQLite database is created automatically in data/.
config.sample.php copy to config.php, holds keys and settings
public/
api.php the API your game clients call
admin.php admin area for the queue, matches, and action logs
demo.php playable tic tac toe demo (two tabs = two players)
lib/
gameserver.php matchmaking, lobbies, and action sync core
db.php SQLite storage layer
data/ SQLite database lives here (gitignored)
Everything is in config.php (copied from config.sample.php):
| Setting | What it does |
|---|---|
| apiKey | the key game clients must send with every API call |
| adminPassword | password for admin.php |
| corsOrigin | which browser origins may call the API, "*" or your game's origin |
| dbPath | SQLite file location |
| queueSeconds | how long a waiting queue entry lives (default 120) |
| matchSeconds | how long a match lives with no new actions (default 3600) |
| maxPlayers | largest match size a client may request (default 10) |
All endpoints are POST with a JSON body (a form post works too). Send your key in the X-API-Key header, or as an apiKey field in the body. Paths use path info, and if your server does not support that, api.php?action=join works the same as api.php/join.
Join public matchmaking, or open or join a private lobby.
curl -X POST http://localhost:8080/api.php/join \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_KEY" \
-d '{"playerid": "player-abc123", "gameMode": "1V1", "numPlayers": 2, "username": "Paul"}'
Request fields:
| Field | Notes |
|---|---|
| playerid | required, your client's unique ID for this player (3+ characters) |
| gameMode | one mode or several joined with &, like "1V1&2V2". Players match when they share a mode. Default "1V1" |
| numPlayers | how many players this match should have, 2 to maxPlayers, default 2 |
| username | optional display name shared with the other players |
| playerInfo, talentInfo, charInfo | optional opaque strings shared with the other players, for loadouts, decks, skins, whatever your game needs |
| private | send "1" to open a private lobby instead of public matchmaking |
| joinCode | join an open private lobby by its code. With private: "1" and no code, the server generates one for you |
| timeLimit | optional string stored on the match for your clients to read |
Response: either a matched object (see below) if enough compatible players were already waiting, or:
{"status": "waiting", "numPlayers": 2}
Private lobbies also return joinCode and playersWaiting. After a waiting response, poll status every couple of seconds.
Where is this player right now? Fields: playerid. Returns one of:
- A matched object, the same shape join returns when a match forms.
{"status": "waiting", ...}still in the queue (private lobbies includejoinCodeandplayersWaiting).{"status": "none"}not in a queue or match, either the queue entry expired or the player never joined. Re-join to try again.
The matched object:
{
"status": "matched",
"matchid": "g17529339421234",
"gameMode": "1V1",
"numPlayers": 2,
"yourPlayerNum": 1,
"timeLimit": "0",
"private": 0,
"joinCode": "",
"lastActionID": 0,
"players": [
{"playerNum": 0, "username": "Paul", "playerInfo": "", "talentInfo": "", "charInfo": ""},
{"playerNum": 1, "username": "Ana", "playerInfo": "", "talentInfo": "", "charInfo": ""}
]
}
yourPlayerNum is this player's slot. A common convention is that slot 0 moves first, but that is your game's call.
Send one game action to a match.
curl -X POST http://localhost:8080/api.php/action \
-H "Content-Type: application/json" \
-H "X-API-Key: YOUR_KEY" \
-d '{"playerid": "player-abc123", "matchid": "g17529339421234", "actionType": "move", "data1": "e2e4"}'
Fields: playerid, matchid, actionType, and optional data1, data2, data3, totalTime. All data fields are opaque strings, the server never interprets them. Actions like quitting, offering a draw, or chatting are just action types your game defines. Returns {"status": "ok", "actionID": 1752933999}.
Poll for actions newer than the last one this client has. Fields: matchid, since (the highest actionID already received, 0 for everything).
{"status": "ok", "count": 1, "lastActionID": 1752933999, "actions": [
{"actionID": 1752933999, "player": 0, "actionType": "move", "data1": "e2e4", "data2": "", "data3": "", "totalTime": "0"}
]}
Send the returned lastActionID as since on the next poll. The list includes your own actions too, which doubles as delivery confirmation. Polling every 1 to 3 seconds is plenty for turn-based games.
Leave the matchmaking queue. Fields: playerid. Returns {"status": "left"}. Leaving a match already in progress is a game-level event, send an action your game defines (like actionType: "quit") so the other players hear about it.
- Each client joins with a
playeridyou generate, agameMode, and anumPlayers. - Clients that get
waitingpollstatusuntil they get amatchedobject. - Everyone learns their
yourPlayerNumand the other players' info from the matched object. - Players take turns: send an
action, and pollactionsto receive everyone's moves in order. - Your clients decide what actions mean, whose turn it is, and when the game ends.
Add format=legacy to any call (query string or body field) and responses become plain text lines instead of JSON, matching the original Unity integration this server grew out of. JSON field names are unchanged on the request side, and the legacy field names currentActionID (for since), multirequest (as actionType=data1=data2=data3), and totalMoveTimer are also accepted.
Responses:
| Call | Legacy response |
|---|---|
| join, status | matched: line one is 1,matchid,numPlayers,yourPlayerNum,timeLimit,gameMode,private,0, then one line per player slot as playerInfo*talentInfo*charInfo. Waiting: 2. Not anywhere: 3. Error: 0,message |
| action | 1 ok, 0 error |
| actions | one line per action as actionID,player,actionType,data1,data2,data3,totalTime, or OK when there is nothing new |
| leave | 3 |
A minimal Unity example using the legacy mode:
IEnumerator JoinQueue() {
WWWForm form = new WWWForm();
form.AddField("apiKey", "YOUR_KEY");
form.AddField("format", "legacy");
form.AddField("playerid", myPlayerId);
form.AddField("gameMode", "1V1");
using (UnityWebRequest req = UnityWebRequest.Post(baseUrl + "/api.php/join", form)) {
yield return req.SendWebRequest();
string[] lines = req.downloadHandler.text.Split('\n');
string[] header = lines[0].Split(',');
if (header[0] == "1") { matchId = header[1]; myPlayerNum = int.Parse(header[3]); }
else if (header[0] == "2") { /* waiting, poll status */ }
}
}Note that the legacy format uses commas, asterisks, and newlines as separators, so keep those characters out of playerInfo, talentInfo, and charInfo if you use this mode. JSON mode has no such restriction.
demo.php is a complete two-player tic tac toe built on nothing but the five API calls above, in one page of plain JavaScript. It is the fastest way to check your setup and a working reference client: join, poll status, render state from the action log, send moves. Open it in two tabs, or on two devices, and play.
- Set
apiKeyandadminPasswordto long random strings before exposing the server. - A key shipped inside a browser game is visible to players. That lets anyone call your API, which for a game server mostly means they could fill your queue, so lock
corsOrigindown and keepqueueSecondsmodest, or route calls through your own game server so the key stays private. - The server trusts clients on game rules by design. For casual play between friends that is fine. For competitive games, have your clients validate each other's moves from the shared action log and end the game on any illegal move.
- Serve over HTTPS in production.
- Optional server-side turn enforcement (reject actions out of turn order).
- Spectator access to a match's action log.
- Skill brackets for public matchmaking (the protocol already reserves the field).
Paul Crinigan, https://www.aiappsapi.com/
- AbraTabia AI Storyteller, a self-hosted AI storyteller and game host API. Its gamehost bots make a great AI game master on top of this server's player sync.
- SMS Campaign Engine, a self-hosted SMS campaign engine with AI chatbot replies.
- Email Campaign Engine, the same idea for email: broadcasts, drips, and webhooks over plain SMTP.
- Auto Learning Agents, AI agents that learn your business and get better every day.
- Adaptive Recall, a persistent memory system for AI assistants.
- ACP Payments Module, a free agentic commerce payments module.
MIT, see LICENSE.