# nodelink

**A high-level Python multiplayer library. Just works.**

No presets. No opinions. You define the events, the data, and the behavior — nodelink handles the connections, state sync, and networking underneath.

```
pip install nodelink
```

---

## Quickstart

**Server:**
```python
from nodelink import Server

server = Server(port=5000)

@server.on("connect")
def on_connect(player):
    server.broadcast("joined", {"id": player.id})

@server.on("move")
def on_move(player, data):
    player.update_state({"x": data["x"], "y": data["y"]})

@server.on("disconnect")
def on_disconnect(player):
    server.broadcast("left", {"id": player.id})

@server.on_error()
def on_error(error, player):
    print(f"Error from {player.id}: {error}")

server.start()
```

**Client:**
```python
from nodelink import Client

client = Client("localhost", 5000)

@client.on("connect")
def on_connect():
    client.send("move", {"x": 10, "y": 20})

@client.on("joined")
def on_joined(data):
    print(f"Player {data['id']} joined!")

@client.on("__state_sync__")
def on_sync(data):
    for player_id, state in data["players"].items():
        print(f"{player_id}: {state}")

@client.on_error()
def on_error(error):
    print(f"Error: {error}")

client.connect()
```

---

## Core concepts

### Events
Everything is event-based. Send an event from the client, handle it on the server (and vice versa) with a simple decorator.

```python
# Client sends:
client.send("chat", {"msg": "hello!"})

# Server handles:
@server.on("chat")
def on_chat(player, data):
    server.broadcast("chat", {"from": player.id, "msg": data["msg"]})

# All clients receive:
@client.on("chat")
def on_chat(data):
    print(f"[{data['from']}]: {data['msg']}")
```

### Player state sync
Each player has a `.state` dict. Update it on the server and nodelink automatically broadcasts only the changed fields to all clients at 20 ticks/sec.

```python
@server.on("move")
def on_move(player, data):
    player.update_state({"x": data["x"], "y": data["y"]})
    # That's it — state is synced automatically
```

Force an immediate sync if you can't wait for the next tick:
```python
server.sync_state()        # delta only
server.sync_state(full=True)  # full state
```

### Sending to one player
```python
@server.on("connect")
def on_connect(player):
    # Send something only to this player
    asyncio.ensure_future(player.send("welcome", {"id": player.id}))
```

### Error handling
Errors in event handlers are caught and routed to your error handler instead of crashing everything.

```python
@server.on_error()
def on_error(error, player):
    print(f"Error from {player.id}: {error}")

@client.on_error()
def on_error(error):
    print(f"Client error: {error}")
```

### Send queue
Messages sent before the client is fully connected are automatically queued and delivered the moment the connection opens. No timing issues.

```python
client.send("hello", {"msg": "this works even before connect() is called"})
client.connect()  # queued message is flushed on connect
```

---

## API Reference

### `Server(host, port, tick_rate, delta_only)`

| Arg | Default | Description |
|-----|---------|-------------|
| `host` | `"0.0.0.0"` | Hostname to bind to |
| `port` | `5000` | Port to listen on |
| `tick_rate` | `20` | State syncs per second |
| `delta_only` | `True` | Only broadcast changed fields |

| Method | Description |
|--------|-------------|
| `server.on(event)` | Register an event handler |
| `server.on_error()` | Register an error handler |
| `server.broadcast(event, data)` | Send event to all players |
| `server.sync_state(full=False)` | Force immediate state sync |
| `server.get_players()` | List of all connected Players |
| `server.get_player(id)` | Look up a player by ID |
| `server.player_count` | Number of connected players |
| `server.start()` | Start the server (blocking) |

### `Client(host, port)`

| Method | Description |
|--------|-------------|
| `client.on(event)` | Register an event handler |
| `client.on_error()` | Register an error handler |
| `client.send(event, data)` | Send event to server |
| `client.connect()` | Connect and start listening (blocking) |

### `Player`

| Attribute/Method | Description |
|-----------------|-------------|
| `player.id` | Unique player ID |
| `player.state` | Dict of synced values |
| `player.set_state(key, value)` | Set one state value |
| `player.get_state(key, default)` | Get one state value |
| `player.update_state(dict)` | Merge dict into state |
| `player.clear_state()` | Wipe state entirely |
| `player.send(event, data)` | Send directly to this player |
| `player.connected_at` | Unix timestamp of connection time |
| `player.last_seen` | Unix timestamp of last message |

---

## Built-in events

| Event | Where | Handler signature |
|-------|-------|------------------|
| `"connect"` | server | `fn(player)` |
| `"disconnect"` | server | `fn(player)` |
| `"connect"` | client | `fn()` |
| `"disconnect"` | client | `fn()` |
| `"__state_sync__"` | client | `fn(data)` — `data["players"]` is a dict of `player_id -> state_delta` |

---

## Requirements

- Python 3.8+
- `websockets >= 12.0`

---

## License

MIT
