Living Museum of Learning

Where real moments become exhibits
← Prev Next β†’
The Game Bridge

The Game Bridge

When Wi-Fi Failed, We Built Another Door

Situation

For months, Golden Xiangqi and Golden 24 had relied on Apple's Multipeer Connectivity (MPC) for multiplayer games.

It sounded perfect.

Two people have two Apple devices. They are sitting together. They should be able to play.

Then came Vancouver.

At the waterfront airport, I invited Cindy and An to play Golden 24.

The three of us were physically together.

But there was no Wi-Fi.

And the game failed.

That was shocking.

"Nearby" was supposed to mean nearby. Yet being physically close was not enough for our particular MPC setup.

Back in Toronto, I shut Wi-Fi off and reproduced the problem again and again. We tested it carefully and confirmed the uncomfortable fact:

For our games, MPC was not a reliable offline-nearby transport.

That changed the question.

We did not need a prettier multiplayer interface.

We needed another door.

Turning Point

The obvious temptation would have been to redesign the multiplayer system.

We resisted.

The games already had a useful abstraction hiding inside them:

Game
↓
GTSessionManager
↓
GTPacket

The game did not really care how a packet travelled.

A move was a packet.

A player update was a packet.

A game state was a packet.

A resignation was a packet.

So instead of replacing MPC, we asked a smaller question:

Can we give the packets another way to travel?

That became the Game Bridge.

The First Bridge

We already had a tiny Node service running on muzhi.com.

So instead of building a complicated multiplayer server, we started with something almost embarrassingly simple:

a mailbox.

One player can POST a message.

Another player can GET a message.

The server puts messages into a FIFO queue.

That was enough to prove the fundamental idea:

iOS
↓ POST
Game Bridge
↓ mailbox
iOS

There was no grand multiplayer architecture.

No database.

No game-specific server logic.

No attempt to make the server understand chess.

The server simply carried a message.

And that was exactly what we wanted.

The Packet

Our games already had GTPacket:

public struct GTPacket: Codable {
public let packetId: UInt64
public let type: GTPacketType
public let payload: Data
}

That was a beautiful discovery.

The existing game protocol could survive.

The Game Bridge did not need to understand:

chess
Chinese chess
Junqi
Golden 24
players
turns
boards
cards

It only needed to transport a GTPacket.

So the server became almost completely ignorant of the game.

That made the bridge surprisingly powerful.

First Proof: iOS ↔ Server ↔ iOS

We wrote tests that could:

drain the mailbox,
create a GTPacket,
POST it to the production server,
GET it back,
decode it,
compare every important field.

We tested:

host β†’ guest
guest β†’ host
move packets

And they worked.

The Game Bridge was no longer an idea.

It was carrying the same packets our games already understood.

Then We Made the Internet Transport

We built GTInternetService.

Its job was intentionally tiny:

GTPacket
↓
JSONEncoder
↓
HTTP POST
↓
Game Bridge

And on the other side:

Game Bridge
↓
HTTP GET
↓
JSONDecoder
↓
GTPacket

The first standalone Internet test passed.

That gave us an important separation:

the server worked independently of the games.

We didn't yet need to risk the seven existing apps.

The Oops Detector

Then came the dangerous part.

Our existing transport interface was synchronous.

Internet communication is not.

We briefly tried to hide the asynchronous HTTP operation inside a fire-and-forget Task.

That looked convenient.

It wasn't.

The test immediately exposed the problem.

The caller returned before the HTTP POST had completed, and the next GET found an empty mailbox.

Oops.

Exactly the kind of Oops detector we wanted.

The lesson was simple:

Don't pretend network communication is synchronous.

So we changed the transport boundary itself:

func broadcast(_ packet: GTPacket) async throws

Now completion actually meant something.

If the caller awaited the broadcast, the network operation had completed.

The Hundreds-of-Changes Experiment

This was the scary part.

GTSessionManager is shared by seven apps.

Changing its transport API meant the compiler suddenly found all the places where the old assumption existed.

Golden 24 had eight errors.

Golden Xiangqi had similar errors.

Then Junqi.

Then Chess.

Then Golden Five.

And the remaining games.

Hundreds of places changed.

But something remarkable happened.

The errors were not architectural disasters.

They were mostly the same small pattern.

For example:

sessionManager.broadcast(
type: .playersState,
payload: data
)

became:

Task {
try? await sessionManager.broadcast(
type: .playersState,
payload: data
)
}

The game logic itself did not need to become an asynchronous world.

We simply created an asynchronous boundary at the point where game code asked the transport to send something.

One by one, the compiler breadcrumbs disappeared.

And then:

all seven apps compiled.

The More Important Test

Compilation wasn't enough.

We had not tested MPC for months.

So I took an iPhone and an iPad and played a few moves of Golden Xiangqi.

Then I resigned.

πŸ˜‚

It worked.

The old MPC path still worked exactly as before.

Then Golden 24 worked too.

So we had something much better than "the compiler is happy."

We had:

Seven apps
↓
shared library changed
↓
seven apps compile
↓
seven apps launch
↓
real iPhone ↔ iPad MPC
↓
Golden Xiangqi works
↓
Golden 24 works

The old door had survived.

Two Transports, One Doorway

Now we introduced the actual abstraction:

public protocol GTPacketTransport: AnyObject {
func broadcast(_ packet: GTPacket) async throws
}

GTNearbyService became one implementation.

GTInternetService became another.

Conceptually:

(See the 1st diagram on the cover image)

This was a surprisingly important moment.

The games did not need to know which transport was underneath.

They knew only:

"Broadcast this packet."

We Tested the Abstraction

It was not enough to say that GTInternetService happened to have the same method.

We explicitly treated it as:

let transport: any GTPacketTransport =
GTInternetService()

and sent a real packet through the protocol.

The test passed.

Then we went one level higher.

We changed GTSessionManager so that a transport could be injected:

GTSessionManager(
packetTransport: transport
)

MPC remained the default.

Nothing about the existing games had to change.

And then we tested the actual chain:

GTSessionManager
↓
GTPacketTransport
↓
GTInternetService
↓
HTTPS
↓
muzhi.com
↓
mailbox

The test passed.

We had finally connected the new transport to the existing session architecture.

What We Have Actually Built

It is tempting to say:

"We built an Internet multiplayer server."

But that isn't quite right.

We built something more interesting.

We built a transport bridge.

The games already know how to communicate using GTPacket.

The Game Bridge gives those packets another route.

(See the 2nd diagram on the cover image)

The server does not need to understand the game.

That is the key.

Why the Game Bridge Exists

The Game Bridge was not born from an architectural exercise.

It was born from a very concrete embarrassment:

Vancouver waterfront airport.

Three people.

Three Apple devices.

A multiplayer game.

No Wi-Fi.

And no game.

That experience exposed an assumption we had been making:

"Nearby devices should be able to play nearby."

Our actual technology stack had a more complicated definition of "nearby."

The Game Bridge removes that dependency.

If two players have Internet access, they don't need to be on the same local network.

Grandpa can be in Toronto.

Rhea can be in Sunnyvale.

The devices don't need to discover each other in a room.

They only need to reach the bridge.

The Beautiful Part

We didn't have to throw away MPC.

We didn't have to rewrite the games.

We didn't have to create a chess server.

We didn't have to teach the server the rules of Xiangqi.

We didn't have to redesign GTPacket.

We didn't even have to turn the game view models into giant asynchronous machines.

Instead, we found one seam:

.broadcast()

That seam was enough.

Hundreds of compiler errors later, all seven apps still stood.

πŸ˜‚

And that may be the most interesting lesson of the whole experiment:

A good abstraction can survive a surprisingly large change underneath it.

Emergence

The Game Bridge started as a workaround for a frustrating networking failure.

It is becoming something larger.

The same transport abstraction can potentially serve:

Golden Xiangqi
Golden 24
Junqi
Chess
Golden Five
future games
future multiplayer experiments
perhaps even p5.js games

And because the packets are game-independent, the bridge can become a common piece of infrastructure rather than a feature belonging to one game.

The server doesn't know whether a packet represents a chess move or a player's answer in Golden 24.

It just carries the packet.

That is exactly what a bridge should do.

Where We Are Now

At this checkpoint:

Server

Game Bridge mailbox works in production.
iOS can POST packets.
iOS can retrieve packets.
Packets survive JSON encoding and decoding.

Transport

GTPacketTransport exists.
GTNearbyService implements it.
GTInternetService implements it.
Both use the same packet abstraction.

Session Manager

GTSessionManager accepts an injected transport.
MPC remains the default.
Internet transport can be injected for testing.

Games

All seven apps compile.
All seven apps run.
Golden Xiangqi MPC was tested on real iPhone/iPad hardware.
Golden 24 was tested too.

Tests

Standalone Game Bridge tests pass.
Internet transport tests pass.
Internet transport-through-protocol tests pass.
Session Manager β†’ Internet transport β†’ Game Bridge tests pass.

And all of this started with one question:

What if two people are sitting right next to each other, but their Apple devices can't actually talk to each other?

Now we have another door.

The Next Door

The remaining question is no longer technical plumbing.

It is a player experience question:

When someone chooses "Host Game" or "Join Game," which transport should they use?

We can expose the choice.

And perhaps, given what we learned in Vancouver, Internet should be the natural path rather than treating MPC as the dependable default.

MPC can remain available.

But the Game Bridge gives our games something they did not have before:

multiplayer that doesn't require the players to be nearbyβ€”and doesn't depend on their devices sharing the same local networking environment.

The bridge is small.

The idea behind it is not.

Sometimes the best way to make a door reliable is to build another door.