diff --git a/src/godotiq/bridge/__init__.py b/src/godotiq/bridge/__init__.py
new file mode 100644
index 0000000..c100e69
--- /dev/null
+++ b/src/godotiq/bridge/__init__.py
@@ -0,0 +1 @@
+"""GodotIQ bridge layer — WebSocket communication with the Godot editor addon."""
diff --git a/src/godotiq/bridge/connection.py b/src/godotiq/bridge/connection.py
new file mode 100644
index 0000000..4bf2201
--- /dev/null
+++ b/src/godotiq/bridge/connection.py
@@ -0,0 +1,199 @@
+"""Async WebSocket client for communicating with the Godot editor addon."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any, Callable
+
+from godotiq.bridge.protocol import BridgeRequest, parse_message, BridgeResponse, BridgeEvent, get_timeout
+
+
+class BridgeConnectionError(Exception):
+    """Raised when the bridge is not connected or connection fails."""
+
+
+class BridgeTimeoutError(Exception):
+    """Raised when a request times out waiting for a response."""
+
+
+class BridgeError(Exception):
+    """Raised when the addon returns an error response."""
+
+    def __init__(self, code: str, error_message: str) -> None:
+        self.code = code
+        self.error_message = error_message
+        super().__init__(f"[{code}] {error_message}")
+
+
+class BridgeConnection:
+    """Async WebSocket client for the Godot addon bridge."""
+
+    def __init__(self, host: str = "127.0.0.1", port: int = 6007) -> None:
+        self._host = host
+        self._port = port
+        self._ws: Any = None
+        self._pending: dict[str, asyncio.Future[BridgeResponse]] = {}
+        self._event_handlers: dict[str, list[Callable]] = {}
+        self._request_counter: int = 0
+        self._connected: bool = False
+        self._game_running: bool = False
+        self._listen_task: asyncio.Task[None] | None = None
+
+    @property
+    def is_connected(self) -> bool:
+        """Whether the bridge is currently connected."""
+        return self._connected
+
+    @property
+    def is_game_running(self) -> bool:
+        """Whether the Godot game is currently running."""
+        return self._game_running
+
+    async def connect(self, timeout: float = 5.0) -> None:
+        """Connect to the Godot addon WebSocket server.
+
+        Imports websockets lazily to avoid import-time failures before
+        the dependency is installed.
+        """
+        try:
+            import websockets
+        except ImportError as exc:
+            raise BridgeConnectionError(
+                "websockets package not installed. Install with: pip install websockets"
+            ) from exc
+
+        try:
+            self._ws = await asyncio.wait_for(
+                websockets.connect(
+                    f"ws://{self._host}:{self._port}",
+                    max_size=16 * 1024 * 1024,
+                ),
+                timeout=timeout,
+            )
+        except Exception as exc:
+            raise BridgeConnectionError(f"Failed to connect to {self._host}:{self._port}: {exc}") from exc
+
+        self._connected = True
+        self.on_event("game_started", self._on_game_started)
+        self.on_event("game_stopped", self._on_game_stopped)
+        self._listen_task = asyncio.create_task(self._listen_loop())
+
+    async def disconnect(self) -> None:
+        """Disconnect from the addon and clean up."""
+        if self._listen_task and not self._listen_task.done():
+            self._listen_task.cancel()
+            try:
+                await self._listen_task
+            except asyncio.CancelledError:
+                pass
+        self._listen_task = None
+
+        if self._ws:
+            await self._ws.close()
+            self._ws = None
+
+        for future in self._pending.values():
+            if not future.done():
+                future.set_exception(BridgeConnectionError("Disconnected"))
+        self._pending.clear()
+        self._connected = False
+
+    async def request(self, method: str, params: dict | None = None, timeout: float | None = None) -> dict:
+        """Send a request and await the response.
+
+        Returns the result dict on success.
+        Raises BridgeConnectionError if not connected.
+        Raises BridgeTimeoutError if the request times out.
+        Raises BridgeError if the addon returns an error.
+        """
+        if not self._connected:
+            raise BridgeConnectionError("Not connected to Godot addon")
+
+        self._request_counter += 1
+        req_id = str(self._request_counter)
+        req = BridgeRequest(id=req_id, method=method, params=params or {})
+
+        loop = asyncio.get_running_loop()
+        future: asyncio.Future[BridgeResponse] = loop.create_future()
+        self._pending[req_id] = future
+
+        await self._ws.send(req.to_json())
+
+        effective_timeout = timeout if timeout is not None else get_timeout(method)
+        try:
+            response = await asyncio.wait_for(future, timeout=effective_timeout)
+        except asyncio.TimeoutError:
+            self._pending.pop(req_id, None)
+            raise BridgeTimeoutError(f"Request '{method}' timed out after {effective_timeout}s")
+
+        if response.is_ok:
+            return response.result or {}
+        raise BridgeError(response.error_code, response.error_message)
+
+    async def request_with_retry(
+        self,
+        method: str,
+        params: dict | None = None,
+        max_retries: int = 3,
+        retry_delay: float = 0.5,
+    ) -> dict:
+        """Send a request with automatic retry on timeout or error.
+
+        BridgeConnectionError is never retried.
+        """
+        last_exc: Exception | None = None
+        for attempt in range(max_retries):
+            try:
+                return await self.request(method, params)
+            except BridgeConnectionError:
+                raise
+            except (BridgeTimeoutError, BridgeError) as exc:
+                last_exc = exc
+                if attempt < max_retries - 1:
+                    await asyncio.sleep(retry_delay)
+        raise last_exc  # type: ignore[misc]
+
+    def on_event(self, event: str, handler: Callable) -> None:
+        """Register an event handler."""
+        if event not in self._event_handlers:
+            self._event_handlers[event] = []
+        self._event_handlers[event].append(handler)
+
+    def _dispatch_event(self, event: str, data: dict) -> None:
+        """Dispatch an event to all registered handlers."""
+        for handler in self._event_handlers.get(event, []):
+            handler(data)
+
+    def _on_game_started(self, data: dict) -> None:
+        """Internal handler for game_started events."""
+        self._game_running = True
+
+    def _on_game_stopped(self, data: dict) -> None:
+        """Internal handler for game_stopped events."""
+        self._game_running = False
+
+    async def _listen_loop(self) -> None:
+        """Background task that reads incoming messages from the WebSocket."""
+        try:
+            async for raw in self._ws:
+                try:
+                    msg = parse_message(raw)
+                except ValueError:
+                    continue
+
+                if isinstance(msg, BridgeResponse):
+                    future = self._pending.pop(msg.id, None)
+                    if future and not future.done():
+                        future.set_result(msg)
+                elif isinstance(msg, BridgeEvent):
+                    self._dispatch_event(msg.event, msg.data)
+        except asyncio.CancelledError:
+            raise
+        except Exception:
+            pass
+        finally:
+            for future in self._pending.values():
+                if not future.done():
+                    future.set_exception(BridgeConnectionError("Connection lost"))
+            self._pending.clear()
+            self._connected = False
diff --git a/src/godotiq/bridge/protocol.py b/src/godotiq/bridge/protocol.py
new file mode 100644
index 0000000..76dbee2
--- /dev/null
+++ b/src/godotiq/bridge/protocol.py
@@ -0,0 +1,102 @@
+"""Bridge protocol dataclasses and JSON wire format utilities."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+
+
+TOOL_TIMEOUTS: dict[str, float] = {
+    "ping": 2.0,
+    "screenshot": 30.0,
+    "perf_snapshot": 5.0,
+    "editor_context": 5.0,
+    "run": 10.0,
+    "stop": 5.0,
+}
+
+SCREENSHOT_MAX_RETRIES: int = 3
+SCREENSHOT_RETRY_DELAY: float = 0.5
+
+
+def get_timeout(method: str) -> float:
+    """Return the configured timeout for a method, defaulting to 5.0s."""
+    return TOOL_TIMEOUTS.get(method, 5.0)
+
+
+@dataclass(frozen=True)
+class BridgeRequest:
+    """Outgoing request to the Godot addon."""
+
+    id: str
+    method: str
+    params: dict = field(default_factory=dict)
+
+    def to_json(self) -> str:
+        """Serialize to JSON wire format."""
+        return json.dumps({"id": self.id, "method": self.method, "params": self.params})
+
+
+@dataclass(frozen=True)
+class BridgeResponse:
+    """Response from the Godot addon."""
+
+    id: str
+    status: str
+    result: dict | None = None
+    error: dict | None = None
+
+    @property
+    def is_ok(self) -> bool:
+        """Whether this is a successful response."""
+        return self.status == "ok"
+
+    @property
+    def error_code(self) -> str:
+        """Error code from the error dict, or empty string."""
+        if self.error:
+            return self.error.get("code", "UNKNOWN")
+        return ""
+
+    @property
+    def error_message(self) -> str:
+        """Error message from the error dict, or empty string."""
+        if self.error:
+            return self.error.get("message", "")
+        return ""
+
+
+@dataclass(frozen=True)
+class BridgeEvent:
+    """Push event from the Godot addon."""
+
+    event: str
+    data: dict = field(default_factory=dict)
+
+
+def parse_message(text: str) -> BridgeResponse | BridgeEvent:
+    """Parse a JSON message from the addon into a response or event.
+
+    Raises ValueError for invalid JSON or unrecognized message format.
+    """
+    try:
+        parsed = json.loads(text)
+    except json.JSONDecodeError as exc:
+        raise ValueError(f"Invalid JSON: {exc}") from exc
+
+    if not isinstance(parsed, dict):
+        raise ValueError("Message must be a JSON object")
+
+    if "id" in parsed:
+        return BridgeResponse(
+            id=str(parsed["id"]),
+            status=parsed.get("status", ""),
+            result=parsed.get("result"),
+            error=parsed.get("error"),
+        )
+    if "event" in parsed:
+        return BridgeEvent(
+            event=parsed["event"],
+            data=parsed.get("data", {}),
+        )
+    raise ValueError("Message must contain 'id' or 'event'")
diff --git a/tests/test_bridge/__init__.py b/tests/test_bridge/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_bridge/test_connection.py b/tests/test_bridge/test_connection.py
new file mode 100644
index 0000000..e603ba9
--- /dev/null
+++ b/tests/test_bridge/test_connection.py
@@ -0,0 +1,95 @@
+"""Tests for BridgeConnection async WebSocket client."""
+
+from __future__ import annotations
+
+import pytest
+
+from godotiq.bridge.connection import (
+    BridgeConnection,
+    BridgeConnectionError,
+    BridgeError,
+)
+
+
+class TestBridgeConnectionInit:
+    """Constructor and initial state."""
+
+    def test_default_host_and_port(self) -> None:
+        """Default host is 127.0.0.1 and port is 6007."""
+        conn = BridgeConnection()
+        assert conn._host == "127.0.0.1"
+        assert conn._port == 6007
+
+    def test_custom_host_and_port(self) -> None:
+        """Custom host and port are stored correctly."""
+        conn = BridgeConnection(host="192.168.1.1", port=9999)
+        assert conn._host == "192.168.1.1"
+        assert conn._port == 9999
+
+    def test_initial_state_disconnected(self) -> None:
+        """is_connected and is_game_running are False initially."""
+        conn = BridgeConnection()
+        assert conn.is_connected is False
+        assert conn.is_game_running is False
+
+
+class TestBridgeConnectionRequest:
+    """Request behavior without connection."""
+
+    async def test_request_without_connect_raises(self) -> None:
+        """request() without connect() raises BridgeConnectionError."""
+        conn = BridgeConnection()
+        with pytest.raises(BridgeConnectionError):
+            await conn.request("ping")
+
+
+class TestBridgeConnectionEvents:
+    """Game state tracking and event dispatch."""
+
+    def test_on_game_started(self) -> None:
+        """_on_game_started sets is_game_running to True."""
+        conn = BridgeConnection()
+        conn._on_game_started({})
+        assert conn.is_game_running is True
+
+    def test_on_game_stopped(self) -> None:
+        """_on_game_stopped sets is_game_running to False."""
+        conn = BridgeConnection()
+        conn._game_running = True
+        conn._on_game_stopped({})
+        assert conn.is_game_running is False
+
+    def test_event_handler_called(self) -> None:
+        """Registered event handler is called with correct data."""
+        conn = BridgeConnection()
+        received = []
+        conn.on_event("test_event", lambda data: received.append(data))
+        conn._dispatch_event("test_event", {"key": "value"})
+        assert len(received) == 1
+        assert received[0] == {"key": "value"}
+
+    def test_multiple_handlers_same_event(self) -> None:
+        """Multiple handlers for same event are all called."""
+        conn = BridgeConnection()
+        results_a = []
+        results_b = []
+        conn.on_event("multi", lambda d: results_a.append(d))
+        conn.on_event("multi", lambda d: results_b.append(d))
+        conn._dispatch_event("multi", {"x": 1})
+        assert len(results_a) == 1
+        assert len(results_b) == 1
+
+
+class TestBridgeConnectionErrorTypes:
+    """Custom exception attributes."""
+
+    def test_bridge_error_attributes(self) -> None:
+        """BridgeError stores code and error_message attributes."""
+        err = BridgeError("GAME_NOT_RUNNING", "Start the game first")
+        assert err.code == "GAME_NOT_RUNNING"
+        assert err.error_message == "Start the game first"
+
+    def test_bridge_error_str_includes_code(self) -> None:
+        """BridgeError str includes the error code."""
+        err = BridgeError("TIMEOUT", "Request timed out")
+        assert "TIMEOUT" in str(err)
diff --git a/tests/test_bridge/test_protocol.py b/tests/test_bridge/test_protocol.py
new file mode 100644
index 0000000..f50eb7d
--- /dev/null
+++ b/tests/test_bridge/test_protocol.py
@@ -0,0 +1,117 @@
+"""Tests for bridge protocol dataclasses and message parsing."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from godotiq.bridge.protocol import (
+    BridgeRequest,
+    BridgeResponse,
+    BridgeEvent,
+    parse_message,
+    get_timeout,
+    TOOL_TIMEOUTS,
+)
+
+
+class TestBridgeRequest:
+    """BridgeRequest serialization."""
+
+    def test_to_json_no_params(self) -> None:
+        """to_json() with no params produces valid JSON with empty params dict."""
+        req = BridgeRequest(id="1", method="ping", params={})
+        raw = req.to_json()
+        parsed = json.loads(raw)
+        assert parsed["id"] == "1"
+        assert parsed["method"] == "ping"
+        assert parsed["params"] == {}
+
+    def test_to_json_with_params(self) -> None:
+        """to_json() with params preserves all param values."""
+        req = BridgeRequest(id="2", method="screenshot", params={"scale": 0.5, "format": "webp"})
+        raw = req.to_json()
+        parsed = json.loads(raw)
+        assert parsed["params"]["scale"] == 0.5
+        assert parsed["params"]["format"] == "webp"
+
+
+class TestParseMessage:
+    """parse_message() routing and error handling."""
+
+    def test_parse_ok_response(self) -> None:
+        """Parse ok response extracts id, status, result correctly."""
+        text = json.dumps({"id": "1", "status": "ok", "result": {"version": "4.3"}})
+        msg = parse_message(text)
+        assert isinstance(msg, BridgeResponse)
+        assert msg.id == "1"
+        assert msg.status == "ok"
+        assert msg.result == {"version": "4.3"}
+        assert msg.is_ok
+
+    def test_parse_error_response(self) -> None:
+        """Parse error response extracts error code and message."""
+        text = json.dumps({
+            "id": "2",
+            "status": "error",
+            "error": {"code": "GAME_NOT_RUNNING", "message": "Start the game first"},
+        })
+        msg = parse_message(text)
+        assert isinstance(msg, BridgeResponse)
+        assert not msg.is_ok
+        assert msg.error_code == "GAME_NOT_RUNNING"
+        assert msg.error_message == "Start the game first"
+
+    def test_parse_event(self) -> None:
+        """Parse event message returns BridgeEvent with event name and data."""
+        text = json.dumps({"event": "game_started", "data": {}})
+        msg = parse_message(text)
+        assert isinstance(msg, BridgeEvent)
+        assert msg.event == "game_started"
+
+    def test_parse_event_with_data(self) -> None:
+        """Parse event with data preserves nested data fields."""
+        text = json.dumps({"event": "runtime_error", "data": {"message": "null ref", "line": 42}})
+        msg = parse_message(text)
+        assert isinstance(msg, BridgeEvent)
+        assert msg.data["message"] == "null ref"
+        assert msg.data["line"] == 42
+
+    def test_invalid_json_raises(self) -> None:
+        """Invalid JSON raises ValueError with 'Invalid JSON' message."""
+        with pytest.raises(ValueError, match="Invalid JSON"):
+            parse_message("not json {{{")
+
+    def test_missing_id_and_event_raises(self) -> None:
+        """Message without id or event raises ValueError."""
+        with pytest.raises(ValueError):
+            parse_message(json.dumps({"status": "ok"}))
+
+    def test_non_object_json_raises(self) -> None:
+        """Non-object JSON (string, array) raises ValueError."""
+        with pytest.raises(ValueError):
+            parse_message(json.dumps("just a string"))
+        with pytest.raises(ValueError):
+            parse_message(json.dumps([1, 2, 3]))
+
+
+class TestTimeouts:
+    """get_timeout() and TOOL_TIMEOUTS configuration."""
+
+    def test_known_method_screenshot(self) -> None:
+        """Known method returns configured timeout (screenshot=30)."""
+        assert get_timeout("screenshot") == 30.0
+
+    def test_known_method_ping(self) -> None:
+        """Known method returns configured timeout (ping=2)."""
+        assert get_timeout("ping") == 2.0
+
+    def test_unknown_method_default(self) -> None:
+        """Unknown method returns default 5.0."""
+        assert get_timeout("nonexistent_method") == 5.0
+
+    def test_all_timeouts_positive(self) -> None:
+        """All configured timeouts are positive."""
+        for method, timeout in TOOL_TIMEOUTS.items():
+            assert timeout > 0, f"Timeout for {method} is not positive: {timeout}"
