@watchtower-sdk/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,336 @@
1
+ # @watchtower/sdk
2
+
3
+ Simple game backend SDK - cloud saves, multiplayer rooms, automatic state sync.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @watchtower/sdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { Watchtower } from '@watchtower/sdk'
15
+
16
+ const wt = new Watchtower({
17
+ gameId: 'my-game',
18
+ apiKey: 'wt_live_...' // Get from dashboard
19
+ })
20
+
21
+ // Cloud saves
22
+ await wt.save('progress', { level: 5, coins: 100 })
23
+ const data = await wt.load('progress')
24
+
25
+ // Multiplayer
26
+ const room = await wt.createRoom()
27
+ console.log('Share this code:', room.code) // e.g., "ABCD"
28
+ ```
29
+
30
+ ## Cloud Saves
31
+
32
+ Simple key-value storage per player. Works across devices.
33
+
34
+ ```typescript
35
+ // Save anything JSON-serializable
36
+ await wt.save('progress', { level: 5, coins: 100 })
37
+ await wt.save('settings', { music: true, sfx: true })
38
+ await wt.save('inventory', ['sword', 'shield', 'potion'])
39
+
40
+ // Load it back
41
+ const progress = await wt.load('progress')
42
+ const settings = await wt.load('settings')
43
+
44
+ // List all save keys
45
+ const keys = await wt.listSaves() // ['progress', 'settings', 'inventory']
46
+
47
+ // Delete a save
48
+ await wt.deleteSave('inventory')
49
+ ```
50
+
51
+ ## Multiplayer Rooms
52
+
53
+ Create rooms with 4-letter codes. Share with friends to play together.
54
+
55
+ ```typescript
56
+ // Create a room (you become the host)
57
+ const room = await wt.createRoom()
58
+ console.log('Room code:', room.code)
59
+
60
+ // Join an existing room
61
+ const room = await wt.joinRoom('ABCD')
62
+
63
+ // Check room properties
64
+ room.isHost // true if you're the host
65
+ room.hostId // current host's player ID
66
+ room.playerId // your player ID
67
+ room.playerCount // number of players
68
+ room.players // all players' states
69
+ ```
70
+
71
+ ## Player State Sync
72
+
73
+ Automatically sync your player's position/state to all other players.
74
+
75
+ ```typescript
76
+ // Set your player state (automatically synced at 20Hz)
77
+ room.player.set({
78
+ x: 100,
79
+ y: 200,
80
+ sprite: 'running',
81
+ health: 100
82
+ })
83
+
84
+ // State is merged, so you can update individual fields
85
+ room.player.set({ x: 150 }) // keeps y, sprite, health
86
+
87
+ // Force immediate sync
88
+ room.player.sync()
89
+
90
+ // See all players' states
91
+ room.on('players', (players) => {
92
+ for (const [playerId, state] of Object.entries(players)) {
93
+ if (playerId !== room.playerId) {
94
+ // Update other player's sprite
95
+ updateOtherPlayer(playerId, state.x, state.y, state.sprite)
96
+ }
97
+ }
98
+ })
99
+ ```
100
+
101
+ ## Game State (Host-Controlled)
102
+
103
+ Shared state for things like game phase, scores, round number. Only the host can modify it.
104
+
105
+ ```typescript
106
+ // Host sets game state
107
+ if (room.isHost) {
108
+ room.state.set({
109
+ phase: 'lobby',
110
+ round: 0,
111
+ scores: {}
112
+ })
113
+
114
+ // Start the game
115
+ room.state.set({ phase: 'playing', round: 1 })
116
+ }
117
+
118
+ // Everyone receives state updates
119
+ room.on('state', (state) => {
120
+ if (state.phase === 'playing') {
121
+ startGame()
122
+ }
123
+ if (state.phase === 'gameover') {
124
+ showWinner(state.winner)
125
+ }
126
+ })
127
+
128
+ // Read current state anytime
129
+ const currentState = room.state.get()
130
+ ```
131
+
132
+ ## Broadcast Messages
133
+
134
+ For one-off events that don't need persistent state.
135
+
136
+ ```typescript
137
+ // Broadcast to all players
138
+ room.broadcast({ type: 'explosion', x: 50, y: 50 })
139
+ room.broadcast({ type: 'chat', message: 'gg!' })
140
+
141
+ // Send to specific player
142
+ room.sendTo(playerId, { type: 'private_message', text: 'hey' })
143
+
144
+ // Receive messages
145
+ room.on('message', (from, data) => {
146
+ if (data.type === 'explosion') {
147
+ createExplosion(data.x, data.y)
148
+ }
149
+ if (data.type === 'chat') {
150
+ showChat(from, data.message)
151
+ }
152
+ })
153
+ ```
154
+
155
+ ## Room Events
156
+
157
+ ```typescript
158
+ // Connection established
159
+ room.on('connected', ({ playerId, room }) => {
160
+ console.log('Connected as', playerId)
161
+ console.log('Host is', room.hostId)
162
+ })
163
+
164
+ // Player joined
165
+ room.on('playerJoined', (playerId, playerCount) => {
166
+ console.log(`${playerId} joined (${playerCount} players)`)
167
+ spawnPlayer(playerId)
168
+ })
169
+
170
+ // Player left
171
+ room.on('playerLeft', (playerId, playerCount) => {
172
+ console.log(`${playerId} left (${playerCount} players)`)
173
+ removePlayer(playerId)
174
+ })
175
+
176
+ // Host changed (automatic migration when host leaves)
177
+ room.on('hostChanged', (newHostId) => {
178
+ console.log('New host:', newHostId)
179
+ if (newHostId === room.playerId) {
180
+ console.log("I'm the host now!")
181
+ }
182
+ })
183
+
184
+ // Disconnected
185
+ room.on('disconnected', () => {
186
+ console.log('Lost connection')
187
+ })
188
+
189
+ // Error
190
+ room.on('error', (error) => {
191
+ console.error('Room error:', error)
192
+ })
193
+ ```
194
+
195
+ ## Host Transfer
196
+
197
+ ```typescript
198
+ // Host can transfer to another player
199
+ if (room.isHost) {
200
+ room.transferHost(otherPlayerId)
201
+ }
202
+ ```
203
+
204
+ ## Full Example: Simple Multiplayer Game
205
+
206
+ ```typescript
207
+ import { Watchtower } from '@watchtower/sdk'
208
+
209
+ const wt = new Watchtower({ gameId: 'my-game', apiKey: 'wt_...' })
210
+
211
+ // Join or create room
212
+ async function joinGame(code?: string) {
213
+ const room = code
214
+ ? await wt.joinRoom(code)
215
+ : await wt.createRoom()
216
+
217
+ console.log('Room:', room.code)
218
+
219
+ // Game loop - update player position
220
+ function gameLoop() {
221
+ room.player.set({
222
+ x: myPlayer.x,
223
+ y: myPlayer.y,
224
+ animation: myPlayer.currentAnim
225
+ })
226
+ requestAnimationFrame(gameLoop)
227
+ }
228
+ gameLoop()
229
+
230
+ // Render other players
231
+ const otherPlayers: Record<string, Sprite> = {}
232
+
233
+ room.on('players', (players) => {
234
+ for (const [id, state] of Object.entries(players)) {
235
+ if (id === room.playerId) continue
236
+
237
+ // Create sprite if new player
238
+ if (!otherPlayers[id]) {
239
+ otherPlayers[id] = createSprite()
240
+ }
241
+
242
+ // Update position
243
+ otherPlayers[id].x = state.x as number
244
+ otherPlayers[id].y = state.y as number
245
+ otherPlayers[id].play(state.animation as string)
246
+ }
247
+ })
248
+
249
+ // Clean up when players leave
250
+ room.on('playerLeft', (id) => {
251
+ otherPlayers[id]?.destroy()
252
+ delete otherPlayers[id]
253
+ })
254
+
255
+ // Handle game events
256
+ room.on('message', (from, data: any) => {
257
+ if (data.type === 'shoot') {
258
+ createBullet(data.x, data.y, data.dir)
259
+ }
260
+ })
261
+
262
+ // Game state (host manages rounds, scores)
263
+ room.on('state', (state: any) => {
264
+ if (state.phase === 'playing') {
265
+ showRound(state.round)
266
+ }
267
+ if (state.phase === 'gameover') {
268
+ showWinner(state.winner)
269
+ }
270
+ })
271
+
272
+ // If we're host, start game when 2 players join
273
+ room.on('playerJoined', (_, count) => {
274
+ if (room.isHost && count >= 2) {
275
+ room.state.set({ phase: 'playing', round: 1 })
276
+ }
277
+ })
278
+
279
+ return room
280
+ }
281
+ ```
282
+
283
+ ## API Reference
284
+
285
+ ### Watchtower
286
+
287
+ ```typescript
288
+ const wt = new Watchtower(config)
289
+ ```
290
+
291
+ | Config | Type | Description |
292
+ |--------|------|-------------|
293
+ | `gameId` | `string` | Your game's unique identifier |
294
+ | `apiKey` | `string` | API key from dashboard |
295
+ | `playerId` | `string?` | Custom player ID (auto-generated if not provided) |
296
+ | `apiUrl` | `string?` | Custom API URL (default: Watchtower API) |
297
+
298
+ ### Room
299
+
300
+ | Property | Type | Description |
301
+ |----------|------|-------------|
302
+ | `code` | `string` | 4-letter room code |
303
+ | `isHost` | `boolean` | True if you're the host |
304
+ | `hostId` | `string` | Current host's player ID |
305
+ | `playerId` | `string` | Your player ID |
306
+ | `playerCount` | `number` | Number of players |
307
+ | `players` | `PlayersState` | All players' states |
308
+ | `connected` | `boolean` | Connection status |
309
+
310
+ | Method | Description |
311
+ |--------|-------------|
312
+ | `player.set(state)` | Set your player state (auto-synced) |
313
+ | `player.get()` | Get your current player state |
314
+ | `player.sync()` | Force immediate sync |
315
+ | `state.set(state)` | Set game state (host only) |
316
+ | `state.get()` | Get current game state |
317
+ | `broadcast(data)` | Send to all players |
318
+ | `sendTo(id, data)` | Send to specific player |
319
+ | `transferHost(id)` | Transfer host (host only) |
320
+ | `disconnect()` | Leave the room |
321
+
322
+ | Event | Callback | Description |
323
+ |-------|----------|-------------|
324
+ | `connected` | `({playerId, room}) => void` | Connected to room |
325
+ | `players` | `(players) => void` | Player states updated |
326
+ | `state` | `(state) => void` | Game state updated |
327
+ | `playerJoined` | `(id, count) => void` | Player joined |
328
+ | `playerLeft` | `(id, count) => void` | Player left |
329
+ | `hostChanged` | `(newHostId) => void` | Host changed |
330
+ | `message` | `(from, data) => void` | Received broadcast |
331
+ | `disconnected` | `() => void` | Lost connection |
332
+ | `error` | `(error) => void` | Error occurred |
333
+
334
+ ## License
335
+
336
+ MIT
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Watchtower SDK
3
+ * Simple game backend - saves, multiplayer rooms, and more
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * import { Watchtower } from '@watchtower/sdk'
8
+ *
9
+ * const wt = new Watchtower({ gameId: 'my-game', apiKey: 'wt_...' })
10
+ *
11
+ * // Cloud saves
12
+ * await wt.save('progress', { level: 5, coins: 100 })
13
+ * const data = await wt.load('progress')
14
+ *
15
+ * // Multiplayer
16
+ * const room = await wt.createRoom()
17
+ * console.log('Room code:', room.code) // e.g., "ABCD"
18
+ *
19
+ * // Player state (auto-synced to others)
20
+ * room.player.set({ x: 100, y: 200, sprite: 'idle' })
21
+ *
22
+ * // See other players
23
+ * room.on('players', (players) => {
24
+ * for (const [id, state] of Object.entries(players)) {
25
+ * updatePlayer(id, state)
26
+ * }
27
+ * })
28
+ *
29
+ * // Shared game state (host-controlled)
30
+ * if (room.isHost) {
31
+ * room.state.set({ phase: 'playing', round: 1 })
32
+ * }
33
+ * room.on('state', (state) => updateGameState(state))
34
+ *
35
+ * // One-off events
36
+ * room.broadcast({ type: 'explosion', x: 50, y: 50 })
37
+ * room.on('message', (from, data) => handleEvent(from, data))
38
+ * ```
39
+ */
40
+ interface WatchtowerConfig {
41
+ /** Your game's unique identifier */
42
+ gameId: string;
43
+ /** Player ID (auto-generated if not provided) */
44
+ playerId?: string;
45
+ /** API base URL (default: https://watchtower-api.watchtower-host.workers.dev) */
46
+ apiUrl?: string;
47
+ /** API key for authenticated requests (optional for now) */
48
+ apiKey?: string;
49
+ }
50
+ interface SaveData<T = unknown> {
51
+ key: string;
52
+ data: T;
53
+ }
54
+ interface PlayerInfo {
55
+ id: string;
56
+ joinedAt: number;
57
+ }
58
+ interface RoomInfo {
59
+ code: string;
60
+ gameId: string;
61
+ hostId: string;
62
+ players: PlayerInfo[];
63
+ playerCount: number;
64
+ }
65
+ /** Game-wide stats */
66
+ interface GameStats {
67
+ /** Players currently online */
68
+ online: number;
69
+ /** Unique players today (DAU) */
70
+ today: number;
71
+ /** Unique players this month (MAU) */
72
+ monthly: number;
73
+ /** Total unique players all time */
74
+ total: number;
75
+ /** Currently active rooms */
76
+ rooms: number;
77
+ /** Players currently in multiplayer rooms */
78
+ inRooms: number;
79
+ /** Average session length in seconds */
80
+ avgSession: number;
81
+ /** Average players per room */
82
+ avgRoomSize: number;
83
+ /** Last update timestamp */
84
+ updatedAt: number | null;
85
+ }
86
+ /** Current player's stats */
87
+ interface PlayerStats {
88
+ /** When the player first connected */
89
+ firstSeen: string | null;
90
+ /** When the player last connected */
91
+ lastSeen: string | null;
92
+ /** Total sessions */
93
+ sessions: number;
94
+ /** Total playtime in seconds */
95
+ playtime: number;
96
+ }
97
+ /** Player state - position, animation, custom data */
98
+ type PlayerState = Record<string, unknown>;
99
+ /** All players' states indexed by player ID */
100
+ type PlayersState = Record<string, PlayerState>;
101
+ /** Shared game state - host controlled */
102
+ type GameState = Record<string, unknown>;
103
+ type RoomEventMap = {
104
+ /** Fired when connected to room */
105
+ connected: (info: {
106
+ playerId: string;
107
+ room: RoomInfo;
108
+ }) => void;
109
+ /** Fired when a player joins */
110
+ playerJoined: (playerId: string, playerCount: number) => void;
111
+ /** Fired when a player leaves */
112
+ playerLeft: (playerId: string, playerCount: number) => void;
113
+ /** Fired when players' states update (includes all players) */
114
+ players: (players: PlayersState) => void;
115
+ /** Fired when shared game state updates */
116
+ state: (state: GameState) => void;
117
+ /** Fired when host changes */
118
+ hostChanged: (newHostId: string) => void;
119
+ /** Fired when receiving a broadcast message */
120
+ message: (from: string, data: unknown) => void;
121
+ /** Fired on disconnect */
122
+ disconnected: () => void;
123
+ /** Fired on error */
124
+ error: (error: Error) => void;
125
+ };
126
+ declare class PlayerStateManager {
127
+ private room;
128
+ private _state;
129
+ private syncInterval;
130
+ private dirty;
131
+ private syncRateMs;
132
+ constructor(room: Room, syncRateMs?: number);
133
+ /** Set player state (merged with existing) */
134
+ set(state: PlayerState): void;
135
+ /** Replace entire player state */
136
+ replace(state: PlayerState): void;
137
+ /** Get current player state */
138
+ get(): PlayerState;
139
+ /** Clear player state */
140
+ clear(): void;
141
+ /** Start automatic sync */
142
+ startSync(): void;
143
+ /** Stop automatic sync */
144
+ stopSync(): void;
145
+ /** Force immediate sync */
146
+ sync(): void;
147
+ }
148
+ declare class GameStateManager {
149
+ private room;
150
+ private _state;
151
+ constructor(room: Room);
152
+ /** Set game state (host only, merged with existing) */
153
+ set(state: GameState): void;
154
+ /** Replace entire game state (host only) */
155
+ replace(state: GameState): void;
156
+ /** Get current game state */
157
+ get(): GameState;
158
+ /** Update internal state (called on sync from server) */
159
+ _update(state: GameState): void;
160
+ }
161
+ declare class Room {
162
+ readonly code: string;
163
+ private ws;
164
+ private listeners;
165
+ private config;
166
+ /** Player state manager - set your position/state here */
167
+ readonly player: PlayerStateManager;
168
+ /** Game state manager - shared state (host-controlled) */
169
+ readonly state: GameStateManager;
170
+ /** All players' current states */
171
+ private _players;
172
+ /** Current host ID */
173
+ private _hostId;
174
+ /** Room info from initial connection */
175
+ private _roomInfo;
176
+ constructor(code: string, config: Required<WatchtowerConfig>);
177
+ /** Get the current host ID */
178
+ get hostId(): string;
179
+ /** Check if current player is the host */
180
+ get isHost(): boolean;
181
+ /** Get current player's ID */
182
+ get playerId(): string;
183
+ /** Get all players' states */
184
+ get players(): PlayersState;
185
+ /** Get player count */
186
+ get playerCount(): number;
187
+ /** Connect to the room via WebSocket */
188
+ connect(): Promise<void>;
189
+ private handleMessage;
190
+ /** Subscribe to room events */
191
+ on<K extends keyof RoomEventMap>(event: K, callback: RoomEventMap[K]): void;
192
+ /** Unsubscribe from room events */
193
+ off<K extends keyof RoomEventMap>(event: K, callback: RoomEventMap[K]): void;
194
+ private emit;
195
+ /** Broadcast data to all players in the room (for one-off events) */
196
+ broadcast(data: unknown, excludeSelf?: boolean): void;
197
+ /** Send data to a specific player */
198
+ sendTo(playerId: string, data: unknown): void;
199
+ /** Send a ping to measure latency */
200
+ ping(): void;
201
+ /** Request host transfer (host only) */
202
+ transferHost(newHostId: string): void;
203
+ private send;
204
+ /** Disconnect from the room */
205
+ disconnect(): void;
206
+ /** Check if connected */
207
+ get connected(): boolean;
208
+ }
209
+ declare class Watchtower {
210
+ /** @internal - Config is non-enumerable to prevent accidental API key exposure */
211
+ private readonly config;
212
+ constructor(config: WatchtowerConfig);
213
+ private generatePlayerId;
214
+ /** Get the current player ID */
215
+ get playerId(): string;
216
+ /** Get the game ID */
217
+ get gameId(): string;
218
+ private fetch;
219
+ /**
220
+ * Save data to the cloud
221
+ * @param key - Save slot name (e.g., "progress", "settings")
222
+ * @param data - Any JSON-serializable data
223
+ */
224
+ save(key: string, data: unknown): Promise<void>;
225
+ /**
226
+ * Load data from the cloud
227
+ * @param key - Save slot name
228
+ * @returns The saved data, or null if not found
229
+ */
230
+ load<T = unknown>(key: string): Promise<T | null>;
231
+ /**
232
+ * List all save keys for this player
233
+ */
234
+ listSaves(): Promise<string[]>;
235
+ /**
236
+ * Delete a save
237
+ * @param key - Save slot name
238
+ */
239
+ deleteSave(key: string): Promise<void>;
240
+ /**
241
+ * Create a new multiplayer room
242
+ * @returns A Room instance (already connected)
243
+ */
244
+ createRoom(): Promise<Room>;
245
+ /**
246
+ * Join an existing room by code
247
+ * @param code - The 4-letter room code
248
+ * @returns A Room instance (already connected)
249
+ */
250
+ joinRoom(code: string): Promise<Room>;
251
+ /**
252
+ * Get info about a room without joining
253
+ * @param code - The 4-letter room code
254
+ */
255
+ getRoomInfo(code: string): Promise<RoomInfo>;
256
+ /**
257
+ * Get game-wide stats
258
+ * @returns Stats like online players, DAU, rooms active, etc.
259
+ *
260
+ * @example
261
+ * ```ts
262
+ * const stats = await wt.getStats()
263
+ * console.log(`${stats.online} players online`)
264
+ * console.log(`${stats.rooms} active rooms`)
265
+ * ```
266
+ */
267
+ getStats(): Promise<GameStats>;
268
+ /**
269
+ * Get the current player's stats
270
+ * @returns Player's firstSeen, sessions count, playtime
271
+ *
272
+ * @example
273
+ * ```ts
274
+ * const me = await wt.getPlayerStats()
275
+ * console.log(`You've played ${Math.floor(me.playtime / 3600)} hours`)
276
+ * console.log(`Member since ${new Date(me.firstSeen).toLocaleDateString()}`)
277
+ * ```
278
+ */
279
+ getPlayerStats(): Promise<PlayerStats>;
280
+ /**
281
+ * Track a session start (call on game load)
282
+ * This is called automatically if you use createRoom/joinRoom
283
+ */
284
+ trackSessionStart(): Promise<void>;
285
+ /**
286
+ * Track a session end (call on game close)
287
+ */
288
+ trackSessionEnd(): Promise<void>;
289
+ /**
290
+ * Convenience getter for stats (same as getStats but as property style)
291
+ * Note: This returns a promise, use `await wt.stats` or `wt.getStats()`
292
+ */
293
+ get stats(): Promise<GameStats>;
294
+ }
295
+
296
+ export { type GameState, type GameStats, type PlayerInfo, type PlayerState, type PlayerStats, type PlayersState, Room, type RoomEventMap, type RoomInfo, type SaveData, Watchtower, type WatchtowerConfig, Watchtower as default };