The Move That Disappeared
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.