Buckshot Roulette: From Player to Hacker to Game Developer against my will
Act 1: The initial approach
Like most people, I'm introduced to new games thanks to social media — in this case, a video from Delightful Kissboy (yeah, that's the name of the channel) about Buckshot and how the Dealer works (Link). As explained in the video, the AI behind the Dealer isn't exactly the smartest.
Even the developer behind Buckshot, Mike Klubnika, admitted in the game code:
class_name DealerIntelligence extends Node
#ALTHOUGH THE CLASS NAME SUGGESTS THAT THE DEALER HAS INTELLIGENCE, AND IT'S TRUE THERE ARE MANY INTELLIGENT DEALERS,
#CONTRARY TO THOSE THIS ONE IS NOT. HERE THERE IS NO INTELLIGENCE. ONLY LAYERS, HEAPS AND CLUMPS OF SPAGHETTI JUMBLE CODE.
The problem isn't just with the Dealer being a bit less intelligent, it's the fact that it had a little funny bug.
Magnifying Glass + Beer Combo
The AI would use a magnifying glass to peek at the current shell and see a blank. Then it would crack open a beer to eject that shell from the chamber, and proceed to shoot itself.
Now, you might be asking "Why does it behave like that?" The answer is pretty easy: the Dealer when looking at the magnifier, stores in a variable that the current shell is blank — pretty standard. The bad part is that when using the beer, it doesn't clear the variable, so the Dealer still thinks that the current shell is a blank.
So I did what any reasonable person would do: I decided to learn GDScript and fix it myself (Fun fact: Godot games are trivial to decompile).
As soon as I open the decompiled project, it became apparent that Mike was not a professional programmer, and it explained a lot. Although, since I was still learning the language, I didn't judge too hard.
I ended fixing the bug, and since I was working on it, I decided to implement a few extra features to Dealer, like keeping count of shells and health, pre-programming item combos and preserving the shooting order after reloads — making the game harder.
In my eyes, the games was better now and I was satisfied. This is where this story should have ended.
Act 2: Multiplayer
After a while, Buckshot was released on Steam, and with it multiplayer using Steam's lobby system. Of course I had to take a peek inside.
What I found was that the multiplayer was just the singleplayer game copied inside itself. Like the whole game. We are talking repeated textures and logic with some adaptions to work in a client-host system. The game went from ~450MB to ~760MB.
A good example of repeated textures is that the Dealers hand texture was repeated 22 times. The exact same texture.
But I digress. While exploring this architectural nightmare, I discovered something worse than bad code: the multiplayer implementation was fundamentally broken from a security perspective.
Act 3: The exploits
Exploit #1: The shell order was never a secret
So, you know how the dramatic tension comes from not knowing the order of the shells loaded?
Well, in multiplayer, you can throw that all out. You can just see the shell order without even having to mess with the game code or reading the computer memory.
You can just open the command prompt, type ./buckshot_roulette.exe and there it is, printed directly to the console:
{
"packet category": "MP_RoundManager",
"packet alias": "load shotgun routine",
"packet_id": 8,
"sequence_dictionary": {
"sequence": ["live", "live", "live", "blank"],
"sequence_visible": ["live", "live", "live", "blank"],
"sequence_in_shotgun": ["live", "live", "blank", "live"], # The problem
"shuffling_visible_sequence": false
},
"first_turn_socket": 1
}
(Yes, the shells aren't send as a boolean or a integer, but as a string)
Every player could see this, not just the lobby host. The host was sending the shuffled order to all clients.
Surely, this is an oversight. Of course the game has proper security measures for the actual game logic, right?
Sadly, there were not.
Exploit #2: Packet Manipulation
A brief note on the architecture: Buckshot uses a Host-Client model with Steam as relay. One player acts as the host, running the authoritative game logic. Other players are clients who send action requests. Steam's networking provides packet delivery and, crucially, sender authentication through Steam IDs.
In a properly implemented Host-Client system, the host uses this authentication to verify every action: Is this request coming from who they claim to be? Is it their turn? Is this action valid?
Buckshot's implementation did none of this. It trusted the client completely.
This is a example how the system worked:
# Client sends action packet
packet = {
"action": "shoot",
"shooter": 1,
"target": 2,
"packet_id": 24
}
send_to_server()
# The server receives and processes the packet
func handle_packet(packet):
shooter = get_player(packet.shooter)
target = get_player(packet.target)
execute_shot(shooter, target)
There's no verification that it's the shooter's turn.
With minimal knowledge about the game, you could craft packets to:
- Make any player shoot another, including themselves (extremely funny)
- Skip turns
- Use items that don't exist
- Crash the game of everyone in the same lobby
- Etc...
This exploit could make any player do anything, puppeteering the entire game.
Act 4: Reporting
Of course, I did the responsible thing. I wrote a report of both exploits and I personally emailed Mike.
I didn't get any answer, so I escalated and contacted one of the Critical Reflex managers (the publisher), then I made a Reddit post detailing the issues. But still no real response.
A month later, an update dropped. Mike had fixed exploit #1! The host no longer sent the shell order to the other players. Success!
Then I looked at how they fixed exploit #2. A verification check was added:
func handle_packet(packet):
if packet.packet_id == 24:
if packet.shooter != current_turn:
reject()
return
if packet.action == "shoot":
shooter = get_player(packet.shooter)
target = get_player(packet.target)
execute_shot(shooter, target)
Can you see the problem? The verification checks the packet_id but it used the action to actually execute the action.
I bypassed it the same day:
crafted_packet = {
"packet_id": 12, # an easier packet to pass verification
"action": "shoot", # used for the actual action
"shooter": 1,
"target": 2
}
Then there was another exploit, one that I didn't notice at first...
Exploit #3: The Shell Revelation
While exploit #1 got patched, a related issue remained: every item usage packet includes the current loaded shell.
Not just the magnifying glass (which is supposed to reveal it) or the phone (which reveals a specific position).
Here's what the packet structure looks like:
{
"packet_id": 23, // Interact with Item
"item_id": 4, // Cigarettes
"current_shell_in_chamber": "live", // It should only show when using beer or magnifying glass
}
The game only needed to send this information for items that actually reveal shells, but instead it's embedded in the networking protocol for all items.
The worst part? It's nearly undetectable in competitive play. It just looks like an optimal decision-making that happens to align perfectly with hidden information right after using an item.
Yet again, I reported immediately. The manager from CR said that my report was sent to Mike.
Act 5: If you want something done right
After a month, there was no patch in sight. A small Buckshot tournament was about to start, and since I wanted the tournament to be fair, I made a mod that patched the exploits.
The key insight: never trust the client to say they are who they are. The server knows who sent the packet because it came through their connection. Use that. Verify everything against the server-side state.
This isn't innovative security research. This is Client-Server Architecture 101. It's been standard practice since multiplayer gaming began.
The implementation
The fix required changes in two places: verifying packet sender identity and controlling information flow.
Packet Manager
func steam_id_to_socket_number(steam_id : int):
for i in instance_handler.instance_property_array:
if i.user_id == steam_id:
return i.socket_number
return -1
func read_p2p_packet():
# ...
if readable_data.has("sent_from_socket"):
# Replace client's self-reported identity with server's authoritative record
readable_data["sent_from_socket"] = steam_id_to_socket_number(packet_sender)
Packet Verification
func PacketSort(dict : Dictionary):
var dict_id = dict.get("id", 0) # Using the ID instead of alias prevents verification bypass
match dict_id:
# Replace all other cases with ids
22:
packet = { # Trimmed just to the important parts
"packet alias": "interact with item",
"sent_from": "host",
"packet_id": 23,
"current_shell_in_chamber": "", # Removing "sequence_in_shotgun[0]" from here prevents the client to spy on next shell when they shouldn't
"phone_verbal_shell": "",
"phone_verbal_index": "",
}
# Re-adding "sequence_in_shotgun[0]" on the items that really need it
if dict.item_id == 2: # Magnifier
custom_target_packet = packet.duplicate(true)
custom_target_packet.current_shell_in_chamber = game_state.MAIN_active_sequence_dict.sequence_in_shotgun[0]
custom_target_id = game_state.GetSocketID(dict.sent_from_socket)
else if dict.item_id == 5: # Beer
packet.current_shell_in_chamber = game_state.MAIN_active_sequence_dict.sequence_in_shotgun[0]
Results
The patch prevented:
- Player impersonation (the original update never addressed this)
- Out-of-turn actions
- Shell information leaking through unrelated item packets
And it worked perfectly, to the point of testing successfully against a public hacked client. The tournament went without a hitch.
Having a fool proof patch, of course I offered to show my code for free so Mike could fix it. It was acknowledged, but as far as I know, nothing came of it.
Months later, I even demonstrated to the CR manager that my patch defeated the public hack client, with the cheat developer himself confirming that his tools wouldn't work against it. Their response:


"Feel free to promote it in the server!"
Needless to say I was underwhelmed by that answer. Even if they didn't implement my specific patch, I think they should have taken it more seriously.
Act 6: Nowadays
Well, here we are, nearly a year later.
I have documented all the packets used for the multiplayer.
And yet no official patch.
So I decided that I had to do something. I wanted to do this right. I wanted to build what Buckshot Roulette's multiplayer should have been from the start.
I started building my own client from scratch. With some stylistic liberties.
The custom client

Why 2D? One of the bigger complaints that I heard from friends is that somehow Buckshot was running at low FPS, even on decent computers. So making it 2D will be less resource-intensive, as well as easier to develop and iterate on.
Cross-Compatibility: Why would I segregate people using my client from their friends? My client implements the original game's network protocol to maintain compatibility with vanilla players (it still requires users to own the original game on Steam).
The best part is when someone using my client hosts a lobby, that lobby automatically benefits from my patch.
The vision for my client is just "make it work correctly". The point isn't to revolutionize Buckshot, but simply to give a a game I really love the security features and care I think it deserves.
Interlude: A Brief Tour of the Codebase
While building my client and documenting all the packets, I kept stumbling across... choices in the original code.
Dictionaries used as Arrays:
packet = {
"action": "shoot",
"shooter": 1,
"target": 2,
"packet_id": 24
}
# How you should access it
target = packet.get("target")
# How it was actually accessed
target = packet.values()[2]
This is the programming equivalent of organizing your spice rack alphabetically and then asking someone to "grab the third one". Dictionaries don't guarantee order in most languages. You access them by key. That's literally the point
Funny enough, this choice prevented problems due to mistyped dictionary keys. Here is my initial reaction upon finding it:

The reverse YanDev (and not in a good way):
For those who don't know, YandereDev is (in)famous for writing code like this:
if thing == 1:
pass # Actual code
elif thing == 2:
pass
elif thing == 3:
pass
# ... etc for 20 lines
The classic advice for code like this is "use a switch statement" (or match in Python/GDScript). Switch statements are cleaner and more performant when you're checking the same variable against multiple values.
Well, it seems that advice was taken to the absolute extreme:
# How it's on the game
match item_id:
1:
return false
2:
return false
3:
return false
4:
return false
5:
return sequence_dict.sequence_in_shotgun.size() - 1 == 0
6:
return false
7:
return false
8:
return false
9:
return false
10:
return false
return false
# How it should have been done
return item_id == 5 && sequence_dict.sequence_in_shotgun.size() == 1
Ten cases in a switch statement where 9 return false and the remaining 1 does a specific check.
This is the programming equivalent of being told "Umbrellas are useful when it rains" and carrying 67 umbrellas everywhere just in case. Yes, technically you're following the advice, but you missed the entire point.
Shells stored as strings:
As you saw in the exploit example, shells weren't stored as booleans (true/false) or even as integers (0/1). They are strings ("blank"/"live").
Every single comparison, every check, every piece of logic had to do string comparisons instead of simple boolean checks. It works, but it's like using a sledgehammer to hang a picture frame, bringing the wall (performance) down.
Epilogue: God is Dead and this code killed Him
In the game's story mode, God is already dead. The Dealer you're playing with exists in some purgatorial space where divinity has collapsed. It's thematically appropriate, then, that the code itself reflects this divine absence.
I didn't set out on this story to become a game developer. I just wanted the dealer to stop shooting itself after a beer (relatable fr).
Links and Resources related
If you're a game developer reading this: please validate your packets. Your players will (probably?) thank you, and you won't end up as a blog post.
And if you're Mike Klubnika or anyone at Critical Reflex: the patch offer still stands. I genuinely want Buckshot to be good. Let me help.
Edits:
- Changed the phrasing because I was being unnecessarily harsh to Mike.
- Added explanations about the patch and Steam's game lobby architecture.