itch.io is community of indie game creators and players

Devlogs

PATCH 1.1 - Realtime Multiplayer

Project Void
A browser game made in HTML5

After weeks of intensive development, debugging, and testing, real-time multiplayer is fully operational in Project Void. This is not a limited beta. This is not a preview. Every feature described below is live, tested, and running right now on the production build.

And here's something that might surprise you: you barely need an internet connection to play. Once it loads, the ongoing data usage is almost nothing. Firebase SSE connections hold open a persistent stream that transmits only tiny JSON fragments when something actually changes. Combat WebSocket messages are delta-compressed, meaning only the fields that changed since the last tick are sent. A full combat encounter with 4 players and 4 enemies uses less data than loading a single thumbnail image on social media. You could play this game all day on a weak 3G connection or a coffee shop's barely-functional WiFi and never notice a hiccup.


⚔️ PARTY SYSTEM

Players can now form parties of up to 4 members. The party leader creates the group and invites other players by character name. You don't even need to be on each other's friends list. The invite arrives instantly in the target player's inbox through Firebase, and they can accept or decline on the spot.

Once in a party, all members travel together. The leader initiates travel to a new zone, and every member receives a synchronized vote popup. All members must vote yes for the party to move. If anyone votes no or doesn't respond within 15 seconds, travel is cancelled and the leader gets a short cooldown before they can try again. The same vote system applies to exploration. The leader proposes exploring a danger zone, members vote, and if it passes, everyone enters combat together.

Party members who go offline are tracked in real time. The leader cannot travel or explore while any member is offline, preventing the party from splitting up. Offline members show a clear "OFFLINE" status on their party frame during combat and in the character tab.

If a member disconnects and logs back in, the game automatically detects their active party. Their zone is synced to the leader's current location, the party SSE listener is reopened, and they're right back in the group as if they never left. If they disconnect during combat, the game detects the active combat room on the server and rejoins them with a combat transition screen, picking up exactly where they left off with full state recovery.

The party persists across sessions. Your party ID is saved to your Firebase save data, so even if you close the app entirely and come back hours later, you'll rejoin your party automatically on login.

Every party member can see every other member's health bar in real time on the Character tab. When someone uses a health potion, enters a safe zone and heals to full, or takes damage, every other party member sees the HP bar update instantly. This works through the party document in Firebase: whenever your HP changes, it's written to the shared party doc, and every member's SSE listener picks it up within milliseconds.

Data cost for the party system is remarkably low. The party doc is a single Firebase node. SSE delivers only the changed fields on each update. A full evening of party play, traveling between zones, voting on actions, and tracking each other's HP generates less data than a single high-resolution photo upload.


🗡️ REAL-TIME PARTY COMBAT

Combat runs on a dedicated WebSocket server hosted on Railway. When the party leader initiates exploration and enemies appear, every party member is connected to the same combat room on the server. The server is the single source of truth. It tracks every member's HP, energy, cooldowns, and alive status. It tracks every enemy's HP, energy, and attack timers. No game logic runs on the client during combat. The client sends actions, the server processes them, and broadcasts the results to everyone.

Energy regenerates server-side at 10 points per second, delivered in chunks of 10 every 1000ms to minimize WebSocket traffic. The client interpolates smoothly, incrementing the displayed energy by 1 every 100ms so the bar fills up visually without stuttering. When you hit 80 energy, your action buttons light up gold and you can attack.

Every enemy runs a threat-based AI system. Enemies don't randomly pick targets. They track a threat table that records how much damage each party member has dealt to them specifically. The enemy targets whoever has hurt them the most. If multiple members are tied on damage, the enemy picks the one with the highest current HP. If that's also tied, it picks randomly. Burn damage and any future damage-over-time effects are attributed to the player who cast them, so the threat table stays accurate.

When a party member flees combat, they stay on the combat screen watching the fight continue. Their action buttons are grayed out and disabled. Their party frame shows "ESCAPED" in yellow. The remaining members fight on. If the rest of the party wins, the fled member gets the victory screen and loot. If the rest of the party dies, the fled member sees an escape transition screen and is teleported to the leader's respawn zone with full HP. They don't see the death screen because they escaped.

When the entire party flees, everyone sees the escape transition screen and returns to their current zone's navigation screen with their current HP intact. No zone change, no respawn penalty. When the entire party dies, everyone sees the death screen and respawns at the leader's respawn zone with full HP.

Attack lines are drawn in real time between the attacker and their target. Every member sees every other member's attacks. Green lines for player hits, gray for misses, red for enemy attacks. Damage popups float above the target showing the exact damage dealt. The server sends the attacker's UID with every combat event, and the client resolves it to the correct party frame using a UID-to-username map built at combat start.

Party combat frames show live HP bars for every member. When a member is alive, you see their HP bar with exact numbers. When they die, the frame dims and shows "DEAD" in red. When they flee, it shows "ESCAPED" in yellow. When they go offline mid-combat, it shows "OFFLINE" in gray. Your own card does the same. A dark overlay appears with your name centered and the status label below.

The data efficiency of combat is where the engineering really shines. Each combat tick is a single WebSocket frame containing only delta-compressed JSON. If nothing changed for a member since the last tick, their data isn't included at all. A typical 60-second combat encounter with 4 players and 4 enemies transmits roughly 15 to 25 kilobytes total, including all attack events, damage popups, HP updates, and energy ticks. That's less data than a single email.


💬 LIVE ZONE CHAT

Every zone has a real-time chat channel powered by Firebase Server-Sent Events. When you type a message and hit send, it's written to Firebase and delivered to every player in that zone within milliseconds. No polling, no refresh, no delay.

Player names in chat are clickable. Tap someone's name and a popup appears with options to inspect their character sheet, send them a direct message, or add them as a friend. Direct messages are also real time. They arrive instantly through a dedicated Firebase SDK listener that's always active, regardless of what screen you're on or what chat mode you're in.

Zone chat automatically switches when you travel. The old zone's SSE listener closes, the new zone's listener opens, and you're immediately in the new zone's conversation. Chat messages are timestamped and filtered so you never see stale messages from a previous session.

Chat uses almost no data. Each message is a tiny JSON object with a sender name, message text, and timestamp. The SSE connection stays open with zero overhead when nobody is talking, and transmits only the new message when someone does talk. Hours of active chatting costs less bandwidth than loading a single web page.


👥 FULL SOCIAL SYSTEM

The friends system is fully operational with real-time presence tracking. Add a friend by typing their character name. The game searches the Firebase charnames index for an instant O(1) lookup. Friend requests are delivered through the inbox system and arrive in real time via Firebase SDK listeners. Accept, decline, or ignore. It's all instant.

Your friends list shows online and offline status in real time. Online friends show their current zone. Friends who are in your party show "In Party" instead of an invite button. The presence system uses Firebase's onDisconnect handler for crash detection. If someone's phone dies or they lose connection, their presence is automatically set to offline by the Firebase server. No stale "online" ghosts.

The block system prevents all interaction. Blocked players can't send you friend requests, party invites, or direct messages. You can't accidentally invite someone who has blocked you. The game checks both directions before allowing any social action. The block list is accessible from the Character tab with a dedicated "Blocked" button.

Party invites work for any player, not just friends. The party leader can tap the "Invite" button in the Party section, type any character name, and send an invite. The game validates that the target exists, is online, and hasn't blocked you before sending. The invite arrives in the target's inbox instantly.

Presence tracking is one of the lightest features in the entire game. Each player's online status is a single Firebase node containing a timestamp. The SDK .on("value") listener holds open a connection that costs zero data when the status doesn't change, and transmits a few bytes when it does. Tracking 50 friends online and offline all day uses less data than sending a single text message.


🔥 FIREBASE REAL-TIME DATABASE

The entire multiplayer backend runs on Firebase Realtime Database with zero polling. Every data flow uses either Firebase SDK .on("value") listeners or REST-based Server-Sent Events.

The SDK handles inbox (friend requests, party invites), direct messages, and per-friend presence tracking. These listeners are always active from login to logout.

SSE handles the party document (member list, vote requests, travel signals, combat signals, HP broadcasts) and zone chat. These are opened and closed dynamically as you join or leave parties and travel between zones.

All writes use Firebase REST API with authentication tokens. Security rules enforce that players can only write to their own data paths. Party documents use auth != null rules since the party ID itself is secret. Only invited members know it.

The party vote system is fully synchronized through Firebase. The leader writes a voteRequest to the party doc, all members see it via SSE, cast their votes to a shared partyvote node, and the leader polls the votes until everyone has responded or the 15-second deadline expires. Vote cleanup wipes the entire vote node atomically to prevent stale votes from contaminating the next round.

Social data (friends, pending requests, notifications, block list) is persisted separately from the main save blob at pv/social/{uid}. This keeps the save data small and allows the social system to update independently.

The total data footprint of Firebase for a typical play session is strikingly low. Login loads your save data (a few KB), opens 3 to 5 persistent SSE/SDK connections (each costs essentially zero bytes when idle), and then only transmits data when something actually happens. A player who logs in, joins a party, chats for an hour, fights 10 combats, and logs out will use roughly 200 to 400 kilobytes of total data transfer for the entire session. That's less than loading a single modern web page. You could play on the weakest cellular connection imaginable and it would feel instant.


🖥️ DEDICATED COMBAT SERVER

All combat, solo and party, runs through a WebSocket server hosted on Railway. The server is a Node.js process running the ws library with Firebase Admin SDK for authentication and save data access.

When combat starts, the leader's client connects to the WebSocket server, authenticates with a Firebase ID token, and sends a start_combat message with the enemy list and member UIDs. The server loads each member's save data from Firebase, creates a CombatRoom object, and starts the tick loop.

The tick loop runs every 1000ms. Each tick: burn damage is applied, energy regenerates for all alive members and enemies, enemy AI selects targets and attacks, death checks run, and a delta-compressed state update is broadcast to all connected clients. Delta compression means only fields that actually changed since the last broadcast are sent. If a member's HP didn't change, their data isn't in the packet. This keeps bandwidth minimal even with 4 members and 4 enemies.

The server handles all combat outcomes: victory (all enemies dead), death (all members dead), and flee (all members fled). On victory, it calculates gold rewards, updates kill stats, and writes each member's save back to Firebase. On death, it respawns all members at the leader's respawn zone with full HP. On flee, it preserves each member's current HP and zone.

Reconnection is built in. If a client disconnects mid-combat, the CombatRoom keeps running. When the client reconnects, either through the automatic 2-second retry or by relogging entirely, the server detects that their UID is in an active room and sends a combat_rejoin signal. The client requests full_state, receives the complete current combat state (all member HP, energy, alive and fled status, all enemy HP, full combat log), and re-enters the fight with a combat transition screen.

The server enforces rate limits on all actions to prevent spam. Authentication is verified on every connection using Firebase Admin SDK's verifyIdToken. There is no way to manipulate combat results from the client.

The combat server is designed to scale. Each CombatRoom is independent, with its own tick loop and state. The server can run hundreds of simultaneous combat rooms with minimal memory overhead. WebSocket frames are tiny. A full 4-player combat room broadcasting at 1 tick per second generates roughly 200 to 500 bytes per tick depending on how many fields changed. A hundred simultaneous rooms would use less bandwidth than streaming a single YouTube video at 144p.

Leave a comment