Product

Shuffly

The backend behind a real-time voice platform. Two markets run on it, and a separate team builds the apps.

The published API reference. Three surfaces across the top navigation: REST, the WebSocket protocol and a server-to-server API. Endpoints run down the left, grouped into identity, voice rooms, games and economy. The open page is the token refresh endpoint, showing which credential takes precedence and why, the request body schema, and the responses with their status codes.

Scroll for the rest of the page

The refresh endpoint documents that the body wins over the header, and why: mobile clients attach a Bearer header on their own. Written for the engineers doing the integration.

The system

Real-time voice rooms in two markets, with a third coming. A separate team of four engineers builds the mobile apps. Everything behind the API they call is mine: the HTTP application, the WebSocket daemons holding room state, moderation and bans, purchases, push, and the deploy pipeline. The operator panel the operations team runs it from is mine as well.

Each market is a config file, not a fork. Localisation goes all the way to the client. Opening a market is a deploy.

KeyBuilt by the client teamRuns on my serversDirect path
  1. Clients

    • Per-market mobile clients

      Flutter · Dart

      Built by the client team

    • Operator panel

      Next.js · TypeScript

  2. Contract

    • REST and WebSocket API

      OpenAPI · WebSocket

      The client team builds against this surface and cannot see behind it. It is the one part of the system that cannot break quietly.

  3. Application

    • HTTP application

      PHP · Slim

    • Room daemon

      Workerman

      Why

      Holds tick-based room state: who is on which seat and for how long. REST does not share a process with it, they talk across Redis lists.

    • Direct-message daemon

      Ratchet

      Why

      Ratchet, handling one-to-one messages. Kept apart from the room daemon, which is tick-based and holds seat state.

  4. Services

    • Voice rooms

      LiveKit

      Why

      Swapped from the previous provider on a system already serving users. Clients hold the session, the token and the room state, so the apps had to ship in the same release.

    • Moderation and bans

      Why

      Mine end to end. The reporting pipeline, the automated checks and the ban system, none of it bought.

    • Wallet and purchases

      RevenueCat

      Why

      Third-party. A salaried team can buy back receipt validation and the store edge cases; on my own game I write the same job myself, because there the per-user bill is mine.

    • Push notifications

      Firebase Cloud Messaging

  5. State

    • Relational store

      MySQL

    • Cache and message bus

      Redis

Friend graph traversalPHPFrom HTTP application
/** * Pure breadth-first search over the friend graph. No database access: the * caller injects a neighbor provider, so this class can be unit-tested with an * in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS * distance (closer first), then by bridge count (more mutual paths first). * * Performance guards are class constants; the constructor accepts overrides so * tests can trip the caps cheaply without changing production behaviour. */class FriendGraphTraversal{    public const HARD_MAX_DEPTH = 4;    public const MAX_VISITED_NODES = 5000;    public const MAX_FRONTIER = 2000;    public function __construct(        private int $maxVisited = self::MAX_VISITED_NODES,        private int $maxFrontier = self::MAX_FRONTIER,    ) {    }    public function clampDepth(int $requested): int    {        if ($requested < 2) {            return 2;        }        return min($requested, self::HARD_MAX_DEPTH);    }    /**     * @param list<string>                                          $level1Keys     * @param callable(list<string>): array<string, list<string>>  $neighborProvider     * @param array<string, true>                                   $excludedKeys     *     * @return array{     *   candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,     *   truncated: bool     * }     */    public function run(        string $seedKey,        array $level1Keys,        callable $neighborProvider,        array $excludedKeys,        int $maxDepth,    ): array {        $maxDepth = $this->clampDepth($maxDepth);        // distance[key] = shortest hop count from seed. seed=0, direct friends=1.        $distance = [$seedKey => 0];        foreach ($level1Keys as $k) {            $distance[$k] = 1;        }        /** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */        $bridges = [];        $truncated = false;        $visited = count($distance);        $frontier = array_values(array_unique($level1Keys));        $d = 1;        while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {            if (count($frontier) > $this->maxFrontier) {                $frontier = array_slice($frontier, 0, $this->maxFrontier);                $truncated = true;            }            $neighbors = $neighborProvider($frontier);            /** @var array<string, array<string, true>> $nextLayer */            $nextLayer = [];            foreach ($frontier as $node) {                // Bridges that reach $node on a shortest path. A level-1 node is                // its own bridge; deeper nodes carry the accumulated set.                $nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);                foreach (($neighbors[$node] ?? []) as $nb) {                    if (isset($distance[$nb]) && $distance[$nb] <= $d) {                        continue; // reached earlier/at this layer — not via $node's next hop                    }                    if (!isset($nextLayer[$nb])) {                        $nextLayer[$nb] = [];                    }                    foreach ($nodeBridges as $b => $_) {                        $nextLayer[$nb][$b] = true; // union accumulates distinct paths                    }                }            }            $nextFrontier = [];            foreach ($nextLayer as $nb => $brs) {                if (isset($distance[$nb])) {                    continue; // committed in an earlier (shorter) layer                }                $distance[$nb] = $d + 1;                $bridges[$nb] = $brs;                $nextFrontier[] = (string) $nb;                $visited++;                if ($visited >= $this->maxVisited) {                    $truncated = true;                    break;                }            }            $frontier = $nextFrontier;            $d++;        }        $candidates = [];        foreach ($bridges as $key => $brs) {            if (isset($excludedKeys[$key])) {                continue;            }            $bridgeKeys = array_map('strval', array_keys($brs));            sort($bridgeKeys);            $candidates[] = [                'userKey' => (string) $key,                'distance' => $distance[$key],                'bridgeCount' => count($brs),                'bridgeKeys' => $bridgeKeys,            ];        }        usort($candidates, static function (array $a, array $b): int {            return [$a['distance'], -$a['bridgeCount'], $a['userKey']]                <=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];        });        return ['candidates' => $candidates, 'truncated' => $truncated];    }}
A breadth-first search with no database in it. The caller passes in a neighbour lookup, so the same search runs against an in-memory graph in a test and against the store in production, and the traversal never learns which. Candidates come back ranked by distance first, then by how many distinct paths reach them, and the guards on visited nodes and frontier size are constants the constructor can override so a test can trip them cheaply.

shuffly-serverb7006f04app/Services/Recommendation/FriendGraphTraversal.phpLines 6 to 136131 lines

/**
 * Pure breadth-first search over the friend graph. No database access: the
 * caller injects a neighbor provider, so this class can be unit-tested with an
 * in-memory graph. Produces distance-2..maxDepth candidates ranked by BFS
 * distance (closer first), then by bridge count (more mutual paths first).
 *
 * Performance guards are class constants; the constructor accepts overrides so
 * tests can trip the caps cheaply without changing production behaviour.
 */
class FriendGraphTraversal
{
    public const HARD_MAX_DEPTH = 4;
    public const MAX_VISITED_NODES = 5000;
    public const MAX_FRONTIER = 2000;

    public function __construct(
        private int $maxVisited = self::MAX_VISITED_NODES,
        private int $maxFrontier = self::MAX_FRONTIER,
    ) {
    }

    public function clampDepth(int $requested): int
    {
        if ($requested < 2) {
            return 2;
        }
        return min($requested, self::HARD_MAX_DEPTH);
    }

    /**
     * @param list<string>                                          $level1Keys
     * @param callable(list<string>): array<string, list<string>>  $neighborProvider
     * @param array<string, true>                                   $excludedKeys
     *
     * @return array{
     *   candidates: list<array{userKey: string, distance: int, bridgeCount: int, bridgeKeys: list<string>}>,
     *   truncated: bool
     * }
     */
    public function run(
        string $seedKey,
        array $level1Keys,
        callable $neighborProvider,
        array $excludedKeys,
        int $maxDepth,
    ): array {
        $maxDepth = $this->clampDepth($maxDepth);

        // distance[key] = shortest hop count from seed. seed=0, direct friends=1.
        $distance = [$seedKey => 0];
        foreach ($level1Keys as $k) {
            $distance[$k] = 1;
        }

        /** @var array<string, array<string, true>> $bridges candidateKey => set of level-1 bridge keys */
        $bridges = [];
        $truncated = false;
        $visited = count($distance);

        $frontier = array_values(array_unique($level1Keys));
        $d = 1;

        while ($d < $maxDepth && $frontier !== [] && $visited < $this->maxVisited) {
            if (count($frontier) > $this->maxFrontier) {
                $frontier = array_slice($frontier, 0, $this->maxFrontier);
                $truncated = true;
            }

            $neighbors = $neighborProvider($frontier);

            /** @var array<string, array<string, true>> $nextLayer */
            $nextLayer = [];
            foreach ($frontier as $node) {
                // Bridges that reach $node on a shortest path. A level-1 node is
                // its own bridge; deeper nodes carry the accumulated set.
                $nodeBridges = $d === 1 ? [(string) $node => true] : ($bridges[$node] ?? []);
                foreach (($neighbors[$node] ?? []) as $nb) {
                    if (isset($distance[$nb]) && $distance[$nb] <= $d) {
                        continue; // reached earlier/at this layer — not via $node's next hop
                    }
                    if (!isset($nextLayer[$nb])) {
                        $nextLayer[$nb] = [];
                    }
                    foreach ($nodeBridges as $b => $_) {
                        $nextLayer[$nb][$b] = true; // union accumulates distinct paths
                    }
                }
            }

            $nextFrontier = [];
            foreach ($nextLayer as $nb => $brs) {
                if (isset($distance[$nb])) {
                    continue; // committed in an earlier (shorter) layer
                }
                $distance[$nb] = $d + 1;
                $bridges[$nb] = $brs;
                $nextFrontier[] = (string) $nb;
                $visited++;
                if ($visited >= $this->maxVisited) {
                    $truncated = true;
                    break;
                }
            }

            $frontier = $nextFrontier;
            $d++;
        }

        $candidates = [];
        foreach ($bridges as $key => $brs) {
            if (isset($excludedKeys[$key])) {
                continue;
            }
            $bridgeKeys = array_map('strval', array_keys($brs));
            sort($bridgeKeys);
            $candidates[] = [
                'userKey' => (string) $key,
                'distance' => $distance[$key],
                'bridgeCount' => count($brs),
                'bridgeKeys' => $bridgeKeys,
            ];
        }

        usort($candidates, static function (array $a, array $b): int {
            return [$a['distance'], -$a['bridgeCount'], $a['userKey']]
                <=> [$b['distance'], -$b['bridgeCount'], $b['userKey']];
        });

        return ['candidates' => $candidates, 'truncated' => $truncated];
    }
}
  • Analytical queries read the store directly rather than through the API, so reporting load never reaches the application server.

Replacing the voice engine while it ran

The biggest job was swapping the real-time voice provider on a system already serving users.

The swap could not stop at the backend. Clients hold the session, the token and the room state, so the apps had to ship in the same release. I spent that migration committing to the client team’s repo, on their branch, instead of sending a changelog and waiting.

Contribution

Commits authored
2,074
In another team's codebase
175
Decisions recorded
102

22 of 23 weeks active · 6 Apr 2026 – 13 Sep 2026

Longest run · 15 weeks · Jun – Sep

I maintain the service four client engineers build against. When the voice provider changed I worked in their repository alongside them, on their branch.

Measured 2026-09-08

We settled what the platform stores at the same time:

  • One-to-one messages: persisted. A delivery guarantee is worth the storage it costs.
  • Voice-room chat: not persisted. Message bodies are never written anywhere, which keeps the biggest table in the system from existing.

The API is the product

Four engineers build on it and cannot read the code behind it, so it gets treated like a product.

  • Versioned and published, split by path, schema and channel. A pipeline publishes on merge, so their docs are never older than my code.
  • Every endpoint states its side effects: whether calling it twice is safe, what to do with each response, which errors are worth a retry.
  • Integration guides written for those engineers, covering whole flows rather than single endpoints.
The operator panel's currency economy report. Date range, user type and gender filters across the top, and five summary figures beneath them: issued, spent, net change in circulation, transactions for the period, and the ratio between in and out. Below those, two ranked breakdowns of where currency enters and leaves, and a cumulative supply chart.

Scroll for the rest of the page

The panel is the other client on this contract, and the one I build. Aggregations like this one are why it reads the store directly instead of going through the API.

The stack I inherited

PHP 8, Slim 4, MySQL, Redis, with the WebSocket daemons beside the HTTP application instead of inside it.

I made the case for replacing it early, while it was still cheap. That call went the other way, so the job became making it safe for other people to work in.

53

Subsystem docs

How each part behaves.

102

Decision records

Why it behaves that way.

4

Test suites

Against real MySQL and Redis.

Mocks hide the bugs that matter here, so the suites run against the real thing.

Past the code

  • Deploy and rollback. Four branches. Only a commit whose tests passed can reach production, and a tag enforces it.
  • The deploy verifies itself. After a reload it probes the WebSocket proxy for a real answer. This host’s own health report is not reliable.
  • Also mine: moderation and bans, push delivery, staging, CI.