Living Museum of Learning

Where real moments become exhibits
← Prev Next β†’
The Move That Disappeared

The Move That Disappeared

A real three-player game, one mysterious frozen hand, and the detective story of 10 3 7 6

Situation

It was a real game.

Three people were playing Golden 24 over the Internet:

Rhea on an iPhone, hosting the room
Cindy on another iPhone
Grandpa on an iPad

This was not a demo.

The three devices were connected through our new Internet Game Bridge: a tiny server at muzhi.com, rooms identified by four-letter codes, and small messages β€” GTPackets β€” traveling between players.

The game had already survived a lot of testing.

Players appeared.

Scores synchronized.

Game hands changed.

Moves traveled between devices.

So we played.

And played.

And played.

Then, after a long session, Grandpa encountered this hand:

10 3 7 6

He solved it:

(6 Γ· 3) Γ— 7 + 10

That is:

2 Γ— 7 + 10
= 14 + 10
= 24

Perfect.

Grandpa had solved the puzzle.

But something strange happened.

His score increased.

The next hand did not appear.

The old hand β€”

10 3 7 6

β€” remained on the screen.

That was the mystery.

Chapter 1 β€” The Impossible Symptom

At first glance, the symptom seemed contradictory.

If Grandpa's score increased, surely the game had received his solution.

But if the game had received his move, why didn't the host start the next hand?

Golden 24's host logic was straightforward.

When the host receives a move, it eventually does this:

receive move
↓
show the answer
↓
initialize a new game
↓
broadcast the new game state

So if the host never received the move, the old hand would remain.

But why would the score update?

That suggested that two different messages were involved.

The score update came through a playerUpdate packet.

The actual solution came through a .move packet.

Perhaps one had arrived while the other had not.

That was the first important clue.

Chapter 2 β€” Go to the Crime Scene

We went to the host's log.

Rhea's iPhone was the host.

And the log contained something extraordinary.

At around the time Grandpa solved the hand, the host received:

RECV #1790257781406 playerUpdate (158 bytes)

Then the host sent the updated player roster:

SEND #18 playersState (658 bytes)

So Grandpa's player update had arrived.

That explained the score.

But then we looked for the move.

Nothing.

There was no:

RECV ... move

for Grandpa's move.

Instead, the host eventually reported:

Internet room polling failed:
DecodingError.dataCorrupted:
Data was corrupted.
Path: payload.
Encountered Data is not valid Base64.

Now we had a much stronger hypothesis.

The move had probably been sent.

It had probably reached the server.

But somewhere between sending and decoding, its payload had been corrupted.

Chapter 3 β€” Follow Grandpa's Move

So we went to Grandpa's iPad.

And there it was:

SEND #2 move (39 bytes)

The iPad had definitely sent the .move packet.

Even better, the packet was then broadcast toward both other players:

Internet broadcast move β†’ Rhea
Internet broadcast move β†’ Cindy

So the game logic on Grandpa's iPad had done its job.

The host should have received the packet.

But it didn't.

Instead, the host's decoder eventually complained:

Encountered Data is not valid Base64.

That phrase was the detective's equivalent of finding a fingerprint.

Why Base64?

Because a GTPacket contains a Swift Data value:

packetId
type
payload: Data

When Swift's JSONEncoder encodes a Data value, it represents the data as Base64 text.

So the .move packet looked conceptually like this:

{
"packetId": 2,
"type": "move",
"payload": "....Base64 text...."
}

The payload was binary data.

But JSON could not directly carry arbitrary binary data.

So Swift turned it into Base64.

That gave us the next place to investigate.

Chapter 4 β€” The Innocent-Looking +

Now came the beautiful coincidence.

Remember Grandpa's actual hand?

10 3 7 6

His solution was:

(6 Γ· 3) Γ— 7 + 10

There was a + right there.

πŸ˜‚

But we had to be careful.

That + was in Grandpa's mathematical answer. It was not the corrupted network character.

The network problem involved a different +:

a + character appearing somewhere inside the Base64 text representing the .move packet's Data payload.

Base64 is made from a particular alphabet of characters, and + is one of those characters.

So we had two completely different things:

Grandpa's solution:
(6 Γ· 3) Γ— 7 + 10
↑
mathematical +

Network packet:
Base64 payload "...+..."
↑
packet-data +

The first + belonged to mathematics.

The second + belonged to Base64.

But that second + was about to become very important.

Chapter 5 β€” Enter HTTP Form Encoding

How was the JSON packet sent to our server?

The request used:

application/x-www-form-urlencoded

This is an old and very common HTTP form format.

A simplified request looks like:

roomCode=ABCD
&senderID=...
&recipientID=...
&message={JSON packet}

The entire JSON packet was stored in the message field.

And here is the trap.

In URL form encoding, an unescaped:

+

means:

space

So this:

abc+XYZ

can be interpreted by the form decoder as:

abc XYZ

That is perfectly normal behavior for form encoding.

The problem was that our Base64 data did not mean:

abc XYZ

It meant:

abc+XYZ

We had accidentally allowed a character with one meaning in Base64 to enter a protocol where that same character had another meaning.

That was the crime.

Chapter 6 β€” Reconstructing the Failure

Now we could reconstruct the entire journey of Grandpa's move.

Step 1 β€” Grandpa solves the puzzle

The cards are:

10 3 7 6

He finds:

(6 Γ· 3) Γ— 7 + 10

Golden 24 recognizes the successful operation.

Step 2 β€” The game creates a .move packet

The move becomes a GTPacket.

Conceptually:

GTPacket
β”œβ”€β”€ packetId
β”œβ”€β”€ type = .move
└── payload = Data
Step 3 β€” Swift encodes the packet as JSON

JSONEncoder converts the binary Data payload into Base64.

Conceptually:

Data
↓
Base64
↓
"...some characters...+...some characters..."

That + is perfectly legal Base64.

Nothing is wrong yet.

Step 4 β€” The JSON becomes the HTTP message field

Our client builds a form body:

message=<JSON packet>

The old encoding code used URL-query-style percent encoding.

But the resulting form value could still contain a +.

So the request could effectively contain:

message=...+...
Step 5 β€” The server parses the form

The Node server uses:

URLSearchParams

to parse:

application/x-www-form-urlencoded

And form encoding says:

+ means space

So the Base64 text silently changes:

+ β†’ space

No network error occurs.

The HTTP request itself is perfectly valid.

The server has no idea that a character inside a Base64 string has just been damaged.

Step 6 β€” The corrupted JSON comes back to Swift

The host polls the mailbox.

The mailbox returns the message.

The JSON decoder tries to reconstruct:

GTPacket

But its payload is Base64.

And the Base64 string now contains a space where it originally contained +.

So Swift says:

Data is corrupted.
Path: payload.
Encountered Data is not valid Base64.

Exactly what we saw in the real log.

Step 7 β€” The move never reaches game logic

Because decoding the packet failed, the host never reaches:

receivedMove(_ move:)

Therefore:

game.initGame()

does not happen.

And:

broadcastGameState()

does not happen.

So the old cards remain:

10 3 7 6
Step 8 β€” But Grandpa's score still increases

This was the deceptive part.

Grandpa also sent a playerUpdate.

That packet arrived successfully.

The host processed it.

The authoritative player roster was updated.

The score therefore increased.

So the screen could truthfully show:

Grandpa: score increased
Cards: still 10 3 7 6

It looked like the game had accepted the solution but refused to start the next hand.

In reality, two independent packets had taken two different paths:

playerUpdate
↓
arrived
↓
score updated


move
↓
Base64 damaged
↓
decode failed
↓
host never processed move
↓
hand stayed

The apparent contradiction disappeared.

The mystery was solved.

Chapter 7 β€” Don't Guess. Write a Test.

Now we knew what we believed.

But believing a diagnosis is not the same thing as fixing a bug.

So we opened a brand-new test target:

Golden_24Tests

And wrote the smallest possible test.

First:

abc+XYZ

We reproduced the essential problem.

The test failed exactly as expected:

"abc XYZ" != "abc+XYZ"

That was wonderful.

We had caught the ghost.

Chapter 8 β€” Test the Real Packet

But "abc+XYZ" was still an artificial example.

We wanted the real thing.

So we created an actual GTPacket with:

type = .move
payload = Data(...)

Then:

GTPacket
↓
JSONEncoder
↓
JSON string
↓
form encoding

And verified that the Base64 + became:

%2B

rather than remaining:

+

Now the test was connected to the actual transport.

Chapter 9 β€” The Full Round Trip

Then came the decisive test.

We sent the packet through the complete conceptual journey:

GTPacket
↓
JSONEncoder
↓
formEncode
↓
form decoding
↓
JSONDecoder
↓
GTPacket

And compared the packet we started with to the packet we got back.

The important assertion was not merely:

β€œThe string looks okay.”

It was:

The decoded packet must be the same packet we originally sent.

Same packet ID.

Same packet type.

Same payload.

The test passed.

Four tests turned green.

Chapter 10 β€” The Fix

The actual production fix was tiny.

We introduced a form encoder that explicitly protects the character:

+ β†’ %2B

Then GTInternetService.send() used that encoder for the form fields.

So instead of sending a raw + inside the form body, the client sends:

%2B

When the server parses the form, it correctly recovers:

+

The Base64 string remains intact.

Swift can decode it.

The GTPacket survives.

The .move reaches the host.

The host initializes the next hand.

The game continues.

Chapter 11 β€” Why We Didn't Fix the Server

This is an important part of the story.

The server was not actually doing anything wrong.

It was correctly interpreting:

application/x-www-form-urlencoded

according to the rules of that format.

The client had simply failed to encode the value correctly for that format.

So we didn't make the server smarter.

We didn't teach the server about Golden 24.

We didn't add special handling for .move.

We didn't create a Golden 24-specific protocol.

We fixed the client at the boundary where JSON became form data.

That kept the Game Bridge generic.

Chapter 12 β€” The Final Test

The most satisfying test was no longer theoretical.

It was the test inspired by the real incident.

A .move packet goes into the transport.

Its Data becomes Base64.

The Base64 contains characters that have special meaning in form encoding.

The form encoder protects them.

The server decodes the form.

The JSON survives.

The Base64 survives.

The Data survives.

The GTPacket survives.

And the game receives the move.

Four tests.

All green.

Then we committed the fix.

What Really Happened

The whole mystery can now be told in one diagram:

Grandpa sees:

10 3 7 6

↓

Grandpa solves:

(6 Γ· 3) Γ— 7 + 10

↓

Golden 24 creates:

.move GTPacket

↓

Data payload

↓

JSONEncoder

↓

Base64

↓

somewhere inside the Base64:
+

↓

application/x-www-form-urlencoded

↓

OLD CLIENT:
+ ─────────→ +

SERVER FORM DECODER:
+ ─────────→ space

↓

Base64 becomes invalid

↓

GTPacket decoding fails

↓

.move never reaches the host

↓

10 3 7 6 remains on screen

↓

BUT...

playerUpdate arrived

↓

Grandpa's score increased

↓

A seemingly impossible symptom

And after the fix:

Base64:

+

↓

formEncode:

%2B

↓

server form decoder:

%2B

↓

+

↓

valid Base64

↓

valid Data

↓

valid GTPacket

↓

.move received

↓

new hand

↓

game continues

Theme

Reality is often hiding one layer deeper than the symptom.

What Is Possible

A tiny Internet mailbox can become a multiplayer bridge for real games.

No giant backend.

No accounts.

No matchmaking service.

Just rooms, player identities, packets, and a server that knows almost nothing about the game.

How Does It Happen

A real game is built from layers.

Golden 24 thinks in cards and arithmetic.

The game thinks in moves.

The session layer thinks in packets.

Swift thinks in Data.

JSON turns that data into text.

Base64 turns binary data into characters.

HTTP form encoding gives those characters another set of rules.

And a server finally receives the result.

A bug can hide at the boundary between any two of those layers.

Why Does It Matter

Because a computer does not know what we meant.

It only follows the rules of each layer.

A + can mean one thing to Base64 and another thing to form encoding.

The code may be perfectly correct inside each individual layer.

The bug appears in the space between them.

Learning to cross those boundaries β€” and to test them β€” is real engineering.

Epilogue

The funniest part of the story may be the hand itself.

10 3 7 6

Grandpa solved it with:

(6 Γ· 3) Γ— 7 + 10

There was a + in the mathematics.

But the + that broke the Internet game was not that one.

It was a completely different +, hidden inside the Base64 representation of the .move packet's Data payload.

One belonged to the solution.

One belonged to the packet.

And neither looked suspicious until we followed the evidence far enough.

The score told us something had arrived.

The frozen hand told us something was missing.

The logs told us which packet was missing.

The Base64 error told us where to look.

The + told us what had gone wrong.

The unit test made the ghost reproducible.

And the tiny %2B fix made it disappear.

That is the part worth preserving in a museum.

Not just that we fixed a multiplayer bug.

But that a real game, played by real people on real devices, gave us a mystery β€” and we followed the clues all the way from

10 3 7 6

to

Base64

to

HTTP

to

+ β†’ space

to

four green tests

to

a game that works again.

Sometimes the best computer-science lesson begins with a game that refuses to move.