Migrating from Hathora to asobi

View Source

Hathora's game-hosting service shut down on 2026-05-05. This guide takes you from "we need a new backend" to a running asobi deployment.

Nobody has migrated a Hathora game to asobi end to end yet. The asobi-side endpoints and events below are verified against this repository; the Hathora-side method names are from memory of the pre-shutdown SDK and may have drifted. The fastest route is pairing with us in the Discord #migrations channel.

This guide targets studios on Hathora's managed service. Self-hosted hathora-core users have a different problem - skip to Self-hosted Hathora users.

What asobi is

One Erlang/OTP node containing the game backend, the Lua runtime and the operator console. Two ways in: run ghcr.io/widgrensit/asobi and write Lua, or depend on the Hex package and write Erlang. Same node either way.

Today, in 15 minutes

Four steps. They unblock you even if the full port takes a week.

1. Write a minimal game. asobi loads Lua from /app/game, and without a mode declared there the matchmaker has nothing to match on. In an empty directory:

-- lua/match.lua

match_size = 2

function init(_config)
    return { players = {} }
end

function join(player_id, state)
    state.players[player_id] = { score = 0 }
    return state
end

function leave(player_id, state)
    state.players[player_id] = nil
    return state
end

function handle_input(_player_id, _input, state)
    return state
end

function tick(state)
    return state
end

function get_state(_player_id, state)
    return { players = state.players }
end

2. Stand the backend up. Next to lua/:

services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: my_game
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s

  asobi:
    image: ghcr.io/widgrensit/asobi:latest
    depends_on:
      postgres: { condition: service_healthy }
    ports: ["8084:8084"]
    volumes: ["./lua:/app/game:ro"]
    environment:
      ASOBI_DB_HOST: postgres
      ASOBI_DB_NAME: my_game
      ASOBI_CORS_ORIGINS: "http://localhost:5173"
      ASOBI_CONSOLE: "true"
      ASOBI_OPS_SECRET_FILE: /run/secrets/ops_secret
    secrets: [ops_secret]

secrets:
  ops_secret:
    file: ./ops_secret.txt

openssl rand -hex 32 > ops_secret.txt, then docker compose up -d. HTTP is on :8084, the WebSocket is on /ws, the console is on /console.

ASOBI_CORS_ORIGINS is not optional for a browser client: unset, the node sends an empty Access-Control-Allow-Origin and every fetch from a page is blocked.

3. Register one player.

curl -s localhost:8084/api/v1/auth/register \
  -H 'content-type: application/json' \
  -d '{"username":"test","password":"test1234"}'
# { "player_id": "019de3...", "access_token": "...", "refresh_token": "...", "username": "test" }

access_token is what the client passes as Authorization: Bearer ... from here on. refresh_token buys a new pair from POST /api/v1/auth/refresh. There is no session_token anywhere in asobi.

4. Queue for matchmaking.

curl -s localhost:8084/api/v1/matchmaker \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer <access_token>' \
  -d '{"mode":"default","properties":{}}'
# { "ticket_id": "019de3...", "status": "pending" }

A 400 with matchmaker.unknown_mode means the node found no mode called default, which almost always means lua/match.lua is not mounted where step 2 puts it.

Then open a tracking issue at github.com/widgrensit/asobi/issues and say hello in the Discord #migrations channel with your setup: engine, language, lobby versus matchmaker, server-authoritative versus P2P. We will tell you which sections below apply to you.

The full port, in outline

  1. Your game-server logic keeps running in its own process while you migrate.
  2. asobi comes up alongside it. Your game server talks to it over WebSocket like any other auth, matchmaker or leaderboard service.
  3. You port the Hathora-specific calls to the equivalents in the concept map.
  4. Once asobi owns auth, matchmaking and lobbies, you drop Hathora and either keep your game server in a plain container, or fold its logic into a match.lua and delete the container.

For most Hathora games the game server is a few hundred lines of state mutation, which is well within the scope of one Lua file.

Concept map

HathoraasobiNotes
Applicationasobi deploymentOne container per environment.
RoomMatchOne process per match; state lives in the process heap.
ProcessNo equivalentasobi does not spin a container per match. One container hosts thousands of matches as BEAM processes.
LobbyMatchmaker ticket plus a match in its waiting phasePOST /api/v1/matchmaker; when match_size is reached the match starts.
RegionDeployment locationOne container per region, chosen by you. There is no region abstraction.
Matchmaker 2.0asobi_matchmakerStrategies fill and skill_based, or your own via the asobi_matchmaker_strategy behaviour.
HathoraClient.loginAnonymousPOST /api/v1/auth/guestDevice-backed anonymous auth: device_id plus device_secret, and you get a real player back. Claim it later with POST /api/v1/auth/guest/upgrade. Opt-in - see the note below the table.
HathoraClient.loginGooglePOST /api/v1/auth/oauthOAuth/OIDC.
createLobby, createRoom, queuePOST /api/v1/matchmakerBody {"mode": "...", "properties": {}}, response {"ticket_id": "...", "status": "pending"}.
Ticket pollGET /api/v1/matchmaker/:ticket_id
CancelDELETE /api/v1/matchmaker/:ticket_id
listActivePublicLobbiesGET /api/v1/matches/liveLive, joinable matches; filter with mode and has_capacity. Matches are unlisted by default and a mode opts in with listed = true (a Lua global, or listed => true in the operator's game_modes config). Not GET /api/v1/matches, which is the finished-match record table.
getConnectionInfo(roomId)WebSocket upgrade on GET /wsSee WebSocket handshake. The first frame must authenticate.
Custom room messagesExtension RPCFrame rpc.call with {protocol: 1, method, params}; replies rpc.ok {result} or rpc.error {error: {code, message, details}}, correlated by cid. All seven client SDKs support it. See Extensions.
ping region APINoneProbe each deployment endpoint yourself if you need client-side region selection.
Hathora SDKasobi SDKsUnity, Unreal, JS/TS, Godot, Defold, LÖVE, Dart, Flame.
Hathora ConsoleBuilt-in operator console at /consoleOff by default, and reads plus player erasure/export. See the note below the table.
hathora.ymldocker-compose.ymlPlain Compose, no proprietary spec.

Guest auth is off until two things are true: the game declares guest_auth in its Lua config, and the operator supplies a pepper of at least 32 bytes. Either one missing and POST /api/v1/auth/guest answers guest.disabled. See Authentication.

A stock node serves neither the console nor the ops API; you turn them on - see Operator console. When you do, the plane is reads plus player erasure and export, apart from actions an extension declares. Coming from the Hathora console you will look for a restart-this-process button; there is not one, because there is no process per match to restart.

Migration path

Phase 1 - stand up asobi alongside Hathora (1 day)

Use the compose file from step 2 above, on the same cloud or locally, without touching the Hathora deployment. Goal: auth, a lobby and a match working end to end from your client. Requirements and the production compose are in Self-hosting.

Phase 2 - port the client SDK calls (2 to 5 days)

Swap the Hathora SDK for the asobi SDK for the same engine. Do it one feature at a time: auth first, then the WebSocket handshake, then matchmaking, then the game-session messages. Hathora and asobi coexist in the client during this phase behind different base URLs.

Matchmaker tickets resolve asynchronously over the WebSocket as match.matched with payload {match_id, players}. Polling GET /api/v1/matchmaker/:ticket_id works too.

Each SDK's README carries its own call names; this guide does not restate them because they differ per language.

WebSocket handshake

asobi expects every WebSocket client to authenticate with a session.connect frame before any other message type is accepted. The payload field is token, carrying the access_token from register, login or guest:

{"type":"session.connect","payload":{"token":"<access_token>"}}

The server replies:

{"type":"session.connected","payload":{"player_id":"019de3..."}}

A missing or misspelled token field is not a shape error - it is treated as a token that did not resolve, so the reply is an error frame carrying the wire code unauthenticated:

{"type":"error","payload":{"reason":"invalid_token","error":{"code":"unauthenticated","message":"The credentials are missing, expired, or invalid.","details":{}}}}

After a successful handshake the server routes match, matchmaker, chat and world events to this player. The message types a client may send are:

session.connect, session.heartbeat, matchmaker.add, matchmaker.remove, match.join, match.leave, match.input, match.list, world.create, world.find_or_create, world.join, world.leave, world.input, world.list, chat.send, chat.join, chat.leave, dm.send, presence.update, vote.cast, vote.veto, rpc.call.

Server-pushed types follow {domain}.{event}: match.matched, match.state, match.finished, world.tick, world.terrain, chat.message, dm.message, notification.new, error, plus any leaf name your script broadcasts under match. or world.. The full reference is WebSocket protocol.

Phase 3 - port the game logic (2 days to 2 weeks)

Option A - keep your existing game server. If you have a lot of C# or Go server code you would rather not rewrite, keep running it in its own container. Use asobi for auth, matchmaking, lobbies, leaderboards and persistence. When the matchmaker fires match.matched, the client has an access_token from asobi; pass it, plus player_id and match_id, to your game server over your own connection, and have your game server check the token with asobi before accepting input.

There is no dedicated server-to-server token-introspection route. Check a token by calling any authenticated GET with it (GET /api/v1/friends is a cheap one) and treating 200 as accepted, 401 as not. Two caveats before you build on it: no core route reliably reports the caller's own player_id - a friends, notifications or saves response carries it only on rows the player already has, and is empty otherwise - so a 200 proves the token is valid, not whose it is. And do not use POST /api/v1/auth/refresh for the check. That endpoint takes a refresh_token, not an access token, so an access token simply fails there; and a refresh token rotates the pair, with a second presentation of a rotated token revoking the whole token family and logging the player out. If you need real introspection, an extension can add it: an RPC handler receives the caller's player_id in its context. See Extensions.

Option B - fold the game logic into Lua. Rewrite your tick, input and state logic as a match.lua, using the six callbacks from step 1:

  • init(config) - once per match, returns the initial state
  • join(player_id, state) and leave(player_id, state)
  • handle_input(player_id, input, state) - one client match.input frame
  • tick(state) - every 100ms
  • get_state(player_id, state) - the per-player view

Matches tick every 100ms and that is fixed; tick_rate is a world-mode setting. You get live reload for free - edit, save, and the next tick re-evaluates the file against the running state - and you delete a container. See Lua scripting.

Phase 4 - cut over (1 day)

Point the client at the asobi endpoint behind a feature flag. Monitor for 24h. Shut Hathora down.

Deploy story

asobi runs anywhere Docker runs. The managed version is asobi.dev/cloud, the same open-source core.

A single node holds 3,000-7,000 concurrent WebSocket connections in measurement, at 4.4ms p50 round-trip with 3,500 of them - see Benchmarks. Most games' first deployment is one small machine plus a Postgres, which is where the saving against process-hour billing comes from.

If you plan on more than one node, read Clustering first: the matchmaker queue is per node, so players queuing against different nodes never match each other, rate limits are per node, and the console needs a sticky route.

Self-hosted Hathora users

If you run hathora-core on your own infrastructure you still own the stack and can keep running it as long as it works. The same migration strategy applies when you decide to move.

Things asobi does not do

  • No UDP transport. WebSocket over TCP only. A twitch FPS, fighting game or racer that needs sub-3ms physics should pair asobi with a UDP relay and use asobi for auth, matchmaking, economy, leaderboards and social.
  • Guest auth is opt-in and off by default. It exists and it is device-backed rather than a throwaway username, but it stays off until the game declares guest_auth and the operator supplies a pepper of at least 32 bytes.
  • No server-to-server token introspection route. See Option A above.
  • No automatic multi-region. One container per region, deployed by you.
  • No client-side prediction or rollback netcode primitives.
  • Pre-1.0 API. Minor breaking changes are possible until 1.0.

Do this today

  • Run the compose from step 2 locally and register a player.
  • Pick one SDK call in your client to port first, usually loginAnonymous.
  • Join the Discord.
  • Decide Option A or Option B and open a thread in Discussions.

Getting help

See also