diff --git a/README.md b/README.md
index 9d3c826..ccccb61 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Syncer
-Four playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
+Five playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
## Structure
@@ -10,7 +10,7 @@ apps/
web/ React + Vite predicted client
packages/
engine/ Generic clocks, authority, prediction, validation, and protocol
- shared/ Game definitions: Arena, Flux Relay, Royale, and Bad Movers
+ shared/ Game definitions: Arena, Flux Relay, Royale, Bad Movers, and DEAD AIR
```
## Play
@@ -20,14 +20,15 @@ npm install
npm run dev
```
-Open `http://localhost:5173`. Use the selector to switch between four games:
+Open `http://localhost:5173`. Use the selector to switch between five games:
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1`–`3`.
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`.
+- **DEAD AIR** — use `WASD`, mouse look, left click, `E`, `F`, `Q`, and `R` to steal a screaming cursed microwave from five authority wardens. Hidden enemies are omitted from snapshots; only anonymous 8-way, 3-distance-band sound cues cross the wire. Open it directly at `http://localhost:5173/#dead-air`.
-The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic.
+The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. DEAD AIR proves secure multimodal perception: exact visible entities and deliberately lossy audio-only knowledge share one generic authority boundary without leaking hidden coordinates or identities.
## Define a game
diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 36e3484..940656d 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -1,10 +1,12 @@
import {
API_PATHS,
+ DEAD_AIR_SOCKET_PATH,
FLUX_SOCKET_PATH,
GAME_SOCKET_PATH,
MOVERS_SOCKET_PATH,
ROYALE_SOCKET_PATH,
createShooterGame,
+ deadAirGame,
fluxGame,
moversGame,
royaleGame,
@@ -24,6 +26,7 @@ const clientProfiles = new ClientProfileStore();
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
const gameLoops = [
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
+ hostNetworkedGame(app, DEAD_AIR_SOCKET_PATH, deadAirGame),
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
hostNetworkedGame(app, MOVERS_SOCKET_PATH, moversGame),
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index d07ca69..b167846 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -6,14 +6,17 @@ import {
} from "@syncer/shared";
import { useEffect, useState } from "react";
import { Arena3D } from "./Arena3D.js";
+import { DeadAirGame } from "./DeadAirGame.js";
import { FluxGame } from "./FluxGame.js";
import { MoversGame } from "./MoversGame.js";
import { RoyaleGame } from "./RoyaleGame.js";
import { useGameClient } from "./useGameClient.js";
export function App() {
- const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers">(() =>
- window.location.hash === "#movers"
+ const [demo, setDemo] = useState<"arena" | "flux" | "royale" | "movers" | "dead-air">(() =>
+ window.location.hash === "#dead-air"
+ ? "dead-air"
+ : window.location.hash === "#movers"
? "movers"
: window.location.hash === "#royale"
? "royale"
@@ -28,7 +31,7 @@ export function App() {
return (
<>
- {demo === "arena" ? : demo === "flux" ? : demo === "royale" ? : }
+ {demo === "arena" ? : demo === "flux" ? : demo === "royale" ? : demo === "movers" ? : }
>
);
diff --git a/apps/web/src/DeadAir3D.tsx b/apps/web/src/DeadAir3D.tsx
new file mode 100644
index 0000000..5959d1d
--- /dev/null
+++ b/apps/web/src/DeadAir3D.tsx
@@ -0,0 +1,478 @@
+import { useEffect, useRef, useState, type MutableRefObject } from "react";
+import * as THREE from "three";
+import {
+ DEAD_AIR_EXTRACTION,
+ DEAD_AIR_MAP_SIZE,
+ DEAD_AIR_POWER_SWITCH,
+ DEAD_AIR_TICK_RATE,
+ DEAD_AIR_WALLS,
+ type DeadAirClientState,
+ type DeadAirPlayerView,
+} from "@syncer/shared";
+import type { DeadAirRenderSource } from "./useDeadAirClient.js";
+
+interface DeadAir3DProps {
+ source: DeadAirRenderSource;
+ playerId: number | null;
+ unlockAudio(): void;
+}
+
+interface PlayerMesh extends THREE.Group {
+ userData: {
+ lamp?: THREE.PointLight;
+ beam?: THREE.SpotLight;
+ };
+}
+
+interface TemporaryEffect {
+ object: THREE.Object3D;
+ expiresAt: number;
+}
+
+interface DeadAirRuntime {
+ renderer: THREE.WebGLRenderer;
+ scene: THREE.Scene;
+ camera: THREE.PerspectiveCamera;
+ ambient: THREE.HemisphereLight;
+ poweredLights: THREE.PointLight[];
+ flashlight: THREE.SpotLight;
+ flashlightLens: THREE.Mesh;
+ weapon: THREE.Group;
+ muzzle: THREE.PointLight;
+ players: Map;
+ artifact: THREE.Group;
+ effects: TemporaryEffect[];
+ resizeObserver: ResizeObserver;
+ animationFrame: number;
+}
+
+export function DeadAir3D({ source, playerId, unlockAudio }: DeadAir3DProps) {
+ const hostRef = useRef(null);
+ const playerIdRef = useRef(playerId);
+ const sourceRef = useRef(source);
+ const runtimeRef = useRef(null);
+ const lastEventIdRef = useRef(0);
+ const [locked, setLocked] = useState(false);
+ playerIdRef.current = playerId;
+ sourceRef.current = source;
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+ const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 0.82;
+ host.append(renderer.domElement);
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x010304);
+ scene.fog = new THREE.FogExp2(0x020506, 0.026);
+ const camera = new THREE.PerspectiveCamera(76, 1, 0.035, 110);
+ camera.rotation.order = "YXZ";
+ scene.add(camera);
+
+ const ambient = new THREE.HemisphereLight(0x68828a, 0x050506, 0.16);
+ scene.add(ambient);
+ const poweredLights = buildLevel(scene);
+ const artifact = buildArtifact();
+ artifact.visible = false;
+ scene.add(artifact);
+
+ const flashlightTarget = new THREE.Object3D();
+ flashlightTarget.position.set(0, -0.08, -12);
+ camera.add(flashlightTarget);
+ const flashlight = new THREE.SpotLight(0xdffaff, 112, 31, 0.42, 0.42, 1.5);
+ flashlight.position.set(0.12, -0.05, -0.12);
+ flashlight.target = flashlightTarget;
+ flashlight.castShadow = true;
+ flashlight.shadow.mapSize.set(512, 512);
+ flashlight.shadow.bias = -0.0005;
+ camera.add(flashlight);
+
+ const weapon = buildWeapon();
+ camera.add(weapon);
+ const flashlightLens = weapon.getObjectByName("flashlight-lens") as THREE.Mesh;
+ const muzzle = new THREE.PointLight(0xffd19b, 0, 5, 2);
+ muzzle.position.set(0.33, -0.2, -1.05);
+ camera.add(muzzle);
+
+ const runtime: DeadAirRuntime = {
+ renderer,
+ scene,
+ camera,
+ ambient,
+ poweredLights,
+ flashlight,
+ flashlightLens,
+ weapon,
+ muzzle,
+ players: new Map(),
+ artifact,
+ effects: [],
+ resizeObserver: new ResizeObserver(() => resize(runtime, host)),
+ animationFrame: 0,
+ };
+ runtimeRef.current = runtime;
+ runtime.resizeObserver.observe(host);
+ resize(runtime, host);
+
+ const animate = (time: number) => {
+ updateRuntime(runtime, sourceRef.current, playerIdRef.current, lastEventIdRef, time);
+ renderer.render(scene, camera);
+ runtime.animationFrame = window.requestAnimationFrame(animate);
+ };
+ runtime.animationFrame = window.requestAnimationFrame(animate);
+ const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
+ document.addEventListener("pointerlockchange", pointerLockChanged);
+
+ return () => {
+ document.removeEventListener("pointerlockchange", pointerLockChanged);
+ runtime.resizeObserver.disconnect();
+ window.cancelAnimationFrame(runtime.animationFrame);
+ scene.traverse(disposeObject);
+ renderer.dispose();
+ renderer.domElement.remove();
+ runtimeRef.current = null;
+ };
+ }, []);
+
+ const enter = () => {
+ unlockAudio();
+ const canvas = runtimeRef.current?.renderer.domElement;
+ if (canvas) void canvas.requestPointerLock().catch(() => setLocked(false));
+ };
+
+ return (
+
+ {!locked ? (
+
+ HEADPHONES STRONGLY RECOMMENDED
+ ENTER THE DEAD AIR
+ STEAL THE SCREAMING MICROWAVE // TRUST YOUR EARS
+ WASD · MOUSE · CLICK FIRE · E INTERACT · F LIGHT · Q DECOY · R RELOAD
+
+ ) : null}
+