Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7f39500
restore players[MAXPLAYERS] — call back the cooperative marines
mrq1911 May 10, 2026
a890ed6
bring up WiFi station mode before D_DoomMain
mrq1911 May 10, 2026
16e17c0
UDP socket, d_net state machine, PC relay
mrq1911 May 10, 2026
6f998d5
dropout + retransmit + relay self-reset — survive the imp ambush
mrq1911 May 10, 2026
b5a3756
skip D_StartTitle in netplay so E1M1 actually loads
mrq1911 May 10, 2026
afda728
P_SpawnPlayer/G_PlayerReborn: use the player param, not consoleplayer
mrq1911 May 10, 2026
04ac9b5
add tic-stage breadcrumbs to chase the netplay phantom
mrq1911 May 10, 2026
d5bdfb9
loop reborn/load-level over all marines, drop dual-mobj fallback
mrq1911 May 10, 2026
017fbe7
dynamic roster — pixies join and leave the deathmatch whenever
mrq1911 May 16, 2026
47c1ea2
generation byte, same-IP rejoin, MAXPLAYERS=8
mrq1911 May 16, 2026
4794562
relay heartbeat: per-slot contiguous frontier, not stale max
mrq1911 May 16, 2026
f4e474e
offset spawn for missing starts, grace, all-dead restart
mrq1911 May 16, 2026
2dd5af0
round-over banner + extended gen-mismatch grace
mrq1911 May 16, 2026
1ab2255
docs: WiFi multiplayer setup in README
mrq1911 May 16, 2026
0cff680
P2P mesh — pixies form their own coven, no relay required
mrq1911 May 17, 2026
93a737c
restart epoch fixes last-marine-standing and peer-reboot stall
mrq1911 May 17, 2026
e478279
liveness on direct evidence only, 5s timeout, drop gossip ghosts
mrq1911 May 17, 2026
c49f5c1
docs: README mesh-default, parallel-flash gotcha, drop relay IP step
mrq1911 May 17, 2026
bb5e71b
mp: color marines by slot — green/indigo/brown/red, wraps at 4
mrq1911 May 17, 2026
a109081
relay: 5s timeout for active marines, 15s grace for cold-start joiners
mrq1911 May 17, 2026
680a0ad
relay: reset has_run/last_seen on restart so survivors get fresh grace
mrq1911 May 17, 2026
e332efb
relay: RETRANS counts as heartbeat — stalled survivors stop sending T…
mrq1911 May 17, 2026
9c82e77
docs and .gitignore polish for PR — drop branch ref, mention player c…
mrq1911 May 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,13 @@ dependencies.lock
.iterm2-project

build/**

# Per-developer WiFi credentials (template at main/wifi-creds.h.example)
main/wifi-creds.h

# idf.py set-target leftovers
sdkconfig.old

# IDE / tool caches
.cache/
.idea/
122 changes: 122 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,134 @@ required. The device is played sideways (with the buttons down):
```


Multiplayer (WiFi co-op)
------------------------

Lockstep co-op over WiFi for up to 8 Pixies. The default build is a
**peer-to-peer mesh**: no host machine, no server, no IP to configure.
Power on the Pixies on the same WiFi and they find each other
automatically.

Pixies can join and leave at any time. Each roster change triggers a
clean restart at E1M1 with a fresh RNG seed. Each marine is rendered
in one of four colors — green / indigo / brown / red — assigned by
slot so you can tell yourself apart from the rest of the squad. When
only one player is left alive, the round ends with a "PLAYER N WINS!"
banner and a 10-second auto-restart for everyone. Solo deaths trigger
"GAME OVER" with the same 10-second restart.

A legacy **relay mode** is also available (Python UDP relay on a
laptop / phone / RPi) — useful for client-isolated networks where
peer-to-peer UDP is blocked, or for diagnostics (the relay exposes a
heartbeat + per-slot tic frontier).

### What you need

- 1–8 Firefly Pixies (or Gremlins) flashed from this branch
- A WiFi network — home WiFi, laptop hotspot, or phone hotspot. All
Pixies must associate with the same SSID.
- (Relay mode only) any machine on that WiFi running Python 3.

### Mode select

`main/net-config.h`:

```c
#define DOOM_NETPLAY_MESH 1 // 1: mesh (default), 0: relay
#define DOOM_NETPLAY_PORT 5029
```

You only need to edit this if you want relay mode. In relay mode also
set `DOOM_SERVER_IP` to the relay machine's IP.

### One-time setup

```bash
cp main/wifi-creds.h.example main/wifi-creds.h
$EDITOR main/wifi-creds.h # WIFI_SSID, WIFI_PASSWORD

. $HOME/esp/esp-idf/export.sh
idf.py build
idf.py -p /dev/cu.usbmodemXXXX app-flash
```

Repeat the `app-flash` line for each Pixie's serial port — **one at a
time**, not in parallel. Concurrent `idf.py` invocations share the
same `build/` directory and race on cmake regeneration; some will
silently fail, leaving you with mixed firmware versions and a confused
mesh.

### Running a session — mesh mode (default)

1. **Power on the Pixies.** They WiFi-associate, broadcast a
`PKT_HELLO` every 2 s on `255.255.255.255:5029`, observe each
other's MACs, and deterministically agree on slot assignment and
RNG seed (sorted MAC list → FNV-1a hash). All Pixies drop straight
into E1M1 together.

2. **Join late** by powering on more Pixies — within ~2 s of the new
joiner's first HELLO reaching the others, everyone restarts at
E1M1 with a fresh seed reflecting the new roster.

3. **Leave any time** by powering off a Pixie. The others notice ≤5 s
of silence from that MAC (no HELLOs, no ticcmds) and restart with
the survivors. The departed Pixie can rejoin by powering on again.

### Running a session — relay mode

Set `DOOM_NETPLAY_MESH 0` and `DOOM_SERVER_IP` to the relay machine's
IP in `net-config.h`, rebuild and reflash, then:

```bash
python3 tools/relay.py
```

The relay binds `0.0.0.0:5029` and sits idle until the first Pixie
joins. The output logs every join / leave / restart plus a 2 Hz
status line:

```
[relay] gen=3 released=5891 frontier={0: 5892, 1: 5891, 2: 5891} ...
```

### Troubleshooting

- **Pixies don't see each other (mesh mode):** the network is blocking
UDP broadcasts. Guest WiFi with "client isolation" enabled is the
usual culprit. Either switch networks (personal hotspot, regular
home WiFi) or flip `DOOM_NETPLAY_MESH` to `0` and use relay mode.
- **One Pixie is out of sync after a flash run:** check that all of
them are on the same protocol version. A parallel `idf.py flash`
may have failed on one without you noticing — re-flash that one
sequentially. HELLO payload version mismatches are silently dropped,
so the symptom is "one Pixie is invisible to the others".
- **Pixie shows "Waiting for server…" forever (relay mode):** the
relay isn't reachable. Check that `DOOM_SERVER_IP` matches the
relay machine's current IP on the WiFi the Pixies actually joined.
Phone hotspots and laptops can change IP between sessions.
- **Game freezes mid-session with no obvious cause:** rare but
observed on long-running sessions — the engine appears to hang
without an `I_Error`. Power-cycle one Pixie; in mesh mode the
others will detect the reboot and re-converge, in relay mode the
relay will reissue `SETUP+GO` to everyone.

Protocol details and on-device state machine: `main/doom/d_net.c`
(both modes, picked at compile time) and `tools/relay.py` (relay
only). Both modes carry an 8-bit generation byte in every packet
header so peers detect missed restarts and re-sync.


To Do
-----

- save/load
- more visible menu items (bigger? different color? dim/blur the background?)
- click forward twice to begin running
- multiplayer: track down the rare silent-stall (engine hangs with
no `I_Error` on long sessions; recoverable by power-cycling one
Pixie, then the others re-converge)
- multiplayer: deathmatch toggle (mesh: gametype byte in HELLO
payload; relay: flag in `setup_packet_s`)


Credits
Expand Down
3 changes: 2 additions & 1 deletion main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
idf_component_register(
SRCS
"main.c" "device-info.c" "keypad.c"
"main.c" "device-info.c" "keypad.c" "wifi.c"

"doom/am_map.c" "doom/d_client.c" "doom/d_items.c" "doom/d_main.c"
"doom/i_network.c" "doom/d_net.c"
"doom/f_finale.c" "doom/f_wipe.c" "doom/g_game.c" "doom/global_data.c"
"doom/hu_lib.c" "doom/hu_stuff.c" "doom/i_audio.c"
"doom/info.c" "doom/i_system.c" "doom/i_video.c" "doom/lprintf.c"
Expand Down
30 changes: 15 additions & 15 deletions main/doom/am_map.c
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,8 @@ static void AM_initVariables(void)
_g->m_h = FTOM(_g->f_h);


_g->m_x = (_g->player.mo->x >> FRACTOMAPBITS) - _g->m_w/2;//e6y
_g->m_y = (_g->player.mo->y >> FRACTOMAPBITS) - _g->m_h/2;//e6y
_g->m_x = (_g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS) - _g->m_w/2;//e6y
_g->m_y = (_g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS) - _g->m_h/2;//e6y
AM_changeWindowLoc();

// inform the status bar of the change
Expand Down Expand Up @@ -401,7 +401,7 @@ boolean AM_Responder
_g->automapmode ^= am_follow; // CPhipps - put all automap mode stuff into one enum
_g->f_oldloc.x = INT_MAX;
// Ty 03/27/98 - externalized
_g->player.message = (_g->automapmode & am_follow) ? AMSTR_FOLLOWON : AMSTR_FOLLOWOFF;
_g->players[_g->consoleplayer].message = (_g->automapmode & am_follow) ? AMSTR_FOLLOWON : AMSTR_FOLLOWOFF;
} // |
else if (ch == key_map_zoomout)
{
Expand Down Expand Up @@ -511,14 +511,14 @@ static void AM_changeWindowScale(void)
//
static void AM_doFollowPlayer(void)
{
if (_g->f_oldloc.x != _g->player.mo->x || _g->f_oldloc.y != _g->player.mo->y)
if (_g->f_oldloc.x != _g->players[_g->consoleplayer].mo->x || _g->f_oldloc.y != _g->players[_g->consoleplayer].mo->y)
{
_g->m_x = FTOM(MTOF(_g->player.mo->x >> FRACTOMAPBITS)) - _g->m_w/2;//e6y
_g->m_y = FTOM(MTOF(_g->player.mo->y >> FRACTOMAPBITS)) - _g->m_h/2;//e6y
_g->m_x = FTOM(MTOF(_g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS)) - _g->m_w/2;//e6y
_g->m_y = FTOM(MTOF(_g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS)) - _g->m_h/2;//e6y
_g->m_x2 = _g->m_x + _g->m_w;
_g->m_y2 = _g->m_y + _g->m_h;
_g->f_oldloc.x = _g->player.mo->x;
_g->f_oldloc.y = _g->player.mo->y;
_g->f_oldloc.x = _g->players[_g->consoleplayer].mo->x;
_g->f_oldloc.y = _g->players[_g->consoleplayer].mo->y;
}
}

Expand Down Expand Up @@ -837,8 +837,8 @@ static void AM_drawWalls(void)

if (_g->automapmode & am_rotate)
{
AM_rotate(&l.a.x, &l.a.y, ANG90-_g->player.mo->angle, _g->player.mo->x, _g->player.mo->y);
AM_rotate(&l.b.x, &l.b.y, ANG90-_g->player.mo->angle, _g->player.mo->x, _g->player.mo->y);
AM_rotate(&l.a.x, &l.a.y, ANG90-_g->players[_g->consoleplayer].mo->angle, _g->players[_g->consoleplayer].mo->x, _g->players[_g->consoleplayer].mo->y);
AM_rotate(&l.b.x, &l.b.y, ANG90-_g->players[_g->consoleplayer].mo->angle, _g->players[_g->consoleplayer].mo->x, _g->players[_g->consoleplayer].mo->y);
}

// if line has been seen or IDDT has been used
Expand Down Expand Up @@ -979,7 +979,7 @@ static void AM_drawWalls(void)
}
}
} // now draw the lines only visible because the player has computermap
else if (_g->player.powers[pw_allmap]) // computermap visible lines
else if (_g->players[_g->consoleplayer].powers[pw_allmap]) // computermap visible lines
{
if (!(_g->lines[i].flags & ML_DONTDRAW)) // invisible flag lines do not show
{
Expand Down Expand Up @@ -1023,7 +1023,7 @@ static void AM_drawLineCharacter
int i;
mline_t l;

if (_g->automapmode & am_rotate) angle -= _g->player.mo->angle - ANG90; // cph
if (_g->automapmode & am_rotate) angle -= _g->players[_g->consoleplayer].mo->angle - ANG90; // cph

for (i=0;i<lineguylines;i++)
{
Expand Down Expand Up @@ -1076,10 +1076,10 @@ static void AM_drawPlayers(void)
player_arrow,
NUMPLYRLINES,
0,
_g->player.mo->angle,
_g->players[_g->consoleplayer].mo->angle,
mapcolor_sngl, //jff color
_g->player.mo->x >> FRACTOMAPBITS,//e6y
_g->player.mo->y >> FRACTOMAPBITS);//e6y
_g->players[_g->consoleplayer].mo->x >> FRACTOMAPBITS,//e6y
_g->players[_g->consoleplayer].mo->y >> FRACTOMAPBITS);//e6y

}

Expand Down
Loading