Skip to content

feat(coop): online co-op for up to eight players (Network tab) - #2578

Draft
itzdaface wants to merge 8 commits into
NotRexed:mainfrom
itzdaface:feat/coop
Draft

itzdaface wants to merge 8 commits into
NotRexed:mainfrom
itzdaface:feat/coop

Conversation

@itzdaface

@itzdaface itzdaface commented Nov 12, 2026 •

Copy link
Copy Markdown

Friends who each build Hookshot from their own copy have no way to play it together. This adds online co-op: up to eight players in one Hyrule, each on their own PC with their own copy of the game, from a new Network tab in the menu.

At a glance

What it is Online co-op for two to eight players. One hosts, the others join. It is off, and costs nothing, until Connect is pressed in the Network tab
What you see The other players as Link in your game, with their own animation, sword and shield, a name over their head, and a line in the corner saying who is where
What is shared Lasting items, heart containers and pieces, save flags, chests and pickups, small keys, rupees and ammunition picked up. A player who joins later is levelled with the rest
Enemies One enemy for everyone: one life (a hit from any player counts in every game, and it dies for all) and one place (the nearest player's game leads it, the others' copies follow and play the same animation)
Bosses One leader, the player who started the fight. A second player's hit that takes no life hands the boss over, so a stun works from either side
PvP Optional, only between two players who both turn it on. The other player becomes a real target for the game's own combat: lock-on, hit flash, sparks, knockback, hearts lost
How friends link up Radmin VPN (free) for friends in different homes, TCP port 43400. The full steps are below and in docs/coop.md
Platform Windows. On Linux the tab is there and the entry points do nothing yet
Size 18 files, about 4,600 lines added. Nearly all of it is one new file, host/oot_coop.c (3,101 lines), plus the Network tab, two data scripts, a test file and the docs
Game files None in this PR. Two scripts make the other players' actors and the list of shared flags on each PC, from that player's own dump
How it was made About sixty small builds on a private branch across v2.0.13, v2.0.14 and v2.1.0, each played before the next. Directed and played by Kuba, written with Claude Code. The whole account, with what did not work, is under "How it was made"
Played by real players Friends over the internet on the v2.0.14 build: shared items, flags and keys, seeing each other, the first PvP
Tried with one game and a scripted second player On v2.1.0: PvP through the game's own combat, shared enemy life, enemies following their leader, the lead passing to the nearer player, bosses. Nine bosses fought as the leader with no crash; Gohma also fought from the second player's side by a person
Not done yet Not built into a game or run on main: this branch is the port, and it has only been compiled file by file and unit-tested. Enemies and bosses have not been played by two real games. Every limit is listed in docs/coop.md
Status Draft. Six questions for the maintainers are at the bottom

Pictures and video

All of these are from the earlier builds this branch was ported from, not from main.

Two real players, each on their own PC, linked over Radmin VPN (42 seconds, no sound):

coop-two-players.mp4

The other player in Hyrule Field and in the Market, with the name tag and the line in the corner:

Two players in Hyrule Field Two players in the Market

PvP: lock-on on the other player and the game's own hit effects (four frames of one sword hit; the second player here is the scripted one):

A sword hit on another player

The Network tab (an older build: today the first row is Play as, and there are Connection, Go to player and PvP rows too):

The Network tab

Pictures from a build of main will follow once there is one.

What it does

  • Network tab: Host or Join, a name, the host's address (typed, kept off the screen), port (43400), PvP on or off. Nothing starts until Connect is pressed; with co-op off it costs nothing.
  • The other players are drawn as Link with their own animation, sword and shield, a name tag, and a line in the corner saying who is where.
  • Shared progress: lasting items, save flags, chests and keys, and counted pickups, levelled when a player joins.
  • PvP (when both players turn it on): the other player is a real target for the game's own combat: lock-on, the game's hit effects, hearts lost on the other side.
  • Enemies are one for everyone: a hit from any player takes the same life off that enemy in every game, and it dies for all. The game of the nearest player leads each enemy; the others' copies are moved to the same place and play the same animation.
  • Bosses: one leader (whoever started the fight); a second player's hit that takes no life hands the boss over to them, so a stun works from either side.
  • Linking up needs Radmin VPN for friends in different homes: the game only opens a plain TCP link, with no relay. The whole process is below.
  • Switches: HOS_COOP_ENEMY_PLACE=0, HOS_COOP_BOSS=0, HOS_COOP_TEST=1. All documented in docs/coop.md.

How friends link up (Radmin VPN), step by step

This is the way every session so far was played: friends in different homes, joined through Radmin VPN.

Once, on every player's PC

  1. Build Hookshot from your own copy of the game. Everyone must be on the same version of Hookshot.
  2. Make co-op's two data files (nx2/coop_flags.py and nx2/coop_bodies.py, run once against the game folder; the commands are in docs/coop.md). Do it again after a rebuild that replaces the game folder.
  3. Install Radmin VPN (free, from radmin-vpn.com), start it, and switch it on with the large button.

Once, for the group

  1. The host, in Radmin VPN: Network > Create Network. Pick a network name and a password and send both to your
    friends in private.
  2. Everyone else, in Radmin VPN: Network > Join Network, and type that name and password. Each player now sees the
    others in the Radmin window.
  3. Every player has an address in the Radmin window, a number that starts with 26.. The one that matters is the
    host's. The joiners right-click the host's name there and copy the address. Treat it like a password: do not
    show it on a stream. Hookshot keeps it off the screen for that reason.

Every time you play

  1. Everyone starts Radmin VPN first and checks that the host shows as online in it. Then start Hookshot and load your
    save (a slot you can afford to lose). With a randomizer, everyone must be on the same seed.
  2. The host goes first. Open the menu (Insert or F1), go to Network, set Play as to Host, set
    Your name, then press Enter on Connection. The first time, Windows Firewall may ask about Hookshot: allow
    it, or nobody can reach you.
  3. Then each joiner. In Network, set Play as to Join. On Host address press Enter, paste the
    host's Radmin address with Ctrl+V, and press Enter again. Leave Port at 43400 unless the host changed theirs.
    Set Your name, then press Enter on Connection.
  4. Status turns to Connected and names who is there, and the top left corner of the screen says the same. After a
    moment everyone holds the same items and progress. The other players are Links with their name over their head;
    if you are in different maps, press Enter on a player's Go to player row.
  5. PvP is off until two players both turn PvP on.
  6. To stop, press Enter on Connection again, or close the game. A game always starts not connected, so steps 8
    and 9 are done each time; the name, the address and the port are remembered.

If it does not connect

  • Status says why. Read it first.
  • Is the host connected before the joiners try? A joiner who was early presses Enter on Connection again.
  • Does Radmin VPN show the host as online for the joiner? If not, the trouble is in Radmin, not in Hookshot.
  • Did the joiner paste the host's address, not their own?
  • Is Port the same number for everyone?
  • Did the host allow Hookshot when Windows Firewall asked? If the question was closed or refused, allow Hookshot under
    "Allow an app through Windows Firewall". Do not turn the firewall or the antivirus off.
  • Is everyone on the same version of Hookshot?

On one home network Radmin is not needed: the joiners type the host's address on that network instead. Other virtual
networks (Tailscale, ZeroTier) or a port forwarded to the host work the same way, but have not been tried.

What changed

  • host/oot_coop.c (new): the whole runtime: the link (plain text lines over TCP), the players' bodies, what is shared, enemies and bosses.
  • nx2/oot_patches.py, host/hos_main.c, host/nn_vi.c, host/oot_menu.c: the calls into it (Link's update, the frame, pickups, the Life component's update).
  • gpu/vk/vk_menu.cpp, vk_menu.h, oot_coop_opts.h, vk_replay.cpp: the Network tab and what is drawn over the game.
  • nx2/coop_bodies.py, nx2/coop_flags.py, nx2/byml_rw.py: two scripts, run by hand, that make the other players' actors and the list of shared flags from the player's own dump into a mod folder. Nothing made from the game is in this PR.
  • tests/test_oot_coop.py, docs/coop.md, the README and docs/development.md.

How it was made

Co-op was built on a private branch in about sixty small builds, first on v2.0.13, then v2.0.14, then v2.1.0, and each build was tried in the game before the next one was started. Kuba set what it had to do, played every build (alone, with friends over Radmin VPN, and in a test copy) and judged it on the screen. The code was written with Claude Code. Several parts below exist because something looked wrong to him while playing, not because a check failed.

How things were found

  • The lifted C and a disassembly of the game image. A string (a script node's name, an actor's name) leads to the code that uses it. A script node's run function sits 0xa0 after its name function in its vtable, which is how the game's own "get life", "play animation" and "create actor" functions were found.
  • The game's own data files. Most of what the other players' bodies can do is data, not code. Dark Link is a Link-shaped enemy, so his actor pack was the model: what he has that the pause menu's Link lacks is what makes a body something the game's combat can hit and lock on to.
  • Reading the running game's memory from outside, to see which fields change when Link is hit, picks something up or opens a chest.
  • Logging first. For shared enemies the first build only logged every actor that has a life, to learn what names an enemy the same in two games, before anything was sent.

How it was tested while being built

  • A test copy of the game on a copy of a save, with its own user folder, usually with no window, driven by scripted pad input and judged from the game's own frame dumps, read as runs of consecutive frames (a single still hides a sliding or frozen body).
  • A stand-in second player: a script that joins over the link and says what a second game would. It can stand, run in a circle, mirror the real player, hit back, and replay a recording.
  • Recorded fights. A game that leads an enemy can write its Q lines to a file. Kuba fought the bosses in a playable test copy while it recorded; the recording is then played to a second game, which must show the same fight.
  • The test lines behind HOS_COOP_TEST=1 (travel to a map, stand at a place, make an actor beside Link) put Link next to a given enemy without playing there.
  • Real sessions with friends on the v2.0.14 build. Their logs found bugs no script did (below).

In the order it was built, with what did not work

  1. The link and shared items. Text lines over TCP, the host passing them on. Every pickup goes through the pouch's add by name (f_8112d570), so one call there sees them all. Heart containers are given through the pouch too: the max-hearts value in the game's data turned out to be a mirror the game rewrites every frame, so writing it does nothing.
  2. Shared flags. The first rule was "a flag that went from off to on is shared". The first night with three players showed it was wrong: 54 of the shared flags start on, and a character who had moved aside came back for everyone. The rule became "a flag that has left the value a new file starts with". A flag written without the game's changed-since-save mark is also not saved, which cost another build. Per-object flags are shared only where the game never resets them: pots, enemies and shop stock come back by design and are left alone.
  3. Seeing the other players. The first body was the pause menu's Link, made with the game's own create-actor call and moved by its place request. He slid about in a stiff pose, because his animation graph only has menu poses. Writing a pose straight into the animation result buffer was tried for three builds and never moved the drawn body. What works: a new actor, made on each PC from the player's own files, that is the same body with Link's own animation graph. Each game says which command each of Link's nine animation slots is playing and the animation inputs that changed, and the body plays the same.
  4. Sword and shield. Calling the game's equip function put the sword at the body's feet, and a second call into a used slot crashed. It became data instead: an equipment table and two names, as Dark Link's pack has them, and the sword and shield then follow the mirrored animation by themselves.
  5. PvP. The first version went by reach: the attacker's game decided a swing within 1.9 m had landed. A session with friends showed hits landing during a cutscene, which broke the scene for the player being moved by it, so hits are now ignored while a player's game is in an event and for 1.5 s after. Then the body was made a real target: Dark Link's life, hit, affiliation and target parts, on the body's own physics with one added sensor. Taking Dark Link's whole physics gave the body a mind of its own: it walked away from where its player stood. The slash flash, sparks, sound and lock-on arrows came from three more parts of data. Dark Link's own lock-on part could not be used, because it checks a value only his fight sets.
  6. One life for an enemy. The Life component's update (f_804bd8c0) is where any actor's life goes down, in two compare-and-swap writes. Nothing inside an actor names it the same in two games, so an enemy is matched by its name and the place it was first seen. Writing life 0 by hand does not kill: the enemy stands there at zero. The damage is given through the game's own life message instead, and the enemy then dies as it does to a real hit. One early build crashed at every save load: an address of 0 passed the range check, so every read of game memory is now checked.
  7. One place for an enemy. The place request that moves a player's body does nothing on an enemy; the game's own move request, which goes through physics, does. The leader was at first whichever game updated the enemy first. That made an enemy ignore the player standing next to it, so the nearest player's game leads now, with a margin so the lead does not flap. For two builds every player stood frozen: animation command names live in memory above the heap, and a check that had been made too narrow refused to read them.
  8. Bosses. A boss fight is a string of states only the leader's copy is in, so a second player's seed in Gohma's eye was blocked by his own copy. Putting the hit back into the game's hit list was tried and does nothing: damage and reactions happen at the real hit, not later. The answer was the hand-over: a hit that takes no life passes the boss to the game that made it. It was found and proven with Kuba playing the second player against a recorded leader. He then fought Gohma, King Dodongo, Barinade, Phantom Ganon, Volvagia, Morpha, Bongo Bongo, Twinrova and Ganondorf as the leader with this code on, with no crash in any fight.
  9. The menu. The Network tab was written for the old menu and fitted again when v2.1.0 rewrote the menu on Dear ImGui. Its option ids are in a header of their own so the shader cache stamp does not move.
  10. The port to main. The co-op parts were lifted out of the private branch on their own, renamed to the project's oot_* names, and everything else from that branch left behind. This is the step that has only been compiled and unit-tested so far.

How it was tested

  • This branch, on main: every C and C++ file touched passes a syntax-only compile with llvm-mingw; python -I -B tests/run_all.py gives 427 passed, 6 failed, 38 skipped on Windows 11, and the same 6 fail on an untouched main on this PC. Not built into a game, not started. Nothing compiled for Linux: co-op is Windows-only here, and elsewhere its entry points do nothing.
  • Two real players over the internet (v2.0.14 build): shared items, flags and keys, the other players' bodies, and the first PvP (by reach).
  • One game plus a scripted second player (v2.1.0, RTX 3060): PvP through the game's own combat, shared enemy life, enemies following their leader, the lead passing to the nearer player, and bosses. A person played the second player's side of the Gohma fight against a recorded leader: following it, being hurt by it, stunning and killing it. Gohma is the only boss tried from that side.
  • Not tested: enemies and bosses with two real games; any boss but Gohma as a follower; enemies the game spawns on the fly (each game still has its own).

Questions for the maintainers

  1. Is a feature this size welcome as one PR, or would you rather have it in pieces (runtime, menu, builders)?
  2. The option ids sit in their own header (oot_coop_opts.h) so the shader cache stamp does not move, and the options file keys are play_as, port, pvp. Should they move to vk_api.h and become coop_*?
  3. Should the build make the co-op data files itself, perhaps behind an opt-in, instead of the two scripts being run by hand?
  4. oot_coop.c carries copies of two static pouch functions from hos_main.c. Would you export those instead?
  5. Linux: stand-ins for now, or sockets through host/posix first?
  6. The link is plain text with no password. Is that acceptable for a first version?

Co-op by Kuba. Written with Claude Code; the trailers are on each commit.

🤖 Generated with Claude Code

itzdaface and others added 8 commits November 11, 2026 18:02
Two to four players, each in their own copy of the game, share one adventure
over a TCP link one of them hosts (port 43400): items, save flags, chests and
pickups, small keys, where each player is, and enemies (one life for all, one
place for all, led by the nearest player's game; a boss keeps its leader, and a
follower's own hit that takes no life takes the boss over).

host/oot_coop.c holds all of it: the link's thread, what is shared, the other
players' bodies, PvP, go to a player. Nothing calls it yet. It talks to the
menu through the option ids in gpu/vk/oot_coop_opts.h, read by both sides.

Beside it, the two things it asks of the runtime:
- hos_oot_travel (host/hos_main.c): the debug tools' teleport by a map's own
  name, for going to a player on another map.
- hos_oot_menu_in_front (host/oot_menu.c): whether one of the game's own menus
  is in front, so co-op's corner and the names are not drawn over it.

Windows only for now: the sockets are Winsock's. Elsewhere the file builds to
entry points that do nothing.

Written and played on v2.1.0; this is its port to main. Not built into a game
or run on main: see docs/coop.md.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
- host/nn_vi.c: oot_coop_frame with every frame shown (the Network tab's rows,
  the link's comings and goings; at the title screen too).
- host/hos_main.c: oot_coop_player from Link's own update (where he stands, and
  everything that reads or changes the game).
- nx2/oot_patches.py, the supported build only: patch_coop_pickup puts
  oot_coop_pouch_add first in the pouch's add by name (f_8112d570), so a pickup
  can be told to the other players; patch_coop_life puts oot_coop_life_seen at
  the head of the Life component's update (f_804bd8c0) and oot_coop_life_write
  before each of its two writes of the life, after the heart protection's
  guard, so an enemy's life is one for all. Both are counted in report(), as
  the other hooks are.

Each returns at once while no link is up.

tests/test_present_trace.py pastes hos_vi_native_presented into its fixture:
it gets a stand-in for oot_coop_frame.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
A sixth tab: Play as (Host or Join), Host address, Port, Your name, Connection,
Status, a Go to player row for each other player, and PvP. The name and the
address are typed in their rows (Enter, the text, Enter; Ctrl+V pastes; the
address shows only its last letter). The mouse and the compact bar work on the
rows as on any other.

While connected, the top left corner shows the status, the other players and
where they are, and a line for a few seconds when something happens; each other
player has their name over their head. Neither is drawn over the game's own
menus.

The host and the menu talk through the HOS_OPT_COOP_* ids
(gpu/vk/oot_coop_opts.h, not vk_api.h: that file is part of the shader cache's
stamp): vk_replay.cpp's option hands that range to menu_coop_option, and
menu_option_set takes the host's texts a letter at a time.

Play as, Port and PvP are saved in hookshot_options.ini by their names, as
every row (play_as, port, pvp), and no preset sets them. The tab strip steps
its text and padding down where six tabs do not fit a narrow window. The
control bridge names the tab "network".

tests/test_fps_overlay_order.py pastes menu_record into its fixture: it now
checks co-op's corner too (under the menu, not over the start-up screen).

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
Two scripts, run by hand on a built game folder. Everything they write is made
from the player's own game files and stays in the game folder.

- nx2/coop_flags.py writes coop/flags.bin: the hashes of the save flags the
  players share, chosen from the game's own flag list (a Bool kept in the save
  that the game never resets, less the ones that are each player's own). Without
  it items are shared and flags are not.
- nx2/coop_bodies.py writes the mod mods/00-coop: CoopPlayer_Child/_Adult, the
  pause menu's Link with Link's own animation graph, sounds and equipment, and
  CoopFoe_Child/_Adult, the same body as something the game's combat can hit
  (PvP), with the size table entries and actor table rows new actors need.
  Without it the other players are drawn as the pause menu's Link and there is
  no PvP.
- nx2/byml_rw.py: the game's little-endian BYML, read and written without loss.

make_game.py does not run them: a mod that carries the resource size table and
the actor tables in every game folder is a choice for the maintainers.

They need zstandard, as the pipeline does.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
tests/test_oot_coop.py: the three calls nx2/oot_patches.py makes (once,
declared, first or before each write, after the heart protection's guard, left
alone when patched before, only in the supported build's lift); every entry
point defined for Windows and stood in for elsewhere; the runtime's calls; the
option ids one run of numbers that are no other row's, each answered or taken
by the menu; the Network tab's rows; and of the builders what needs no game:
BYML and actor packs written and read back the same, the size table's entries
kept in order and only ever raised.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
docs/coop.md: what co-op is, what it needs, the Network tab, what is shared,
how it works (the link's lines, the bodies, items and flags, one enemy for
everyone, bosses), the switches, and what is not done or not tested.

The README's menu table gets the Network tab's rows; the development guide the
HOS_COOP_* switches and the three rows' keys in the options file.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
The doc listed Radmin as one of three virtual networks. It is the one every
session so far was played over, so it is now named as what is needed, with the
other ways (another virtual network, a forwarded port) after it.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
A new section in docs/coop.md: what each player does once (build, the two data
files, Radmin VPN), what the group does once (one Radmin network, the host's
address), what is done every session in the Network tab, host first, and what
to check when it does not connect.

Assisted-by: Claude Code
Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
@mooki000

mooki000 commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

@itzdaface this is great to see, and you are well ahead of me where it counts for players: animated Links with sword and shield, name tags, shared enemies, bosses, PvP, and real sessions with friends. I opened a co-op draft the same evening, #2274, and the two collide file for file (host/oot_coop.c, docs/coop.md, tests/test_oot_coop.py, a new menu tab each), so the maintainers could only take one as they stand. I'd rather put them together than have anyone pick, because they are strong in different places:

#2578 (yours) #2274 (mine)
What players see and do bodies with animation, names, go to player, enemies, bosses, PvP shared progress and a Co-op tab; the rest only started
On main ported, compiled file by file built and played: two games in one room
Platform Windows Linux; Windows written (Winsock, BCrypt) but never run
The link TCP text lines to a host; Radmin VPN UDP through a small relay, sealed with a key from the room's secret
Before a save is changed nothing the player accepts the room, and the save folder is copied first
Tests hooks, ids, builders the C compiled and run under sanitizers, a hostile relay, two-game sessions

What combining would give, under the four things I'd judge either pull request by:

Simpler

  • One link instead of two, and no VPN step: Host/Join/address/port become "make a room" and "join with its secret". host/coop_net.c already has the sockets for Winsock and POSIX, which answers your question 5.
  • Your 3,100-line file splits along lines that exist: the session (coop.c), the wire (coop_proto.c), the progress merge (coop_state.c), and the game side, where bodies, enemies, bosses and PvP would each be a file that sends and takes typed messages.
  • The typed name and address rows can use the menu's text fields (a commit of feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept #2274, and on its own as feat(menu): text fields in the Insert menu, and a Custom output size typed in it #2594).
  • Your questions 2 to 4 get one answer each: coop_* keys, the pouch functions exported once, and the body's actor made by the build instead of a script run by hand (I have a build-time generator for it on a branch, not yet proven in the game; yours is the part that makes it move).

Faster

  • Place and animation as datagrams. Over TCP one lost packet holds back everything behind it, which shows as a body that freezes and jumps; over UDP the next state simply replaces the lost one.
  • Fixed binary messages instead of text and base64 parsed with sscanf thirty times a second for each player.
  • A relay passes the messages on, not the host's game.
  • On a join every game says everything again. feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept #2274 merges values by "the highest wins" and catches a joiner up by digests: a sum for each of 64 sections every 2 s and at a join, and only the sections that differ are sent.
  • A game that shows nobody, because the others are in other maps, can say its place slowly (a commit on my players branch does that).
  • Neither of us has measured the cost per frame. I can: I have a harness that runs two games on one PC without a window and logs their frame times.

More for players

  • Linux, the Steam Deck and Windows from one code path.
  • Friends in different homes without a VPN: one relay everyone can reach (a small server, or one UDP port forwarded where it runs); the players' own games need no forwarded port.
  • Nobody is the host: any player can leave and come back, and a room stays for 10 minutes after the last one left. The maker can remove a player and reset the pools from the tab.
  • Consent and a way back: nothing from a room is applied before the player accepts it, and the whole save folder is copied first.
  • Room rules the maker signs: each kind of progress shared, pooled or kept per player.
  • And on top, everything yours has that mine lacks. I'd port yours rather than finish my own versions.

Safer (your question 6)

  • Today whoever can reach the host's port joins with H 1 <name>. From then on their lines give items (I, up to 99 a line), set shared flags (F) and per-object flags such as opened chests (K), change small-key counts (N), take life off any enemy (U) and, with PvP on, hearts off a player (D); the items and flags are then saved by each game. With Radmin the network's password is the only gate; on a home network or a forwarded port the text travels in the clear.
  • In feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept #2274 a room is a secret. Every message is sealed under a key derived from it, so the relay can neither read nor change one; the room's rules are signed by its maker's key; each player has a key whose fingerprint the others read aloud before they accept; and values are only raised, only inside the game's own table, only through its own setters.
  • For your messages that would mean: enemy and PvP lines taken only from members this player accepted, only while a signed room rule allows them, with a limit per member, and a hit decided by the receiver's game wherever it can be.

How I'd go about it, if you like the idea: your gameplay commits on top of #2274's six, with you as their author, ported to typed messages in the order bodies, enemies, PvP. You test on Windows with your friends, which I cannot; I test on Linux with two games on one PC, which you have not had. Or the other way round, my base as commits into your branch, if you'd rather keep #2578 as the one. Either way the first question of both drafts, whether a socket in the host is acceptable at all, is the maintainers', and I'd leave both drafts open until they answer.

What do you think?

Edited once, before any reply: #2594 named for the text fields; #2274's digest catch-up stated as existing rather than proposed; the relay's need for a reachable port said exactly; K and N named for what they are.

@itzdaface

Copy link
Copy Markdown
Author

@mooki000 thanks for this, and for reading mine that closely. Yes, I'm interested, and the connection code most of all. I've read through coop_net, coop_proto, the session interface and the relay doc, so the questions below come from that.

The VPN step is the first thing people push back on with mine. Someone in Discord already asked why it needs Radmin, and my answer was that I want linking up built into the game. Your rooms are much closer to that than anything I have: a secret to share, no addresses passed around between players, and a relay that can't read or change what goes through it. You're also right about my link. Anyone who can reach the port can join and send items, flags and hits. I knew that was the weak part.

So in principle: one co-op, your link and session underneath, my bodies, enemies, bosses and PvP on top as typed messages. I'm fine with it landing under your pull request, #2274. On credit I'd keep it simple and mutual: we're both on it. My parts go in as my commits, yours as yours, and if the project squashes it into one commit, whichever of us isn't the PR author gets a Co-authored-by line. Your order (bodies, then enemies, then PvP) makes sense to me.

Four things I'd like to sort out first, because they decide how my side gets ported:

  1. Who runs the relay for ordinary players? The doc says the players run one, which means someone forwards a UDP port or rents a server. For most groups that's the same hurdle as a VPN, just moved. Is the idea that the project hosts a public one? And could two games on one home network talk without a relay at all?
  2. The per-member limit. 200 datagrams a second with a broadcast to seven counted seven times is about 28 broadcasts a second in a full room. Mine sends a player's place 30 times a second, animation changes on top, and 15 a second for every enemy a game leads, and a game can lead several at once. I can pack all the enemies a game leads into one message and drop the rates, but it would help to know what budget you'd want me to design to, or whether the limit can move.
  3. Messages that must arrive. Nothing is acknowledged, which is fine for place and animation: the next one replaces the lost one. But "my hit took this much life off that enemy" and a PvP hit are one-off events. I can turn enemy life into state ("this enemy's life is now X, lowest wins") so a lost message heals itself. PvP hits and the boss hand-over are harder. Would you take a small resend-until-acked helper in the session, or should each layer do its own?
  4. Size. A message tops out near 1,100 bytes. My animation inputs can be larger than that when a player first appears, so I'd split them. Your POS already carries four animation slots with a node name and a frame. Mine mirrors all nine of Link's slots plus the inputs that drive them, which is what makes the body move exactly as its player does. I'd rather extend or sit beside POS than fight it. Which would you prefer?

What I can do right away: you said the Windows side is written but never run. I'll build your branch on Windows and try two games through a relay, and tell you what happens. That seems like the most useful first thing from me.

Where mine stands, so there are no surprises: it was played by real players on v2.0.14 and tested with a scripted second player on v2.1.0, but the port to main has only been compiled, not run. Getting it running on main was my next step anyway, so doing that on top of your base costs me little.

I agree on leaving both drafts open until the maintainers say whether a socket in the host is acceptable at all. That also goes for the size of the whole thing: yours plus mine is a lot to review, so it might be worth asking them early whether they'd want it in stages.

@mooki000

Copy link
Copy Markdown
Contributor

@itzdaface good, then we do it that way: one co-op under #2274, my link and session underneath, your bodies, enemies, bosses and PvP on top as typed messages, your commits yours and mine mine, a Co-authored-by for whoever is not the author if it gets squashed. Your four questions are the right ones; none has a finished answer in my code, so here is where each stands and what I would do.

1. Who runs the relay. Today a player or a server does, and you are right that a forwarded port is the same hurdle as a VPN. On one home network any PC can run coop_relay.py without touching the router, but it is still a second thing to start. What I would do, in this order:

  • Host on this PC: the relay's loop inside the game, so that the maker's game is the relay. The session already has the socket and the format. On one network that is "Make a room" and nothing else; across the internet the maker forwards one UDP port, which is your Host/Join of today, sealed.
  • A public relay is the maintainers' call (question 3 of feat(coop): co-op rooms: a relay, progress sync, and a Co-op tab to make, join and accept #2274). It cannot read or change what it forwards and has limits per member and per network, so it mostly costs bandwidth. I will ask them there.

2. The limit. It is a default in the relay, not a law: 200 forwarded copies and 64 KB a second per member, sized for progress sync, which paces itself at 32 KB a second. Bodies and enemies need more. Design to this, and I move the relay to fit it with room to spare and make the two numbers options:

  • one body message per player at 15 a second (my POS runs at that rate; if it looks choppy we smooth on the receiving side before we raise it);
  • one message for all the enemies a game leads, at 10 to 15 a second, not one per enemy;
  • events on top.
    That is about 30 datagrams a second per player, 210 copies in a full room, so the default becomes 480 copies and 256 KB.

3. Messages that must arrive. Enemy life as state is the right turn, and it fits what the progress layer already does: make it "damage taken so far, the highest wins" and a lost message heals itself. For the true one-offs (a PvP hit, the boss hand-over) I would rather have one helper in the session than one per layer: an id per sender for each event, the receiver's acknowledgements riding on its next message, a resend until acknowledged or the member is gone, repeats dropped by id, a bounded queue. One detail: the envelope refuses the same sealed datagram twice, so a resend is a new message carrying the same event id. I will write it, with a test at 30 % loss like the session's.

4. Size, and POS. Beside it, not inside it. POS stays the small "where" message every game sends, also one that shows nobody (map, place, facing, flags). Your body state is its own kind, with all nine slots and the inputs, at the body rate; the four slots in my POS go away when yours is in, since yours is the better body. For the first appearance, when the inputs are larger than one message, the helper from 3 can carry a larger piece in parts. Kinds are only numbers inside the sealed message; 1 to 7 are in #2274, and I will put the list into docs/coop.md so we take the next ones together.

Windows: yes please, that is the most useful thing right now. The branch is #2274's head (coop-relay-wire on my fork, 71d4ab0f); "On one PC, two folders" in its description is the quickest way to two games through a relay. Two things you would otherwise trip over: the project's Windows job shows two of my tests not building there (%zu in host/coop.c under the runner's gcc, and deprecation errors in the text-field test's fixture). Both are in how the tests compile, and fixes are on the way; I expect the game's own build is not affected, but that is exactly what I have not seen. What I would most like to hear: whether it builds, whether Make a room and Join work through a relay on 127.0.0.1, whether typing in the tab's fields works (WM_CHAR), and what the firewall asks.

For your layers I will add one call to the base that says whether this player has accepted a given member, so that body, enemy and PvP messages count only from accepted members.

And agreed on asking early about stages. I will put it to the maintainers on #2274 as: the link and session; progress sync and the tab; bodies; enemies and bosses; PvP.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants